Browse Source

feat(dashboard): Implement meta title based on breadcrumb (#3834)

Michael Bromley 3 months ago
parent
commit
cb0463d60b

+ 11 - 12
packages/dashboard/index.html

@@ -1,15 +1,14 @@
 <!doctype html>
 <html lang="en">
-    <head>
-        <meta charset="UTF-8" />
-        <link rel="icon" type="image/png" href="/favicon.png" />
-        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-        <meta name="description" content="Vendure Admin Dashboard" />
-        <meta name="author" content="Vendure" />
-        <title>Vendure Admin Dashboard</title>
-    </head>
-    <body>
-        <div id="app"></div>
-        <script type="module" src="/src/app/main.jsx"></script>
-    </body>
+<head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/png" href="/favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="Vendure Admin Dashboard" />
+    <meta name="author" content="Vendure" />
+</head>
+<body>
+<div id="app"></div>
+<script type="module" src="/src/app/main.jsx"></script>
+</body>
 </html>

+ 263 - 0
packages/dashboard/src/app/common/use-page-title.test.ts

@@ -0,0 +1,263 @@
+import React from 'react';
+import { describe, expect, it } from 'vitest';
+
+import { normalizeBreadcrumb } from './use-page-title.js';
+
+describe('normalizeBreadcrumb', () => {
+    describe('null and undefined handling', () => {
+        it('should return empty string for null', () => {
+            expect(normalizeBreadcrumb(null)).toBe('');
+        });
+
+        it('should return empty string for undefined', () => {
+            expect(normalizeBreadcrumb(undefined)).toBe('');
+        });
+
+        it('should return empty string for empty array', () => {
+            expect(normalizeBreadcrumb([])).toBe('');
+        });
+    });
+
+    describe('string handling', () => {
+        it('should return string as-is', () => {
+            expect(normalizeBreadcrumb('Home')).toBe('Home');
+        });
+
+        it('should return empty string as-is', () => {
+            expect(normalizeBreadcrumb('')).toBe('');
+        });
+
+        it('should handle strings with special characters', () => {
+            expect(normalizeBreadcrumb('Settings & Config')).toBe('Settings & Config');
+        });
+    });
+
+    describe('number handling', () => {
+        it('should convert number to string', () => {
+            expect(normalizeBreadcrumb(42)).toBe('42');
+        });
+
+        it('should handle zero', () => {
+            expect(normalizeBreadcrumb(0)).toBe('0');
+        });
+
+        it('should handle negative numbers', () => {
+            expect(normalizeBreadcrumb(-123)).toBe('-123');
+        });
+
+        it('should handle decimal numbers', () => {
+            expect(normalizeBreadcrumb(3.14)).toBe('3.14');
+        });
+    });
+
+    describe('function handling', () => {
+        it('should call function and normalize the result', () => {
+            const fn = () => 'Dashboard';
+            expect(normalizeBreadcrumb(fn)).toBe('Dashboard');
+        });
+
+        it('should handle nested function returns', () => {
+            const fn = () => () => 'Nested Function';
+            expect(normalizeBreadcrumb(fn)).toBe('Nested Function');
+        });
+
+        it('should handle function returning React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'React Content');
+            const fn = () => mockReactElement;
+            expect(normalizeBreadcrumb(fn)).toBe('React Content');
+        });
+
+        it('should handle function returning array', () => {
+            const fn = () => ['First', 'Second', 'Third'];
+            expect(normalizeBreadcrumb(fn)).toBe('Third');
+        });
+
+        it('should handle function returning object with label', () => {
+            const fn = () => ({ label: 'Settings', path: '/settings' });
+            expect(normalizeBreadcrumb(fn)).toBe('Settings');
+        });
+    });
+
+    describe('array handling', () => {
+        it('should return last element of string array', () => {
+            expect(normalizeBreadcrumb(['Home', 'Products', 'Details'])).toBe('Details');
+        });
+
+        it('should handle single element array', () => {
+            expect(normalizeBreadcrumb(['Single'])).toBe('Single');
+        });
+
+        it('should handle array with React element at the end', () => {
+            const mockReactElement = React.createElement('span', {}, 'Last Item');
+            expect(normalizeBreadcrumb(['First', mockReactElement])).toBe('Last Item');
+        });
+
+        it('should handle array with object containing label at the end', () => {
+            const breadcrumbs = [
+                'Home',
+                { label: 'Products', path: '/products' },
+                { label: 'Details', path: '/products/123' },
+            ];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Details');
+        });
+
+        it('should handle array with function at the end', () => {
+            const breadcrumbs = ['Home', () => 'Dynamic Content'];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Dynamic Content');
+        });
+
+        it('should handle nested arrays', () => {
+            const breadcrumbs = ['First', ['Nested1', 'Nested2']];
+            expect(normalizeBreadcrumb(breadcrumbs)).toBe('Nested2');
+        });
+    });
+
+    describe('object with label property handling', () => {
+        it('should extract string label from object', () => {
+            const breadcrumb = { label: 'Settings', path: '/settings' };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Settings');
+        });
+
+        it('should handle React element as label', () => {
+            const mockReactElement = React.createElement('span', {}, 'Global Settings');
+            const breadcrumb = { label: mockReactElement, path: '/global-settings' };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
+        });
+
+        it('should handle function as label', () => {
+            const breadcrumb = {
+                label: () => 'Dynamic Label',
+                path: '/dynamic',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Dynamic Label');
+        });
+
+        it('should handle nested object with label', () => {
+            const breadcrumb = {
+                label: {
+                    label: 'Nested Label',
+                },
+                path: '/nested',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Nested Label');
+        });
+
+        it('should handle object with label containing array', () => {
+            const breadcrumb = {
+                label: ['First', 'Second', 'Third'],
+                path: '/array-label',
+            };
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Third');
+        });
+    });
+
+    describe('React element handling', () => {
+        it('should extract text from simple React element', () => {
+            const element = React.createElement('div', {}, 'Simple Text');
+            expect(normalizeBreadcrumb(element)).toBe('Simple Text');
+        });
+
+        it('should handle nested React elements', () => {
+            const innerElement = React.createElement('span', {}, 'Nested Text');
+            const element = React.createElement('div', {}, innerElement);
+            expect(normalizeBreadcrumb(element)).toBe('Nested Text');
+        });
+
+        it('should handle React element with array of children', () => {
+            const element = React.createElement('div', {}, ['Part 1', ' ', 'Part 2']);
+            expect(normalizeBreadcrumb(element)).toBe('Part 1 Part 2');
+        });
+
+        it('should handle React element with mixed children types', () => {
+            const nestedElement = React.createElement('span', {}, ' nested');
+            const element = React.createElement('div', {}, ['Text', 42, nestedElement]);
+            expect(normalizeBreadcrumb(element)).toBe('Text42 nested');
+        });
+    });
+
+    describe('complex nested scenarios', () => {
+        it('should handle array with object containing function returning React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'Complex Content');
+            const breadcrumb = [
+                'Home',
+                {
+                    label: () => mockReactElement,
+                    path: '/complex',
+                },
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Complex Content');
+        });
+
+        it('should handle function returning array with object containing React element', () => {
+            const mockReactElement = React.createElement('div', {}, 'Deep Nested');
+            const fn = () => ['Start', { label: mockReactElement, path: '/deep' }];
+            expect(normalizeBreadcrumb(fn)).toBe('Deep Nested');
+        });
+
+        it('should handle array with multiple levels of nesting', () => {
+            const breadcrumb = [
+                'Level1',
+                [
+                    'Level2-1',
+                    {
+                        label: () => ['Level3-1', 'Level3-2'],
+                        path: '/multi-level',
+                    },
+                ],
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Level3-2');
+        });
+
+        it('should handle Trans-like component structure', () => {
+            // Simulating a <Trans>Global Settings</Trans> component
+            const transElement = React.createElement(
+                'Trans',
+                {
+                    i18nKey: 'global.settings',
+                },
+                'Global Settings',
+            );
+            const breadcrumb = [
+                {
+                    path: '/global-settings',
+                    label: transElement,
+                },
+            ];
+            expect(normalizeBreadcrumb(breadcrumb)).toBe('Global Settings');
+        });
+    });
+
+    describe('edge cases', () => {
+        it('should handle boolean values', () => {
+            expect(normalizeBreadcrumb(true)).toBe('');
+            expect(normalizeBreadcrumb(false)).toBe('');
+        });
+
+        it('should handle objects without label property', () => {
+            const obj = { path: '/test', name: 'Test' };
+            expect(normalizeBreadcrumb(obj)).toBe('');
+        });
+
+        it('should handle symbols', () => {
+            const sym = Symbol('test');
+            expect(normalizeBreadcrumb(sym)).toBe('');
+        });
+
+        it('should handle circular references gracefully', () => {
+            const circular: any = { label: null };
+            circular.label = circular;
+            // This should not cause infinite recursion
+            // The function should detect the circular reference and return empty string
+            expect(() => normalizeBreadcrumb(circular)).not.toThrow();
+            expect(normalizeBreadcrumb(circular)).toBe('');
+        });
+
+        it('should handle very deeply nested structures', () => {
+            let deep: any = 'Final Value';
+            for (let i = 0; i < 100; i++) {
+                deep = { label: deep };
+            }
+            expect(normalizeBreadcrumb(deep)).toBe('Final Value');
+        });
+    });
+});

+ 86 - 0
packages/dashboard/src/app/common/use-page-title.ts

@@ -0,0 +1,86 @@
+import { useMatches } from '@tanstack/react-router';
+import React, { isValidElement, ReactElement, useEffect, useState } from 'react';
+
+const DEFAULT_TITLE = 'Vendure';
+
+/**
+ * @description
+ * Derives the meta title of the page based on the current route's breadcrumb
+ * data from the route loader.
+ */
+export function usePageTitle() {
+    const matches = useMatches();
+    const [pageTitle, setPageTitle] = useState<string>(DEFAULT_TITLE);
+
+    useEffect(() => {
+        const lastMatch = matches.at(-1);
+        const breadcrumb = (lastMatch?.loaderData as any)?.breadcrumb;
+
+        const breadcrumbTitle = normalizeBreadcrumb(breadcrumb);
+        setPageTitle([breadcrumbTitle, DEFAULT_TITLE].filter(x => !!x).join(' • '));
+    }, [matches]);
+
+    return pageTitle;
+}
+
+const renderNodeAsString = function (reactNode: React.ReactNode): string {
+    let string = '';
+    if (typeof reactNode === 'string') {
+        string = reactNode;
+    } else if (typeof reactNode === 'number') {
+        string = reactNode.toString();
+    } else if (Array.isArray(reactNode)) {
+        reactNode.forEach(function (child) {
+            string += renderNodeAsString(child);
+        });
+    } else if (isValidElement(reactNode)) {
+        string += renderNodeAsString((reactNode as ReactElement<any>).props.children);
+    }
+    return string;
+};
+
+/**
+ * Recursively normalizes a breadcrumb value to a string.
+ * Handles functions, arrays, objects with labels, and React nodes.
+ */
+export const normalizeBreadcrumb = (value: any, visited = new WeakSet()): string => {
+    // Handle null/undefined
+    if (value == null) {
+        return '';
+    }
+
+    // If it's a function, call it and normalize the result
+    if (typeof value === 'function') {
+        return normalizeBreadcrumb(value(), visited);
+    }
+
+    // If it's already a string, return it
+    if (typeof value === 'string') {
+        return value;
+    }
+
+    // If it's an array, normalize the last element
+    if (Array.isArray(value)) {
+        if (value.length === 0) {
+            return '';
+        }
+        return normalizeBreadcrumb(value.at(-1), visited);
+    }
+
+    // For objects, check for circular references
+    if (typeof value === 'object') {
+        // Prevent circular reference infinite loops
+        if (visited.has(value)) {
+            return '';
+        }
+        visited.add(value);
+
+        // If it's an object with a label property, normalize the label
+        if ('label' in value) {
+            return normalizeBreadcrumb(value.label, visited);
+        }
+    }
+
+    // For everything else (React nodes, numbers, etc.), use renderNodeAsString
+    return renderNodeAsString(value);
+};

+ 4 - 4
packages/dashboard/src/app/routes/__root.tsx

@@ -1,6 +1,7 @@
 import { AuthContext } from '@/vdb/providers/auth.js';
 import { QueryClient } from '@tanstack/react-query';
-import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
+import { createRootRouteWithContext, HeadContent, Outlet } from '@tanstack/react-router';
+import { usePageTitle } from '../common/use-page-title.js';
 
 export interface MyRouterContext {
     auth: AuthContext;
@@ -9,14 +10,13 @@ export interface MyRouterContext {
 
 export const Route = createRootRouteWithContext<MyRouterContext>()({
     component: RootComponent,
-    search: {
-        // middlewares: [retainSearchParams(['page', 'perPage', 'sort'] as any)],
-    },
 });
 
 function RootComponent() {
+    document.title = usePageTitle();
     return (
         <>
+            <HeadContent />
             <Outlet />
         </>
     );