Sfoglia il codice sorgente

feat(dashboard): Allow override of all detail form inputs (#3642)

Michael Bromley 6 mesi fa
parent
commit
bce0ec9bf5

+ 1 - 1
packages/dashboard/src/lib/components/shared/copyable-text.tsx

@@ -14,7 +14,7 @@ export function CopyableText({ text }: Readonly<{ text: string }>) {
 
     return (
         <div className="flex items-center gap-2">
-            <div className="font-mono text-sm">{text}</div>
+            <div className="font-mono">{text}</div>
             <button
                 onClick={() => handleCopy(text, 'page')}
                 className="p-1 hover:bg-muted rounded-md transition-colors"

+ 26 - 12
packages/dashboard/src/lib/components/shared/form-field-wrapper.tsx

@@ -1,3 +1,5 @@
+import { OverriddenFormComponent } from '@/vdb/framework/form-engine/overridden-form-component.js';
+import { LocationWrapper } from '@/vdb/framework/layout-engine/location-wrapper.js';
 import { FieldPath, FieldValues } from 'react-hook-form';
 import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '../ui/form.js';
 
@@ -30,17 +32,29 @@ export function FormFieldWrapper<
     renderFormControl = true,
 }: FormFieldWrapperProps<TFieldValues, TName>) {
     return (
-        <FormField
-            control={control}
-            name={name}
-            render={renderArgs => (
-                <FormItem>
-                    {label && <FormLabel>{label}</FormLabel>}
-                    {renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
-                    {description && <FormDescription>{description}</FormDescription>}
-                    <FormMessage />
-                </FormItem>
-            )}
-        />
+        <LocationWrapper identifier={name}>
+            <FormField
+                control={control}
+                name={name}
+                render={renderArgs => (
+                    <FormItem>
+                        {label && <FormLabel>{label}</FormLabel>}
+                        {renderFormControl ? (
+                            <FormControl>
+                                <OverriddenFormComponent field={renderArgs.field} fieldName={name}>
+                                    {render(renderArgs)}
+                                </OverriddenFormComponent>
+                            </FormControl>
+                        ) : (
+                            <OverriddenFormComponent field={renderArgs.field} fieldName={name}>
+                                {render(renderArgs)}
+                            </OverriddenFormComponent>
+                        )}
+                        {description && <FormDescription>{description}</FormDescription>}
+                        <FormMessage />
+                    </FormItem>
+                )}
+            />
+        </LocationWrapper>
     );
 }

+ 26 - 12
packages/dashboard/src/lib/components/shared/translatable-form-field.tsx

@@ -1,3 +1,5 @@
+import { OverriddenFormComponent } from '@/vdb/framework/form-engine/overridden-form-component.js';
+import { LocationWrapper } from '@/vdb/framework/layout-engine/location-wrapper.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { Controller, ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
 import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '../ui/form.js';
@@ -53,17 +55,29 @@ export const TranslatableFormFieldWrapper = <
     ...props
 }: TranslatableFormFieldWrapperProps<TFieldValues>) => {
     return (
-        <TranslatableFormField
-            control={props.control}
-            name={name}
-            render={renderArgs => (
-                <FormItem>
-                    {label && <FormLabel>{label}</FormLabel>}
-                    {renderFormControl ? <FormControl>{render(renderArgs)}</FormControl> : render(renderArgs)}
-                    {description && <FormDescription>{description}</FormDescription>}
-                    <FormMessage />
-                </FormItem>
-            )}
-        />
+        <LocationWrapper identifier={name as string}>
+            <TranslatableFormField
+                control={props.control}
+                name={name}
+                render={renderArgs => (
+                    <FormItem>
+                        {label && <FormLabel>{label}</FormLabel>}
+                        {renderFormControl ? (
+                            <FormControl>
+                                <OverriddenFormComponent field={renderArgs.field} fieldName={name as string}>
+                                    {render(renderArgs)}
+                                </OverriddenFormComponent>
+                            </FormControl>
+                        ) : (
+                            <OverriddenFormComponent field={renderArgs.field} fieldName={name as string}>
+                                {render(renderArgs)}
+                            </OverriddenFormComponent>
+                        )}
+                        {description && <FormDescription>{description}</FormDescription>}
+                        <FormMessage />
+                    </FormItem>
+                )}
+            />
+        </LocationWrapper>
     );
 };

+ 51 - 0
packages/dashboard/src/lib/framework/form-engine/overridden-form-component.tsx

