Selaa lähdekoodia

fix(dashboard): Allow rendering multiple blocks in same location (#3937)

Alexander Berger 2 kuukautta sitten
vanhempi
sitoutus
1206605251

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

@@ -0,0 +1,138 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { PageBlock, PageLayout } from './page-layout.js';
+import { registerDashboardPageBlock } from './layout-extensions.js';
+import { PageContext } from './page-provider.js';
+import { globalRegistry } from '../registry/global-registry.js';
+import { UserSettingsContext, type UserSettingsContextType } from '../../providers/user-settings.js';
+
+const useMediaQueryMock = vi.hoisted(() => vi.fn());
+const useCopyToClipboardMock = vi.hoisted(() => vi.fn(() => [null, vi.fn()]));
+
+vi.mock('@uidotdev/usehooks', () => ({
+    useMediaQuery: useMediaQueryMock,
+    useCopyToClipboard: useCopyToClipboardMock,
+}));
+
+function registerBlock(
+    id: string,
+    order: 'before' | 'after' | 'replace',
+    pageId = 'customer-list',
+): void {
+    registerDashboardPageBlock({
+        id,
+        title: id,
+        location: {
+            pageId,
+            column: 'main',
+            position: { blockId: 'list-table', order },
+        },
+        component: ({ context }) => <div data-testid={`page-block-${id}`}>{context.pageId}</div>,
+});
+}
+
+function renderPageLayout(children: React.ReactNode, { isDesktop = true } = {}) {
+    useMediaQueryMock.mockReturnValue(isDesktop);
+    const noop = () => undefined;
+    const contextValue = {
+        settings: {
+            displayLanguage: 'en',
+            contentLanguage: 'en',
+            theme: 'system',
+            displayUiExtensionPoints: false,
+            mainNavExpanded: true,
+            activeChannelId: '',
+            devMode: false,
+            hasSeenOnboarding: false,
+            tableSettings: {},
+        },
+        settingsStoreIsAvailable: true,
+        setDisplayLanguage: noop,
+        setDisplayLocale: noop,
+        setContentLanguage: noop,
+        setTheme: noop,
+        setDisplayUiExtensionPoints: noop,
+        setMainNavExpanded: noop,
+        setActiveChannelId: noop,
+        setDevMode: noop,
+        setHasSeenOnboarding: noop,
+        setTableSettings: () => undefined,
+        setWidgetLayout: noop,
+    } as UserSettingsContextType;
+
+    return renderToStaticMarkup(
+        <UserSettingsContext.Provider value={contextValue}>
+            <PageContext.Provider value={{ pageId: 'customer-list' }}>
+                <PageLayout>{children}</PageLayout>
+            </PageContext.Provider>
+        </UserSettingsContext.Provider>,
+    );
+}
+
+function getRenderedBlockIds(markup: string) {
+    return Array.from(markup.matchAll(/data-testid="(page-block-[^"]+)"/g)).map(match => match[1]);
+}
+
+describe('PageLayout', () => {
+    beforeEach(() => {
+        useMediaQueryMock.mockReset();
+        useCopyToClipboardMock.mockReset();
+        useCopyToClipboardMock.mockReturnValue([null, vi.fn()]);
+        const pageBlockRegistry = globalRegistry.get('dashboardPageBlockRegistry');
+        pageBlockRegistry.clear();
+    });
+
+    it('renders multiple before/after extension blocks in registration order', () => {
+        registerBlock('before-1', 'before');
+        registerBlock('before-2', 'before');
+        registerBlock('after-1', 'after');
+
+        const markup = renderPageLayout(
+            <PageBlock column="main" blockId="list-table">
+        <div data-testid="page-block-original">original</div>
+            </PageBlock>,
+        { isDesktop: true },
+    );
+
+        expect(getRenderedBlockIds(markup)).toEqual([
+            'page-block-before-1',
+            'page-block-before-2',
+            'page-block-original',
+            'page-block-after-1',
+        ]);
+    });
+
+    it('replaces original block when replacement extensions are registered', () => {
+        registerBlock('replacement-1', 'replace');
+        registerBlock('replacement-2', 'replace');
+
+        const markup = renderPageLayout(
+            <PageBlock column="main" blockId="list-table">
+        <div data-testid="page-block-original">original</div>
+            </PageBlock>,
+        { isDesktop: true },
+    );
+
+        expect(getRenderedBlockIds(markup)).toEqual(['page-block-replacement-1', 'page-block-replacement-2']);
+    });
+
+    it('renders extension blocks in mobile layout', () => {
+        registerBlock('before-mobile', 'before');
+        registerBlock('after-mobile', 'after');
+
+        const markup = renderPageLayout(
+            <PageBlock column="main" blockId="list-table">
+        <div data-testid="page-block-original">original</div>
+            </PageBlock>,
+        { isDesktop: false },
+    );
+
+        expect(getRenderedBlockIds(markup)).toEqual([
+            'page-block-before-mobile',
+            'page-block-original',
+            'page-block-after-mobile',
+        ]);
+    });
+});

+ 79 - 38
packages/dashboard/src/lib/framework/layout-engine/page-layout.tsx

@@ -234,59 +234,100 @@ export function PageLayout({ children, className }: Readonly<PageLayoutProps>) {
             const blockId =
                 childBlock.props.blockId ??
                 (isOfType(childBlock, CustomFieldsPageBlock) ? 'custom-fields' : undefined);
-            const extensionBlock = extensionBlocks.find(block => block.location.position.blockId === blockId);
 
-            if (extensionBlock) {
-                let extensionBlockShouldRender = true;
-                if (typeof extensionBlock?.shouldRender === 'function') {
-                    extensionBlockShouldRender = extensionBlock.shouldRender(page);
+            // Get all extension blocks with the same position blockId
+            const matchingExtensionBlocks = extensionBlocks.filter(
+                block => block.location.position.blockId === blockId,
+            );
+
+            // sort the blocks to make sure we have the correct order
+            const arrangedExtensionBlocks = matchingExtensionBlocks.sort((a, b) => {
+                const orderPriority = { 'before': 1, 'replace': 2, 'after': 3 };
+                return orderPriority[a.location.position.order] - orderPriority[b.location.position.order];
+            })
+
+            // get the length of blocks with the "before" position to know when to insert the child block
+            const beforeExtensionBlocksLength = arrangedExtensionBlocks.filter(
+                block => block.location.position.order === 'before',
+            ).length;
+
+            const replacementBlockExists = arrangedExtensionBlocks.some(
+                block => block.location.position.order === 'replace',
+            )
+
+            let childBlockInserted = false;
+            if (matchingExtensionBlocks.length > 0) {
+                for (const extensionBlock of arrangedExtensionBlocks) {
+
+                    let extensionBlockShouldRender = true;
+                    if (typeof extensionBlock?.shouldRender === 'function') {
+                        extensionBlockShouldRender = extensionBlock.shouldRender(page);
+                    }
+
+                    // Insert child block before the first non-"before" block
+                    if (!childBlockInserted && !replacementBlockExists && extensionBlock.location.position.order !== 'before') {
+                        finalChildArray.push(childBlock)
+                        childBlockInserted = true
+                      }
+
+                    const ExtensionBlock =
+                        extensionBlock.component && extensionBlockShouldRender ? (
+                            <PageBlock
+                                key={extensionBlock.id}
+                                column={extensionBlock.location.column}
+                                blockId={extensionBlock.id}
+                                title={extensionBlock.title}
+                            >
+                                {<extensionBlock.component context={page} />}
+                            </PageBlock>
+                        ) : undefined;
+
+                    if(extensionBlockShouldRender && ExtensionBlock) {
+                        finalChildArray.push(ExtensionBlock)
+                    }
                 }
-                const ExtensionBlock =
-                    extensionBlock.component && extensionBlockShouldRender ? (
-                        <PageBlock
-                            key={childBlock.key}
-                            column={extensionBlock.location.column}
-                            blockId={extensionBlock.id}
-                            title={extensionBlock.title}
-                        >
-                            {<extensionBlock.component context={page} />}
-                        </PageBlock>
-                    ) : undefined;
-                if (extensionBlock.location.position.order === 'before') {
-                    finalChildArray.push(...[ExtensionBlock, childBlock].filter(x => !!x));
-                } else if (extensionBlock.location.position.order === 'after') {
-                    finalChildArray.push(...[childBlock, ExtensionBlock].filter(x => !!x));
-                } else if (
-                    extensionBlock.location.position.order === 'replace' &&
-                    extensionBlockShouldRender &&
-                    ExtensionBlock
-                ) {
-                    finalChildArray.push(ExtensionBlock);
+
+                // If all blocks were "before", insert child block at the end
+                if (!childBlockInserted && !replacementBlockExists) {
+                    finalChildArray.push(childBlock)
                 }
+
             } else {
                 finalChildArray.push(childBlock);
             }
         }
     }
 
-    const fullWidthBlocks = finalChildArray.filter(
-        child => isPageBlock(child) && isOfType(child, FullWidthPageBlock),
-    );
-    const mainBlocks = finalChildArray.filter(child => isPageBlock(child) && child.props.column === 'main');
-    const sideBlocks = finalChildArray.filter(child => isPageBlock(child) && child.props.column === 'side');
-
     return (
         <div className={cn('w-full space-y-4', className, '@container/layout')}>
             {isDesktop ? (
                 <div className="grid grid-cols-1 gap-4 @3xl/layout:grid-cols-4">
-                    {fullWidthBlocks.length > 0 && (
-                        <div className="@md/layout:col-span-5 space-y-4">{fullWidthBlocks}</div>
-                    )}
-                    <div className="@3xl/layout:col-span-3 space-y-4">{mainBlocks}</div>
-                    <div className="@3xl/layout:col-span-1 space-y-4">{sideBlocks}</div>
+                    {finalChildArray.map((child, index) => {
+                        const key = child.props?.blockId ?? `block-${index}`;
+                        if(isPageBlock(child )) {
+                            if (isOfType(child, FullWidthPageBlock)) {
+                                return (
+                                    <div key={key} className="@md/layout:col-span-5 space-y-4">{child}</div>
+                                );
+                            }
+
+                            if (child.props.column === 'main') {
+                                return (
+                                    <div key={key} className="@3xl/layout:col-span-3 space-y-4">{child}</div>
+                                )
+                            }
+
+                            if (child.props.column === 'side') {
+                                return (
+                                    <div key={key} className="@3xl/layout:col-span-1 space-y-4">{child}</div>
+                                )
+                            }
+                        }
+                        return null
+                    })}
                 </div>
             ) : (
-                <div className="space-y-4">{children}</div>
+                <div className="space-y-4">{finalChildArray}</div>
             )}
         </div>
     );