فهرست منبع

feat(dashboard): Add custom fields support to data layer & list view

Michael Bromley 10 ماه پیش
والد
کامیت
a8c61aaec8

+ 43 - 18
packages/dashboard/src/components/shared/paginated-list-data-table.tsx

@@ -3,7 +3,8 @@ import { DataTable } from '@/components/data-table/data-table.js';
 import { useComponentRegistry } from '@/framework/component-registry/component-registry.js';
 import { useComponentRegistry } from '@/framework/component-registry/component-registry.js';
 import {
 import {
     FieldInfo,
     FieldInfo,
-    getQueryName
+    getQueryName,
+    getTypeFieldInfo,
 } from '@/framework/document-introspection/get-document-structure.js';
 } from '@/framework/document-introspection/get-document-structure.js';
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { useListQueryFields } from '@/framework/document-introspection/hooks.js';
 import { api } from '@/graphql/api.js';
 import { api } from '@/graphql/api.js';
@@ -124,7 +125,15 @@ export function PaginatedListDataTable<
             const transformedVariables = transformVariables ? transformVariables(variables) : variables;
             const transformedVariables = transformVariables ? transformVariables(variables) : variables;
             return api.query(listQuery, transformedVariables);
             return api.query(listQuery, transformedVariables);
         },
         },
-        queryKey: ['PaginatedListDataTable', listQuery, page, itemsPerPage, sorting, filter, debouncedSearchTerm],
+        queryKey: [
+            'PaginatedListDataTable',
+            listQuery,
+            page,
+            itemsPerPage,
+            sorting,
+            filter,
+            debouncedSearchTerm,
+        ],
     });
     });
 
 
     const fields = useListQueryFields(listQuery);
     const fields = useListQueryFields(listQuery);