@@ -0,0 +1,51 @@
+import {
+    DataDisplayComponent,
+    DataInputComponent,
+    useComponentRegistry,
+} from '@/vdb/framework/component-registry/component-registry.js';
+import { generateInputComponentKey } from '@/vdb/framework/extension-api/input-component-extensions.js';
+import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
+import { usePage } from '@/vdb/hooks/use-page.js';
+import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
+
+export interface OverriddenFormComponent<
+    TFieldValues extends FieldValues = any,
+    TName extends FieldPath<TFieldValues> = any,
+> {
+    fieldName: string;
+    field: ControllerRenderProps<TFieldValues, TName>;
+    children?: React.ReactNode;
+}
+
+/**
+ * @description
+ * Based on the pageId and blockId of where this is placed, it will check whether any custom components
+ * are registered and render them if so. Otherwise, it will render the children, which act as the
+ * default if this location has not been overridden.
+ *
+ * ```tsx
+ * <OverriddenFormComponent fieldName="myField" field={field}>
+ *   <Input {...field} />
+ * </OverriddenFormComponent>
+ * ```
+ */
+export function OverriddenFormComponent({ fieldName, field, children }: Readonly<OverriddenFormComponent>) {
+    const page = usePage();
+    const pageBlock = usePageBlock({ optional: true });
+    const componentRegistry = useComponentRegistry();
+    let DisplayComponent: DataDisplayComponent | undefined;
+    let InputComponent: DataInputComponent | undefined;
+    if (page.pageId && pageBlock?.blockId) {
+        const customInputComponentKey = generateInputComponentKey(page.pageId, pageBlock.blockId, fieldName);
+        DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);
+        InputComponent = componentRegistry.getInputComponent(customInputComponentKey);
+    }
+    if (DisplayComponent) {
+        return <DisplayComponent {...field} />;
+    }
+
+    if (InputComponent) {
+        return <InputComponent {...field} />;
+    }
+    return children ?? null;
+}

+ 99 - 69
packages/dashboard/src/lib/framework/layout-engine/location-wrapper.tsx

@@ -1,98 +1,128 @@
 import { CopyableText } from '@/vdb/components/shared/copyable-text.js';
 import { Button } from '@/vdb/components/ui/button.js';
 import { Popover, PopoverContent, PopoverTrigger } from '@/vdb/components/ui/popover.js';
+import { usePageBlock } from '@/vdb/hooks/use-page-block.js';
 import { usePage } from '@/vdb/hooks/use-page.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
-import { Trans } from '@/vdb/lib/trans.js';
 import { cn } from '@/vdb/lib/utils.js';
-import { CodeXmlIcon, InfoIcon } from 'lucide-react';
-import { createContext, useContext, useState } from 'react';
+import { CodeXmlIcon } from 'lucide-react';
+import React, { useEffect, useState } from 'react';
 
-const LocationWrapperContext = createContext<{
-    parentId: string | null;
-    hoveredId: string | null;
-    setHoveredId: ((id: string | null) => void) | null;
-}>({
-    parentId: null,
-    hoveredId: null,
-    setHoveredId: null,
-});
+// Singleton state for hover tracking
+let globalHoveredId: string | null = null;
+const hoverListeners: Set<(id: string | null) => void> = new Set();
 
