diff --git a/.changeset/async-select-query-name.md b/.changeset/async-select-query-name.md new file mode 100644 index 00000000000..21a9210fa60 --- /dev/null +++ b/.changeset/async-select-query-name.md @@ -0,0 +1,11 @@ +--- +"@comet/admin-generator": minor +--- + +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. + +An optional `queryName` setting was also added to both `asyncSelect` and `asyncSelectFilter` to manually override the generated operation name when needed. 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 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..b476a0f990d 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 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; /** 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 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; /** Whether Autocomplete or Select should be used. * 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 58126bf9cb5..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 @@ -477,4 +477,232 @@ describe("AsyncSelect filter", () => { expect(formOutput.code).toMatchSnapshot(); }); }); + + it("uses field name for query name by default (multiple fields with same rootQuery)", 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", + name: "host", + }; + const guestFieldConfig: FormFieldConfig = { + type: "asyncSelect", + rootQuery: "people", + 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", + }); + + // 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"); + }); + + 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 9910fa46886..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"; @@ -189,7 +190,14 @@ 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`; + if (config.queryName !== undefined) { + 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}".`, + ); + } + } + const queryName = config.queryName ?? `${name[0].toUpperCase() + name.substring(1)}Select`; const rootQueryType = findQueryTypeOrThrow(rootQuery, gqlIntrospection); let formFragmentFields: string[]; 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); +}