Browse Source

feat(dashboard): Implement API to extend detail page queries

Michael Bromley 6 months ago
parent
commit
e83ad3b0fa

+ 50 - 0
packages/dashboard/src/lib/framework/document-extension/extend-detail-form-query.ts

@@ -0,0 +1,50 @@
+import { extendDocument } from '@/framework/document-extension/extend-document.js';
+import { getDetailQueryDocuments } from '@/framework/form-engine/custom-form-component-extensions.js';
+import { DocumentNode } from 'graphql';
+
+/**
+ * @description
+ * Extends a detail page query document with any registered extensions provided by
+ * the `extendDetailDocument` function for the given page.
+ */
+export function extendDetailFormQuery<T extends DocumentNode>(
+    detailQuery: T,
+    pageId?: string,
+): {
+    extendedQuery: T;
+    errorMessage?: string | null;
+} {
+    let result: T = detailQuery;
+    let errorMessage: string | null = null;
+
+    if (!pageId) {
+        // If no pageId is provided, return the original query without any extensions
+        return { extendedQuery: detailQuery };
+    }
+
+    const detailQueryExtensions = getDetailQueryDocuments(pageId);
+
+    try {
+        result = detailQueryExtensions.reduce(
+            (acc, extension) => extendDocument(acc, extension),
+            detailQuery,
+        ) as T;
+    } catch (err) {
+        errorMessage = err instanceof Error ? err.message : String(err);
+        // Continue with the original query instead of the extended one
+        result = detailQuery;
+    }
+
+    // Store error for useEffect to handle
+    if (errorMessage) {
+        // Log the error and continue with the original query
+        // eslint-disable-next-line no-console
+        console.warn(`${errorMessage}. Continuing with original query.`, {
+            pageId,
+            extensionsCount: detailQueryExtensions.length,
+            error: errorMessage,
+        });
+    }
+
+    return { extendedQuery: result, errorMessage };
+}

+ 335 - 0
packages/dashboard/src/lib/framework/document-extension/extend-document.spec.ts

@@ -546,4 +546,339 @@ describe('extendDocument', () => {
             `),
             `),
         );
         );
     });
     });