-export function LocationWrapper({
-    children,
-    blockId,
-}: Readonly<{ children: React.ReactNode; blockId?: string }>) {
+const setGlobalHoveredId = (id: string | null) => {
+    globalHoveredId = id;
+    hoverListeners.forEach(listener => listener(id));
+};
+
+export interface LocationWrapperProps {
+    children: React.ReactNode;
+    identifier?: string;
+}
+
+export function LocationWrapper({ children, identifier }: Readonly<LocationWrapperProps>) {
     const page = usePage();
+    const pageBlock = usePageBlock({ optional: true });
     const { settings } = useUserSettings();
     const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+    const blockId = pageBlock?.blockId ?? null;
     const isPageWrapper = !blockId;
 
-    const [hoveredIdTopLevel, setHoveredIdTopLevel] = useState<string | null>(null);
-    const { hoveredId, setHoveredId, parentId } = useContext(LocationWrapperContext);
-    const id = `${page.pageId}-${blockId ?? 'page'}`;
-    const isHovered = hoveredId === id || hoveredIdTopLevel === id;
+    const [hoveredId, setHoveredId] = useState<string | null>(globalHoveredId);
+    const id = `${page.pageId}-${blockId ?? 'page'}-${identifier ?? ''}`;
+    const isHovered = hoveredId === id;
+
+    // Subscribe to global hover changes
+    useEffect(() => {
+        const listener = (newHoveredId: string | null) => {
+            setHoveredId(newHoveredId);
+        };
+        hoverListeners.add(listener);
+        return () => {
+            hoverListeners.delete(listener);
+        };
+    }, []);
 
     const setHoverId = (id: string | null) => {
-        if (setHoveredId) {
-            setHoveredId(id);
-        } else {
-            setHoveredIdTopLevel(id);
+        setGlobalHoveredId(id);
+    };
+
+    const handleMouseEnter = () => {
+        // Set this element as hovered
+        setHoverId(id);
+    };
+
+    const handleMouseLeave = () => {
+        // If we're at the top level (page wrapper), go to null
+        // If we're at block level, go to page level
+        // If we're at identifier level, go to block level
+        if (isPageWrapper) {
+            setHoverId(null);
+        } else if (blockId && !identifier) {
+            // Block level - go to page level
+            setHoverId(`${page.pageId}-page-`);
+        } else if (identifier) {
+            // Identifier level - go to block level
+            setHoverId(`${page.pageId}-${blockId}-`);
         }
     };
 
     if (settings.devMode) {
         const pageId = page.pageId;
         return (
-            <LocationWrapperContext.Provider
-                value={{ hoveredId: hoveredIdTopLevel, setHoveredId: setHoveredIdTopLevel, parentId: id }}
+            <div
+                className={cn(
+                    `ring-2 transition-all delay-50 relative`,
+                    isHovered || isPopoverOpen ? 'ring-dev-mode' : 'ring-transparent',
+                    isPageWrapper ? 'ring-inset' : '',
+                    identifier ? 'rounded-md' : 'rounded-xl',
+                )}
+                onMouseEnter={handleMouseEnter}
+                onMouseLeave={handleMouseLeave}
             >
                 <div
-                    className={cn(
-                        `ring-2 rounded-xl transition-all delay-50 relative`,
-                        isHovered || isPopoverOpen ? 'ring-dev-mode' : 'ring-transparent',
-                        isPageWrapper ? 'ring-inset' : '',
-                    )}
-                    onMouseEnter={() => setHoverId(id)}
-                    onMouseLeave={() => setHoverId(parentId)}
+                    className={`absolute top-1 right-1 transition-all delay-50 z-10 ${isHovered || isPopoverOpen ? 'visible' : 'invisible'}`}
                 >
-                    <div
-                        className={`absolute top-0.5 right-0.5 transition-all delay-50 z-10 ${isHovered || isPopoverOpen ? 'visible' : 'invisible'}`}
-                    >
-                        <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
-                            <PopoverTrigger asChild>
-                                <Button variant="ghost" size="icon" className="rounded-lg">
-                                    <CodeXmlIcon className="text-dev-mode w-5 h-5" />
-                                </Button>
-                            </PopoverTrigger>
-                            <PopoverContent className="w-60">
-                                <div className="space-y-2">
-                                    <div className="flex items-center gap-2">
-                                        <InfoIcon className="h-4 w-4 text-dev-mode" />
-                                        <span className="font-medium">
-                                            <Trans>Location Details</Trans>
-                                        </span>
-                                    </div>
-                                    <div className="space-y-1.5">
-                                        {pageId && (
-                                            <div>
-                                                <div className="text-xs text-muted-foreground">pageId</div>
-                                                <CopyableText text={pageId} />
-                                            </div>
-                                        )}
-                                        {blockId && (
-                                            <div>
-                                                <div className="text-xs text-muted-foreground">blockId</div>
-                                                <CopyableText text={blockId} />
-                                            </div>
-                                        )}
-                                    </div>
+                    <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+                        <PopoverTrigger asChild>
+                            <Button
+                                variant="secondary"
+                                size="icon"
+                                className="h-8 w-8 rounded-full bg-dev-mode/10 hover:bg-dev-mode/20 border border-dev-mode/20 shadow-sm"
+                            >
+                                <CodeXmlIcon className="text-dev-mode w-4 h-4" />
+                            </Button>
+                        </PopoverTrigger>
+                        <PopoverContent className="w-48 p-3">
+                            <div className="space-y-2">
+                                <div className="space-y-1">
+                                    {pageId && (
+                                        <div className="text-xs">
+                                            <div className="text-muted-foreground mb-0.5">pageId</div>
+                                            <CopyableText text={pageId} />
+                                        </div>
+                                    )}
+                                    {blockId && (
+                                        <div className="text-xs">
+                                            <div className="text-muted-foreground mb-0.5">blockId</div>
+                                            <CopyableText text={blockId} />
+                                        </div>
+                                    )}
+                                    {identifier && (
+                                        <div className="text-xs">
+                                            <div className="text-muted-foreground mb-0.5">identifier</div>
+                                            <CopyableText text={identifier} />
+                                        </div>
+                                    )}
                                 </div>
-                            </PopoverContent>
-                        </Popover>
-                    </div>
-                    {children}
+                            </div>
+                        </PopoverContent>
+                    </Popover>
                 </div>
-            </LocationWrapperContext.Provider>
+                {children}
+            </div>
         );
     }
     return children;

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

@@ -364,8 +364,8 @@ export function PageBlock({
     column,
 }: Readonly<PageBlockProps>) {
     return (
-        <LocationWrapper blockId={blockId}>
-            <PageBlockContext.Provider value={{ blockId, title, description, column }}>
+        <PageBlockContext.Provider value={{ blockId, title, description, column }}>
+            <LocationWrapper>
                 <Card className={cn('w-full', className)}>
                     {title || description ? (
                         <CardHeader>
@@ -375,8 +375,8 @@ export function PageBlock({
                     ) : null}
                     <CardContent className={cn(!title ? 'pt-6' : '')}>{children}</CardContent>
                 </Card>
-            </PageBlockContext.Provider>
-        </LocationWrapper>
+            </LocationWrapper>
+        </PageBlockContext.Provider>
     );
 }
 
@@ -397,11 +397,11 @@ export function FullWidthPageBlock({
     blockId,
 }: Pick<PageBlockProps, 'children' | 'className' | 'blockId'>) {
     return (
-        <LocationWrapper blockId={blockId}>
-            <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
+        <PageBlockContext.Provider value={{ blockId, column: 'main' }}>
+            <LocationWrapper>
                 <div className={cn('w-full', className)}>{children}</div>
-            </PageBlockContext.Provider>
-        </LocationWrapper>
+            </LocationWrapper>
+        </PageBlockContext.Provider>
     );
 }
 

+ 3 - 31
packages/dashboard/src/lib/framework/page/detail-page.tsx

@@ -19,8 +19,6 @@ import {
 import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
 import { FormControl } from '@/vdb/components/ui/form.js';
 import { ControllerRenderProps, FieldPath, FieldValues } from 'react-hook-form';
-import { useComponentRegistry } from '../component-registry/component-registry.js';
-import { generateInputComponentKey } from '../extension-api/input-component-extensions.js';
 import {
     CustomFieldsPageBlock,
     DetailFormGrid,
@@ -96,8 +94,6 @@ export interface DetailPageFieldProps<
 > {
     fieldInfo: FieldInfo;
     field: ControllerRenderProps<TFieldValues, TName>;
-    blockId: string;
-    pageId: string;
 }
 
 /**
@@ -106,21 +102,7 @@ export interface DetailPageFieldProps<
 function FieldInputRenderer<
     TFieldValues extends FieldValues = FieldValues,
     TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
->({ fieldInfo, field, blockId, pageId }: DetailPageFieldProps<TFieldValues, TName>) {
-    const componentRegistry = useComponentRegistry();
-    const customInputComponentKey = generateInputComponentKey(pageId, blockId, fieldInfo.name);
-
-    const DisplayComponent = componentRegistry.getDisplayComponent(customInputComponentKey);
-    const InputComponent = componentRegistry.getInputComponent(customInputComponentKey);
-
-    if (DisplayComponent) {
-        return <DisplayComponent {...field} />;
-    }
-
-    if (InputComponent) {
-        return <InputComponent {...field} />;
-    }
-
+>({ fieldInfo, field }: DetailPageFieldProps<TFieldValues, TName>) {
     switch (fieldInfo.type) {
         case 'Int':
         case 'Float':
@@ -244,12 +226,7 @@ export function DetailPage<
                                         label={fieldInfo.name}
                                         renderFormControl={false}
                                         render={({ field }) => (
-                                            <FieldInputRenderer
-                                                fieldInfo={fieldInfo}
-                                                field={field}
-                                                blockId="main-form"
-                                                pageId={pageId}
-                                            />
+                                            <FieldInputRenderer fieldInfo={fieldInfo} field={field} />
                                         )}
                                     />
                                 );
@@ -267,12 +244,7 @@ export function DetailPage<
                                         label={fieldInfo.name}
                                         renderFormControl={false}
                                         render={({ field }) => (
-                                            <FieldInputRenderer
-                                                fieldInfo={fieldInfo}
-                                                field={field}
-                                                blockId="main-form"
-                                                pageId={pageId}
-                                            />
+                                            <FieldInputRenderer fieldInfo={fieldInfo} field={field} />
                                         )}
                                     />
                                 );

+ 10 - 2
packages/dashboard/src/lib/hooks/use-page-block.tsx

@@ -1,9 +1,17 @@
 import { PageBlockContext } from '@/vdb/framework/layout-engine/page-block-provider.js';
 import { useContext } from 'react';
 
-export function usePageBlock() {
+/**
+ * @description
+ * Returns the current PageBlock context, which means there must be
+ * a PageBlock ancestor component higher in the tree.
+ *
+ * If `optional` is set to true, the hook will not throw if no PageBlock
+ * exists higher in the tree, but will just return undefined.
+ */
+export function usePageBlock({ optional }: { optional?: boolean } = {}) {
     const pageBlock = useContext(PageBlockContext);
-    if (!pageBlock) {
+    if (!pageBlock && !optional) {
         throw new Error('PageBlockProvider not found');
     }
     return pageBlock;