Browse Source

feat(dashboard): Support custom fields in draft orders

Michael Bromley 9 months ago
parent
commit
6c1614704e

+ 11 - 0
packages/core/src/api/resolvers/admin/draft-order.resolver.ts

@@ -24,6 +24,7 @@ import {
     Permission,
     QueryEligibleShippingMethodsForDraftOrderArgs,
     ShippingMethodQuote,
+    MutationSetDraftOrderCustomFieldsArgs,
 } from '@vendure/common/lib/generated-types';
 
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
@@ -125,6 +126,16 @@ export class DraftOrderResolver {
         return this.orderService.removeItemFromOrder(ctx, args.orderId, args.orderLineId);
     }
 
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.CreateOrder)
+    async setDraftOrderCustomFields(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationSetDraftOrderCustomFieldsArgs,
+    ): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
+        return this.orderService.updateCustomFields(ctx, args.orderId, args.input.customFields ?? {});
+    }
+
     @Transaction()
     @Mutation()
     @Allow(Permission.CreateOrder)

+ 17 - 3
packages/dashboard/src/app/routes/_authenticated/_orders/components/edit-order-table.tsx

@@ -18,6 +18,8 @@ import { draftOrderEligibleShippingMethodsDocument, orderDetailDocument, orderLi
 import { MoneyGrossNet } from './money-gross-net.js';
 import { OrderTableTotals } from './order-table-totals.js';
 import { ShippingMethodSelector } from './shipping-method-selector.js';
+import { OrderLineCustomFieldsForm } from './order-line-custom-fields-form.js';
+import { UseFormReturn } from 'react-hook-form';
 
 type OrderFragment = NonNullable<ResultOf<typeof orderDetailDocument>['order']>;
 type OrderLineFragment = ResultOf<typeof orderLineFragment>;
@@ -28,14 +30,17 @@ export interface OrderTableProps {
     order: OrderFragment;
     eligibleShippingMethods: ShippingMethodQuote[];
     onAddItem: (event: { productVariantId: string; }) => void;
-    onAdjustLine: (event: { lineId: string; quantity: number }) => void;
+    onAdjustLine: (event: { lineId: string; quantity: number; customFields: Record<string, any> }) => void;
     onRemoveLine: (event: { lineId: string }) => void;
     onSetShippingMethod: (event: { shippingMethodId: string }) => void;
     onApplyCouponCode: (event: { couponCode: string }) => void;
     onRemoveCouponCode: (event: { couponCode: string }) => void;
+    orderLineForm: UseFormReturn<any>;
 }
 
-export function EditOrderTable({ order, eligibleShippingMethods, onAddItem, onAdjustLine, onRemoveLine, onSetShippingMethod, onApplyCouponCode, onRemoveCouponCode }: OrderTableProps) {
+export function EditOrderTable({ order, eligibleShippingMethods, onAddItem, onAdjustLine, onRemoveLine,
+    onSetShippingMethod, onApplyCouponCode, onRemoveCouponCode, orderLineForm }: OrderTableProps) {
+
     const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
     const [couponCode, setCouponCode] = useState('');
 
@@ -72,10 +77,19 @@ export function EditOrderTable({ order, eligibleShippingMethods, onAddItem, onAd
             accessorKey: 'quantity',
             cell: ({ row }) => {
                 return <div className="flex gap-2">
-                    <Input type="number" value={row.original.quantity} onChange={e => onAdjustLine({ lineId: row.original.id, quantity: e.target.valueAsNumber })} />
+                    <Input type="number" value={row.original.quantity} onChange={e => onAdjustLine({ lineId: row.original.id, quantity: e.target.valueAsNumber, customFields: row.original.customFields })} />
                     <Button variant="outline" size="icon" onClick={() => onRemoveLine({ lineId: row.original.id })}>
                         <Trash2 />
                     </Button>
+                    {row.original.customFields &&
+                        <OrderLineCustomFieldsForm onUpdate={(customFields) => {
+                            
+                            onAdjustLine({
+                                lineId: row.original.id,
+                                quantity: row.original.quantity,
+                                customFields: customFields
+                            });
+                        }} form={orderLineForm} />}
                 </div>;
             },
         },

+ 38 - 0
packages/dashboard/src/app/routes/_authenticated/_orders/components/order-line-custom-fields-form.tsx

@@ -0,0 +1,38 @@
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { Settings2 } from 'lucide-react';
+import { UseFormReturn } from 'react-hook-form';
+import { Form } from '@/components/ui/form.js';
+
+interface OrderLineCustomFieldsFormProps {
+    onUpdate: (customFieldValues: Record<string, any>) => void;
+    form: UseFormReturn<any>;
+}
+
+export function OrderLineCustomFieldsForm({ onUpdate, form }: OrderLineCustomFieldsFormProps) {
+    const onSubmit = (values: any) => {
+        onUpdate(values.input?.customFields);
+    };
+    
+    return (
+        <Popover>
+            <PopoverTrigger asChild>
+                <Button variant="ghost" size="icon">
+                    <Settings2 className="h-4 w-4" />
+                </Button>
+            </PopoverTrigger>
+            <PopoverContent className="w-80">
+                <Form {...form}>
+                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+                        <h4 className="font-medium leading-none">Custom Fields</h4>
+                        <CustomFieldsForm entityType="OrderLine" control={form.control} formPathPrefix='input' />
+                        <Button type="submit" className="w-full" disabled={!form.formState.isValid}>
+                            Update
+                        </Button>
+                    </form>
+                </Form>
+            </PopoverContent>
+        </Popover>
+    );
+}

+ 16 - 10
packages/dashboard/src/app/routes/_authenticated/_orders/orders.graphql.ts

@@ -163,6 +163,7 @@ export const orderLineFragment = graphql(
             linePriceWithTax
             discountedLinePrice
             discountedLinePriceWithTax
+            customFields
         }
     `,
     [assetFragment],
@@ -333,17 +334,14 @@ export const createDraftOrderDocument = graphql(`
     }
 `);
 
-export const deleteDraftOrderDocument = graphql(
-    `
-        mutation DeleteDraftOrder($orderId: ID!) {
-            deleteDraftOrder(orderId: $orderId) {
-                result
-                message
-            }
+export const deleteDraftOrderDocument = graphql(`
+    mutation DeleteDraftOrder($orderId: ID!) {
+        deleteDraftOrder(orderId: $orderId) {
+            result
+            message
         }
-    `,
-    [errorResultFragment],
-);
+    }
+`);
 
 export const addItemToDraftOrderDocument = graphql(
     `
@@ -489,6 +487,14 @@ export const setDraftOrderShippingMethodDocument = graphql(
     [errorResultFragment],
 );
 
+export const setDraftOrderCustomFieldsDocument = graphql(`
+    mutation SetDraftOrderCustomFields($orderId: ID!, $input: UpdateOrderInput!) {
+        setDraftOrderCustomFields(orderId: $orderId, input: $input) {
+            id
+        }
+    }
+`);
+
 export const transitionOrderToStateDocument = graphql(
     `
         mutation TransitionOrderToState($id: ID!, $state: String!) {

+ 96 - 10
packages/dashboard/src/app/routes/_authenticated/_orders/orders_.draft.$id.tsx

@@ -1,23 +1,25 @@
+import { ConfirmationDialog } from '@/components/shared/confirmation-dialog.js';
 import { CustomerSelector } from '@/components/shared/customer-selector.js';
 import { ErrorPage } from '@/components/shared/error-page.js';
 import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Button } from '@/components/ui/button.js';
+import { Form } from '@/components/ui/form.js';
 import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
-import { Page, PageActionBar, PageActionBarRight, PageBlock, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
+import { useGeneratedForm } from '@/framework/form-engine/use-generated-form.js';
+import { CustomFieldsPageBlock, Page, PageActionBar, PageActionBarRight, PageBlock, PageLayout, PageTitle } from '@/framework/layout-engine/page-layout.js';
 import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
 import { api } from '@/graphql/api.js';
 import { Trans, useLingui } from '@/lib/trans.js';
 import { useMutation, useQuery } from '@tanstack/react-query';
 import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
 import { ResultOf } from 'gql.tada';
-import { User, Trash2 } from 'lucide-react';
+import { User } from 'lucide-react';
 import { toast } from 'sonner';
 import { CustomerAddressSelector } from './components/customer-address-selector.js';
 import { EditOrderTable } from './components/edit-order-table.js';
 import { OrderAddress } from './components/order-address.js';
-import { addItemToDraftOrderDocument, adjustDraftOrderLineDocument, applyCouponCodeToDraftOrderDocument, draftOrderEligibleShippingMethodsDocument, orderDetailDocument, removeCouponCodeFromDraftOrderDocument, removeDraftOrderLineDocument, setBillingAddressForDraftOrderDocument, setCustomerForDraftOrderDocument, setDraftOrderShippingMethodDocument, setShippingAddressForDraftOrderDocument, transitionOrderToStateDocument, unsetBillingAddressForDraftOrderDocument, unsetShippingAddressForDraftOrderDocument } from './orders.graphql.js';
-import { Input } from '@/components/ui/input.js';
-import { useState } from 'react';
+import { addItemToDraftOrderDocument, adjustDraftOrderLineDocument, applyCouponCodeToDraftOrderDocument, deleteDraftOrderDocument, draftOrderEligibleShippingMethodsDocument, orderDetailDocument, removeCouponCodeFromDraftOrderDocument, removeDraftOrderLineDocument, setBillingAddressForDraftOrderDocument, setCustomerForDraftOrderDocument, setDraftOrderCustomFieldsDocument, setDraftOrderShippingMethodDocument, setShippingAddressForDraftOrderDocument, transitionOrderToStateDocument, unsetBillingAddressForDraftOrderDocument, unsetShippingAddressForDraftOrderDocument } from './orders.graphql.js';
+import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
 
 export const Route = createFileRoute('/_authenticated/_orders/orders_/draft/$id')({
     component: DraftOrderPage,
@@ -56,8 +58,8 @@ function DraftOrderPage() {
     const { i18n } = useLingui();
     const navigate = useNavigate();
 
-    const { entity, refreshEntity } = useDetailPage({
-        queryDocument: orderDetailDocument,
+    const { entity, refreshEntity, form } = useDetailPage({
+        queryDocument: addCustomFields(orderDetailDocument),
         setValuesForUpdate: entity => {
             return {
                 id: entity.id,
@@ -67,6 +69,44 @@ function DraftOrderPage() {
         params: { id: params.id },
     });
 
+    const { form: orderLineForm } = useGeneratedForm({
+        document: addCustomFields(adjustDraftOrderLineDocument),
+        varName: undefined,
+        entity: entity?.lines[0],
+        setValues: entity => {
+            return {
+                orderId: entity.id,
+                input: {
+                    quantity: entity.quantity,
+                    orderLineId: entity.id,
+                    customFields: entity.customFields,
+                }
+            };
+        },
+    });
+
+    const { form: orderCustomFieldsForm } = useGeneratedForm({
+        document: setDraftOrderCustomFieldsDocument,
+        varName: undefined,
+        entity: entity,
+        setValues: entity => {
+            return {
+                orderId: entity.id,
+                input: {
+                    id: entity.id,
+                    customFields: entity.customFields,
+                }
+            };
+        },
+    });
+
+    const { mutate: setDraftOrderCustomFields } = useMutation({
+        mutationFn: api.mutate(setDraftOrderCustomFieldsDocument),
+        onSuccess: (result: ResultOf<typeof setDraftOrderCustomFieldsDocument>) => {
+            refreshEntity();
+        },
+    });
+
     const { data: eligibleShippingMethods } = useQuery({
         queryKey: ['eligibleShippingMethods', entity?.id],
         queryFn: () => api.query(draftOrderEligibleShippingMethodsDocument, { orderId: entity?.id ?? '' }),
@@ -185,7 +225,6 @@ function DraftOrderPage() {
         },
     });
 
-    // coupon code
     const { mutate: setCouponCodeForDraftOrder } = useMutation({
         mutationFn: api.mutate(applyCouponCodeToDraftOrderDocument),
         onSuccess: (result: ResultOf<typeof applyCouponCodeToDraftOrderDocument>) => {
@@ -230,15 +269,45 @@ function DraftOrderPage() {
         },
     });
 
+    const { mutate: deleteDraftOrder } = useMutation({
+        mutationFn: api.mutate(deleteDraftOrderDocument),
+        onSuccess: (result: ResultOf<typeof deleteDraftOrderDocument>) => {
+            if (result.deleteDraftOrder.result === 'DELETED') {
+                toast.success(i18n.t('Draft order deleted'));
+                navigate({ to: '/orders' });
+            } else {
+                toast.error(result.deleteDraftOrder.message);
+            }
+        },
+    });
+
     if (!entity) {
         return null;
     }
 
+    const onSaveCustomFields = (values: any) => {
+        setDraftOrderCustomFields({ input: { id: entity.id, customFields: values.input?.customFields }, orderId: entity.id });
+    }
+
     return (
-        <Page pageId="order-detail">
+        <Page pageId="draft-order-detail" form={form}>
             <PageTitle><Trans>Draft order</Trans>: {entity?.code ?? ''}</PageTitle>
             <PageActionBar>
                 <PageActionBarRight>
+                    <PermissionGuard requires={['DeleteOrder']}>
+                        <ConfirmationDialog
+                            title={i18n.t('Delete draft order')}
+                            description={i18n.t('Are you sure you want to delete this draft order?')}
+                            onConfirm={() => {
+                                deleteDraftOrder({ orderId: entity.id });
+                            }}
+                        >
+                            <Button variant="destructive">
+                                <Trans>Delete draft</Trans>
+                            </Button>
+                        </ConfirmationDialog>
+
+                    </PermissionGuard>
                     <PermissionGuard requires={['UpdateOrder']}>
                         <Button type="submit"
                             disabled={!entity.customer || entity.lines.length === 0 || entity.shippingLines.length === 0 || entity.state !== 'Draft'}
@@ -255,12 +324,29 @@ function DraftOrderPage() {
                         eligibleShippingMethods={eligibleShippingMethods?.eligibleShippingMethodsForDraftOrder ?? []}
                         onSetShippingMethod={(e) => setShippingMethodForDraftOrder({ orderId: entity.id, shippingMethodId: e.shippingMethodId })}
                         onAddItem={(e) => addItemToDraftOrder({ orderId: entity.id, input: { productVariantId: e.productVariantId, quantity: 1 } })}
-                        onAdjustLine={(e) => adjustDraftOrderLine({ orderId: entity.id, input: { orderLineId: e.lineId, quantity: e.quantity } })}
+                        onAdjustLine={(e) => adjustDraftOrderLine({ orderId: entity.id, input: { orderLineId: e.lineId, quantity: e.quantity, customFields: e.customFields } as any })}
                         onRemoveLine={(e) => removeDraftOrderLine({ orderId: entity.id, orderLineId: e.lineId })}
                         onApplyCouponCode={(e) => setCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
                         onRemoveCouponCode={(e) => removeCouponCodeForDraftOrder({ orderId: entity.id, couponCode: e.couponCode })}
+                        orderLineForm={orderLineForm}
                     />
                 </PageBlock>
+                <PageBlock column="main" blockId="order-custom-fields" title={<Trans>Custom fields</Trans>}>
+                    <Form {...orderCustomFieldsForm}>
+                        <CustomFieldsForm entityType="Order" control={orderCustomFieldsForm.control} formPathPrefix='input' />
+                        <div className="mt-4">
+                            <Button type="submit" className=""
+                                disabled={!orderCustomFieldsForm.formState.isValid || !orderCustomFieldsForm.formState.isDirty}
+                                onClick={(e) => {
+                                    e.preventDefault();
+                                    e.stopPropagation();
+                                    orderCustomFieldsForm.handleSubmit(onSaveCustomFields)();
+                                }}>
+                                <Trans>Set custom fields</Trans>
+                            </Button>
+                        </div>
+                    </Form>
+                </PageBlock>
                 <PageBlock column="side" blockId="customer" title={<Trans>Customer</Trans>}>
                     {entity?.customer?.id ? <Button variant="ghost" asChild className="mb-4">
                         <Link to={`/customers/${entity?.customer?.id}`}>

+ 4 - 3
packages/dashboard/src/lib/components/shared/custom-fields-form.tsx

@@ -22,9 +22,10 @@ type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
 interface CustomFieldsFormProps {
     entityType: string;
     control: Control<any, any>;
+    formPathPrefix?: string;
 }
 
-export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps) {
+export function CustomFieldsForm({ entityType, control, formPathPrefix }: CustomFieldsFormProps) {
     const {
         settings: { displayLanguage },
     } = useUserSettings();
@@ -39,7 +40,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
                     {fieldDef.type === 'localeString' || fieldDef.type === 'localeText' ? (
                         <TranslatableFormField
                             control={control}
-                            name={`customFields.${fieldDef.name}`}
+                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
                             render={({ field }) => (
                                 <FormItem>
                                     <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
@@ -52,7 +53,7 @@ export function CustomFieldsForm({ entityType, control }: CustomFieldsFormProps)
                     ) : (
                         <FormField
                             control={control}
-                            name={`customFields.${fieldDef.name}`}
+                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
                             render={({ field }) => (
                             <FormItem>
                                 <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>

+ 113 - 3
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.spec.ts

@@ -1,7 +1,7 @@
 import { graphql } from 'gql.tada';
 import { describe, expect, it, vi } from 'vitest';
 
-import { getListQueryFields } from './get-document-structure.js';
+import { getListQueryFields, getOperationVariablesFields } from './get-document-structure.js';
 
 vi.mock('virtual:admin-api-schema', () => {
     return {
@@ -12,7 +12,10 @@ vi.mock('virtual:admin-api-schema', () => {
                     product: ['Product', false, false, false],
                     collection: ['Collection', false, false, false],
                 },
-                Mutation: {},
+                Mutation: {
+                    updateProduct: ['Product', false, false, false],
+                    adjustDraftOrderLine: ['Order', false, false, false],
+                },
 
                 Collection: {
                     id: ['ID', false, false, false],
@@ -126,8 +129,25 @@ vi.mock('virtual:admin-api-schema', () => {
                     languageCode: ['LanguageCode', false, false, false],
                     name: ['String', false, false, false],
                 },
+                Order: {
+                    id: ['ID', false, false, false],
+                    lines: ['OrderLine', false, true, false],
+                },
+                OrderLine: {
+                    id: ['ID', false, false, false],
+                    quantity: ['Int', false, false, false],
+                },
+            },
+            inputs: {
+                UpdateProductInput: {
+                    id: ['ID', false, false, false],
+                    name: ['String', false, false, false],
+                },
+                AdjustDraftOrderLineInput: {
+                    orderLineId: ['ID', false, false, false],
+                    quantity: ['Int', false, false, false],
+                },
             },
-            inputs: {},
             scalars: ['ID', 'String', 'Int', 'Boolean', 'Float', 'JSON', 'DateTime', 'Upload', 'Money'],
             enums: {},
         },
@@ -308,3 +328,93 @@ describe('getListQueryFields', () => {
         ]);
     });
 });
+
+describe('getOperationVariablesFields', () => {
+    it('should extract fields from a simple mutation', () => {
+        const doc = graphql(`
+            mutation UpdateProduct($input: UpdateProductInput!) {
+                updateProduct(input: $input) {
+                    ...ProductDetail
+                }
+            }
+
+            fragment ProductDetail on Product {
+                id
+                name
+            }
+        `);
+
+        const fields = getOperationVariablesFields(doc, 'input');
+        expect(fields).toEqual([
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'id',
+                nullable: false,
+                type: 'ID',
+                typeInfo: undefined,
+            },
+            {
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                name: 'name',
+                nullable: false,
+                type: 'String',
+                typeInfo: undefined,
+            },
+        ]);
+    });
+
+    it('should handle a mutation with a nested input', () => {
+        const doc = graphql(`
+            mutation AdjustDraftOrderLine($orderId: ID!, $input: AdjustDraftOrderLineInput!) {
+                adjustDraftOrderLine(orderId: $orderId, input: $input) {
+                    id
+                }
+            }
+        `);
+
+        const fields = getOperationVariablesFields(doc, undefined);
+        expect(fields).toEqual([
+            {
+                name: 'orderId',
+                isPaginatedList: false,
+                isScalar: true,
+                list: false,
+                nullable: false,
+                type: 'ID',
+                typeInfo: undefined,
+            },
+            {
+                name: 'input',
+                isPaginatedList: false,
+                isScalar: false,
+                list: false,
+                nullable: true,
+                type: 'AdjustDraftOrderLineInput',
+                typeInfo: [
+                    {
+                        name: 'orderLineId',
+                        isPaginatedList: false,
+                        isScalar: true,
+                        list: false,
+                        nullable: false,
+                        type: 'ID',
+                        typeInfo: undefined,
+                    },
+                    {
+                        name: 'quantity',
+                        isPaginatedList: false,
+                        isScalar: true,
+                        list: false,
+                        nullable: false,
+                        type: 'Int',
+                        typeInfo: undefined,
+                    },
+                ],
+            },
+        ]);
+    });
+});

+ 70 - 11
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts

@@ -1,3 +1,5 @@
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { VariablesOf } from 'gql.tada';
 import {
     DocumentNode,
     OperationDefinitionNode,
@@ -146,7 +148,10 @@ function findNestedPaginatedLists(
  *
  * The operation variables fields are the fields of the `UpdateProductInput` type.
  */
-export function getOperationVariablesFields(documentNode: DocumentNode): FieldInfo[] {
+export function getOperationVariablesFields<T extends TypedDocumentNode<any, any>>(
+    documentNode: T,
+    varName?: keyof VariablesOf<T>,
+): FieldInfo[] {
     const fields: FieldInfo[] = [];
 
     const operationDefinition = documentNode.definitions.find(
@@ -154,11 +159,31 @@ export function getOperationVariablesFields(documentNode: DocumentNode): FieldIn
     );
 
     if (operationDefinition?.variableDefinitions) {
-        operationDefinition.variableDefinitions.forEach(variable => {
-            const unwrappedType = unwrapVariableDefinitionType(variable.type);
-            const inputName = unwrappedType.name.value;
-            const inputFields = getInputTypeInfo(inputName);
-            fields.push(...inputFields);
+        const variableDefinitions = varName
+            ? operationDefinition.variableDefinitions.filter(
+                  variable => variable.variable.name.value === varName,
+              )
+            : operationDefinition.variableDefinitions;
+        variableDefinitions.forEach(variableDef => {
+            const unwrappedType = unwrapVariableDefinitionType(variableDef.type);
+            const isScalar = isScalarType(unwrappedType.name.value);
+            const fieldName = variableDef.variable.name.value;
+            const typeName = unwrappedType.name.value;
+            const inputTypeInfo = isScalar
+                ? {
+                      name: fieldName,
+                      type: typeName,
+                      nullable: false,
+                      list: false,
+                      isScalar: true,
+                      isPaginatedList: false,
+                  }
+                : getInputTypeInfo(fieldName, typeName);
+            if (varName && inputTypeInfo?.name === varName) {
+                fields.push(...(inputTypeInfo.typeInfo ?? []));
+            } else {
+                fields.push(inputTypeInfo);
+            }
         });
     }
 
@@ -331,7 +356,9 @@ export function getOperationTypeInfo(
     if (definitionNode.kind === 'OperationDefinition') {
         const firstSelection = definitionNode?.selectionSet.selections[0];
         if (firstSelection?.kind === 'Field') {
-            return getQueryInfo(firstSelection.name.value);
+            return definitionNode.operation === 'query'
+                ? getQueryInfo(firstSelection.name.value)
+                : getMutationInfo(firstSelection.name.value);
         }
     }
     if (definitionNode.kind === 'Field' && parentTypeName) {
@@ -349,7 +376,7 @@ export function getTypeFieldInfo(typeName: string): FieldInfo[] {
             }
             return fieldInfo;
         })
-        .filter(x => x != null) as FieldInfo[];
+        .filter(x => x != null);
 }
 
 function getQueryInfo(name: string): FieldInfo {
@@ -364,8 +391,40 @@ function getQueryInfo(name: string): FieldInfo {
     };
 }
 
-function getInputTypeInfo(name: string): FieldInfo[] {
-    return Object.entries(schemaInfo.inputs[name]).map(([fieldName, fieldInfo]: [string, any]) => {
+function getMutationInfo(name: string): FieldInfo {
+    const fieldInfo = schemaInfo.types.Mutation[name];
+    return {
+        name,
+        type: fieldInfo[0],
+        nullable: fieldInfo[1],
+        list: fieldInfo[2],
+        isPaginatedList: fieldInfo[3],
+        isScalar: schemaInfo.scalars.includes(fieldInfo[0]),
+    };
+}
+
+function getInputTypeInfo(name: string, type: string): FieldInfo {
+    const fieldInfo = schemaInfo.inputs[type];
+    if (!fieldInfo) {
+        throw new Error(`Input type ${type} not found`);
+    }
+    return {
+        name,
+        type,
+        nullable: true,
+        list: false,
+        isPaginatedList: false,
+        isScalar: false,
+        typeInfo: getInputTypeFields(type),
+    };
+}
+
+function getInputTypeFields(name: string): FieldInfo[] {
+    const inputType = schemaInfo.inputs[name];
+    if (!inputType) {
+        throw new Error(`Input type ${name} not found`);
+    }
+    return Object.entries(inputType).map(([fieldName, fieldInfo]: [string, any]) => {
         const type = fieldInfo[0];
         const isScalar = isScalarType(type);
         const isEnum = isEnumType(type);
@@ -376,7 +435,7 @@ function getInputTypeInfo(name: string): FieldInfo[] {
             list: fieldInfo[2],
             isPaginatedList: fieldInfo[3],
             isScalar,
-            typeInfo: !isScalar && !isEnum ? getInputTypeInfo(type) : undefined,
+            typeInfo: !isScalar && !isEnum ? getInputTypeFields(type) : undefined,
         };
     });
 }

+ 8 - 7
packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx

@@ -13,13 +13,14 @@ import { useForm } from 'react-hook-form';
 
 export interface GeneratedFormOptions<
     T extends TypedDocumentNode<any, any>,
-    VarName extends keyof VariablesOf<T> = 'input',
+    VarName extends (keyof VariablesOf<T>) | undefined = 'input',
     E extends Record<string, any> = Record<string, any>,
 > {
     document?: T;
+    varName?: VarName;
     entity: E | null | undefined;
-    setValues: (entity: NonNullable<E>) => VariablesOf<T>[VarName];
-    onSubmit?: (values: VariablesOf<T>[VarName]) => void;
+    setValues: (entity: NonNullable<E>) => VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>;
+    onSubmit?: (values: VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>) => void;
 }
 
 /**
@@ -31,13 +32,13 @@ export interface GeneratedFormOptions<
  */
 export function useGeneratedForm<
     T extends TypedDocumentNode<any, any>,
-    VarName extends keyof VariablesOf<T> = 'input',
+    VarName extends keyof VariablesOf<T> | undefined,
     E extends Record<string, any> = Record<string, any>,
 >(options: GeneratedFormOptions<T, VarName, E>) {
-    const { document, entity, setValues, onSubmit } = options;
+    const { document, entity, setValues, onSubmit, varName } = options;
     const { activeChannel } = useChannel();
     const availableLanguages = useServerConfig()?.availableLanguages || [];
-    const updateFields = document ? getOperationVariablesFields(document) : [];
+    const updateFields = document ? getOperationVariablesFields(document, varName) : [];
     const schema = createFormSchemaFromFields(updateFields);
     const defaultValues = getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode);
     const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
@@ -58,7 +59,7 @@ export function useGeneratedForm<
     };
     if (onSubmit) {
         submitHandler = (event: FormEvent) => {
-            form.handleSubmit(onSubmit)(event);
+            form.handleSubmit(onSubmit as any)(event);
         };
     }
 

+ 1 - 0
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -195,6 +195,7 @@ export function useDetailPage<
     const document = isNew ? (createDocument ?? updateDocument) : updateDocument;
     const { form, submitHandler } = useGeneratedForm({
         document,
+        varName: 'input',
         entity,
         setValues: setValuesForUpdate,
         onSubmit(values: any) {