Просмотр исходного кода

fix(dashboard): Improve layout of permissions grid (#3827)

Michael Bromley 3 месяцев назад
Родитель
Сommit
5c3ae0f5dc

+ 0 - 120
packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-grid.tsx

@@ -1,120 +0,0 @@
-import {
-    Accordion,
-    AccordionContent,
-    AccordionItem,
-    AccordionTrigger,
-} from '@/vdb/components/ui/accordion.js';
-import { Button } from '@/vdb/components/ui/button.js';
-import { Switch } from '@/vdb/components/ui/switch.js';
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
-import { useGroupedPermissions } from '@/vdb/hooks/use-grouped-permissions.js';
-import { Trans, useLingui } from '@/vdb/lib/trans.js';
-import { ServerConfig } from '@/vdb/providers/server-config.js';
-import { useState } from 'react';
-
-interface PermissionsGridProps {
-    value: string[];
-    onChange: (permissions: string[]) => void;
-    readonly?: boolean;
-}
-
-export function PermissionsGrid({ value, onChange, readonly = false }: Readonly<PermissionsGridProps>) {
-    const { i18n } = useLingui();
-    const groupedPermissions = useGroupedPermissions();
-
-    const setPermission = (permission: string, checked: boolean) => {
-        if (readonly) return;
-
-        const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
-        onChange(newPermissions);
-    };
-
-    const toggleAll = (defs: ServerConfig['permissions']) => {
-        if (readonly) return;
-
-        const shouldEnable = defs.some(d => !value.includes(d.name));
-        const newPermissions = shouldEnable
-            ? [...new Set([...value, ...defs.map(d => d.name)])]
-            : value.filter(p => !defs.some(d => d.name === p));
-        onChange(newPermissions);
-    };
-
-    // Get default expanded sections based on which ones have active permissions
-    const defaultExpandedSections = groupedPermissions
-        .map(section => ({
-            section,
-            hasActivePermissions: section.permissions.some(permission => value.includes(permission.name)),
-        }))
-        .filter(({ hasActivePermissions }) => hasActivePermissions)
-        .map(({ section }) => section.id);
-
-    const [accordionValue, setAccordionValue] = useState<string[]>(defaultExpandedSections);
-
-    return (
-        <div className="w-full">
-            <Accordion
-                type="multiple"
-                value={accordionValue.length ? accordionValue : defaultExpandedSections}
-                onValueChange={setAccordionValue}
-                className="space-y-4"
-            >
-                {groupedPermissions.map((section, index) => (
-                    <AccordionItem key={index} value={section.id} className="border rounded-lg px-6">
-                        <AccordionTrigger className="hover:no-underline">
-                            <div className="flex flex-col items-start gap-1 text-sm py-2">
-                                <div>{i18n.t(section.label)}</div>
-                                <div className="text-muted-foreground text-sm font-normal">
-                                    {i18n.t(section.description)}
-                                </div>
-                            </div>
-                        </AccordionTrigger>
-                        <AccordionContent>
-                            <div className="pb-4 space-y-4">
-                                {section.permissions.length > 1 && !readonly && (
-                                    <Button
-                                        variant="outline"
-                                        type="button"
-                                        size="sm"
-                                        onClick={() => toggleAll(section.permissions)}
-                                        className="w-fit"
-                                    >
-                                        <Trans>Toggle all</Trans>
-                                    </Button>
-                                )}
-                                <div className="md:grid md:grid-cols-4 md:gap-2 space-y-2">
-                                    {section.permissions.map(permission => (
-                                        <div key={permission.name} className="flex items-center space-x-2">
-                                            <Switch
-                                                id={permission.name}
-                                                checked={value.includes(permission.name)}
-                                                onCheckedChange={checked =>
-                                                    setPermission(permission.name, checked)
-                                                }
-                                                disabled={readonly}
-                                            />
-                                            <TooltipProvider>
-                                                <Tooltip>
-                                                    <TooltipTrigger asChild>
-                                                        <label
-                                                            htmlFor={permission.name}
-                                                            className="text-sm whitespace-nowrap"
-                                                        >
-                                                            {i18n.t(permission.name)}
-                                                        </label>
-                                                    </TooltipTrigger>
-                                                    <TooltipContent align="end">
-                                                        <p>{i18n.t(permission.description)}</p>
-                                                    </TooltipContent>
-                                                </Tooltip>
-                                            </TooltipProvider>
-                                        </div>
-                                    ))}
-                                </div>
-                            </div>
-                        </AccordionContent>
-                    </AccordionItem>
-                ))}
-            </Accordion>
-        </div>
-    );
-}

+ 251 - 0
packages/dashboard/src/app/routes/_authenticated/_roles/components/permissions-table-grid.tsx

@@ -0,0 +1,251 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { Switch } from '@/vdb/components/ui/switch.js';
+import { Table, TableBody, TableCell, TableRow } from '@/vdb/components/ui/table.js';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/vdb/components/ui/tooltip.js';
+import { useGroupedPermissions } from '@/vdb/hooks/use-grouped-permissions.js';
+import { Trans, useLingui } from '@/vdb/lib/trans.js';
+import { ServerConfig } from '@/vdb/providers/server-config.js';
+import { InfoIcon } from 'lucide-react';
+
+interface PermissionsTableGridProps {
+    value: string[];
+    onChange: (permissions: string[]) => void;
+    readonly?: boolean;
+}
+
+export function PermissionsTableGrid({
+    value,
+    onChange,
+    readonly = false,
+}: Readonly<PermissionsTableGridProps>) {
+    const { i18n } = useLingui();
+    const groupedPermissions = useGroupedPermissions();
+
+    const setPermission = (permission: string, checked: boolean) => {
+        if (readonly) return;
+
+        const newPermissions = checked ? [...value, permission] : value.filter(p => p !== permission);
+        onChange(newPermissions);
+    };
+
+    const toggleAll = (defs: ServerConfig['permissions']) => {
+        if (readonly) return;
+
+        const shouldEnable = defs.some(d => !value.includes(d.name));
+        const newPermissions = shouldEnable
+            ? [...new Set([...value, ...defs.map(d => d.name)])]
+            : value.filter(p => !defs.some(d => d.name === p));
+        onChange(newPermissions);
+    };
+
+    // Extract CRUD operation from permission name (e.g., "CreateAdministrator" -> "Create")
+    const getPermissionLabel = (permission: ServerConfig['permissions'][0], groupLabel: string) => {
+        const name = permission.name;
+        const crudPrefixes = ['Create', 'Read', 'Update', 'Delete'];
+
+        for (const prefix of crudPrefixes) {
+            if (name.startsWith(prefix)) {
+                // Check if the rest matches the group name (singular form)
+                const remainder = name.substring(prefix.length);
+                const groupSingular = groupLabel.replace(/s$/, ''); // Simple singularization
+                if (remainder.toLowerCase() === groupSingular.toLowerCase().replace(/\s/g, '')) {
+                    return prefix;
+                }
+            }
+        }
+
+        // Fallback to full name if not a CRUD operation
+        return i18n.t(name);
+    };
+
+    return (
+        <div className="w-full">
+            {/* Desktop Table View */}
+            <div className="hidden md:block border rounded-lg">
+                <Table>
+                    <TableBody>
+                        {groupedPermissions.map((section, index) => (
+                            <TableRow key={index} className="hover:bg-transparent">
+                                <TableCell className="bg-muted/50 p-3 align-top w-[150px] min-w-[150px] border-r">
+                                    <div className="space-y-2">
+                                        <div className="flex items-center gap-2">
+                                            <span className="font-semibold text-sm">
+                                                {i18n.t(section.label)}
+                                            </span>
+                                            <TooltipProvider>
+                                                <Tooltip>
+                                                    <TooltipTrigger asChild>
+                                                        <InfoIcon className="h-3 w-3 text-muted-foreground" />
+                                                    </TooltipTrigger>
+                                                    <TooltipContent side="right" className="max-w-[250px]">
+                                                        <p className="text-xs">
+                                                            {i18n.t(section.description)}
+                                                        </p>
+                                                    </TooltipContent>
+                                                </Tooltip>
+                                            </TooltipProvider>
+                                        </div>
+                                        {section.permissions.length > 1 && !readonly && (
+                                            <Button
+                                                variant="outline"
+                                                size="sm"
+                                                onClick={() => toggleAll(section.permissions)}
+                                                className="h-6 px-2 text-xs"
+                                            >
+                                                <Trans>Toggle all</Trans>
+                                            </Button>
+                                        )}
+                                    </div>
+                                </TableCell>
+                                {sortPermissions(section.permissions).map((permission, permIndex) => (
+                                    <TableCell
+                                        key={permission.name}
+                                        className="p-2 text-center align-top min-w-[80px]"
+                                        colSpan={section.permissions.length === 1 ? 4 : 1}
+                                    >
+                                        <TooltipProvider>
+                                            <Tooltip>
+                                                <TooltipTrigger asChild>
+                                                    <div className="flex flex-col items-center space-y-1.5">
+                                                        <Switch
+                                                            id={`${section.id}-${permission.name}`}
+                                                            checked={value.includes(permission.name)}
+                                                            onCheckedChange={checked =>
+                                                                setPermission(permission.name, checked)
+                                                            }
+                                                            disabled={readonly}
+                                                            className="scale-90"
+                                                        />
+                                                        <label
+                                                            htmlFor={`${section.id}-${permission.name}`}
+                                                            className="text-xs text-center cursor-pointer leading-tight"
+                                                        >
+                                                            {getPermissionLabel(permission, section.label)}
+                                                        </label>
+                                                    </div>
+                                                </TooltipTrigger>
+                                                <TooltipContent side="top" className="max-w-[250px]">
+                                                    <div className="text-xs">
+                                                        <div className="font-medium">
+                                                            {i18n.t(permission.name)}
+                                                        </div>
+                                                        <div className="text-accent-foreground/70 mt-1">
+                                                            {i18n.t(permission.description)}
+                                                        </div>
+                                                    </div>
+                                                </TooltipContent>
+                                            </Tooltip>
+                                        </TooltipProvider>
+                                    </TableCell>
+                                ))}
+                                {/* Fill remaining columns if less than 4 permissions */}
+                                {section.permissions.length < 4 &&
+                                    section.permissions.length > 1 &&
+                                    Array.from({ length: 4 - section.permissions.length }).map(
+                                        (_, fillIndex) => (
+                                            <TableCell key={`fill-${fillIndex}`} className="p-3" />
+                                        ),
+                                    )}
+                            </TableRow>
+                        ))}
+                    </TableBody>
+                </Table>
+            </div>
+
+            {/* Mobile Card View */}
+            <div className="md:hidden space-y-4">
+                {groupedPermissions.map((section, index) => (
+                    <div key={index} className="border rounded-lg p-4 bg-card">
+                        <div className="mb-3">
+                            <div className="flex items-center gap-2 mb-2">
+                                <span className="font-semibold text-sm">{i18n.t(section.label)}</span>
+                                <TooltipProvider>
+                                    <Tooltip>
+                                        <TooltipTrigger asChild>
+                                            <InfoIcon className="h-3 w-3 text-muted-foreground" />
+                                        </TooltipTrigger>
+                                        <TooltipContent side="right" className="max-w-[250px]">
+                                            <p className="text-xs">{i18n.t(section.description)}</p>
+                                        </TooltipContent>
+                                    </Tooltip>
+                                </TooltipProvider>
+                            </div>
+                            {section.permissions.length > 1 && !readonly && (
+                                <Button
+                                    variant="outline"
+                                    size="sm"
+                                    onClick={() => toggleAll(section.permissions)}
+                                    className="h-6 px-2 text-xs"
+                                >
+                                    <Trans>Toggle all</Trans>
+                                </Button>
+                            )}
+                        </div>
+                        <div className="grid grid-cols-2 gap-3">
+                            {sortPermissions(section.permissions).map(permission => (
+                                <div
+                                    key={permission.name}
+                                    className="flex items-center space-x-3 p-2 rounded border"
+                                >
+                                    <Switch
+                                        id={`mobile-${section.id}-${permission.name}`}
+                                        checked={value.includes(permission.name)}
+                                        onCheckedChange={checked => setPermission(permission.name, checked)}
+                                        disabled={readonly}
+                                    />
+                                    <div className="flex-1 min-w-0">
+                                        <TooltipProvider>
+                                            <Tooltip>
+                                                <TooltipTrigger asChild>
+                                                    <label
+                                                        htmlFor={`mobile-${section.id}-${permission.name}`}
+                                                        className="text-xs cursor-pointer block truncate"
+                                                    >
+                                                        {getPermissionLabel(permission, section.label)}
+                                                    </label>
+                                                </TooltipTrigger>
+                                                <TooltipContent side="top" className="max-w-[250px]">
+                                                    <div className="text-xs">
+                                                        <div className="font-medium">
+                                                            {i18n.t(permission.name)}
+                                                        </div>
+                                                        <div className="text-muted-foreground mt-1">
+                                                            {i18n.t(permission.description)}
+                                                        </div>
+                                                    </div>
+                                                </TooltipContent>
+                                            </Tooltip>
+                                        </TooltipProvider>
+                                    </div>
+                                </div>
+                            ))}
+                        </div>
+                    </div>
+                ))}
+            </div>
+        </div>
+    );
+}
+
+// Sort permissions in CRUD order
+const sortPermissions = (permissions: ServerConfig['permissions']) => {
+    const crudOrder = ['Create', 'Read', 'Update', 'Delete'];
+
+    return [...permissions].sort((a, b) => {
+        // Find the CRUD prefix for each permission
+        const aPrefix = crudOrder.find(prefix => a.name.startsWith(prefix));
+        const bPrefix = crudOrder.find(prefix => b.name.startsWith(prefix));
+
+        // If both have CRUD prefixes, sort by CRUD order
+        if (aPrefix && bPrefix) {
+            return crudOrder.indexOf(aPrefix) - crudOrder.indexOf(bPrefix);
+        }
+
+        // If only one has CRUD prefix, put it first
+        if (aPrefix && !bPrefix) return -1;
+        if (!aPrefix && bPrefix) return 1;
+
+        // Otherwise, keep original order
+        return 0;
+    });
+};

+ 5 - 3
packages/dashboard/src/app/routes/_authenticated/_roles/roles_.$id.tsx

@@ -19,7 +19,7 @@ import { useDetailPage } from '@/vdb/framework/page/use-detail-page.js';
 import { Trans, useLingui } from '@/vdb/lib/trans.js';
 import { createFileRoute, useNavigate } from '@tanstack/react-router';
 import { toast } from 'sonner';
-import { PermissionsGrid } from './components/permissions-grid.js';
+import { PermissionsTableGrid } from './components/permissions-table-grid.js';
 import { createRoleDocument, roleDetailDocument, updateRoleDocument } from './roles.graphql.js';
 
 const pageId = 'role-detail';
@@ -61,7 +61,9 @@ function RoleDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast.success(i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'));
+            toast.success(
+                i18n.t(creatingNewEntity ? 'Successfully created role' : 'Successfully updated role'),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
@@ -132,7 +134,7 @@ function RoleDetailPage() {
                             name="permissions"
                             label={<Trans>Permissions</Trans>}
                             render={({ field }) => (
-                                <PermissionsGrid
+                                <PermissionsTableGrid
                                     value={field.value ?? []}
                                     onChange={value => field.onChange(value)}
                                 />

+ 7 - 0
packages/dashboard/src/lib/index.ts

@@ -44,6 +44,7 @@ export * from './components/data-table/filters/data-table-string-filter.js';
 export * from './components/data-table/human-readable-operator.js';
 export * from './components/data-table/refresh-button.js';
 export * from './components/data-table/types.js';
+export * from './components/data-table/use-all-bulk-actions.js';
 export * from './components/data-table/use-generated-columns.js';
 export * from './components/labeled-data.js';
 export * from './components/layout/app-layout.js';
@@ -110,6 +111,12 @@ export * from './components/shared/paginated-list-data-table.js';
 export * from './components/shared/permission-guard.js';
 export * from './components/shared/product-variant-selector.js';
 export * from './components/shared/remove-from-channel-bulk-action.js';
+export * from './components/shared/rich-text-editor/image-dialog.js';
+export * from './components/shared/rich-text-editor/link-dialog.js';
+export * from './components/shared/rich-text-editor/responsive-toolbar.js';
+export * from './components/shared/rich-text-editor/rich-text-editor.js';
+export * from './components/shared/rich-text-editor/table-delete-menu.js';
+export * from './components/shared/rich-text-editor/table-edit-icons.js';
 export * from './components/shared/role-code-label.js';
 export * from './components/shared/role-selector.js';
 export * from './components/shared/seller-selector.js';