From 954b4c0fbcbeefd12effeab37356718f529f074a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:52:44 +0000 Subject: [PATCH 1/7] Initial plan From d72da558c1507d1161b0fe638854db3df4aed29d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:02:08 +0000 Subject: [PATCH 2/7] admin-generator asyncSelect: add optional queryName setting Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .changeset/async-select-query-name.md | 9 ++ .../src/commands/generate/generate-command.ts | 12 +++ .../__tests__/asyncSelect.test.ts | 85 +++++++++++++++++++ .../asyncSelect/generateAsyncSelect.ts | 2 +- 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 .changeset/async-select-query-name.md diff --git a/.changeset/async-select-query-name.md b/.changeset/async-select-query-name.md new file mode 100644 index 00000000000..11ddd80ed7f --- /dev/null +++ b/.changeset/async-select-query-name.md @@ -0,0 +1,9 @@ +--- +"@comet/admin-generator": patch +--- + +admin-generator asyncSelect: add optional `queryName` setting to override the generated GraphQL query name + +Previously the query name was always derived from `rootQuery` (e.g. `"people"` → `"PeopleSelect"`). When multiple fields shared the same `rootQuery` this produced duplicate query names, causing errors in `gql:types`. + +The new `queryName` option lets you set a distinct name for each field's query (e.g. `queryName: "HostSelect"` and `queryName: "GuestSelect"` for two fields that both use `rootQuery: "people"`). The default behavior (deriving the name from `rootQuery`) is unchanged. diff --git a/packages/admin/admin-generator/src/commands/generate/generate-command.ts b/packages/admin/admin-generator/src/commands/generate/generate-command.ts index c56fa300875..929daf2f08b 100644 --- a/packages/admin/admin-generator/src/commands/generate/generate-command.ts +++ b/packages/admin/admin-generator/src/commands/generate/generate-command.ts @@ -113,6 +113,12 @@ export type FormFieldConfig = ( type: "asyncSelect"; name: UsableFormFields; rootQuery: string; + /** + * Override the generated GraphQL query name. + * Defaults to the rootQuery name with an uppercased first letter and "Select" appended (e.g. "people" -> "PeopleSelect"). + * Useful when multiple fields share the same rootQuery, which would otherwise generate duplicate query names. + */ + queryName?: string; labelField?: string; /** Whether Autocomplete or Select should be used. * @@ -129,6 +135,12 @@ export type FormFieldConfig = ( name: string; loadValueQueryField: string; //TODO improve typing, use something similar to UsableFormFields; rootQuery: string; + /** + * Override the generated GraphQL query name. + * Defaults to the rootQuery name with an uppercased first letter and "Select" appended (e.g. "people" -> "PeopleSelect"). + * Useful when multiple fields share the same rootQuery, which would otherwise generate duplicate query names. + */ + queryName?: string; labelField?: string; /** Whether Autocomplete or Select should be used. * diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts index 58126bf9cb5..0ff0416c0f6 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts @@ -477,4 +477,89 @@ describe("AsyncSelect filter", () => { expect(formOutput.code).toMatchSnapshot(); }); }); + + it("uses custom queryName when provided", async () => { + const schema = buildSchema(` + type Query { + people(search: String): PeopleConnection! + } + + type PeopleConnection { + nodes: [Person!]! + } + + type Visit { + id: ID! + host: Person! + guest: Person! + } + type Person { + id: ID! + name: String! + } + + type Mutation { + createVisit(input: VisitInput!): Visit! + updateVisit(id: ID!, input: VisitInput!): Visit! + } + + input VisitInput { + host: ID + guest: ID + } + `); + type GQLPerson = { + __typename?: "Person"; + id: string; + name: string; + }; + type GQLVisit = { + __typename?: "Visit"; + id: string; + host: GQLPerson; + guest: GQLPerson; + }; + + const introspection = introspectionFromSchema(schema); + + const hostFieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + queryName: "HostSelect", + name: "host", + }; + const guestFieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + queryName: "GuestSelect", + name: "guest", + }; + const formConfig: FormConfig = { + type: "form", + gqlType: "Visit", + fields: [hostFieldConfig, guestFieldConfig], + }; + + const hostOutput = generateFormField({ + gqlIntrospection: introspection, + baseOutputFilename: "VisitForm", + formFragmentName: "VisitFormFragment", + config: hostFieldConfig, + formConfig, + gqlType: "Visit", + }); + const guestOutput = generateFormField({ + gqlIntrospection: introspection, + baseOutputFilename: "VisitForm", + formFragmentName: "VisitFormFragment", + config: guestFieldConfig, + formConfig, + gqlType: "Visit", + }); + + expect(hostOutput.code).toContain("HostSelect"); + expect(hostOutput.code).not.toContain("PeopleSelect"); + expect(guestOutput.code).toContain("GuestSelect"); + expect(guestOutput.code).not.toContain("PeopleSelect"); + }); }); diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts index 9910fa46886..dc125052910 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts @@ -189,7 +189,7 @@ export function generateAsyncSelect({ } const rootQuery = config.rootQuery; //TODO we should infer a default value from the gql schema - const queryName = `${rootQuery[0].toUpperCase() + rootQuery.substring(1)}Select`; + const queryName = config.queryName ?? `${rootQuery[0].toUpperCase() + rootQuery.substring(1)}Select`; const rootQueryType = findQueryTypeOrThrow(rootQuery, gqlIntrospection); let formFragmentFields: string[]; From 6c62dd1d5102e5019ff0cf4fe54e39cf598808f0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:55:34 +0000 Subject: [PATCH 3/7] admin-generator asyncSelect: derive query name from field name instead of rootQuery Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .changeset/async-select-query-name.md | 12 ++- .../src/commands/generate/generate-command.ts | 8 +- .../__snapshots__/asyncSelect.test.ts.snap | 40 +++++----- .../asyncSelectAutocomplete.test.ts.snap | 8 +- .../asyncSelectFilter.test.ts.snap | 44 +++++------ .../asyncSelectRenderedProps.test.ts.snap | 4 +- .../__tests__/asyncSelect.test.ts | 76 ++++++++++++++++++- .../asyncSelect/generateAsyncSelect.ts | 2 +- 8 files changed, 134 insertions(+), 60 deletions(-) diff --git a/.changeset/async-select-query-name.md b/.changeset/async-select-query-name.md index 11ddd80ed7f..70da20477a1 100644 --- a/.changeset/async-select-query-name.md +++ b/.changeset/async-select-query-name.md @@ -1,9 +1,13 @@ --- -"@comet/admin-generator": patch +"@comet/admin-generator": minor --- -admin-generator asyncSelect: add optional `queryName` setting to override the generated GraphQL query name +admin-generator asyncSelect: query name is now derived from field name instead of rootQuery -Previously the query name was always derived from `rootQuery` (e.g. `"people"` → `"PeopleSelect"`). When multiple fields shared the same `rootQuery` this produced duplicate query names, causing errors in `gql:types`. +Previously the GraphQL operation name was derived from `rootQuery` (e.g. `rootQuery: "people"` → `PeopleSelect`). When multiple fields shared the same `rootQuery`, this produced duplicate operation names, causing errors in `gql:types`. -The new `queryName` option lets you set a distinct name for each field's query (e.g. `queryName: "HostSelect"` and `queryName: "GuestSelect"` for two fields that both use `rootQuery: "people"`). The default behavior (deriving the name from `rootQuery`) is unchanged. +The operation name is now derived from the **field name** (e.g. field `host` → `HostSelect`, field `guest` → `GuestSelect`). This ensures uniqueness across all fields in a form automatically, without any extra configuration. + +**Breaking change:** Existing generated files will have different operation names after re-running the generator. Update any handwritten code that references the old query names. + +An optional `queryName` setting was also added to both `asyncSelect` and `asyncSelectFilter` to manually override the generated operation name when needed. diff --git a/packages/admin/admin-generator/src/commands/generate/generate-command.ts b/packages/admin/admin-generator/src/commands/generate/generate-command.ts index 929daf2f08b..b476a0f990d 100644 --- a/packages/admin/admin-generator/src/commands/generate/generate-command.ts +++ b/packages/admin/admin-generator/src/commands/generate/generate-command.ts @@ -115,8 +115,8 @@ export type FormFieldConfig = ( rootQuery: string; /** * Override the generated GraphQL query name. - * Defaults to the rootQuery name with an uppercased first letter and "Select" appended (e.g. "people" -> "PeopleSelect"). - * Useful when multiple fields share the same rootQuery, which would otherwise generate duplicate query names. + * Defaults to the field name with an uppercased first letter and "Select" appended (e.g. field "host" → "HostSelect"). + * Useful when the auto-generated name (based on the field name) is not descriptive enough. */ queryName?: string; labelField?: string; @@ -137,8 +137,8 @@ export type FormFieldConfig = ( rootQuery: string; /** * Override the generated GraphQL query name. - * Defaults to the rootQuery name with an uppercased first letter and "Select" appended (e.g. "people" -> "PeopleSelect"). - * Useful when multiple fields share the same rootQuery, which would otherwise generate duplicate query names. + * Defaults to the field name with an uppercased first letter and "Select" appended (e.g. field "productCategory" → "ProductCategorySelect"). + * Useful when the auto-generated name (based on the field name) is not descriptive enough. */ queryName?: string; labelField?: string; diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelect.test.ts.snap b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelect.test.ts.snap index eb256e1fde9..89845d70197 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelect.test.ts.snap +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelect.test.ts.snap @@ -11,9 +11,9 @@ exports[`AsyncSelect filter > generates field without filter 1`] = ` label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect { + query CategorySelect { categories { nodes { id name } } @@ -42,9 +42,9 @@ exports[`AsyncSelect filter > generates filter with value dependent on other fie label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($title: String) { + query CategorySelect($title: String) { categories(title: $title) { nodes { id name } } @@ -106,8 +106,8 @@ import { FormApi } from "final-form"; import { useMemo } from "react"; import { GQLCategoryType } from "@src/graphql.generated"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -240,9 +240,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($type: CategoryType) { + query CategorySelect($type: CategoryType) { categories(type: $type) { nodes { id name } } @@ -305,8 +305,8 @@ import { InputAdornment } from "@mui/material"; import { FormApi } from "final-form"; import { useMemo } from "react"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -439,9 +439,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($foo: String) { + query CategorySelect($foo: String) { categories(foo: $foo) { nodes { id name } } @@ -504,8 +504,8 @@ import { InputAdornment } from "@mui/material"; import { FormApi } from "final-form"; import { useMemo } from "react"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -638,9 +638,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($filter: CategoryFilter) { + query CategorySelect($filter: CategoryFilter) { categories(filter: $filter) { nodes { id name } } @@ -704,8 +704,8 @@ import { FormApi } from "final-form"; import { useMemo } from "react"; import { GQLCategoryType } from "@src/graphql.generated"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -838,9 +838,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($filter: CategoryFilter) { + query CategorySelect($filter: CategoryFilter) { categories(filter: $filter) { nodes { id name } } diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectAutocomplete.test.ts.snap b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectAutocomplete.test.ts.snap index 71a624ae68a..f26c8a702af 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectAutocomplete.test.ts.snap +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectAutocomplete.test.ts.snap @@ -11,9 +11,9 @@ exports[`AsyncSelect autocomplete > generates field with default autocomplete be label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($search: String) { + query CategorySelect($search: String) { categories(search: $search) { nodes { id name } } @@ -42,9 +42,9 @@ exports[`AsyncSelect autocomplete > generates field without autocomplete explici label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect { + query CategorySelect { categories { nodes { id name } } diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectFilter.test.ts.snap b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectFilter.test.ts.snap index 8c7220d4f99..ef6d4e2b3b3 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectFilter.test.ts.snap +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectFilter.test.ts.snap @@ -36,12 +36,12 @@ import { MenuItem } from "@mui/material"; import { InputAdornment } from "@mui/material"; import { FormApi } from "final-form"; import { useMemo } from "react"; -import { GQLProductCategoriesSelectQuery } from "./ProductHighlightForm.generated"; -import { GQLProductCategoriesSelectQueryVariables } from "./ProductHighlightForm.generated"; +import { GQLProductCategorySelectQuery } from "./ProductHighlightForm.generated"; +import { GQLProductCategorySelectQueryVariables } from "./ProductHighlightForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { OnChangeField } from "@comet/admin"; -import { GQLProductsSelectQuery } from "./ProductHighlightForm.generated"; -import { GQLProductsSelectQueryVariables } from "./ProductHighlightForm.generated"; +import { GQLProductSelectQuery } from "./ProductHighlightForm.generated"; +import { GQLProductSelectQueryVariables } from "./ProductHighlightForm.generated"; import { productHighlightFormFragment } from "./ProductHighlightForm.gql"; import { GQLProductHighlightFormFragment } from "./ProductHighlightForm.gql.generated"; import { productHighlightQuery } from "./ProductHighlightForm.gql"; @@ -172,9 +172,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query ProductCategoriesSelect { + query ProductCategorySelect { productCategories { nodes { id title } } @@ -200,9 +200,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query ProductsSelect($filter: ProductFilter) { + query ProductSelect($filter: ProductFilter) { products(filter: $filter) { nodes { id title } } @@ -245,9 +245,9 @@ exports[`AsyncSelectFilter > generates filter with value dependent on other fiel label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($title: String) { + query CategorySelect($title: String) { categories(title: $title) { nodes { id name } } @@ -309,8 +309,8 @@ import { FormApi } from "final-form"; import { useMemo } from "react"; import { GQLCategoryType } from "@src/graphql.generated"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -443,9 +443,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($type: CategoryType) { + query CategorySelect($type: CategoryType) { categories(type: $type) { nodes { id name } } @@ -508,8 +508,8 @@ import { InputAdornment } from "@mui/material"; import { FormApi } from "final-form"; import { useMemo } from "react"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -642,9 +642,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($filter: CategoryFilter) { + query CategorySelect($filter: CategoryFilter) { categories(filter: $filter) { nodes { id name } } @@ -708,8 +708,8 @@ import { FormApi } from "final-form"; import { useMemo } from "react"; import { GQLCategoryType } from "@src/graphql.generated"; import { OnChangeField } from "@comet/admin"; -import { GQLCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncSelectField } from "@comet/admin"; import { productFormFragment } from "./ProductForm.gql"; import { GQLProductFormFragment } from "./ProductForm.gql.generated"; @@ -842,9 +842,9 @@ id?: string; label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect($filter: CategoryFilter) { + query CategorySelect($filter: CategoryFilter) { categories(filter: $filter) { nodes { id name } } diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectRenderedProps.test.ts.snap b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectRenderedProps.test.ts.snap index 17b4baf5065..dd549d60d55 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectRenderedProps.test.ts.snap +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/__snapshots__/asyncSelectRenderedProps.test.ts.snap @@ -11,9 +11,9 @@ exports[`AsyncSelect rendered props > generates readonly async select 1`] = ` label={} loadOptions={async () => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql\` - query CategoriesSelect { + query CategorySelect { categories { nodes { id name } } diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts index 0ff0416c0f6..fb08fc272f8 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts @@ -478,7 +478,7 @@ describe("AsyncSelect filter", () => { }); }); - it("uses custom queryName when provided", async () => { + it("uses field name for query name by default (multiple fields with same rootQuery)", async () => { const schema = buildSchema(` type Query { people(search: String): PeopleConnection! @@ -525,13 +525,11 @@ describe("AsyncSelect filter", () => { const hostFieldConfig: FormFieldConfig = { type: "asyncSelect", rootQuery: "people", - queryName: "HostSelect", name: "host", }; const guestFieldConfig: FormFieldConfig = { type: "asyncSelect", rootQuery: "people", - queryName: "GuestSelect", name: "guest", }; const formConfig: FormConfig = { @@ -557,9 +555,81 @@ describe("AsyncSelect filter", () => { gqlType: "Visit", }); + // Each field gets a unique query name derived from the field name, not rootQuery expect(hostOutput.code).toContain("HostSelect"); expect(hostOutput.code).not.toContain("PeopleSelect"); expect(guestOutput.code).toContain("GuestSelect"); expect(guestOutput.code).not.toContain("PeopleSelect"); }); + + it("uses custom queryName when provided", async () => { + const schema = buildSchema(` + type Query { + people(search: String): PeopleConnection! + } + + type PeopleConnection { + nodes: [Person!]! + } + + type Visit { + id: ID! + host: Person! + guest: Person! + } + type Person { + id: ID! + name: String! + } + + type Mutation { + createVisit(input: VisitInput!): Visit! + updateVisit(id: ID!, input: VisitInput!): Visit! + } + + input VisitInput { + host: ID + guest: ID + } + `); + type GQLPerson = { + __typename?: "Person"; + id: string; + name: string; + }; + type GQLVisit = { + __typename?: "Visit"; + id: string; + host: GQLPerson; + guest: GQLPerson; + }; + + const introspection = introspectionFromSchema(schema); + + const hostFieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + queryName: "VisitHostSelect", + name: "host", + }; + + const formConfig: FormConfig = { + type: "form", + gqlType: "Visit", + fields: [hostFieldConfig], + }; + + const hostOutput = generateFormField({ + gqlIntrospection: introspection, + baseOutputFilename: "VisitForm", + formFragmentName: "VisitFormFragment", + config: hostFieldConfig, + formConfig, + gqlType: "Visit", + }); + + // Custom queryName overrides the default field-name-based name + expect(hostOutput.code).toContain("VisitHostSelect"); + expect(hostOutput.code).not.toContain("query HostSelect"); + }); }); diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts index dc125052910..c4de9fcf90a 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts @@ -189,7 +189,7 @@ export function generateAsyncSelect({ } const rootQuery = config.rootQuery; //TODO we should infer a default value from the gql schema - const queryName = config.queryName ?? `${rootQuery[0].toUpperCase() + rootQuery.substring(1)}Select`; + const queryName = config.queryName ?? `${name[0].toUpperCase() + name.substring(1)}Select`; const rootQueryType = findQueryTypeOrThrow(rootQuery, gqlIntrospection); let formFragmentFields: string[]; From ecfd04ae202267d00d0bede67ec05db749e9a914 Mon Sep 17 00:00:00 2001 From: Niko Sams Date: Tue, 10 Mar 2026 09:51:10 +0100 Subject: [PATCH 4/7] Apply suggestion from @nsams --- .changeset/async-select-query-name.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/async-select-query-name.md b/.changeset/async-select-query-name.md index 70da20477a1..21a9210fa60 100644 --- a/.changeset/async-select-query-name.md +++ b/.changeset/async-select-query-name.md @@ -2,12 +2,10 @@ "@comet/admin-generator": minor --- -admin-generator asyncSelect: query name is now derived from field name instead of rootQuery +asyncSelect: query name is now derived from field name instead of rootQuery Previously the GraphQL operation name was derived from `rootQuery` (e.g. `rootQuery: "people"` → `PeopleSelect`). When multiple fields shared the same `rootQuery`, this produced duplicate operation names, causing errors in `gql:types`. The operation name is now derived from the **field name** (e.g. field `host` → `HostSelect`, field `guest` → `GuestSelect`). This ensures uniqueness across all fields in a form automatically, without any extra configuration. -**Breaking change:** Existing generated files will have different operation names after re-running the generator. Update any handwritten code that references the old query names. - An optional `queryName` setting was also added to both `asyncSelect` and `asyncSelectFilter` to manually override the generated operation name when needed. From e540d9f0fe784572851492c9e1f70c50a57c577f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:02:37 +0000 Subject: [PATCH 5/7] admin-generator asyncSelect: validate queryName against GraphQL operation name rules Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .../__tests__/asyncSelect.test.ts | 73 +++++++++++++++++++ .../asyncSelect/generateAsyncSelect.ts | 7 ++ 2 files changed, 80 insertions(+) diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts index fb08fc272f8..a9f9a7fcdae 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/__tests__/asyncSelect.test.ts @@ -632,4 +632,77 @@ describe("AsyncSelect filter", () => { expect(hostOutput.code).toContain("VisitHostSelect"); expect(hostOutput.code).not.toContain("query HostSelect"); }); + + it("throws error for empty queryName", () => { + const schema = buildSchema(` + type Query { + people: PeopleConnection! + } + type PeopleConnection { nodes: [Person!]! } + type Visit { id: ID! host: Person! } + type Person { id: ID! name: String! } + type Mutation { + createVisit(input: VisitInput!): Visit! + updateVisit(id: ID!, input: VisitInput!): Visit! + } + input VisitInput { host: ID } + `); + type GQLPerson = { __typename?: "Person"; id: string; name: string }; + type GQLVisit = { __typename?: "Visit"; id: string; host: GQLPerson }; + const introspection = introspectionFromSchema(schema); + const fieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + queryName: "" as any, + name: "host", + }; + const formConfig: FormConfig = { type: "form", gqlType: "Visit", fields: [fieldConfig] }; + expect(() => + generateFormField({ + gqlIntrospection: introspection, + baseOutputFilename: "VisitForm", + formFragmentName: "VisitFormFragment", + config: fieldConfig, + formConfig, + gqlType: "Visit", + }), + ).toThrow(/queryName/); + }); + + it("throws error for queryName with invalid characters", () => { + const schema = buildSchema(` + type Query { + people: PeopleConnection! + } + type PeopleConnection { nodes: [Person!]! } + type Visit { id: ID! host: Person! } + type Person { id: ID! name: String! } + type Mutation { + createVisit(input: VisitInput!): Visit! + updateVisit(id: ID!, input: VisitInput!): Visit! + } + input VisitInput { host: ID } + `); + type GQLPerson = { __typename?: "Person"; id: string; name: string }; + type GQLVisit = { __typename?: "Visit"; id: string; host: GQLPerson }; + const introspection = introspectionFromSchema(schema); + const fieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + queryName: "Invalid Name!", + name: "host", + }; + const formConfig: FormConfig = { type: "form", gqlType: "Visit", fields: [fieldConfig] }; + expect(() => + generateFormField({ + gqlIntrospection: introspection, + baseOutputFilename: "VisitForm", + formFragmentName: "VisitFormFragment", + config: fieldConfig, + formConfig, + gqlType: "Visit", + }), + ).toThrow(/queryName/); + }); }); diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts index c4de9fcf90a..2817fd8834d 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts @@ -189,6 +189,13 @@ export function generateAsyncSelect({ } const rootQuery = config.rootQuery; //TODO we should infer a default value from the gql schema + if (config.queryName !== undefined) { + if (config.queryName.length === 0 || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(config.queryName)) { + throw new Error( + `Field ${String(config.name)}: "queryName" must be a valid GraphQL operation name (non-empty, starting with a letter or underscore, containing only letters, digits, or underscores), got "${config.queryName}".`, + ); + } + } const queryName = config.queryName ?? `${name[0].toUpperCase() + name.substring(1)}Select`; const rootQueryType = findQueryTypeOrThrow(rootQuery, gqlIntrospection); From f27c2a6b4ddcf2b1da38790c9636e081da196885 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:30:18 +0000 Subject: [PATCH 6/7] demo/admin: regenerate admin-generator output with field-name-based query names Co-authored-by: johnnyomair <48853629+johnnyomair@users.noreply.github.com> --- .../generated/CreateCapProductForm.tsx | 8 +++---- .../generated/ProductCategoryForm.tsx | 8 +++---- .../generator/generated/ProductForm.tsx | 24 +++++++++---------- .../generated/ProductHighlightForm.tsx | 24 +++++++++---------- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/demo/admin/src/products/generator/generated/CreateCapProductForm.tsx b/demo/admin/src/products/generator/generated/CreateCapProductForm.tsx index 1973fe27152..bff3a753deb 100644 --- a/demo/admin/src/products/generator/generated/CreateCapProductForm.tsx +++ b/demo/admin/src/products/generator/generated/CreateCapProductForm.tsx @@ -20,8 +20,8 @@ import { FORM_ERROR } from "final-form"; import { GQLProductType } from "@src/graphql.generated"; import { DamImageBlock } from "@comet/cms-admin"; import { validateTitle } from "../validateTitle"; -import { GQLProductCategoriesSelectQuery } from "./CreateCapProductForm.generated"; -import { GQLProductCategoriesSelectQueryVariables } from "./CreateCapProductForm.generated"; +import { GQLCategorySelectQuery } from "./CreateCapProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./CreateCapProductForm.generated"; import { AsyncAutocompleteField } from "@comet/admin"; import { CalendarToday as CalendarTodayIcon } from "@comet/admin-icons"; import { Future_DatePickerField } from "@comet/admin"; @@ -135,9 +135,9 @@ export function CreateCapProductForm({ onCreate, type }: FormProps) { name="category" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductCategoriesSelect($search: String) { + query CategorySelect($search: String) { productCategories(search: $search) { nodes { id diff --git a/demo/admin/src/products/generator/generated/ProductCategoryForm.tsx b/demo/admin/src/products/generator/generated/ProductCategoryForm.tsx index 75b3af792ce..c77fc2876df 100644 --- a/demo/admin/src/products/generator/generated/ProductCategoryForm.tsx +++ b/demo/admin/src/products/generator/generated/ProductCategoryForm.tsx @@ -16,8 +16,8 @@ import { resolveHasSaveConflict } from "@comet/cms-admin"; import { useFormSaveConflict } from "@comet/cms-admin"; import { FormApi } from "final-form"; import { useMemo } from "react"; -import { GQLProductCategoryTypesSelectQuery } from "./ProductCategoryForm.generated"; -import { GQLProductCategoryTypesSelectQueryVariables } from "./ProductCategoryForm.generated"; +import { GQLTypeSelectQuery } from "./ProductCategoryForm.generated"; +import { GQLTypeSelectQueryVariables } from "./ProductCategoryForm.generated"; import { AsyncAutocompleteField } from "@comet/admin"; import { productCategoryFormFragment } from "./ProductCategoryForm.gql"; import { GQLProductCategoryFormFragment } from "./ProductCategoryForm.gql.generated"; @@ -130,9 +130,9 @@ export function ProductCategoryForm({ onCreate, id }: FormProps) { name="type" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductCategoryTypesSelect($search: String) { + query TypeSelect($search: String) { productCategoryTypes(search: $search) { nodes { id diff --git a/demo/admin/src/products/generator/generated/ProductForm.tsx b/demo/admin/src/products/generator/generated/ProductForm.tsx index b533678947b..89896170709 100644 --- a/demo/admin/src/products/generator/generated/ProductForm.tsx +++ b/demo/admin/src/products/generator/generated/ProductForm.tsx @@ -35,19 +35,19 @@ import { GQLFinalFormFileUploadDownloadableFragment } from "@comet/cms-admin"; import { validateProductSlug } from "../validateProductSlug"; import { Future_DatePickerField } from "@comet/admin"; import { SelectField } from "@comet/admin"; -import { GQLProductCategoriesSelectQuery } from "./ProductForm.generated"; -import { GQLProductCategoriesSelectQueryVariables } from "./ProductForm.generated"; +import { GQLCategorySelectQuery } from "./ProductForm.generated"; +import { GQLCategorySelectQueryVariables } from "./ProductForm.generated"; import { AsyncAutocompleteField } from "@comet/admin"; -import { GQLProductTagsSelectQuery } from "./ProductForm.generated"; -import { GQLProductTagsSelectQueryVariables } from "./ProductForm.generated"; +import { GQLTagsSelectQuery } from "./ProductForm.generated"; +import { GQLTagsSelectQueryVariables } from "./ProductForm.generated"; import { FinalFormSwitch } from "@comet/admin"; import { messages } from "@comet/admin"; import { FormControlLabel } from "@mui/material"; import { FieldSet } from "@comet/admin"; import { FormSpy } from "react-final-form"; import { Location as LocationIcon } from "@comet/admin-icons"; -import { GQLManufacturersSelectQuery } from "./ProductForm.generated"; -import { GQLManufacturersSelectQueryVariables } from "./ProductForm.generated"; +import { GQLManufacturerSelectQuery } from "./ProductForm.generated"; +import { GQLManufacturerSelectQueryVariables } from "./ProductForm.generated"; import { CalendarToday as CalendarTodayIcon } from "@comet/admin-icons"; import { FutureProductNotice } from "../../helpers/FutureProductNotice"; import { Future_DateTimePickerField as DateTimePickerField } from "@comet/admin"; @@ -360,9 +360,9 @@ export function ProductForm({ initialValues: passedInitialValues, onCreate, manu name="category" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductCategoriesSelect($search: String) { + query CategorySelect($search: String) { productCategories(search: $search) { nodes { id @@ -387,9 +387,9 @@ export function ProductForm({ initialValues: passedInitialValues, onCreate, manu name="tags" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductTagsSelect($search: String) { + query TagsSelect($search: String) { productTags(search: $search) { nodes { id @@ -476,9 +476,9 @@ export function ProductForm({ initialValues: passedInitialValues, onCreate, manu } loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ManufacturersSelect($search: String, $filter: ManufacturerFilter) { + query ManufacturerSelect($search: String, $filter: ManufacturerFilter) { manufacturers(search: $search, filter: $filter) { nodes { id diff --git a/demo/admin/src/products/generator/generated/ProductHighlightForm.tsx b/demo/admin/src/products/generator/generated/ProductHighlightForm.tsx index 65b1dff81f0..d4d24ce8f93 100644 --- a/demo/admin/src/products/generator/generated/ProductHighlightForm.tsx +++ b/demo/admin/src/products/generator/generated/ProductHighlightForm.tsx @@ -16,14 +16,14 @@ import { resolveHasSaveConflict } from "@comet/cms-admin"; import { useFormSaveConflict } from "@comet/cms-admin"; import { FormApi } from "final-form"; import { useMemo } from "react"; -import { GQLProductCategoryTypesSelectQuery } from "./ProductHighlightForm.generated"; -import { GQLProductCategoryTypesSelectQueryVariables } from "./ProductHighlightForm.generated"; +import { GQLProductCategoryTypeSelectQuery } from "./ProductHighlightForm.generated"; +import { GQLProductCategoryTypeSelectQueryVariables } from "./ProductHighlightForm.generated"; import { AsyncAutocompleteField } from "@comet/admin"; import { OnChangeField } from "@comet/admin"; -import { GQLProductCategoriesSelectQuery } from "./ProductHighlightForm.generated"; -import { GQLProductCategoriesSelectQueryVariables } from "./ProductHighlightForm.generated"; -import { GQLProductsSelectQuery } from "./ProductHighlightForm.generated"; -import { GQLProductsSelectQueryVariables } from "./ProductHighlightForm.generated"; +import { GQLProductCategorySelectQuery } from "./ProductHighlightForm.generated"; +import { GQLProductCategorySelectQueryVariables } from "./ProductHighlightForm.generated"; +import { GQLProductSelectQuery } from "./ProductHighlightForm.generated"; +import { GQLProductSelectQueryVariables } from "./ProductHighlightForm.generated"; import { productHighlightFormFragment } from "./ProductHighlightForm.gql"; import { GQLProductHighlightFormDetailsFragment } from "./ProductHighlightForm.gql.generated"; import { productHighlightQuery } from "./ProductHighlightForm.gql"; @@ -143,9 +143,9 @@ export function ProductHighlightForm({ onCreate, id }: FormProps) { name="productCategoryType" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductCategoryTypesSelect($search: String) { + query ProductCategoryTypeSelect($search: String) { productCategoryTypes(search: $search) { nodes { id @@ -169,9 +169,9 @@ export function ProductHighlightForm({ onCreate, id }: FormProps) { name="productCategory" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductCategoriesSelect($search: String, $filter: ProductCategoryFilter) { + query ProductCategorySelect($search: String, $filter: ProductCategoryFilter) { productCategories(search: $search, filter: $filter) { nodes { id @@ -204,9 +204,9 @@ export function ProductHighlightForm({ onCreate, id }: FormProps) { name="product" label={} loadOptions={async (search?: string) => { - const { data } = await client.query({ + const { data } = await client.query({ query: gql` - query ProductsSelect($search: String, $filter: ProductFilter) { + query ProductSelect($search: String, $filter: ProductFilter) { products(search: $search, filter: $filter) { nodes { id From c78105a1a29592486cfc405e0889c88b0908c26d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 08:50:37 +0000 Subject: [PATCH 7/7] admin-generator: extract isValidGraphQLOperationName into utility function Co-authored-by: nsams <50764+nsams@users.noreply.github.com> --- .../generate/generateForm/asyncSelect/generateAsyncSelect.ts | 3 ++- .../src/commands/generate/utils/isValidGraphQLOperationName.ts | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/admin/admin-generator/src/commands/generate/utils/isValidGraphQLOperationName.ts diff --git a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts index 2817fd8834d..5faa852c6b7 100644 --- a/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts +++ b/packages/admin/admin-generator/src/commands/generate/generateForm/asyncSelect/generateAsyncSelect.ts @@ -5,6 +5,7 @@ import { findQueryTypeOrThrow } from "../../utils/findQueryType"; import { generateGqlOperation } from "../../utils/generateGqlOperation"; import { type Imports } from "../../utils/generateImportsCode"; import { isFieldOptional } from "../../utils/isFieldOptional"; +import { isValidGraphQLOperationName } from "../../utils/isValidGraphQLOperationName"; import { buildFormFieldOptions } from "../formField/options"; import { findFieldByName, type GenerateFieldsReturn } from "../generateFields"; import { type Prop } from "../generateForm"; @@ -190,7 +191,7 @@ export function generateAsyncSelect({ const rootQuery = config.rootQuery; //TODO we should infer a default value from the gql schema if (config.queryName !== undefined) { - if (config.queryName.length === 0 || !/^[_A-Za-z][_0-9A-Za-z]*$/.test(config.queryName)) { + if (!isValidGraphQLOperationName(config.queryName)) { throw new Error( `Field ${String(config.name)}: "queryName" must be a valid GraphQL operation name (non-empty, starting with a letter or underscore, containing only letters, digits, or underscores), got "${config.queryName}".`, ); diff --git a/packages/admin/admin-generator/src/commands/generate/utils/isValidGraphQLOperationName.ts b/packages/admin/admin-generator/src/commands/generate/utils/isValidGraphQLOperationName.ts new file mode 100644 index 00000000000..21ca3be55f9 --- /dev/null +++ b/packages/admin/admin-generator/src/commands/generate/utils/isValidGraphQLOperationName.ts @@ -0,0 +1,3 @@ +export function isValidGraphQLOperationName(name: string): boolean { + return name.length > 0 && /^[_A-Za-z][_0-9A-Za-z]*$/.test(name); +}