Răsfoiți Sursa

feat(dashboard): Implement mechanism for extending ActionBar and PageBlocks

Michael Bromley 9 luni în urmă
părinte
comite
fb102af1dc

+ 1 - 0
package-lock.json

@@ -4609,6 +4609,7 @@
     },
     "node_modules/@clack/prompts/node_modules/is-unicode-supported": {
       "version": "1.3.0",
+      "extraneous": true,
       "inBundle": true,
       "license": "MIT",
       "engines": {

+ 12 - 0
packages/dashboard/src/framework/extension-api/define-dashboard-extension.ts

@@ -3,6 +3,8 @@ import { DashboardExtension } from '@/framework/extension-api/extension-api-type
 import { addNavMenuItem, NavMenuItem } from '@/framework/nav-menu/nav-menu.js';
 import { registerRoute } from '@/framework/page/page-api.js';
 
+import { registerDashboardActionBarItem, registerDashboardPageBlock } from '../layout-engine/register.js';
+
 const extensionSourceChangeCallbacks = new Set<() => void>();
 
 export function onExtensionSourceChange(callback: () => void) {
@@ -27,6 +29,16 @@ export function defineDashboardExtension(extension: DashboardExtension) {
             }
         }
     }
+    if (extension.actionBarItems) {
+        for (const item of extension.actionBarItems) {
+            registerDashboardActionBarItem(item);
+        }
+    }
+    if (extension.pageBlocks) {
+        for (const block of extension.pageBlocks) {
+            registerDashboardPageBlock(block);
+        }
+    }
     if (extension.widgets) {
         for (const widget of extension.widgets) {
             registerDashboardWidget(widget);

+ 36 - 1
packages/dashboard/src/framework/extension-api/extension-api-types.ts

@@ -1,9 +1,9 @@
-import { PageBreadcrumb } from '@/components/layout/generated-breadcrumbs.js';
 import { NavMenuItem } from '@/framework/nav-menu/nav-menu.js';
 import { AnyRoute, RouteOptions } from '@tanstack/react-router';
 import React from 'react';
 
 import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
+import { PageContext } from '../layout-engine/page-layout.js';
 
 export interface DashboardRouteDefinition {
     component: (route: AnyRoute) => React.ReactNode;
@@ -13,6 +13,39 @@ export interface DashboardRouteDefinition {
     loader?: RouteOptions['loader'];
 }
 
+export interface ActionBarButtonState {
+    disabled: boolean;
+    visible: boolean;
+}
+
+export interface DashboardActionBarItem {
+    locationId: string;
+    component: React.FunctionComponent<{ context: PageContext }>;
+    requiresPermission?: string | string[];
+}
+
+export interface DashboardActionBarDropdownMenuItem {
+    locationId: string;
+    component: React.FunctionComponent<{ context: PageContext }>;
+    requiresPermission?: string | string[];
+}
+
+export type PageBlockPosition = { blockId: string; order: 'before' | 'after' | 'replace' };
+
+export type PageBlockLocation = {
+    pageId: string;
+    position: PageBlockPosition;
+    column: 'main' | 'side';
+};
+
+export interface DashboardPageBlockDefinition {
+    id: string;
+    title?: React.ReactNode;
+    location: PageBlockLocation;
+    component: React.FunctionComponent<{ context: PageContext }>;
+    requiresPermission?: string | string[];
+}
+
 /**
  * @description
  * The main entry point for a dashboard extension.
@@ -21,4 +54,6 @@ export interface DashboardRouteDefinition {
 export interface DashboardExtension {
     routes: DashboardRouteDefinition[];
     widgets: DashboardWidgetDefinition[];
+    actionBarItems: DashboardActionBarItem[];
+    pageBlocks: DashboardPageBlockDefinition[];
 }

+ 97 - 23
packages/dashboard/src/framework/layout-engine/page-layout.tsx

@@ -1,21 +1,34 @@
 import { CustomFieldsForm } from '@/components/shared/custom-fields-form.js';
+import { Button } from '@/components/ui/button.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 React, { ComponentProps, PropsWithChildren } from 'react';
-import { Control, UseFormReturn } from 'react-hook-form';
 import { useMediaQuery } from '@uidotdev/usehooks';
+import React, { ComponentProps, createContext, PropsWithChildren, 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';
 
-export type PageBlockProps = {
-    children: React.ReactNode;
-    /** Which column this block should appear in */
-    column: 'main' | 'side';
-    title?: React.ReactNode | string;
-    description?: React.ReactNode | string;
-    className?: string;
-    borderless?: boolean;
-};
+export interface PageProps extends ComponentProps<'div'> {
+    pageId?: string;
+    entity?: any;
+}
+
+export const PageProvider = createContext<PageContext | undefined>(undefined);
+
+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>
+        </PageProvider>
+    );
+}
 
 export type PageLayoutProps = {
     children: React.ReactNode;
@@ -27,21 +40,51 @@ function isPageBlock(child: unknown): child is React.ReactElement<PageBlockProps
 }
 
 export function PageLayout({ children, className }: PageLayoutProps) {
+    const page = usePage();
     const isDesktop = useMediaQuery('only screen and (min-width : 769px)');
     // Separate blocks into categories
     const childArray: React.ReactElement<PageBlockProps>[] = [];
+    const extensionBlocks = getDashboardPageBlocks(page.pageId ?? '');
     React.Children.forEach(children, child => {
+        let childBlock: React.ReactElement<PageBlockProps> | undefined;
         if (isPageBlock(child)) {
-            childArray.push(child);
+            childBlock = child;
         }
         // check for a React Fragment
         if (React.isValidElement(child) && child.type === React.Fragment) {
             React.Children.forEach((child as React.ReactElement<PageBlockProps>).props.children, child => {
                 if (isPageBlock(child)) {
-                    childArray.push(child);
+                    childBlock = child;
                 }
             });
         }
+
+        if (childBlock) {
+            const blockId =
+                childBlock.props.blockId ??
+                (childBlock.type === CustomFieldsPageBlock ? 'custom-fields' : undefined);
+            const extensionBlock = extensionBlocks.find(block => block.location.position.blockId === blockId);
+            if (extensionBlock) {
+                const ExtensionBlock = (
+                    <PageBlock
+                        column={extensionBlock.location.column}
+                        blockId={extensionBlock.id}
+                        title={extensionBlock.title}
+                    >
+                        {<extensionBlock.component context={page} />}
+                    </PageBlock>
+                );
+                if (extensionBlock.location.position.order === 'before') {
+                    childArray.push(ExtensionBlock, childBlock);
+                } else if (extensionBlock.location.position.order === 'after') {
+                    childArray.push(childBlock, ExtensionBlock);
+                } else if (extensionBlock.location.position.order === 'replace') {
+                    childArray.push(ExtensionBlock);
+                }
+            } else {
+                childArray.push(childBlock);
+            }
+        }
     });
     const mainBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'main');
     const sideBlocks = childArray.filter(child => isPageBlock(child) && child.props.column === 'side');
@@ -69,6 +112,10 @@ export function PageDetailForm({
     form: UseFormReturn<any>;
     submitHandler: any;
 }) {
+    const page = usePage();
+    if (!page.form && form) {
+        page.setForm(form);
+    }
     return (
         <Form {...form}>
             <form onSubmit={submitHandler} className="space-y-8">
@@ -82,12 +129,11 @@ export function DetailFormGrid({ children }: { children: React.ReactNode }) {
     return <div className="md:grid md:grid-cols-2 gap-4 items-start mb-4">{children}</div>;
 }
 
-export function Page({ children, ...props }: PropsWithChildren<ComponentProps<'div'>>) {
-    return (
-        <div className={cn('m-4', props.className)} {...props}>
-            {children}
-        </div>
-    );
+export interface PageContext {
+    pageId?: string;
+    entity?: any;
+    form?: UseFormReturn<any>;
+    setForm: (form: UseFormReturn<any>) => void;
 }
 
 export function PageTitle({ children }: { children: React.ReactNode }) {
@@ -120,12 +166,40 @@ export function PageActionBarLeft({ children }: { children: React.ReactNode }) {
 }
 
 export function PageActionBarRight({ children }: { children: React.ReactNode }) {
-    return <div className="flex justify-end gap-2">{children}</div>;
+    const page = usePage();
+    const actionBarItems = page.pageId ? getDashboardActionBarItems(page.pageId) : [];
+    return (
+        <div className="flex justify-end gap-2">
+            {actionBarItems.map((item, index) => (
+                <PageActionBarItem key={index} item={item} page={page} />
+            ))}
+            {children}
+        </div>
+    );
 }
 
-export function PageBlock({ children, title, description, borderless }: PageBlockProps) {
+function PageActionBarItem({ item, page }: { item: DashboardActionBarItem; page: PageContext }) {
+    return (
+        <PermissionGuard requires={item.requiresPermission ?? []}>
+            <item.component context={page} />
+        </PermissionGuard>
+    );
+}
+
+export type PageBlockProps = {
+    children?: React.ReactNode;
+    /** Which column this block should appear in */
+    column: 'main' | 'side';
+    blockId?: string;
+    title?: React.ReactNode | string;
+    description?: React.ReactNode | string;
+    className?: string;
+    borderless?: boolean;
+};
+
+export function PageBlock({ children, title, description, borderless, className, blockId }: PageBlockProps) {
     return (
-        <Card className={cn('w-full')}>
+        <Card className={cn('w-full', className)}>
             {title || description ? (
                 <CardHeader>
                     {title && <CardTitle>{title}</CardTitle>}
@@ -151,7 +225,7 @@ export function CustomFieldsPageBlock({
         return null;
     }
     return (
-        <PageBlock column={column}>
+        <PageBlock column={column} locationId="custom-fields">
             <CustomFieldsForm entityType={entityType} control={control} />
         </PageBlock>
     );

+ 31 - 0
packages/dashboard/src/framework/layout-engine/register.ts

@@ -0,0 +1,31 @@
+import {
+    DashboardActionBarItem,
+    DashboardPageBlockDefinition,
+} from '../extension-api/extension-api-types.js';
+
+const dashboardActionBarItemRegistry = new Map<string, DashboardActionBarItem[]>();
+const dashboardPageBlockRegistry = new Map<string, DashboardPageBlockDefinition[]>();
+
+export function registerDashboardActionBarItem(item: DashboardActionBarItem) {
+    const items = dashboardActionBarItemRegistry.get(item.locationId) ?? [];
+    items.push(item);
+    dashboardActionBarItemRegistry.set(item.locationId, items);
+}
+
+export function getDashboardActionBarItemRegistry() {
+    return dashboardActionBarItemRegistry;
+}
+
+export function getDashboardActionBarItems(locationId: string) {
+    return dashboardActionBarItemRegistry.get(locationId) ?? [];
+}
+
+export function registerDashboardPageBlock(block: DashboardPageBlockDefinition) {
+    const blocks = dashboardPageBlockRegistry.get(block.location.pageId) ?? [];
+    blocks.push(block);
+    dashboardPageBlockRegistry.set(block.location.pageId, blocks);
+}
+
+export function getDashboardPageBlocks(pageId: string) {
+    return dashboardPageBlockRegistry.get(pageId) ?? [];
+}

+ 11 - 0
packages/dashboard/src/hooks/use-page.tsx

@@ -0,0 +1,11 @@
+import { PageProvider } from "@/framework/layout-engine/page-layout.js";
+import { useContext } from "react";
+
+export function usePage() {
+    const page = useContext(PageProvider);
+    if (!page) {
+        throw new Error('PageProvider not found');
+    }
+    return page;
+}
+    

+ 3 - 0
packages/dashboard/src/hooks/use-permissions.ts

@@ -8,6 +8,9 @@ export function usePermissions() {
     const { settings } = useUserSettings();
 
     function hasPermissions(permissions: string[]) {
+        if (permissions.length === 0) {
+            return true;
+        }
         const activeChannel = (channels ?? []).find(channel => channel.id === settings.activeChannelId);
         if (!activeChannel) {
             return false;

+ 1 - 1
packages/dashboard/src/routes/_authenticated/_collections/collections_.$id.tsx

@@ -118,7 +118,7 @@ export function CollectionDetailPage() {
     const currentInheritFiltersValue = form.watch('inheritFilters');
 
     return (
-        <Page>
+        <Page id="collection-detail">
             <PageTitle>{creatingNewEntity ? <Trans>New collection</Trans> : (entity?.name ?? '')}</PageTitle>
             <PageDetailForm form={form} submitHandler={submitHandler}>
                 <PageActionBar>

+ 7 - 7
packages/dashboard/src/routes/_authenticated/_products/products_.$id.tsx

@@ -89,7 +89,7 @@ export function ProductDetailPage() {
     });
 
     return (
-        <Page>
+        <Page pageId="product-detail" entity={entity}>
             <PageTitle>{creatingNewEntity ? <Trans>New product</Trans> : (entity?.name ?? '')}</PageTitle>
             <PageDetailForm form={form} submitHandler={submitHandler}>
                 <PageActionBar>
@@ -105,7 +105,7 @@ export function ProductDetailPage() {
                     </PageActionBarRight>
                 </PageActionBar>
                 <PageLayout>
-                    <PageBlock column="side">
+                    <PageBlock column="side" blockId="enabled-toggle">
                         <FormFieldWrapper
                             control={form.control}
                             name="enabled"
@@ -116,7 +116,7 @@ export function ProductDetailPage() {
                             )}
                         />
                     </PageBlock>
-                    <PageBlock column="main">
+                    <PageBlock column="main" blockId="main-form">
                         <DetailFormGrid>
                             <TranslatableFormFieldWrapper
                                 control={form.control}
@@ -141,12 +141,12 @@ export function ProductDetailPage() {
                     </PageBlock>
                     <CustomFieldsPageBlock column="main" entityType="Product" control={form.control} />
                     {entity && entity.variantList.totalItems > 0 && (
-                        <PageBlock column="main">
+                        <PageBlock column="main" blockId="product-variants-table">
                             <ProductVariantsTable productId={params.id} />
                         </PageBlock>
                     )}
                     {entity && entity.variantList.totalItems === 0 && (
-                        <PageBlock column="main">
+                        <PageBlock column="main" blockId="create-product-variants-dialog">
                             <CreateProductVariantsDialog
                                 productId={entity.id}
                                 productName={entity.name}
@@ -156,7 +156,7 @@ export function ProductDetailPage() {
                             />
                         </PageBlock>
                     )}
-                    <PageBlock column="side">
+                    <PageBlock column="side" blockId="facet-values">
                         <FormFieldWrapper
                             control={form.control}
                             name="facetValueIds"
@@ -166,7 +166,7 @@ export function ProductDetailPage() {
                             )}
                         />
                     </PageBlock>
-                    <PageBlock column="side">
+                    <PageBlock column="side" blockId="assets">
                         <FormItem>
                             <FormLabel>
                                 <Trans>Assets</Trans>

+ 0 - 1
packages/dashboard/src/routes/login.tsx

@@ -20,7 +20,6 @@ export const Route = createFileRoute('/login')({
 
 export default function LoginPage() {
     const auth = useAuth();
-    console.log('login page', auth);
     const isLoading = useRouterState({ select: s => s.isLoading });
     const navigate = Route.useNavigate();
     const search = Route.useSearch();