+
+    it('should extend detail query with fragments', () => {
+        const detailDocument = graphql(`
+            fragment ProductDetail on Product {
+                id
+                name
+                slug
+                description
+                featuredAsset {
+                    id
+                    preview
+                }
+            }
+
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    ...ProductDetail
+                }
+            }
+        `);
+
+        const extended = extendDocument(
+            detailDocument as any,
+            `
+            fragment ProductDetail on Product {
+                enabled
+                createdAt
+                updatedAt
+                assets {
+                    id
+                    preview
+                }
+            }
+
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    customFields
+                }
+            }
+            `,
+        );
+
+        const printed = print(extended);
+
+        expect(expectedSDL(printed)).toBe(
+            expectedSDL(`
+                query ProductDetail($id: ID!) {
+                    product(id: $id) {
+                        ...ProductDetail
+                        customFields
+                    }
+                }
+                fragment ProductDetail on Product {
+                    id
+                    name
+                    slug
+                    description
+                    featuredAsset {
+                        id
+                        preview
+                    }
+                }
+                fragment ProductDetail on Product {
+                    enabled
+                    createdAt
+                    updatedAt
+                    assets {
+                        id
+                        preview
+                    }
+                }
+            `),
+        );
+    });
+
+    it('should extend detail query with nested translations', () => {
+        const detailDocument = graphql(`
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    id
+                    name
+                    slug
+                    translations {
+                        id
+                        languageCode
+                        name
+                    }
+                }
+            }
+        `);
+
+        const extended = extendDocument(
+            detailDocument as any,
+            `
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    translations {
+                        slug
+                        description
+                    }
+                    facetValues {
+                        id
+                        name
+                        code
+                        facet {
+                            id
+                            name
+                            code
+                        }
+                    }
+                }
+            }
+            `,
+        );
+
+        const printed = print(extended);
+
+        expect(expectedSDL(printed)).toBe(
+            expectedSDL(`
+                query ProductDetail($id: ID!) {
+                    product(id: $id) {
+                        id
+                        name
+                        slug
+                        translations {
+                            id
+                            languageCode
+                            name
+                            slug
+                            description
+                        }
+                        facetValues {
+                            id
+                            name
+                            code
+                            facet {
+                                id
+                                name
+                                code
+                            }
+                        }
+                    }
+                }
+            `),
+        );
+    });
+
+    it('should extend detail query with asset fragments', () => {
+        const detailDocument = graphql(`
+            fragment Asset on Asset {
+                id
+                preview
+            }
+
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    id
+                    featuredAsset {
+                        ...Asset
+                    }
+                }
+            }
+        `);
+
+        const extended = extendDocument(
+            detailDocument as any,
+            `
+            fragment Asset on Asset {
+                name
+                source
+            }
+
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    assets {
+                        ...Asset
+                    }
+                }
+            }
+            `,
+        );
+
+        const printed = print(extended);
+
+        expect(expectedSDL(printed)).toBe(
+            expectedSDL(`
+                query ProductDetail($id: ID!) {
+                    product(id: $id) {
+                        id
+                        featuredAsset {
+                            ...Asset
+                        }
+                        assets {
+                            ...Asset
+                        }
+                    }
+                }
+                fragment Asset on Asset {
+                    id
+                    preview
+                }
+                fragment Asset on Asset {
+                    name
+                    source
+                }
+            `),
+        );
+    });
+
+    it('should extend detail query with custom fields', () => {
+        const detailDocument = graphql(`
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    id
+                    name
+                    customFields
+                }
+            }
+        `);
+
+        const extended = extendDocument(
+            detailDocument as any,
+            `
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    enabled
+                    createdAt
+                    updatedAt
+                    customFields
+                }
+            }
+            `,
+        );
+
+        const printed = print(extended);
+
+        expect(expectedSDL(printed)).toBe(
+            expectedSDL(`
+                query ProductDetail($id: ID!) {
+                    product(id: $id) {
+                        id
+                        name
+                        customFields
+                        enabled
+                        createdAt
+                        updatedAt
+                    }
+                }
+            `),
+        );
+    });
+
+    it('should extend detail query with complex nested structure', () => {
+        const detailDocument = graphql(`
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    id
+                    name
+                    featuredAsset {
+                        id
+                        preview
+                    }
+                    facetValues {
+                        id
+                        name
+                    }
+                }
+            }
+        `);
+
+        const extended = extendDocument(
+            detailDocument as any,
+            `
+            query ProductDetail($id: ID!) {
+                product(id: $id) {
+                    featuredAsset {
+                        name
+                        source
+                    }
+                    facetValues {
+                        code
+                        facet {
+                            id
+                            name
+                            code
+                        }
+                    }
+                    translations {
+                        id
+                        languageCode
+                        name
+                        slug
+                        description
+                    }
+                }
+            }
+            `,
+        );
+
+        const printed = print(extended);
+
+        expect(expectedSDL(printed)).toBe(
+            expectedSDL(`
+                query ProductDetail($id: ID!) {
+                    product(id: $id) {
+                        id
+                        name
+                        featuredAsset {
+                            id
+                            preview
+                            name
+                            source
+                        }
+                        facetValues {
+                            id
+                            name
+                            code
+                            facet {
+                                id
+                                name
+                                code
+                            }
+                        }
+                        translations {
+                            id
+                            languageCode
+                            name
+                            slug
+                            description
+                        }
+                    }
+                }
+            `),
+        );
+    });
 });
 });

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

@@ -25,6 +25,35 @@ type RelationCustomFieldFragment = ResultOf<typeof relationCustomFieldFragment>;
 
 
 let globalCustomFieldsMap: Map<string, CustomFieldConfig[]> = new Map();
 let globalCustomFieldsMap: Map<string, CustomFieldConfig[]> = new Map();
 
 
