Browse Source

feat(dashboard): Implement dev mode UI

Michael Bromley 10 months ago
parent
commit
881a53514a

File diff suppressed because it is too large
+ 140 - 93
package-lock.json


+ 1 - 0
packages/dashboard/package.json

@@ -37,6 +37,7 @@
     "@radix-ui/react-collapsible": "^1.1.3",
     "@radix-ui/react-dialog": "^1.1.6",
     "@radix-ui/react-dropdown-menu": "^2.1.6",
+    "@radix-ui/react-hover-card": "^1.1.6",
     "@radix-ui/react-label": "^2.1.2",
     "@radix-ui/react-popover": "^1.1.6",
     "@radix-ui/react-scroll-area": "^1.2.3",

+ 28 - 10
packages/dashboard/src/components/layout/nav-user.tsx

@@ -3,14 +3,7 @@
 import { useAuth } from '@/hooks/use-auth.js';
 import { Route } from '@/routes/_authenticated.js';
 import { Link, useRouter } from '@tanstack/react-router';
-import {
-    ChevronsUpDown,
-    LogOut,
-    Monitor,
-    Moon,
-    Sparkles,
-    Sun
-} from 'lucide-react';
+import { ChevronsUpDown, LogOut, Monitor, Moon, Sparkles, Sun } from 'lucide-react';
 
 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.js';
 import {
@@ -34,13 +27,15 @@ import { useMemo } from 'react';
 import { Dialog, DialogTrigger } from '../ui/dialog.js';
 import { LanguageDialog } from './language-dialog.js';
 import { Theme } from '@/providers/theme-provider.js';
+import { Badge } from '../ui/badge.js';
+import { Trans } from '@lingui/react/macro';
 
 export function NavUser() {
     const { isMobile } = useSidebar();
     const router = useRouter();
     const navigate = Route.useNavigate();
     const { user, ...auth } = useAuth();
-    const { settings, setTheme } = useUserSettings();
+    const { settings, setTheme, setDevMode } = useUserSettings();
 
     const handleLogout = () => {
         auth.logout(() => {
@@ -58,6 +53,8 @@ export function NavUser() {
         return user.firstName.charAt(0) + user.lastName.charAt(0);
     }, [user]);
 
+    const isDevMode = (import.meta as any).env?.MODE === 'development';
+
     return (
         <SidebarMenu>
             <SidebarMenuItem>
@@ -126,7 +123,7 @@ export function NavUser() {
                                         <DropdownMenuSubContent>
                                             <DropdownMenuRadioGroup
                                                 value={settings.theme}
-                                                onValueChange={(value) => setTheme(value as Theme)}
+                                                onValueChange={value => setTheme(value as Theme)}
                                             >
                                                 <DropdownMenuRadioItem value="light">
                                                     <Sun />
@@ -145,6 +142,27 @@ export function NavUser() {
                                     </DropdownMenuPortal>
                                 </DropdownMenuSub>
                             </DropdownMenuGroup>
+                            {isDevMode && (
+                                <DropdownMenuItem
+                                    onClick={e => {
+                                        e.preventDefault();
+                                        setDevMode(!settings.devMode);
+                                    }}
+                                >
+                                    <div className="flex items-center gap-2">
+                                        <Trans>Dev Mode</Trans>
+                                        {settings.devMode ? (
+                                            <Badge variant="success">
+                                                <Trans>enabled</Trans>
+                                            </Badge>
+                                        ) : (
+                                            <Badge variant="outline">
+                                                <Trans>disabled</Trans>
+                                            </Badge>
+                                        )}
+                                    </div>
+                                </DropdownMenuItem>
+                            )}
                             <DropdownMenuSeparator />
                             <DropdownMenuItem onClick={handleLogout}>
                                 <LogOut />

+ 31 - 0
packages/dashboard/src/components/shared/copyable-text.tsx

@@ -0,0 +1,31 @@
+import { useCopyToClipboard } from '@uidotdev/usehooks';
+import { CopyIcon } from 'lucide-react';
+import { useState } from 'react';
+import { CheckIcon } from 'lucide-react';
+
+export function CopyableText({ text }: { text: string }) {
+    const [copiedId, setCopiedId] = useState<string | null>(null);
+    const [, copy] = useCopyToClipboard();
+
+    const handleCopy = async (text: string, id: string) => {
+        await copy(text);
+        setCopiedId(id);
+        setTimeout(() => setCopiedId(null), 2000);
+    };
+
+    return (
+        <div className="flex items-center gap-2">
+            <div className="font-mono text-sm">{text}</div>
+            <button
+                onClick={() => handleCopy(text, 'page')}
+                className="p-1 hover:bg-muted rounded-md transition-colors"
+            >
+                {copiedId === 'page' ? (
+                    <CheckIcon className="h-4 w-4 text-green-500" />
+                ) : (
+                    <CopyIcon className="h-4 w-4" />
+                )}
+            </button>
+        </div>
+    );
+}

+ 42 - 0
packages/dashboard/src/components/ui/hover-card.tsx

@@ -0,0 +1,42 @@
+import * as React from "react"
+import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({
+  ...props
+}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
+  return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
+}
+
+function HoverCardTrigger({
+  ...props
+}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
+  return (
+    <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
+  )
+}
+
+function HoverCardContent({
+  className,
+  align = "center",
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
+  return (
+    <HoverCardPrimitive.Portal data-slot="hover-card-portal">
+      <HoverCardPrimitive.Content
+        data-slot="hover-card-content"
+        align={align}
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
+          className
+        )}
+        {...props}
+      />
+    </HoverCardPrimitive.Portal>
+  )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }

+ 96 - 0
packages/dashboard/src/framework/layout-engine/location-wrapper.tsx

@@ -0,0 +1,96 @@
+import { CopyableText } from '@/components/shared/copyable-text.js';
+import { Button } from '@/components/ui/button.js';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover.js';
+import { usePage } from '@/hooks/use-page.js';
+import { useUserSettings } from '@/hooks/use-user-settings.js';
+import { cn } from '@/lib/utils.js';
+import { Trans } from '@lingui/react/macro';
+import { CodeXmlIcon, InfoIcon } from 'lucide-react';
+import { createContext, useContext, useState } from 'react';
+
+const LocationWrapperContext = createContext<{
+    parentId: string | null;
+    hoveredId: string | null;
+    setHoveredId: ((id: string | null) => void) | null;
+}>({
+    parentId: null,
+    hoveredId: null,
+    setHoveredId: null,
+});
+
+export function LocationWrapper({ children, blockId }: { children: React.ReactNode; blockId?: string }) {
+    const page = usePage();
+    const { settings } = useUserSettings();
+    const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+    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 setHoverId = (id: string | null) => {
+        if (setHoveredId) {
+            setHoveredId(id);
+        } else {
+            setHoveredIdTopLevel(id);
+        }
+    };
+
+    if (settings.devMode) {
+        const pageId = page.pageId;
+        return (
+            <LocationWrapperContext.Provider
+                value={{ hoveredId: hoveredIdTopLevel, setHoveredId: setHoveredIdTopLevel, parentId: id }}
+            >
+                <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)}
+                >
+                    <div
+                        className={`absolute top-0.5 right-0.5 transition-all delay-50 ${isHovered || isPopoverOpen ? 'visible' : 'invisible'}`}
+                    >
+                        <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+                            <PopoverTrigger asChild>
+                                <Button variant="ghost" size="icon">
+                                    <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>
+                                </div>
+                            </PopoverContent>
+                        </Popover>
+                    </div>
+                    {children}
+                </div>
+            </LocationWrapperContext.Provider>
+        );
+    }
+    return children;
+}

+ 22 - 16
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -1,16 +1,16 @@
 import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
-import { Button } from '@/components/ui/button.js';
+import { PermissionGuard } from '@/components/shared/permission-guard.js';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
 import { Form } from '@/components/ui/form.js';
 import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { usePage } from '@/hooks/use-page.js';
 import { cn } from '@/lib/utils.js';
 import { useMediaQuery } from '@uidotdev/usehooks';
-import React, { ComponentProps, createContext, PropsWithChildren, useState } from 'react';
+import React, { ComponentProps, createContext, useState } from 'react';
 import { Control, UseFormReturn } from 'react-hook-form';
 import { DashboardActionBarItem } from '../extension-api/extension-api-types.js';
 import { getDashboardActionBarItems, getDashboardPageBlocks } from './register.js';
-import { PermissionGuard } from '@/components/shared/permission-guard.js';
+import { LocationWrapper } from './location-wrapper.js';
 
 export interface PageProps extends ComponentProps<'div'> {
     pageId?: string;
@@ -23,9 +23,11 @@ export function Page({ children, ...props }: PageProps) {
     const [form, setForm] = useState<UseFormReturn<any> | undefined>(undefined);
     return (
         <PageProvider value={{ pageId: props.pageId ?? '', form, setForm, entity: props.entity }}>
-            <div className={cn('m-4', props.className)} {...props}>
-                {children}
-            </div>
+            <LocationWrapper>
+                <div className={cn('m-4', props.className)} {...props}>
+                    {children}
+                </div>
+            </LocationWrapper>
         </PageProvider>
     );
 }
@@ -199,15 +201,19 @@ export type PageBlockProps = {
 
 export function PageBlock({ children, title, description, borderless, className, blockId }: PageBlockProps) {
     return (
-        <Card className={cn('w-full', className)}>
-            {title || description ? (
-                <CardHeader>
-                    {title && <CardTitle>{title}</CardTitle>}
-                    {description && <CardDescription>{description}</CardDescription>}
-                </CardHeader>
-            ) : null}
-            <CardContent className={cn(!title ? 'pt-6' : '', borderless && 'p-0')}>{children}</CardContent>
-        </Card>
+        <LocationWrapper blockId={blockId}>
+            <Card className={cn('w-full', className)}>
+                {title || description ? (
+                    <CardHeader>
+                        {title && <CardTitle>{title}</CardTitle>}
+                        {description && <CardDescription>{description}</CardDescription>}
+                    </CardHeader>
+                ) : null}
+                <CardContent className={cn(!title ? 'pt-6' : '', borderless && 'p-0')}>
+                    {children}
+                </CardContent>
+            </Card>
+        </LocationWrapper>
     );
 }
 
@@ -225,7 +231,7 @@ export function CustomFieldsPageBlock({
         return null;
     }
     return (
-        <PageBlock column={column} locationId="custom-fields">
+        <PageBlock column={column} blockId="custom-fields">
             <CustomFieldsForm entityType={entityType} control={control} />
         </PageBlock>
     );

+ 4 - 0
packages/dashboard/src/providers/user-settings.tsx

@@ -9,6 +9,7 @@ export interface UserSettings {
     displayUiExtensionPoints: boolean;
     mainNavExpanded: boolean;
     activeChannelId: string;
+    devMode: boolean;
 }
 
 const defaultSettings: UserSettings = {
@@ -19,6 +20,7 @@ const defaultSettings: UserSettings = {
     displayUiExtensionPoints: false,
     mainNavExpanded: true,
     activeChannelId: '',
+    devMode: false,
 };
 
 interface UserSettingsContextType {
@@ -30,6 +32,7 @@ interface UserSettingsContextType {
     setDisplayUiExtensionPoints: (display: boolean) => void;
     setMainNavExpanded: (expanded: boolean) => void;
     setActiveChannelId: (channelId: string) => void;
+    setDevMode: (devMode: boolean) => void;
 }
 
 export const UserSettingsContext = createContext<UserSettingsContextType | undefined>(undefined);
@@ -75,6 +78,7 @@ export const UserSettingsProvider: React.FC<React.PropsWithChildren<{}>> = ({ ch
         setDisplayUiExtensionPoints: display => updateSetting('displayUiExtensionPoints', display),
         setMainNavExpanded: expanded => updateSetting('mainNavExpanded', expanded),
         setActiveChannelId: channelId => updateSetting('activeChannelId', channelId),
+        setDevMode: devMode => updateSetting('devMode', devMode),
     };
 
     return <UserSettingsContext.Provider value={contextValue}>{children}</UserSettingsContext.Provider>;

+ 6 - 0
packages/dashboard/src/styles.css

@@ -23,6 +23,8 @@
     --destructive-foreground: hsl(0 0% 98%);
     --success: hsl(100, 81%, 35%);
     --success-foreground: hsl(0 0% 98%);
+    --dev-mode: hsl(204, 76%, 62%);
+    --dev-mode-foreground: hsl(0 0% 98%);
     --border: hsl(0 0% 89.8%);
     --input: hsl(0 0% 89.8%);
     --ring: hsl(0 0% 3.9%);
@@ -61,6 +63,8 @@
     --destructive-foreground: hsl(0 0% 98%);
     --success: hsl(100, 100%, 35%);
     --success-foreground: hsl(0 0% 98%);
+    --dev-mode: hsl(204, 86%, 53%);
+    --dev-mode-foreground: hsl(0 0% 98%);
     --border: hsl(0 0% 14.9%);
     --input: hsl(0 0% 14.9%);
     --ring: hsl(0 0% 83.1%);
@@ -98,6 +102,8 @@
     --color-destructive-foreground: var(--destructive-foreground);
     --color-success: var(--success);
     --color-success-foreground: var(--success-foreground);
+    --color-dev-mode: var(--dev-mode);
+    --color-dev-mode-foreground: var(--dev-mode-foreground);
     --color-border: var(--border);
     --color-input: var(--input);
     --color-ring: var(--ring);

+ 25 - 1
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,4 +1,5 @@
-import { defineDashboardExtension } from '@vendure/dashboard';
+import { Button, defineDashboardExtension } from '@vendure/dashboard';
+
 import { CustomWidget } from './custom-widget';
 import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
@@ -13,4 +14,27 @@ export default defineDashboardExtension({
             defaultSize: { w: 3, h: 3 },
         },
     ],
+    actionBarItems: [
+        {
+            label: 'Custom Action Bar Item',
+            component: props => {
+                return <Button>YOLO swag</Button>;
+            },
+            locationId: 'product-detail',
+        },
+    ],
+    pageBlocks: [
+        {
+            id: 'my-block',
+            component: ({ context }) => {
+                return <div>Here is my custom block!</div>;
+            },
+            title: 'My Custom Block',
+            location: {
+                pageId: 'product-detail',
+                column: 'side',
+                position: { blockId: 'main-form', order: 'after' },
+            },
+        },
+    ],
 });

Some files were not shown because too many files changed in this diff