@@ -132,26 +141,42 @@ export function PaginatedListDataTable<
     const columnHelper = createColumnHelper();
     const columnHelper = createColumnHelper();
 
 
     const columns = useMemo(() => {
     const columns = useMemo(() => {
-        return fields.map(field => {
-            const customConfig = customizeColumns?.[field.name as keyof ListQueryFields<T>] ?? {};
+        const columnConfigs: Array<{ fieldInfo: FieldInfo; isCustomField: boolean }> = [];
+
+        columnConfigs.push(
+            ...fields // Filter out custom fields
+                .filter(field => field.name !== 'customFields' && !field.type.endsWith('CustomFields'))
+                .map(field => ({ fieldInfo: field, isCustomField: false })),
+        );
+        
+        const customFieldColumn = fields.find(field => field.name === 'customFields');
+        if (customFieldColumn) {
+            const customFieldFields = getTypeFieldInfo(customFieldColumn.type);
+            columnConfigs.push(
+                ...customFieldFields.map(field => ({ fieldInfo: field, isCustomField: true })),
+            );
+        }
+
+        return columnConfigs.map(({ fieldInfo, isCustomField }) => {
+            const customConfig = customizeColumns?.[fieldInfo.name as keyof ListQueryFields<T>] ?? {};
             const { header, ...customConfigRest } = customConfig;
             const { header, ...customConfigRest } = customConfig;
-            return columnHelper.accessor(field.name as any, {
-                meta: { field },
-                enableColumnFilter: field.isScalar,
-                enableSorting: field.isScalar,
-                cell: ({ cell }) => {
-                    const value = cell.getValue();
-                    if (field.list && Array.isArray(value)) {
+            return columnHelper.accessor(fieldInfo.name as any, {
+                meta: { fieldInfo, isCustomField },
+                enableColumnFilter: fieldInfo.isScalar,
+                enableSorting: fieldInfo.isScalar,
+                cell: ({ cell, row }) => {
+                    const value = !isCustomField ? cell.getValue() : (row.original as any)?.customFields?.[fieldInfo.name];
+                    if (fieldInfo.list && Array.isArray(value)) {
                         return value.join(', ');
                         return value.join(', ');
                     }
                     }
-                    if ((field.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
-                        return <Delegate component='dateTime.display' value={value} />
+                    if ((fieldInfo.type === 'DateTime' && typeof value === 'string') || value instanceof Date) {
+                        return <Delegate component="dateTime.display" value={value} />;
                     }
                     }
-                    if (field.type === 'Boolean') {
-                        return <Delegate component='boolean.display' value={value} />
+                    if (fieldInfo.type === 'Boolean') {
+                        return <Delegate component="boolean.display" value={value} />;
                     }
                     }
-                    if (field.type === 'Asset') {
-                        return <Delegate component='asset.display' value={value} />
+                    if (fieldInfo.type === 'Asset') {
+                        return <Delegate component="asset.display" value={value} />;
                     }
                     }
                     if (value !== null && typeof value === 'object') {
                     if (value !== null && typeof value === 'object') {
                         return JSON.stringify(value);
                         return JSON.stringify(value);
@@ -205,4 +230,4 @@ function getColumnVisibility(
         ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
         ...(allDefaultsFalse ? { ...Object.fromEntries(fields.map(f => [f.name, true])) } : {}),
         ...defaultVisibility,
         ...defaultVisibility,
     };
     };
-} 
+}

+ 242 - 0
packages/dashboard/src/framework/document-introspection/add-custom-fields.spec.ts

@@ -0,0 +1,242 @@
+import { CustomFieldConfig, CustomFields } from '@vendure/common/lib/generated-types';
+import { graphql } from 'gql.tada';
+import {
+    DocumentNode,
+    FieldNode,
+    FragmentDefinitionNode,
+    Kind,
+    OperationDefinitionNode,
+    print,
+} from 'graphql';
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import { addCustomFields } from './add-custom-fields.js';
+
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+describe('addCustomFields()', () => {
+    /**
+     * Normalizes the indentation of a string to make it easier to compare with the expected output
+     */
+    function normalizeIndentation(str: string): string {
+        const lines = str.replace(/    /g, '  ').split('\n');
+        const indentLength = lines[1].search(/\S|$/); // Find the first non-whitespace character
+        return lines
+            .map(line => line.slice(indentLength))
+            .join('\n')
+            .trim()
+            .replace(/"/g, '');
+    }
+
+    describe('Query handling', () => {
+        it('Adds customFields to entity query', () => {
+            const documentNode = graphql(`
+                query GetProduct {
+                    product {
+                        id
+                        name
+                    }
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+            const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
+
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                query GetProduct {
+                    product {
+                        id
+                        name
+                        customFields {
+                            custom1
+                            custom2
+                        }
+                    }
+                }
+            `),
+            );
+        });
+
+        it('Adds customFields to paginated list', () => {
+            const documentNode = graphql(`
+                query GetProducts {
+                    products {
+                        items {
+                            id
+                            name
+                        }
+                    }
+                }
+            `);
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+            const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
+            expect(print(result)).toBe(
+                normalizeIndentation(`
+                query GetProducts {
+                    products {
+                        items {
+                            id
+                            name
+                            customFields {
+                                custom1
+                                custom2
+                            }
+                        }
+                    }
+                }
+            `),
+            );
+        });
+    });
+
+    describe('Fragment handling', () => {
+        const productWithVariantsFragment = graphql(`
+            fragment ProductWithVariants on Product {
+                id
+                translations {
+                    languageCode
+                    name
+                }
+            }
+        `);
+
+        const productVariantFragment = graphql(`
+            fragment ProductVariant on ProductVariant {
+                id
+            }
+        `);
+
+        const productOptionGroupFragment = graphql(`
+            fragment ProductOptionGroup on ProductOptionGroup {
+                id
+            }
+        `);
+
+        const productOptionFragment = graphql(`
+            fragment ProductOption on ProductOption {
+                id
+            }
+        `);
+
+        const userFragment = graphql(`
+            fragment User on User {
+                id
+            }
+        `);
+
+        const customerFragment = graphql(`
+            fragment Customer on Customer {
+                id
+            }
+        `);
+
+        const addressFragment = graphql(`
+            fragment Address on Address {
+                id
+            }
+        `);
+
+        let documentNode: DocumentNode;
+
+        beforeEach(() => {
+            documentNode = graphql(
+                `
+                    query GetProductWithVariants {
+                        product {
+                            ...ProductWithVariants
+                        }
+                    }
+                `,
+                [productWithVariantsFragment],
+            );
+        });
+
+        it('Adds customFields to Product fragment', () => {
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ]);
+
+            const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
+            const productFragmentDef = result.definitions[1] as FragmentDefinitionNode;
+            const customFieldsDef = productFragmentDef.selectionSet.selections[2] as FieldNode;
+            expect(productFragmentDef.selectionSet.selections.length).toBe(3);
+            expect(customFieldsDef.selectionSet!.selections.length).toBe(2);
+            expect((customFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe('custom1');
+            expect((customFieldsDef.selectionSet!.selections[1] as FieldNode).name.value).toBe('custom2');
+        });
+
+        it('Adds customFields to Product translations', () => {
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [
+                { name: 'customLocaleString', type: 'localeString', list: false },
+            ]);
+
+            const result = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
+            const productFragmentDef = result.definitions[1] as FragmentDefinitionNode;
+            const translationsField = productFragmentDef.selectionSet.selections[1] as FieldNode;
+            const customTranslationFieldsDef = translationsField.selectionSet!.selections[2] as FieldNode;
+            expect(translationsField.selectionSet!.selections.length).toBe(3);
+            expect((customTranslationFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe(
+                'customLocaleString',
+            );
+        });
+
+        function addsCustomFieldsToType(type: keyof CustomFields, fragment: DocumentNode) {
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set(type, [{ name: 'custom', type: 'boolean', list: false }]);
+
+            const result = addCustomFields(fragment, { customFieldsMap: customFieldsConfig });
+            const fragmentDef = result.definitions[0] as FragmentDefinitionNode;
+            const customFieldsDef = fragmentDef.selectionSet.selections[1] as FieldNode;
+            expect(fragmentDef.selectionSet.selections.length).toBe(2);
+            expect(customFieldsDef.selectionSet!.selections.length).toBe(1);
+            expect((customFieldsDef.selectionSet!.selections[0] as FieldNode).name.value).toBe('custom');
+        }
+
+        it('Does not duplicate customFields selection set', () => {
+            const customFieldsConfig = new Map<string, CustomFieldConfig[]>();
+            customFieldsConfig.set('Product', [{ name: 'custom', type: 'boolean', list: false }]);
+            const result1 = addCustomFields(documentNode, { customFieldsMap: customFieldsConfig });
+            const result2 = addCustomFields(result1, { customFieldsMap: customFieldsConfig });
+
+            const fragmentDef = result2.definitions[1] as FragmentDefinitionNode;
+            const customFieldSelections = fragmentDef.selectionSet.selections.filter(
+                s => s.kind === Kind.FIELD && s.name.value === 'customFields',
+            );
+            expect(customFieldSelections.length).toBe(1);
+        });
+
+        it('Adds customFields to ProductVariant fragment', () => {
+            addsCustomFieldsToType('ProductVariant', productVariantFragment);
+        });
+
+        it('Adds customFields to ProductOptionGroup fragment', () => {
+            addsCustomFieldsToType('ProductOptionGroup', productOptionGroupFragment);
+        });
+
+        it('Adds customFields to ProductOption fragment', () => {
+            addsCustomFieldsToType('ProductOption', productOptionFragment);
+        });
+
+        it('Adds customFields to User fragment', () => {
+            addsCustomFieldsToType('User', userFragment);
+        });
+
+        it('Adds customFields to Customer fragment', () => {
+            addsCustomFieldsToType('Customer', customerFragment);
+        });
+
+        it('Adds customFields to Address fragment', () => {
+            addsCustomFieldsToType('Address', addressFragment);
+        });
+    });
+});

+ 232 - 0
packages/dashboard/src/framework/document-introspection/add-custom-fields.ts

@@ -0,0 +1,232 @@
+import {
+    getServerConfigDocument,
+    relationCustomFieldFragment,
+    structCustomFieldFragment,
+} from '@/providers/server-config.js';
+import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { CustomFieldConfig, EntityCustomFields } from '@vendure/common/lib/generated-types';
+import { ResultOf } from 'gql.tada';
+import {
+    DefinitionNode,
+    DocumentNode,
+    FieldNode,
+    FragmentDefinitionNode,
+    Kind,
+    OperationDefinitionNode,
+    SelectionNode,
+    SelectionSetNode,
+} from 'graphql';
+import type { Variables } from 'graphql-request';
+
+import { getOperationTypeInfo } from './get-document-structure.js';
+
+type StructCustomFieldFragment = ResultOf<typeof structCustomFieldFragment>;
+type RelationCustomFieldFragment = ResultOf<typeof relationCustomFieldFragment>;
+
+let globalCustomFieldsMap: Map<string, CustomFieldConfig[]>;
+
+export function setCustomFieldsMap(
+    entityCustomFields: ResultOf<
+        typeof getServerConfigDocument
+    >['globalSettings']['serverConfig']['entityCustomFields'],
+) {
+    globalCustomFieldsMap = new Map<string, CustomFieldConfig[]>();
+    for (const entityCustomField of entityCustomFields) {
+        globalCustomFieldsMap.set(
+            entityCustomField.entityName,
+            entityCustomField.customFields as CustomFieldConfig[],
+        );
+    }
+}
+
+/**
+ * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
+ * custom fields to those fragments.
+ */
+export function addCustomFields<T, V extends Variables = Variables>(
+    documentNode: DocumentNode | TypedDocumentNode<T, V>,
+    options?: {
+        customFieldsMap?: Map<string, CustomFieldConfig[]>;
+        includeCustomFields?: string[];
+    },
+): TypedDocumentNode<T, V> {
+    const clone = JSON.parse(JSON.stringify(documentNode)) as DocumentNode;
+    const customFields = options?.customFieldsMap || globalCustomFieldsMap;
+
+    const targetNodes: Array<{ typeName: string; selectionSet: SelectionSetNode }> = [];
+
+    const fragmentDefs = clone.definitions.filter(isFragmentDefinition);
+    for (const fragmentDef of fragmentDefs) {
+        targetNodes.push({
+            typeName: fragmentDef.typeCondition.name.value,
+            selectionSet: fragmentDef.selectionSet,
+        });
+    }
+    const queryDefs = clone.definitions.filter(isOperationDefinition);
+
+    for (const queryDef of queryDefs) {
+        const typeInfo = getOperationTypeInfo(queryDef);
+        const fieldNode = queryDef.selectionSet.selections[0] as FieldNode;
+        if (typeInfo && fieldNode?.selectionSet) {
+            targetNodes.push({
+                typeName: typeInfo.type,
+                selectionSet: fieldNode.selectionSet,
+            });
+            addTargetNodesRecursively(fieldNode.selectionSet, typeInfo.type, targetNodes);
+        }
+    }
+
+    for (const target of targetNodes) {
+        let entityType = target.typeName;
+
+        if (entityType === ('OrderAddress' as any)) {
+            // OrderAddress is a special case of the Address entity, and shares its custom fields
+            // so we treat it as an alias
+            entityType = 'Address';
+        }
+
+        if (entityType === ('Country' as any)) {
+            // Country is an alias of Region
+            entityType = 'Region';
+        }
+
+        const customFieldsForType = customFields.get(entityType);
+        if (customFieldsForType && customFieldsForType.length) {
+            // Check if there is already a customFields field in the fragment
+            // to avoid duplication
+            const existingCustomFieldsField = target.selectionSet.selections.find(
+                selection => isFieldNode(selection) && selection.name.value === 'customFields',
+            ) as FieldNode | undefined;
+            const selectionNodes: SelectionNode[] = customFieldsForType
+                .filter(
+                    field =>
+                        !options?.includeCustomFields || options?.includeCustomFields.includes(field.name),
+                )
+                .map(
+                    customField =>
+                        ({
+                            kind: Kind.FIELD,
+                            name: {
+                                kind: Kind.NAME,
+                                value: customField.name,
+                            },
+                            // For "relation" custom fields, we need to also select
+                            // all the scalar fields of the related type
+                            ...(customField.type === 'relation'
+                                ? {
+                                      selectionSet: {
+                                          kind: Kind.SELECTION_SET,
+                                          selections: (
+                                              customField as RelationCustomFieldFragment
+                                          ).scalarFields.map(f => ({
+                                              kind: Kind.FIELD,
+                                              name: { kind: Kind.NAME, value: f },
+                                          })),
+                                      },
+                                  }
+                                : {}),
+                            ...(customField.type === 'struct'
+                                ? {
+                                      selectionSet: {
+                                          kind: Kind.SELECTION_SET,
+                                          selections: (customField as StructCustomFieldFragment).fields.map(
+                                              f => ({
+                                                  kind: Kind.FIELD,
+                                                  name: { kind: Kind.NAME, value: f.name },
+                                              }),
+                                          ),
+                                      },
+                                  }
+                                : {}),
+                        }) as FieldNode,
+                );
+            if (!existingCustomFieldsField) {
+                // If no customFields field exists, add one
+                (target.selectionSet.selections as SelectionNode[]).push({
+                    kind: Kind.FIELD,
+                    name: {
+                        kind: Kind.NAME,
+                        value: 'customFields',
+                    },
+                    selectionSet: {
+                        kind: Kind.SELECTION_SET,
+                        selections: selectionNodes,
+                    },
+                });
+            } else {
+                // If a customFields field already exists, add the custom fields
+                // to the existing selection set
+                (existingCustomFieldsField.selectionSet as any) = {
+                    kind: Kind.SELECTION_SET,
+                    selections: selectionNodes,
+                };
+            }
+
+            const localizedFields = customFieldsForType.filter(
+                field => field.type === 'localeString' || field.type === 'localeText',
+            );
+
+            const translationsField = target.selectionSet.selections
+                .filter(isFieldNode)
+                .find(field => field.name.value === 'translations');
+
+            if (localizedFields.length && translationsField && translationsField.selectionSet) {
+                (translationsField.selectionSet.selections as SelectionNode[]).push({
+                    name: {
+                        kind: Kind.NAME,
+                        value: 'customFields',
+                    },
+                    kind: Kind.FIELD,
+                    selectionSet: {
+                        kind: Kind.SELECTION_SET,
+                        selections: localizedFields.map(
+                            customField =>
+                                ({
+                                    kind: Kind.FIELD,
+                                    name: {
+                                        kind: Kind.NAME,
+                                        value: customField.name,
+                                    },
+                                }) as FieldNode,
+                        ),
+                    },
+                });
+            }
+        }
+    }
+
+    return clone;
+}
+
+function isFragmentDefinition(value: DefinitionNode): value is FragmentDefinitionNode {
+    return value.kind === Kind.FRAGMENT_DEFINITION;
+}
+
+function isOperationDefinition(value: DefinitionNode): value is OperationDefinitionNode {
+    return value.kind === Kind.OPERATION_DEFINITION;
+}
+
+function isFieldNode(value: SelectionNode): value is FieldNode {
+    return value.kind === Kind.FIELD;
+}
+
+function addTargetNodesRecursively(
+    selectionSet: SelectionSetNode,
+    parentTypeName: string,
+    targetNodes: Array<{ typeName: string; selectionSet: SelectionSetNode }>,
+) {
+    for (const selection of selectionSet.selections) {
+        if (selection.kind === 'Field' && selection.selectionSet) {
+            const fieldNode = selection;
+            const typeInfo = getOperationTypeInfo(fieldNode, parentTypeName); // Assuming this function can handle FieldNode
+            if (typeInfo && fieldNode.selectionSet) {
+                targetNodes.push({
+                    typeName: typeInfo.type,
+                    selectionSet: fieldNode.selectionSet,
+                });
+                // Recursively process the selection set of the current field
+                addTargetNodesRecursively(fieldNode.selectionSet, typeInfo.type, targetNodes);
+            }
+        }
+    }
+}

+ 120 - 2
packages/dashboard/src/framework/document-introspection/get-document-structure.spec.ts

@@ -1,9 +1,127 @@
 import { graphql } from 'gql.tada';
 import { graphql } from 'gql.tada';
-import { parse } from 'graphql';
-import { describe, it, expect } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
 
 
 import { getListQueryFields } from './get-document-structure.js';
 import { getListQueryFields } from './get-document-structure.js';
 
 
+vi.mock('virtual:admin-api-schema', () => {
+    return {
+        schemaInfo: {
+            types: {
+                Query: {
+                    products: ['ProductList', false, false, true],
+                    product: ['Product', false, false, false],
+                },
+                Mutation: {},
+
+                Product: {
+                    channels: ['Channel', false, true, false],
+                    id: ['ID', false, false, false],
+                    createdAt: ['DateTime', false, false, false],
+                    updatedAt: ['DateTime', false, false, false],
+                    languageCode: ['LanguageCode', false, false, false],
+                    name: ['String', false, false, false],
+                    slug: ['String', false, false, false],
+                    description: ['String', false, false, false],
+                    enabled: ['Boolean', false, false, false],
+                    featuredAsset: ['Asset', true, false, false],
+                    assets: ['Asset', false, true, false],
+                    variants: ['ProductVariant', false, true, false],
+                    variantList: ['ProductVariantList', false, false, true],
+                    optionGroups: ['ProductOptionGroup', false, true, false],
+                    facetValues: ['FacetValue', false, true, false],
+                    translations: ['ProductTranslation', false, true, false],
+                    collections: ['Collection', false, true, false],
+                    reviews: ['ProductReviewList', false, false, true],
+                    reviewsHistogram: ['ProductReviewHistogramItem', false, true, false],
+                    customFields: ['ProductCustomFields', true, false, false],
+                },
+                ProductVariantPrice: {
+                    currencyCode: ['CurrencyCode', false, false, false],
+                    price: ['Money', false, false, false],
+                    customFields: ['JSON', true, false, false],
+                },
+                ProductVariant: {
+                    enabled: ['Boolean', false, false, false],
+                    trackInventory: ['GlobalFlag', false, false, false],
+                    stockOnHand: ['Int', false, false, false],
+                    stockAllocated: ['Int', false, false, false],
+                    outOfStockThreshold: ['Int', false, false, false],
+                    useGlobalOutOfStockThreshold: ['Boolean', false, false, false],
+                    prices: ['ProductVariantPrice', false, true, false],
+                    stockLevels: ['StockLevel', false, true, false],
+                    stockMovements: ['StockMovementList', false, false, false],
+                    channels: ['Channel', false, true, false],
+                    id: ['ID', false, false, false],
+                    product: ['Product', false, false, false],
+                    productId: ['ID', false, false, false],
+                    createdAt: ['DateTime', false, false, false],
+                    updatedAt: ['DateTime', false, false, false],
+                    languageCode: ['LanguageCode', false, false, false],
+                    sku: ['String', false, false, false],
+                    name: ['String', false, false, false],
+                    featuredAsset: ['Asset', true, false, false],
+                    assets: ['Asset', false, true, false],
+                    price: ['Money', false, false, false],
+                    currencyCode: ['CurrencyCode', false, false, false],
+                    priceWithTax: ['Money', false, false, false],
+                    stockLevel: ['String', false, false, false],
+                    taxRateApplied: ['TaxRate', false, false, false],
+                    taxCategory: ['TaxCategory', false, false, false],
+                    options: ['ProductOption', false, true, false],
+                    facetValues: ['FacetValue', false, true, false],
+                    translations: ['ProductVariantTranslation', false, true, false],
+                    customFields: ['JSON', true, false, false],
+                },
+                ProductCustomFields: {
+                    custom1: ['String', false, false, false],
+                },
+
+                Asset: {
+                    id: ['ID', false, false, false],
+                    createdAt: ['DateTime', false, false, false],
+                    updatedAt: ['DateTime', false, false, false],
+                    name: ['String', false, false, false],
+                    type: ['AssetType', false, false, false],
+                    fileSize: ['Int', false, false, false],
+                    mimeType: ['String', false, false, false],
+                    width: ['Int', false, false, false],
+                    height: ['Int', false, false, false],
+                    source: ['String', false, false, false],
+                    preview: ['String', false, false, false],
+                    focalPoint: ['Coordinate', true, false, false],
+                    tags: ['Tag', false, true, false],
+                    customFields: ['JSON', true, false, false],
+                },
+                ProductTranslation: {
+                    id: ['ID', false, false, false],
+                    createdAt: ['DateTime', false, false, false],
+                    updatedAt: ['DateTime', false, false, false],
+                    languageCode: ['LanguageCode', false, false, false],
+                    name: ['String', false, false, false],
+                    slug: ['String', false, false, false],
+                    description: ['String', false, false, false],
+                    customFields: ['ProductTranslationCustomFields', true, false, false],
+                },
+                ProductList: {
+                    items: ['Product', false, true, false],
+                    totalItems: ['Int', false, false, false],
+                },
+
+                ProductVariantTranslation: {
+                    id: ['ID', false, false, false],
+                    createdAt: ['DateTime', false, false, false],
+                    updatedAt: ['DateTime', false, false, false],
+                    languageCode: ['LanguageCode', false, false, false],
+                    name: ['String', false, false, false],
+                },
+            },
+            inputs: {},
+            scalars: ['ID', 'String', 'Int', 'Boolean', 'Float', 'JSON', 'DateTime', 'Upload', 'Money'],
+            enums: {},
+        },
+    };
+});
+
 describe('getListQueryFields', () => {
 describe('getListQueryFields', () => {
     it('should extract fields from a simple paginated list query', () => {
     it('should extract fields from a simple paginated list query', () => {
         const doc = graphql(`
         const doc = graphql(`

+ 26 - 2
packages/dashboard/src/framework/document-introspection/get-document-structure.ts

@@ -6,7 +6,7 @@ import {
     FragmentSpreadNode,
     FragmentSpreadNode,
     VariableDefinitionNode,
     VariableDefinitionNode,
 } from 'graphql';
 } from 'graphql';
-import { NamedTypeNode, TypeNode } from 'graphql/language/ast.js';
+import { DefinitionNode, NamedTypeNode, SelectionSetNode, TypeNode } from 'graphql/language/ast.js';
 import { schemaInfo } from 'virtual:admin-api-schema';
 import { schemaInfo } from 'virtual:admin-api-schema';
 
 
 export interface FieldInfo {
 export interface FieldInfo {
@@ -123,6 +123,28 @@ export function getMutationName(documentNode: DocumentNode): string {
     }
     }
 }
 }
 
 
+export function getOperationTypeInfo(
+    definitionNode: DefinitionNode | FieldNode,
+    parentTypeName?: string,
+): FieldInfo | undefined {
+    if (definitionNode.kind === 'OperationDefinition') {
+        const firstSelection = definitionNode?.selectionSet.selections[0];
+        if (firstSelection?.kind === 'Field') {
+            return getQueryInfo(firstSelection.name.value);
+        }
+    }
+    if (definitionNode.kind === 'Field' && parentTypeName) {
+        const fieldInfo = getObjectFieldInfo(parentTypeName, definitionNode.name.value);
+        return fieldInfo;
+    }
+}
+
+export function getTypeFieldInfo(typeName: string): FieldInfo[] {
+    return Object.entries(schemaInfo.types[typeName]).map(([fieldName, fieldInfo]) => {
+        return getObjectFieldInfo(typeName, fieldName);
+    });
+}
+
 function getQueryInfo(name: string): FieldInfo {
 function getQueryInfo(name: string): FieldInfo {
     const fieldInfo = schemaInfo.types.Query[name];
     const fieldInfo = schemaInfo.types.Query[name];
     return {
     return {
@@ -170,13 +192,15 @@ function getPaginatedListType(name: string): string | undefined {
 
 
 function getObjectFieldInfo(typeName: string, fieldName: string): FieldInfo {
 function getObjectFieldInfo(typeName: string, fieldName: string): FieldInfo {
     const fieldInfo = schemaInfo.types[typeName][fieldName];
     const fieldInfo = schemaInfo.types[typeName][fieldName];
+    const type = fieldInfo[0];
+    const isScalar = isScalarType(type);
     return {
     return {
         name: fieldName,
         name: fieldName,
         type: fieldInfo[0],
         type: fieldInfo[0],
         nullable: fieldInfo[1],
         nullable: fieldInfo[1],
         list: fieldInfo[2],
         list: fieldInfo[2],
         isPaginatedList: fieldInfo[3],
         isPaginatedList: fieldInfo[3],
-        isScalar: schemaInfo.scalars.includes(fieldInfo[0]),
+        isScalar,
     };
     };
 }
 }
 
 

+ 7 - 28
packages/dashboard/src/graphql/api.ts

@@ -1,6 +1,6 @@
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { GraphQLClient, RequestDocument } from 'graphql-request';
-import { request as graphqlRequest, Variables } from 'graphql-request';
+import { parse } from 'graphql';
+import { GraphQLClient, RequestDocument, Variables } from 'graphql-request';
 
 
 const API_URL = 'http://localhost:3000/admin-api';
 const API_URL = 'http://localhost:3000/admin-api';
 
 
@@ -18,34 +18,13 @@ function query<T, V extends Variables = Variables>(
     document: RequestDocument | TypedDocumentNode<T, V>,
     document: RequestDocument | TypedDocumentNode<T, V>,
     variables?: V,
     variables?: V,
 ) {
 ) {
+    const documentNode = typeof document === 'string' ? parse(document) : document;
     return client.request<T>({
     return client.request<T>({
-        document,
+        document: documentNode,
         variables,
         variables,
     });
     });
 }
 }
 
 
-function mutate2<T, V extends Variables = Variables>(
-    document: TypedDocumentNode<T, V>,
-): (variables: V) => Promise<T>;
-function mutate2<T, V extends Variables = Variables>(
-    document: RequestDocument | TypedDocumentNode<T, V>,
-    maybeVariables?: V,
-): Promise<T> | ((variables: V) => Promise<T>) {
-    if (maybeVariables) {
-        return client.request<T>({
-            document,
-            variables: maybeVariables,
-        });
-    } else {
-        return (variables: V): Promise<T> => {
-            return client.request<T>({
-                document,
-                variables,
-            });
-        };
-    }
-}
-
 function mutate<T, V extends Variables = Variables>(
 function mutate<T, V extends Variables = Variables>(
     document: TypedDocumentNode<T, V>,
     document: TypedDocumentNode<T, V>,
 ): (variables: V) => Promise<T>;
 ): (variables: V) => Promise<T>;
@@ -59,15 +38,16 @@ function mutate<T, V extends Variables = Variables>(
     document: RequestDocument | TypedDocumentNode<T, V>,
     document: RequestDocument | TypedDocumentNode<T, V>,
     maybeVariables?: V,
     maybeVariables?: V,
 ): Promise<T> | ((variables: V) => Promise<T>) {
 ): Promise<T> | ((variables: V) => Promise<T>) {
+    const documentNode = typeof document === 'string' ? parse(document) : document;
     if (maybeVariables) {
     if (maybeVariables) {
         return client.request<T>({
         return client.request<T>({
-            document,
+            document: documentNode,
             variables: maybeVariables,
             variables: maybeVariables,
         });
         });
     } else {
     } else {
         return (variables: V): Promise<T> => {
         return (variables: V): Promise<T> => {
             return client.request<T>({
             return client.request<T>({
-                document,
+                document: documentNode,
                 variables,
                 variables,
             });
             });
         };
         };
@@ -77,5 +57,4 @@ function mutate<T, V extends Variables = Variables>(
 export const api = {
 export const api = {
     query,
     query,
     mutate,
     mutate,
-    mutate2,
 };
 };

+ 10 - 0
packages/dashboard/src/hooks/use-custom-field-config.ts

@@ -0,0 +1,10 @@
+import { useServerConfig } from './use-server-config.js';
+
+export function useCustomFieldConfig(entityType: string) {
+    const serverConfig = useServerConfig();
+    if (!serverConfig) {
+        return [];
+    }
+    const customFieldConfig = serverConfig.entityCustomFields.find(field => field.entityName === entityType);
+    return customFieldConfig?.customFields;
+}

+ 11 - 2
packages/dashboard/src/main.tsx

@@ -1,19 +1,28 @@
 import { AppProviders, queryClient, router } from '@/app-providers.js';
 import { AppProviders, queryClient, router } from '@/app-providers.js';
-import { useAuth } from './hooks/use-auth.js';
+import { Toaster } from '@/components/ui/sonner.js';
 import { useDashboardExtensions } from '@/framework/extension-api/use-dashboard-extensions.js';
 import { useDashboardExtensions } from '@/framework/extension-api/use-dashboard-extensions.js';
 import { useExtendedRouter } from '@/framework/page/use-extended-router.js';
 import { useExtendedRouter } from '@/framework/page/use-extended-router.js';
 import { defaultLocale, dynamicActivate } from '@/providers/i18n-provider.js';
 import { defaultLocale, dynamicActivate } from '@/providers/i18n-provider.js';
-import { Toaster } from '@/components/ui/sonner.js';
+import { useAuth } from './hooks/use-auth.js';
 
 
 import '@/framework/defaults.js';
 import '@/framework/defaults.js';
 import { RouterProvider } from '@tanstack/react-router';
 import { RouterProvider } from '@tanstack/react-router';
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 import ReactDOM from 'react-dom/client';
 import ReactDOM from 'react-dom/client';
+import { setCustomFieldsMap } from './framework/document-introspection/add-custom-fields.js';
+import { useServerConfig } from './hooks/use-server-config.js';
 import './styles.css';
 import './styles.css';
 
 
 function InnerApp() {
 function InnerApp() {
     const auth = useAuth();
     const auth = useAuth();
     const extendedRouter = useExtendedRouter(router);
     const extendedRouter = useExtendedRouter(router);
+    const serverConfig = useServerConfig();
+    useEffect(() => {
+        if (!serverConfig) {
+            return document;
+        }
+        setCustomFieldsMap(serverConfig.entityCustomFields);
+    }, [serverConfig?.entityCustomFields]);
     return <RouterProvider router={extendedRouter} context={{ auth, queryClient }} />;
     return <RouterProvider router={extendedRouter} context={{ auth, queryClient }} />;
 }
 }
 
 

+ 9 - 9
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -1,6 +1,8 @@
 import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { ContentLanguageSelector } from '@/components/layout/content-language-selector.js';
 import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
 import { AssignedFacetValues } from '@/components/shared/assigned-facet-values.js';
 import { EntityAssets } from '@/components/shared/entity-assets.js';
 import { EntityAssets } from '@/components/shared/entity-assets.js';
+import { ErrorPage } from '@/components/shared/error-page.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
 import { TranslatableFormField } from '@/components/shared/translatable-form-field.js';
 import { Button } from '@/components/ui/button.js';
 import { Button } from '@/components/ui/button.js';
 import {
 import {
@@ -15,6 +17,7 @@ import {
 import { Input } from '@/components/ui/input.js';
 import { Input } from '@/components/ui/input.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Switch } from '@/components/ui/switch.js';
 import { Textarea } from '@/components/ui/textarea.js';
 import { Textarea } from '@/components/ui/textarea.js';
+import { NEW_ENTITY_PATH } from '@/constants.js';
 import {
 import {
     Page,
     Page,
     PageActionBar,
     PageActionBar,
@@ -22,18 +25,15 @@ import {
     PageLayout,
     PageLayout,
     PageTitle,
     PageTitle,
 } from '@/framework/layout-engine/page-layout.js';
 } from '@/framework/layout-engine/page-layout.js';
-import { useDetailPage, getDetailQueryOptions } from '@/framework/page/use-detail-page.js';
+import { getDetailQueryOptions, useDetailPage } from '@/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
 import { toast } from 'sonner';
+import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { ProductVariantsTable } from './components/product-variants-table.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
 import { createProductDocument, productDetailDocument, updateProductDocument } from './products.graphql.js';
-import { NEW_ENTITY_PATH } from '@/constants.js';
-import { notFound } from '@tanstack/react-router';
-import { ErrorPage } from '@/components/shared/error-page.js';
-import { CreateProductVariants } from './components/create-product-variants.js';
-import { CreateProductVariantsDialog } from './components/create-product-variants-dialog.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { addCustomFields } from '@/framework/document-introspection/add-custom-fields.js';
+
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
 export const Route = createFileRoute('/_authenticated/_products/products_/$id')({
     component: ProductDetailPage,
     component: ProductDetailPage,
     loader: async ({ context, params }) => {
     loader: async ({ context, params }) => {
@@ -41,7 +41,7 @@ export const Route = createFileRoute('/_authenticated/_products/products_/$id')(
         const result = isNew
         const result = isNew
             ? null
             ? null
             : await context.queryClient.ensureQueryData(
             : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(productDetailDocument, { id: params.id }),
+                  getDetailQueryOptions(addCustomFields(productDetailDocument), { id: params.id }), { id: params.id },
               );
               );
         if (!isNew && !result.product) {
         if (!isNew && !result.product) {
             throw new Error(`Product with the ID ${params.id} was not found`);
             throw new Error(`Product with the ID ${params.id} was not found`);
@@ -63,7 +63,7 @@ export function ProductDetailPage() {
     const { i18n } = useLingui();
     const { i18n } = useLingui();
 
 
     const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
     const { form, submitHandler, entity, isPending, refreshEntity } = useDetailPage({
-        queryDocument: productDetailDocument,
+        queryDocument: addCustomFields(productDetailDocument),
         entityField: 'product',
         entityField: 'product',
         createDocument: createProductDocument,
         createDocument: createProductDocument,
         updateDocument: updateProductDocument,
         updateDocument: updateProductDocument,