+// Memoization cache using WeakMap to avoid memory leaks
+const memoizationCache = new WeakMap<DocumentNode, Map<string, TypedDocumentNode<any, any>>>();
+
+/**
+ * Creates a cache key for the options object
+ */
+function createOptionsKey(options?: {
+    customFieldsMap?: Map<string, CustomFieldConfig[]>;
+    includeCustomFields?: string[];
+}): string {
+    if (!options) return 'default';
+
+    const parts: string[] = [];
+
+    if (options.customFieldsMap) {
+        // Create a deterministic key for the customFieldsMap
+        const mapEntries = Array.from(options.customFieldsMap.entries())
+            .sort(([a], [b]) => a.localeCompare(b))
+            .map(([key, value]) => `${key}:${value.length}`);
+        parts.push(`map:${mapEntries.join(',')}`);
+    }
+
+    if (options.includeCustomFields) {
+        parts.push(`include:${options.includeCustomFields.sort().join(',')}`);
+    }
+
+    return parts.join('|') || 'default';
+}
+
 /**
 /**
  * @description
  * @description
  * This function is used to set the global custom fields map.
  * This function is used to set the global custom fields map.
@@ -56,6 +85,8 @@ export function getCustomFieldsMap() {
 /**
 /**
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured
  * custom fields to those fragments.
  * custom fields to those fragments.
+ *
+ * This function is memoized to return a stable identity for given inputs.
  */
  */
 export function addCustomFields<T, V extends Variables = Variables>(
 export function addCustomFields<T, V extends Variables = Variables>(
     documentNode: DocumentNode | TypedDocumentNode<T, V>,
     documentNode: DocumentNode | TypedDocumentNode<T, V>,
@@ -64,6 +95,21 @@ export function addCustomFields<T, V extends Variables = Variables>(
         includeCustomFields?: string[];
         includeCustomFields?: string[];
     },
     },
 ): TypedDocumentNode<T, V> {
 ): TypedDocumentNode<T, V> {
+    const optionsKey = createOptionsKey(options);
+
+    // Check if we have a cached result for this document and options
+    let documentCache = memoizationCache.get(documentNode);
+    if (!documentCache) {
+        documentCache = new Map();
+        memoizationCache.set(documentNode, documentCache);
+    }
+
+    const cachedResult = documentCache.get(optionsKey);
+    if (cachedResult) {
+        return cachedResult as TypedDocumentNode<T, V>;
+    }
+
+    // If not cached, compute the result
     const clone = JSON.parse(JSON.stringify(documentNode)) as DocumentNode;
     const clone = JSON.parse(JSON.stringify(documentNode)) as DocumentNode;
     const customFields = options?.customFieldsMap || globalCustomFieldsMap;
     const customFields = options?.customFieldsMap || globalCustomFieldsMap;
 
 
@@ -209,6 +255,8 @@ export function addCustomFields<T, V extends Variables = Variables>(
         }
         }
     }
     }
 
 
+    // Cache the result before returning
+    documentCache.set(optionsKey, clone);
     return clone;
     return clone;
 }
 }
 
 

+ 19 - 1
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -2,7 +2,10 @@ import { addBulkAction, addListQueryDocument } from '@/framework/data-table/data
 import { parse } from 'graphql';
 import { parse } from 'graphql';
 
 
 import { registerDashboardWidget } from '../dashboard-widget/widget-extensions.js';
 import { registerDashboardWidget } from '../dashboard-widget/widget-extensions.js';
-import { addCustomFormComponent } from '../form-engine/custom-form-component-extensions.js';
+import {
+    addCustomFormComponent,
+    addDetailQueryDocument,
+} from '../form-engine/custom-form-component-extensions.js';
 import {
 import {
     registerDashboardActionBarItem,
     registerDashboardActionBarItem,
     registerDashboardPageBlock,
     registerDashboardPageBlock,
@@ -106,6 +109,21 @@ export function defineDashboardExtension(extension: DashboardExtension) {
                 }
                 }
             }
             }
         }
         }
+        if (extension.detailForms) {
+            for (const detailForm of extension.detailForms) {
+                if (detailForm.extendDetailDocument) {
+                    const document =
+                        typeof detailForm.extendDetailDocument === 'function'
+                            ? detailForm.extendDetailDocument()
+                            : detailForm.extendDetailDocument;
+
+                    addDetailQueryDocument(
+                        detailForm.pageId,
+                        typeof document === 'string' ? parse(document) : document,
+                    );
+                }
+            }
+        }
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {
         if (callbacks.size) {
             for (const callback of callbacks) {
             for (const callback of callbacks) {

+ 15 - 2
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -120,7 +120,7 @@ export interface DashboardPageBlockDefinition {
  * @docsCategory extensions
  * @docsCategory extensions
  * @since 3.4.0
  * @since 3.4.0
  */
  */
-export interface DashboardDataTableDefinition {
+export interface DashboardDataTableExtensionDefinition {
     /**
     /**
      * @description
      * @description
      * The ID of the page where the data table is located, e.g. `'product-list'`, `'order-list'`.
      * The ID of the page where the data table is located, e.g. `'product-list'`, `'order-list'`.
@@ -145,6 +145,18 @@ export interface DashboardDataTableDefinition {
     extendListDocument?: string | DocumentNode | (() => DocumentNode | string);
     extendListDocument?: string | DocumentNode | (() => DocumentNode | string);
 }
 }
 
 
+export interface DashboardDetailFormExtensionDefinition {
+    /**
+     * @description
+     * The ID of the page where the detail form is located, e.g. `'product-detail'`, `'order-detail'`.
+     */
+    pageId: string;
+    /**
+     * @description
+     */
+    extendDetailDocument?: string | DocumentNode | (() => DocumentNode | string);
+}
+
 /**
 /**
  * @description
  * @description
  * **Status: Developer Preview**
  * **Status: Developer Preview**
@@ -195,5 +207,6 @@ export interface DashboardExtension {
      * @description
      * @description
      * Allows you to customize aspects of existing data tables in the dashboard.
      * Allows you to customize aspects of existing data tables in the dashboard.
      */
      */
-    dataTables?: DashboardDataTableDefinition[];
+    dataTables?: DashboardDataTableExtensionDefinition[];
+    detailForms?: DashboardDetailFormExtensionDefinition[];
 }
 }

+ 13 - 3
packages/dashboard/src/lib/framework/form-engine/custom-form-component-extensions.ts

@@ -1,3 +1,5 @@
+import { DocumentNode } from 'graphql';
+
 import { DashboardCustomFormComponent } from '../extension-api/extension-api-types.js';
 import { DashboardCustomFormComponent } from '../extension-api/extension-api-types.js';
 import { globalRegistry } from '../registry/global-registry.js';
 import { globalRegistry } from '../registry/global-registry.js';
 
 
@@ -8,9 +10,7 @@ globalRegistry.register(
     new Map<string, React.FunctionComponent<CustomFormComponentInputProps>>(),
     new Map<string, React.FunctionComponent<CustomFormComponentInputProps>>(),
 );
 );
 
 
-export function getCustomFormComponents() {
-    return globalRegistry.get('customFormComponents');
-}
+globalRegistry.register('detailQueryDocumentRegistry', new Map<string, DocumentNode[]>());
 
 
 export function getCustomFormComponent(
 export function getCustomFormComponent(
     id: string,
     id: string,
@@ -26,3 +26,13 @@ export function addCustomFormComponent({ id, component }: DashboardCustomFormCom
     }
     }
     customFormComponents.set(id, component);
     customFormComponents.set(id, component);
 }
 }
+
+export function getDetailQueryDocuments(pageId: string): DocumentNode[] {
+    return globalRegistry.get('detailQueryDocumentRegistry').get(pageId) || [];
+}
+
+export function addDetailQueryDocument(pageId: string, document: DocumentNode) {
+    const listQueryDocumentRegistry = globalRegistry.get('detailQueryDocumentRegistry');
+    const existingDocuments = listQueryDocumentRegistry.get(pageId) || [];
+    listQueryDocumentRegistry.set(pageId, [...existingDocuments, document]);
+}

+ 1 - 0
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -194,6 +194,7 @@ export function PageLayout({ children, className }: PageLayoutProps) {
             if (extensionBlock) {
             if (extensionBlock) {
                 const ExtensionBlock = (
                 const ExtensionBlock = (
                     <PageBlock
                     <PageBlock
+                        key={childBlock.key}
                         column={extensionBlock.location.column}
                         column={extensionBlock.location.column}
                         blockId={extensionBlock.id}
                         blockId={extensionBlock.id}
                         title={extensionBlock.title}
                         title={extensionBlock.title}

+ 13 - 1
packages/dashboard/src/lib/framework/page/detail-page-route-loader.tsx

@@ -1,6 +1,7 @@
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
 
 
 import { PageBreadcrumb } from '@/components/layout/generated-breadcrumbs.js';
 import { PageBreadcrumb } from '@/components/layout/generated-breadcrumbs.js';
+import { extendDetailFormQuery } from '@/framework/document-extension/extend-detail-form-query.js';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { FileBaseRouteOptions, ParsedLocation } from '@tanstack/react-router';
 import { FileBaseRouteOptions, ParsedLocation } from '@tanstack/react-router';
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
 import { addCustomFields } from '../document-introspection/add-custom-fields.js';
@@ -9,6 +10,12 @@ import { DetailEntity } from './page-types.js';
 import { getDetailQueryOptions } from './use-detail-page.js';
 import { getDetailQueryOptions } from './use-detail-page.js';
 
 
 export interface DetailPageRouteLoaderConfig<T extends TypedDocumentNode<any, any>> {
 export interface DetailPageRouteLoaderConfig<T extends TypedDocumentNode<any, any>> {
+    /**
+     * @description
+     * The pageId is used to ensure any detail form extensions (such as extensions to
+     * the detail query document) get correctly applied at the route loader level.
+     */
+    pageId?: string;
     queryDocument: T;
     queryDocument: T;
     breadcrumb: (
     breadcrumb: (
         isNew: boolean,
         isNew: boolean,
@@ -18,6 +25,7 @@ export interface DetailPageRouteLoaderConfig<T extends TypedDocumentNode<any, an
 }
 }
 
 
 export function detailPageRouteLoader<T extends TypedDocumentNode<any, any>>({
 export function detailPageRouteLoader<T extends TypedDocumentNode<any, any>>({
+    pageId,
     queryDocument,
     queryDocument,
     breadcrumb,
     breadcrumb,
 }: DetailPageRouteLoaderConfig<T>) {
 }: DetailPageRouteLoaderConfig<T>) {
@@ -34,10 +42,14 @@ export function detailPageRouteLoader<T extends TypedDocumentNode<any, any>>({
             throw new Error('ID param is required');
             throw new Error('ID param is required');
         }
         }
         const isNew = params.id === NEW_ENTITY_PATH;
         const isNew = params.id === NEW_ENTITY_PATH;
+        const { extendedQuery: extendedQueryDocument } = extendDetailFormQuery(
+            addCustomFields(queryDocument),
+            pageId,
+        );
         const result = isNew
         const result = isNew
             ? null
             ? null
             : await context.queryClient.ensureQueryData(
             : await context.queryClient.ensureQueryData(
-                  getDetailQueryOptions(addCustomFields(queryDocument), { id: params.id }),
+                  getDetailQueryOptions(extendedQueryDocument, { id: params.id }),
                   { id: params.id },
                   { id: params.id },
               );
               );
 
 

+ 11 - 2
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -1,6 +1,7 @@
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { api, Variables } from '@/graphql/api.js';
 import { api, Variables } from '@/graphql/api.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
+import { useExtendedDetailQuery } from '@/hooks/use-extended-detail-query.js';
 import { removeReadonlyCustomFields } from '@/lib/utils.js';
 import { removeReadonlyCustomFields } from '@/lib/utils.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import {
 import {
@@ -48,6 +49,12 @@ export interface DetailPageOptions<
     VarNameCreate extends keyof VariablesOf<C> = 'input',
     VarNameCreate extends keyof VariablesOf<C> = 'input',
     VarNameUpdate extends keyof VariablesOf<U> = 'input',
     VarNameUpdate extends keyof VariablesOf<U> = 'input',
 > {
 > {
+    /**
+     * @description
+     * The page id. This is optional, but if provided, it will be used to
+     * identify the page when extending the detail page query
+     */
+    pageId?: string;
     /**
     /**
      * @description
      * @description
      * The query document to fetch the entity.
      * The query document to fetch the entity.
@@ -237,6 +244,7 @@ export function useDetailPage<
     options: DetailPageOptions<T, C, U, EntityField, VarNameCreate, VarNameUpdate>,
     options: DetailPageOptions<T, C, U, EntityField, VarNameCreate, VarNameUpdate>,
 ): UseDetailPageResult<T, C, U, EntityField> {
 ): UseDetailPageResult<T, C, U, EntityField> {
     const {
     const {
+        pageId,
         queryDocument,
         queryDocument,
         createDocument,
         createDocument,
         updateDocument,
         updateDocument,
@@ -253,11 +261,12 @@ export function useDetailPage<
     const queryClient = useQueryClient();
     const queryClient = useQueryClient();
     const returnEntityName = entityName ?? getEntityName(queryDocument);
     const returnEntityName = entityName ?? getEntityName(queryDocument);
     const customFieldConfig = useCustomFieldConfig(returnEntityName);
     const customFieldConfig = useCustomFieldConfig(returnEntityName);
-    const detailQueryOptions = getDetailQueryOptions(addCustomFields(queryDocument), {
+    const extendedDetailQuery = useExtendedDetailQuery(addCustomFields(queryDocument), pageId);
+    const detailQueryOptions = getDetailQueryOptions(extendedDetailQuery, {
         id: isNew ? '__NEW__' : params.id,
         id: isNew ? '__NEW__' : params.id,
     });
     });
     const detailQuery = useSuspenseQuery(detailQueryOptions);
     const detailQuery = useSuspenseQuery(detailQueryOptions);
-    const entityQueryField = entityField ?? getQueryName(queryDocument);
+    const entityQueryField = entityField ?? getQueryName(extendedDetailQuery);
 
 
     const entity = (detailQuery?.data as any)[entityQueryField] as
     const entity = (detailQuery?.data as any)[entityQueryField] as
         | DetailPageEntity<T, EntityField>
         | DetailPageEntity<T, EntityField>

+ 1 - 0
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -22,4 +22,5 @@ export interface GlobalRegistryContents {
     customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
     customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
     bulkActionsRegistry: Map<string, BulkAction[]>;
     bulkActionsRegistry: Map<string, BulkAction[]>;
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
     listQueryDocumentRegistry: Map<string, DocumentNode[]>;
+    detailQueryDocumentRegistry: Map<string, DocumentNode[]>;
 }
 }

+ 37 - 0
packages/dashboard/src/lib/hooks/use-extended-detail-query.ts

@@ -0,0 +1,37 @@
+import { extendDetailFormQuery } from '@/framework/document-extension/extend-detail-form-query.js';
+import { useLingui } from '@/lib/trans.js';
+import { DocumentNode } from 'graphql';
+import { useEffect, useMemo, useRef } from 'react';
+import { toast } from 'sonner';
+
+/**
+ * @description
+ * Extends a detail page query document with any registered extensions provided by
+ * the `extendDetailDocument` function for the given page.
+ */
+export function useExtendedDetailQuery<T extends DocumentNode>(detailQuery: T, pageId?: string) {
+    const hasShownError = useRef(false);
+    const { i18n } = useLingui();
+
+    const extendedDetailQuery = useMemo(() => {
+        if (!pageId || !detailQuery) {
+            return detailQuery;
+        }
+        const result = extendDetailFormQuery(detailQuery, pageId);
+        if (result.errorMessage && !hasShownError.current) {
+            // Show a user-friendly toast notification
+            toast.error(i18n.t('Query extension error'), {
+                description:
+                    result.errorMessage + '. ' + i18n.t('The page will continue with the default query.'),
+            });
+        }
+        return result.extendedQuery;
+    }, [detailQuery, pageId]);
+
+    // Reset error flag when dependencies change
+    useEffect(() => {
+        hasShownError.current = false;
+    }, [detailQuery, pageId]);
+
+    return extendedDetailQuery;
+}