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

fix(dashboard): Fix usePaginatedList context duplication in extensions (#4164)

Michael Bromley 20 часов назад
Родитель
Сommit
f07fee7d29
33 измененных файлов с 349 добавлено и 104 удалено
  1. 1 1
      .lintstagedrc.json
  2. 71 0
      packages/dashboard/CLAUDE.md
  3. 142 1
      packages/dashboard/scripts/check-lib-imports.js
  4. 3 1
      packages/dashboard/scripts/generate-index.js
  5. 1 1
      packages/dashboard/src/app/common/delete-bulk-action.tsx
  6. 1 1
      packages/dashboard/src/app/common/duplicate-bulk-action.tsx
  7. 1 1
      packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx
  8. 1 1
      packages/dashboard/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx
  9. 1 1
      packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx
  10. 2 10
      packages/dashboard/src/lib/components/data-table/data-table-context.tsx
  11. 1 1
      packages/dashboard/src/lib/components/data-table/global-views-bar.tsx
  12. 1 1
      packages/dashboard/src/lib/components/data-table/my-views-button.tsx
  13. 1 1
      packages/dashboard/src/lib/components/data-table/save-view-button.tsx
  14. 1 1
      packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx
  15. 1 1
      packages/dashboard/src/lib/components/data-table/views-sheet.tsx
  16. 1 1
      packages/dashboard/src/lib/components/shared/assign-to-channel-bulk-action.tsx
  17. 10 0
      packages/dashboard/src/lib/components/shared/paginated-list-context.ts
  18. 1 31
      packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx
  19. 1 1
      packages/dashboard/src/lib/components/shared/remove-from-channel-bulk-action.tsx
  20. 3 12
      packages/dashboard/src/lib/framework/dashboard-widget/base-widget.tsx
  21. 1 1
      packages/dashboard/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx
  22. 1 1
      packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/chart.tsx
  23. 1 1
      packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/index.tsx
  24. 1 1
      packages/dashboard/src/lib/framework/dashboard-widget/orders-summary/index.tsx
  25. 2 20
      packages/dashboard/src/lib/framework/dashboard-widget/widget-filters-context.tsx
  26. 10 0
      packages/dashboard/src/lib/hooks/use-alerts-context.ts
  27. 1 1
      packages/dashboard/src/lib/hooks/use-alerts.ts
  28. 11 0
      packages/dashboard/src/lib/hooks/use-data-table-context.ts
  29. 28 0
      packages/dashboard/src/lib/hooks/use-paginated-list.ts
  30. 12 0
      packages/dashboard/src/lib/hooks/use-widget-dimensions.ts
  31. 21 0
      packages/dashboard/src/lib/hooks/use-widget-filters.ts
  32. 12 0
      packages/dashboard/src/lib/index.ts
  33. 3 11
      packages/dashboard/src/lib/providers/alerts-provider.tsx

+ 1 - 1
.lintstagedrc.json

@@ -2,7 +2,7 @@
     "admin-ui/**/*.ts": ["npm run lint", "npm run format", "git add"],
     "admin-ui/**/*.ts": ["npm run lint", "npm run format", "git add"],
     "admin-ui/**/*.html": ["npm run format", "git add"],
     "admin-ui/**/*.html": ["npm run format", "git add"],
     "packages/!(dev-server)/**/*.ts": ["npm run lint", "npm run format", "git add"],
     "packages/!(dev-server)/**/*.ts": ["npm run lint", "npm run format", "git add"],
-    "packages/dashboard/src/lib/**/use-*.{ts,tsx}": [
+    "packages/dashboard/src/lib/**/*.{ts,tsx}": [
         "node packages/dashboard/scripts/check-lib-imports.js",
         "node packages/dashboard/scripts/check-lib-imports.js",
         "git add"
         "git add"
     ]
     ]

+ 71 - 0
packages/dashboard/CLAUDE.md

@@ -0,0 +1,71 @@
+# Dashboard Package Development Guide
+
+## React Context Pattern for Extension Compatibility
+
+### The Problem
+
+When dashboard extensions dynamically import hooks from `@vendure/dashboard`, Vite can create duplicate module instances. If a React Context is defined in the same file as a hook that consumes it, the extension's hook will reference a different Context object than the one used by the main app's Provider - causing "must be used within a Provider" errors even when the component IS inside the provider.
+
+### The Solution
+
+**Never define a React Context and its consuming hook in the same file within `src/lib/`.**
+
+Split them into separate files:
+
+1. **Context file** - Contains `createContext()` and the Provider component
+2. **Hook file** - In `src/lib/hooks/`, imports context via `@/vdb/` path
+
+### Example
+
+```tsx
+// ❌ BAD: Context and hook in same file (src/lib/components/my-context.tsx)
+export const MyContext = createContext<MyContextValue | undefined>(undefined);
+
+export function MyProvider({ children }) {
+    return <MyContext.Provider value={...}>{children}</MyContext.Provider>;
+}
+
+export function useMyContext() {
+    const context = useContext(MyContext);  // Same file = module identity issues
+    if (!context) throw new Error('...');
+    return context;
+}
+```
+
+```tsx
+// ✅ GOOD: Context in one file, hook in separate file
+
+// src/lib/components/my-context.tsx
+export const MyContext = createContext<MyContextValue | undefined>(undefined);
+
+export function MyProvider({ children }) {
+    return <MyContext.Provider value={...}>{children}</MyContext.Provider>;
+}
+
+// src/lib/hooks/use-my-context.ts
+import { MyContext } from '@/vdb/components/my-context.js';
+
+export function useMyContext() {
+    const context = useContext(MyContext);
+    if (!context) throw new Error('...');
+    return context;
+}
+```
+
+### Why This Works
+
+Both the main app and dynamically-imported extension code resolve the context import to the same module instance when using internal `@/vdb/` paths, preserving React Context identity.
+
+### Automated Enforcement
+
+This pattern is enforced by `scripts/check-lib-imports.js`, which runs on pre-commit. It will fail if any file in `src/lib/` contains both `createContext(` and `useContext(`.
+
+**Allowlist:** Some shadcn UI primitives (carousel, chart, form, toggle-group) are allowlisted because their contexts are internal implementation details not accessed by extensions.
+
+### Hooks Directory Rules
+
+All hooks in `src/lib/hooks/` must:
+- Import using `@/vdb/` prefix (not relative `../` paths)
+- Never import from `@/vdb/index.js` directly
+
+These rules are also enforced by the same lint script.

+ 142 - 1
packages/dashboard/scripts/check-lib-imports.js

@@ -25,6 +25,20 @@ const DASHBOARD_SRC_DIR = isDashboardDir
 const REQUIRED_PREFIX = '@/vdb';
 const REQUIRED_PREFIX = '@/vdb';
 // Banned import pattern
 // Banned import pattern
 const BANNED_IMPORT = '@/vdb/index.js';
 const BANNED_IMPORT = '@/vdb/index.js';
+// Lib directory (auto-exported via index.ts)
+const LIB_DIR = isDashboardDir
+    ? normalizePath(path.join(__dirname, '../src/lib'))
+    : normalizePath(path.join(currentDir, 'packages/dashboard/src/lib'));
+
+// Files allowed to have createContext + useContext in the same file.
+// These are UI primitives (e.g., shadcn components) where the context is internal
+// and not intended to be accessed by extensions.
+const CONTEXT_PATTERN_ALLOWLIST = [
+    'components/ui/carousel.tsx',
+    'components/ui/chart.tsx',
+    'components/ui/form.tsx',
+    'components/ui/toggle-group.tsx',
+];
 
 
 function findHookFiles(dir) {
 function findHookFiles(dir) {
     const files = [];
     const files = [];
@@ -119,6 +133,92 @@ function checkFileForBannedImports(filePath) {
     return badImports;
     return badImports;
 }
 }
 
 
+/**
+ * Check for React Context module identity issues.
+ *
+ * Files in src/lib/ are auto-exported via index.ts. If a file defines a React Context
+ * AND also consumes it (via useContext), this causes module identity issues when
+ * extensions dynamically import from @vendure/dashboard - the context object in the
+ * extension's bundle will be different from the one in the main app's bundle.
+ *
+ * The fix is to split context definition and consumption into separate files:
+ * - Context definition in a dedicated file (e.g., paginated-list-context.ts)
+ * - Hook that consumes the context in src/lib/hooks/ (e.g., use-paginated-list.ts)
+ */
+function checkFileForContextPattern(filePath) {
+    const content = fs.readFileSync(filePath, 'utf8');
+    const issues = [];
+
+    // Check if file is in src/lib/ (auto-exported)
+    if (!filePath.includes(normalizePath('src/lib/'))) {
+        return issues;
+    }
+
+    // Check if file is in the allowlist
+    for (const allowedFile of CONTEXT_PATTERN_ALLOWLIST) {
+        if (filePath.includes(normalizePath(allowedFile))) {
+            return issues;
+        }
+    }
+
+    const hasCreateContext = /createContext\s*[<(]/.test(content);
+    const hasUseContext = /useContext\s*\(/.test(content);
+
+    if (hasCreateContext && hasUseContext) {
+        // Find line numbers for better error reporting
+        const lines = content.split('\n');
+        let createContextLine = 0;
+        let useContextLine = 0;
+
+        for (let i = 0; i < lines.length; i++) {
+            if (/createContext\s*[<(]/.test(lines[i]) && createContextLine === 0) {
+                createContextLine = i + 1;
+            }
+            if (/useContext\s*\(/.test(lines[i]) && useContextLine === 0) {
+                useContextLine = i + 1;
+            }
+        }
+
+        issues.push({
+            createContextLine,
+            useContextLine,
+            reason:
+                'File defines a React Context (createContext) and also consumes it (useContext). ' +
+                'This causes module identity issues when extensions dynamically import from @vendure/dashboard. ' +
+                'Split into separate files: context definition in a dedicated file, hook in src/lib/hooks/',
+        });
+    }
+
+    return issues;
+}
+
+function findLibFiles(dir) {
+    const files = [];
+
+    function scanDirectory(currentDir) {
+        const items = fs.readdirSync(currentDir);
+
+        for (const item of items) {
+            const fullPath = normalizePath(path.join(currentDir, item));
+            const stat = fs.statSync(fullPath);
+
+            if (stat.isDirectory()) {
+                if (!['node_modules', '.git', 'dist', 'build'].includes(item)) {
+                    scanDirectory(fullPath);
+                }
+            } else if (stat.isFile() && (item.endsWith('.ts') || item.endsWith('.tsx'))) {
+                // Skip test and story files
+                if (!item.endsWith('.spec.ts') && !item.endsWith('.stories.tsx')) {
+                    files.push(fullPath);
+                }
+            }
+        }
+    }
+
+    scanDirectory(dir);
+    return files;
+}
+
 function main() {
 function main() {
     console.log('🔍 Checking for import patterns in the dashboard app...\n');
     console.log('🔍 Checking for import patterns in the dashboard app...\n');
 
 
@@ -206,7 +306,48 @@ function main() {
         console.log(`🎉 All dashboard files are free of banned imports`);
         console.log(`🎉 All dashboard files are free of banned imports`);
     }
     }
 
 
-    if (hasBadImports || hasBannedImports) {
+    // Check for React Context module identity issues in lib files
+    console.log('\n📁 Checking src/lib files for React Context patterns...');
+    console.log('✅ Context pattern requirements:');
+    console.log('   - Files must NOT both define (createContext) and consume (useContext) a React Context');
+    console.log('   - Split context definition and hooks into separate files to prevent module identity issues');
+    console.log('');
+
+    if (!fs.existsSync(LIB_DIR)) {
+        console.error('❌ src/lib directory not found!');
+        process.exit(1);
+    }
+
+    const libFiles = findLibFiles(LIB_DIR);
+    let hasContextIssues = false;
+    let totalContextIssues = 0;
+
+    for (const file of libFiles) {
+        const relativePath = normalizePath(path.relative(process.cwd(), file));
+        const contextIssues = checkFileForContextPattern(file);
+
+        if (contextIssues.length > 0) {
+            hasContextIssues = true;
+            totalContextIssues += contextIssues.length;
+
+            console.log(`❌ ${relativePath}:`);
+            for (const issue of contextIssues) {
+                console.log(`   createContext at line ${issue.createContextLine}, useContext at line ${issue.useContextLine}`);
+                console.log(`   Reason: ${issue.reason}`);
+            }
+            console.log('');
+        }
+    }
+
+    if (hasContextIssues) {
+        console.log(`❌ Found ${totalContextIssues} context pattern issue(s) in ${libFiles.length} lib file(s)`);
+        console.log('💡 Move context definitions to dedicated files and hooks to src/lib/hooks/');
+    } else {
+        console.log(`✅ No context pattern issues found in ${libFiles.length} lib file(s)`);
+        console.log('🎉 All lib files follow the correct context pattern');
+    }
+
+    if (hasBadImports || hasBannedImports || hasContextIssues) {
         process.exit(1);
         process.exit(1);
     }
     }
 }
 }

+ 3 - 1
packages/dashboard/scripts/generate-index.js

@@ -23,7 +23,9 @@ function getAllFiles(dir, fileList = []) {
             !file.startsWith('index.') && // Exclude index files
             !file.startsWith('index.') && // Exclude index files
             !file.endsWith('.d.ts') &&
             !file.endsWith('.d.ts') &&
             !file.endsWith('.spec.ts') &&
             !file.endsWith('.spec.ts') &&
-            !file.endsWith('.stories.tsx')
+            !file.endsWith('.spec.tsx') &&
+            !file.endsWith('.stories.tsx') &&
+            !file.endsWith('.stories.ts')
         ) {
         ) {
             fileList.push(filePath);
             fileList.push(filePath);
         }
         }

+ 1 - 1
packages/dashboard/src/app/common/delete-bulk-action.tsx

@@ -3,7 +3,7 @@ import { TrashIcon } from 'lucide-react';
 import { toast } from 'sonner';
 import { toast } from 'sonner';
 
 
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { getMutationName } from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { getMutationName } from '@/vdb/framework/document-introspection/get-document-structure.js';
 import { api } from '@/vdb/graphql/api.js';
 import { api } from '@/vdb/graphql/api.js';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { Trans, useLingui } from '@lingui/react/macro';

+ 1 - 1
packages/dashboard/src/app/common/duplicate-bulk-action.tsx

@@ -5,7 +5,7 @@ import { useState } from 'react';
 import { toast } from 'sonner';
 import { toast } from 'sonner';
 
 
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { api } from '@/vdb/graphql/api.js';
 import { api } from '@/vdb/graphql/api.js';
 import { duplicateEntityDocument } from '@/vdb/graphql/common-operations.js';
 import { duplicateEntityDocument } from '@/vdb/graphql/common-operations.js';
 import { Trans, useLingui } from '@lingui/react/macro';
 import { Trans, useLingui } from '@lingui/react/macro';

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_collections/components/collection-bulk-actions.tsx

@@ -4,7 +4,7 @@ import { useState } from 'react';
 
 
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { api } from '@/vdb/graphql/api.js';
 import { api } from '@/vdb/graphql/api.js';

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_product-variants/components/product-variant-bulk-actions.tsx

@@ -4,7 +4,7 @@ import { useState } from 'react';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
 import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { api } from '@/vdb/graphql/api.js';
 import { api } from '@/vdb/graphql/api.js';

+ 1 - 1
packages/dashboard/src/app/routes/_authenticated/_products/components/product-bulk-actions.tsx

@@ -4,7 +4,7 @@ import { useState } from 'react';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
 import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
 import { usePriceFactor } from '@/vdb/components/shared/assign-to-channel-dialog.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
 import { api } from '@/vdb/graphql/api.js';
 import { api } from '@/vdb/graphql/api.js';

+ 2 - 10
packages/dashboard/src/lib/components/data-table/data-table-context.tsx

@@ -1,7 +1,7 @@
 'use client';
 'use client';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
 import { ColumnFiltersState, SortingState, Table } from '@tanstack/react-table';
-import React, { createContext, ReactNode, useContext } from 'react';
+import React, { createContext, ReactNode } from 'react';
 
 
 export type ColumnConfig = { columnOrder: string[]; columnVisibility: Record<string, boolean> };
 export type ColumnConfig = { columnOrder: string[]; columnVisibility: Record<string, boolean> };
 
 
@@ -21,7 +21,7 @@ interface DataTableContextValue {
     handleApplyView: (filters: ColumnFiltersState, columnConfig: ColumnConfig, searchTerm?: string) => void;
     handleApplyView: (filters: ColumnFiltersState, columnConfig: ColumnConfig, searchTerm?: string) => void;
 }
 }
 
 
-const DataTableContext = createContext<DataTableContextValue | undefined>(undefined);
+export const DataTableContext = createContext<DataTableContextValue | undefined>(undefined);
 
 
 export interface DataTableProviderProps {
 export interface DataTableProviderProps {
     children: ReactNode;
     children: ReactNode;
@@ -96,11 +96,3 @@ export function DataTableProvider({
 
 
     return <DataTableContext.Provider value={value}>{children}</DataTableContext.Provider>;
     return <DataTableContext.Provider value={value}>{children}</DataTableContext.Provider>;
 }
 }
-
-export function useDataTableContext() {
-    const context = useContext(DataTableContext);
-    if (context === undefined) {
-        throw new Error('useDataTableContext must be used within a DataTableProvider');
-    }
-    return context;
-}

+ 1 - 1
packages/dashboard/src/lib/components/data-table/global-views-bar.tsx

@@ -12,7 +12,7 @@ import {
     DropdownMenuItem,
     DropdownMenuItem,
     DropdownMenuTrigger,
     DropdownMenuTrigger,
 } from '../ui/dropdown-menu.js';
 } from '../ui/dropdown-menu.js';
-import { useDataTableContext } from './data-table-context.js';
+import { useDataTableContext } from '@/vdb/hooks/use-data-table-context.js';
 import { ManageGlobalViewsButton } from './manage-global-views-button.js';
 import { ManageGlobalViewsButton } from './manage-global-views-button.js';
 
 
 export const GlobalViewsBar: React.FC = () => {
 export const GlobalViewsBar: React.FC = () => {

+ 1 - 1
packages/dashboard/src/lib/components/data-table/my-views-button.tsx

@@ -5,7 +5,7 @@ import { useSavedViews } from '../../hooks/use-saved-views.js';
 import { findMatchingSavedView } from '../../utils/saved-views-utils.js';
 import { findMatchingSavedView } from '../../utils/saved-views-utils.js';
 import { Button } from '../ui/button.js';
 import { Button } from '../ui/button.js';
 import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
 import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip.js';
-import { useDataTableContext } from './data-table-context.js';
+import { useDataTableContext } from '@/vdb/hooks/use-data-table-context.js';
 import { UserViewsSheet } from './user-views-sheet.js';
 import { UserViewsSheet } from './user-views-sheet.js';
 
 
 export const MyViewsButton: React.FC = () => {
 export const MyViewsButton: React.FC = () => {

+ 1 - 1
packages/dashboard/src/lib/components/data-table/save-view-button.tsx

@@ -4,7 +4,7 @@ import React, { useState } from 'react';
 import { useSavedViews } from '../../hooks/use-saved-views.js';
 import { useSavedViews } from '../../hooks/use-saved-views.js';
 import { isMatchingSavedView } from '../../utils/saved-views-utils.js';
 import { isMatchingSavedView } from '../../utils/saved-views-utils.js';
 import { Button } from '../ui/button.js';
 import { Button } from '../ui/button.js';
-import { useDataTableContext } from './data-table-context.js';
+import { useDataTableContext } from '@/vdb/hooks/use-data-table-context.js';
 import { SaveViewDialog } from './save-view-dialog.js';
 import { SaveViewDialog } from './save-view-dialog.js';
 
 
 interface SaveViewButtonProps {
 interface SaveViewButtonProps {

+ 1 - 1
packages/dashboard/src/lib/components/data-table/use-generated-columns.tsx

@@ -33,8 +33,8 @@ import {
     FacetedFilterConfig,
     FacetedFilterConfig,
     PaginatedListItemFields,
     PaginatedListItemFields,
     RowAction,
     RowAction,
-    usePaginatedList,
 } from '../shared/paginated-list-data-table.js';
 } from '../shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import {
 import {
     AlertDialog,
     AlertDialog,
     AlertDialogAction,
     AlertDialogAction,

+ 1 - 1
packages/dashboard/src/lib/components/data-table/views-sheet.tsx

@@ -23,7 +23,7 @@ import {
 } from '../ui/dropdown-menu.js';
 } from '../ui/dropdown-menu.js';
 import { Input } from '../ui/input.js';
 import { Input } from '../ui/input.js';
 import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet.js';
 import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '../ui/sheet.js';
-import { useDataTableContext } from './data-table-context.js';
+import { useDataTableContext } from '@/vdb/hooks/use-data-table-context.js';
 
 
 interface ViewsSheetProps {
 interface ViewsSheetProps {
     open: boolean;
     open: boolean;

+ 1 - 1
packages/dashboard/src/lib/components/shared/assign-to-channel-bulk-action.tsx

@@ -3,7 +3,7 @@ import { useState } from 'react';
 
 
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { AssignToChannelDialog } from '@/vdb/components/shared/assign-to-channel-dialog.js';
 import { AssignToChannelDialog } from '@/vdb/components/shared/assign-to-channel-dialog.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
 import { Trans } from '@lingui/react/macro';
 import { Trans } from '@lingui/react/macro';
 
 

+ 10 - 0
packages/dashboard/src/lib/components/shared/paginated-list-context.ts

@@ -0,0 +1,10 @@
+import * as React from 'react';
+
+export interface PaginatedListContext {
+    refetchPaginatedList: () => void;
+}
+
+// This is split into a separate file from `paginated-list-data-table.tsx` because having them in the same
+// file causes module identity issues with dynamic imports in dashboard extensions.
+// See: https://github.com/vitejs/vite/issues/3301
+export const PaginatedListContext = React.createContext<PaginatedListContext | undefined>(undefined);

+ 1 - 31
packages/dashboard/src/lib/components/shared/paginated-list-data-table.tsx

@@ -14,6 +14,7 @@ import { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { ColumnFiltersState, ColumnSort, SortingState, Table } from '@tanstack/react-table';
 import { ColumnFiltersState, ColumnSort, SortingState, Table } from '@tanstack/react-table';
 import { ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
 import { ColumnDef, Row, TableOptions, VisibilityState } from '@tanstack/table-core';
 import React from 'react';
 import React from 'react';
+import { PaginatedListContext } from './paginated-list-context.js';
 import { getColumnVisibility, getStandardizedDefaultColumnOrder } from '../data-table/data-table-utils.js';
 import { getColumnVisibility, getStandardizedDefaultColumnOrder } from '../data-table/data-table-utils.js';
 import { useGeneratedColumns } from '../data-table/use-generated-columns.js';
 import { useGeneratedColumns } from '../data-table/use-generated-columns.js';
 
 
@@ -152,37 +153,6 @@ export type AdditionalColumns<T extends TypedDocumentNode<any, any>> = {
     [key: string]: ColumnDefWithMetaDependencies<PaginatedListItemFields<T>>;
     [key: string]: ColumnDefWithMetaDependencies<PaginatedListItemFields<T>>;
 };
 };
 
 
-export interface PaginatedListContext {
-    refetchPaginatedList: () => void;
-}
-
-export const PaginatedListContext = React.createContext<PaginatedListContext | undefined>(undefined);
-
-/**
- * @description
- * Returns the context for the paginated list data table. Must be used within a PaginatedListDataTable.
- *
- * @example
- * ```ts
- * const { refetchPaginatedList } = usePaginatedList();
- *
- * const mutation = useMutation({
- *     mutationFn: api.mutate(updateFacetValueDocument),
- *     onSuccess: () => {
- *         refetchPaginatedList();
- *     },
- * });
- * ```
- * @docsCategory hooks
- * @since 3.4.0
- */
-export function usePaginatedList() {
-    const context = React.useContext(PaginatedListContext);
-    if (!context) {
-        throw new Error('usePaginatedList must be used within a PaginatedListDataTable');
-    }
-    return context;
-}
 
 
 export interface RowAction<T> {
 export interface RowAction<T> {
     label: React.ReactNode;
     label: React.ReactNode;

+ 1 - 1
packages/dashboard/src/lib/components/shared/remove-from-channel-bulk-action.tsx

@@ -3,7 +3,7 @@ import { LayersIcon } from 'lucide-react';
 import { toast } from 'sonner';
 import { toast } from 'sonner';
 
 
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
 import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
-import { usePaginatedList } from '@/vdb/components/shared/paginated-list-data-table.js';
+import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
 import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
 import { DEFAULT_CHANNEL_CODE } from '@/vdb/constants.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { ResultOf } from '@/vdb/graphql/graphql.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';

+ 3 - 12
packages/dashboard/src/lib/framework/dashboard-widget/base-widget.tsx

@@ -1,25 +1,16 @@
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/vdb/components/ui/card.js';
 import { DashboardBaseWidgetProps } from '@/vdb/framework/extension-api/types/index.js';
 import { DashboardBaseWidgetProps } from '@/vdb/framework/extension-api/types/index.js';
 import { cn } from '@/vdb/lib/utils.js';
 import { cn } from '@/vdb/lib/utils.js';
-import { Trans, useLingui } from '@lingui/react/macro';
-import { createContext, useContext, useEffect, useRef, useState } from 'react';
+import { Trans } from '@lingui/react/macro';
+import { createContext, useEffect, useRef, useState } from 'react';
 
 
-type WidgetDimensions = {
+export type WidgetDimensions = {
     width: number;
     width: number;
     height: number;
     height: number;
 };
 };
 
 
 export const WidgetContentContext = createContext<WidgetDimensions>({ width: 0, height: 0 });
 export const WidgetContentContext = createContext<WidgetDimensions>({ width: 0, height: 0 });
 
 
-export const useWidgetDimensions = () => {
-    const { t } = useLingui();
-    const context = useContext(WidgetContentContext);
-    if (!context) {
-        throw new Error(t`useWidgetDimensions must be used within a DashboardBaseWidget`);
-    }
-    return context;
-};
-
 /**
 /**
  * @description
  * @description
  * A wrapper component that should be used for any custom Insights page widgets.
  * A wrapper component that should be used for any custom Insights page widgets.

+ 1 - 1
packages/dashboard/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx

@@ -12,7 +12,7 @@ import { ColumnFiltersState, SortingState } from '@tanstack/react-table';
 import { formatRelative } from 'date-fns';
 import { formatRelative } from 'date-fns';
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
 import { DashboardBaseWidget } from '../base-widget.js';
-import { useWidgetFilters } from '../widget-filters-context.js';
+import { useWidgetFilters } from '@/vdb/hooks/use-widget-filters.js';
 import { latestOrdersQuery } from './latest-orders-widget.graphql.js';
 import { latestOrdersQuery } from './latest-orders-widget.graphql.js';
 
 
 export const WIDGET_ID = 'latest-orders-widget';
 export const WIDGET_ID = 'latest-orders-widget';

+ 1 - 1
packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/chart.tsx

@@ -1,5 +1,5 @@
 import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
 import { Area, AreaChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
-import { useWidgetDimensions } from '../base-widget.js';
+import { useWidgetDimensions } from '@/vdb/hooks/use-widget-dimensions.js';
 
 
 export function MetricsChart({
 export function MetricsChart({
     chartData,
     chartData,

+ 1 - 1
packages/dashboard/src/lib/framework/dashboard-widget/metrics-widget/index.tsx

@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
 import { RefreshCw } from 'lucide-react';
 import { RefreshCw } from 'lucide-react';
 import { useMemo, useState } from 'react';
 import { useMemo, useState } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
 import { DashboardBaseWidget } from '../base-widget.js';
-import { useWidgetFilters } from '../widget-filters-context.js';
+import { useWidgetFilters } from '@/vdb/hooks/use-widget-filters.js';
 import { MetricsChart } from './chart.js';
 import { MetricsChart } from './chart.js';
 import { orderChartDataQuery } from './metrics-widget.graphql.js';
 import { orderChartDataQuery } from './metrics-widget.graphql.js';
 
 

+ 1 - 1
packages/dashboard/src/lib/framework/dashboard-widget/orders-summary/index.tsx

@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query';
 import { differenceInDays, subDays } from 'date-fns';
 import { differenceInDays, subDays } from 'date-fns';
 import { useMemo } from 'react';
 import { useMemo } from 'react';
 import { DashboardBaseWidget } from '../base-widget.js';
 import { DashboardBaseWidget } from '../base-widget.js';
-import { useWidgetFilters } from '../widget-filters-context.js';
+import { useWidgetFilters } from '@/vdb/hooks/use-widget-filters.js';
 import { orderSummaryQuery } from './order-summary-widget.graphql.js';
 import { orderSummaryQuery } from './order-summary-widget.graphql.js';
 
 
 const WIDGET_ID = 'orders-summary-widget';
 const WIDGET_ID = 'orders-summary-widget';

+ 2 - 20
packages/dashboard/src/lib/framework/dashboard-widget/widget-filters-context.tsx

@@ -1,7 +1,6 @@
 'use client';
 'use client';
 
 
-import { useLingui } from '@lingui/react/macro';
-import { createContext, PropsWithChildren, useContext } from 'react';
+import { createContext, PropsWithChildren } from 'react';
 
 
 export interface DefinedDateRange {
 export interface DefinedDateRange {
     from: Date;
     from: Date;
@@ -12,25 +11,8 @@ export interface WidgetFilters {
     dateRange: DefinedDateRange;
     dateRange: DefinedDateRange;
 }
 }
 
 
-const WidgetFiltersContext = createContext<WidgetFilters | undefined>(undefined);
+export const WidgetFiltersContext = createContext<WidgetFilters | undefined>(undefined);
 
 
 export function WidgetFiltersProvider({ children, filters }: PropsWithChildren<{ filters: WidgetFilters }>) {
 export function WidgetFiltersProvider({ children, filters }: PropsWithChildren<{ filters: WidgetFilters }>) {
     return <WidgetFiltersContext.Provider value={filters}>{children}</WidgetFiltersContext.Provider>;
     return <WidgetFiltersContext.Provider value={filters}>{children}</WidgetFiltersContext.Provider>;
 }
 }
-
-/**
- * @description
- * Exposes a context object for use in building Insights page widgets.
- *
- * @docsCategory hooks
- * @docsPage useWidgetFilters
- * @since 3.5.0
- */
-export function useWidgetFilters() {
-    const { t } = useLingui();
-    const context = useContext(WidgetFiltersContext);
-    if (context === undefined) {
-        throw new Error(t`useWidgetFilters must be used within a WidgetFiltersProvider`);
-    }
-    return context;
-}

+ 10 - 0
packages/dashboard/src/lib/hooks/use-alerts-context.ts

@@ -0,0 +1,10 @@
+import { AlertsContext } from '@/vdb/providers/alerts-provider.js';
+import { useContext } from 'react';
+
+export function useAlertsContext() {
+    const context = useContext(AlertsContext);
+    if (!context) {
+        throw new Error('useAlertsContext must be used within AlertsProvider');
+    }
+    return context;
+}

+ 1 - 1
packages/dashboard/src/lib/hooks/use-alerts.ts

@@ -1,5 +1,5 @@
 import { AlertSeverity, DashboardAlertDefinition } from '@/vdb/framework/extension-api/types/alerts.js';
 import { AlertSeverity, DashboardAlertDefinition } from '@/vdb/framework/extension-api/types/alerts.js';
-import { useAlertsContext } from '@/vdb/providers/alerts-provider.js';
+import { useAlertsContext } from '@/vdb/hooks/use-alerts-context.js';
 import { useMemo } from 'react';
 import { useMemo } from 'react';
 
 
 /**
 /**

+ 11 - 0
packages/dashboard/src/lib/hooks/use-data-table-context.ts

@@ -0,0 +1,11 @@
+'use client';
+import { DataTableContext } from '@/vdb/components/data-table/data-table-context.js';
+import { useContext } from 'react';
+
+export function useDataTableContext() {
+    const context = useContext(DataTableContext);
+    if (context === undefined) {
+        throw new Error('useDataTableContext must be used within a DataTableProvider');
+    }
+    return context;
+}

+ 28 - 0
packages/dashboard/src/lib/hooks/use-paginated-list.ts

@@ -0,0 +1,28 @@
+import { PaginatedListContext } from '@/vdb/components/shared/paginated-list-context.js';
+import * as React from 'react';
+
+/**
+ * @description
+ * Returns the context for the paginated list data table. Must be used within a PaginatedListDataTable.
+ *
+ * @example
+ * ```ts
+ * const { refetchPaginatedList } = usePaginatedList();
+ *
+ * const mutation = useMutation({
+ *     mutationFn: api.mutate(updateFacetValueDocument),
+ *     onSuccess: () => {
+ *         refetchPaginatedList();
+ *     },
+ * });
+ * ```
+ * @docsCategory hooks
+ * @since 3.4.0
+ */
+export function usePaginatedList() {
+    const context = React.useContext(PaginatedListContext);
+    if (!context) {
+        throw new Error('usePaginatedList must be used within a PaginatedListDataTable');
+    }
+    return context;
+}

+ 12 - 0
packages/dashboard/src/lib/hooks/use-widget-dimensions.ts

@@ -0,0 +1,12 @@
+import { WidgetContentContext } from '@/vdb/framework/dashboard-widget/base-widget.js';
+import { useLingui } from '@lingui/react/macro';
+import { useContext } from 'react';
+
+export const useWidgetDimensions = () => {
+    const { t } = useLingui();
+    const context = useContext(WidgetContentContext);
+    if (!context) {
+        throw new Error(t`useWidgetDimensions must be used within a DashboardBaseWidget`);
+    }
+    return context;
+};

+ 21 - 0
packages/dashboard/src/lib/hooks/use-widget-filters.ts

@@ -0,0 +1,21 @@
+'use client';
+import { WidgetFiltersContext } from '@/vdb/framework/dashboard-widget/widget-filters-context.js';
+import { useLingui } from '@lingui/react/macro';
+import { useContext } from 'react';
+
+/**
+ * @description
+ * Exposes a context object for use in building Insights page widgets.
+ *
+ * @docsCategory hooks
+ * @docsPage useWidgetFilters
+ * @since 3.5.0
+ */
+export function useWidgetFilters() {
+    const { t } = useLingui();
+    const context = useContext(WidgetFiltersContext);
+    if (context === undefined) {
+        throw new Error(t`useWidgetFilters must be used within a WidgetFiltersProvider`);
+    }
+    return context;
+}

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

@@ -23,6 +23,7 @@ export * from './components/data-input/relation-selector.js';
 export * from './components/data-input/rich-text-input.js';
 export * from './components/data-input/rich-text-input.js';
 export * from './components/data-input/select-with-options.js';
 export * from './components/data-input/select-with-options.js';
 export * from './components/data-input/slug-input.js';
 export * from './components/data-input/slug-input.js';
+export * from './components/data-input/string-list-input.js';
 export * from './components/data-input/struct-form-input.js';
 export * from './components/data-input/struct-form-input.js';
 export * from './components/data-input/text-input.js';
 export * from './components/data-input/text-input.js';
 export * from './components/data-input/textarea-input.js';
 export * from './components/data-input/textarea-input.js';
@@ -85,7 +86,9 @@ export * from './components/shared/asset/asset-preview.js';
 export * from './components/shared/asset/asset-properties.js';
 export * from './components/shared/asset/asset-properties.js';
 export * from './components/shared/assign-to-channel-bulk-action.js';
 export * from './components/shared/assign-to-channel-bulk-action.js';
 export * from './components/shared/assign-to-channel-dialog.js';
 export * from './components/shared/assign-to-channel-dialog.js';
+export * from './components/shared/assigned-channels.js';
 export * from './components/shared/assigned-facet-values.js';
 export * from './components/shared/assigned-facet-values.js';
+export * from './components/shared/channel-chip.js';
 export * from './components/shared/channel-code-label.js';
 export * from './components/shared/channel-code-label.js';
 export * from './components/shared/channel-selector.js';
 export * from './components/shared/channel-selector.js';
 export * from './components/shared/configurable-operation-arg-input.js';
 export * from './components/shared/configurable-operation-arg-input.js';
@@ -121,6 +124,7 @@ export * from './components/shared/logo-mark.js';
 export * from './components/shared/multi-select.js';
 export * from './components/shared/multi-select.js';
 export * from './components/shared/navigation-confirmation.js';
 export * from './components/shared/navigation-confirmation.js';
 export * from './components/shared/option-value-input.js';
 export * from './components/shared/option-value-input.js';
+export * from './components/shared/paginated-list-context.js';
 export * from './components/shared/paginated-list-data-table.js';
 export * from './components/shared/paginated-list-data-table.js';
 export * from './components/shared/permission-guard.js';
 export * from './components/shared/permission-guard.js';
 export * from './components/shared/product-variant-selector.js';
 export * from './components/shared/product-variant-selector.js';
@@ -267,12 +271,16 @@ export * from './graphql/api.js';
 export * from './graphql/common-operations.js';
 export * from './graphql/common-operations.js';
 export * from './graphql/fragments.js';
 export * from './graphql/fragments.js';
 export * from './graphql/graphql.js';
 export * from './graphql/graphql.js';
+export * from './graphql/schema-enums.js';
 export * from './graphql/settings-store-operations.js';
 export * from './graphql/settings-store-operations.js';
+export * from './hooks/use-alerts-context.js';
 export * from './hooks/use-alerts.js';
 export * from './hooks/use-alerts.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-custom-field-config.js';
 export * from './hooks/use-custom-field-config.js';
+export * from './hooks/use-data-table-context.js';
 export * from './hooks/use-display-locale.js';
 export * from './hooks/use-display-locale.js';
+export * from './hooks/use-drag-and-drop.js';
 export * from './hooks/use-dynamic-translations.js';
 export * from './hooks/use-dynamic-translations.js';
 export * from './hooks/use-extended-detail-query.js';
 export * from './hooks/use-extended-detail-query.js';
 export * from './hooks/use-extended-list-query.js';
 export * from './hooks/use-extended-list-query.js';
@@ -282,12 +290,16 @@ export * from './hooks/use-local-format.js';
 export * from './hooks/use-mobile.js';
 export * from './hooks/use-mobile.js';
 export * from './hooks/use-page-block.js';
 export * from './hooks/use-page-block.js';
 export * from './hooks/use-page.js';
 export * from './hooks/use-page.js';
+export * from './hooks/use-paginated-list.js';
 export * from './hooks/use-permissions.js';
 export * from './hooks/use-permissions.js';
 export * from './hooks/use-saved-views.js';
 export * from './hooks/use-saved-views.js';
 export * from './hooks/use-server-config.js';
 export * from './hooks/use-server-config.js';
+export * from './hooks/use-sorted-languages.js';
 export * from './hooks/use-theme.js';
 export * from './hooks/use-theme.js';
 export * from './hooks/use-ui-language-loader.js';
 export * from './hooks/use-ui-language-loader.js';
 export * from './hooks/use-user-settings.js';
 export * from './hooks/use-user-settings.js';
+export * from './hooks/use-widget-dimensions.js';
+export * from './hooks/use-widget-filters.js';
 export * from './lib/load-i18n-messages.js';
 export * from './lib/load-i18n-messages.js';
 export * from './lib/trans.js';
 export * from './lib/trans.js';
 export * from './lib/utils.js';
 export * from './lib/utils.js';

+ 3 - 11
packages/dashboard/src/lib/providers/alerts-provider.tsx

@@ -1,9 +1,9 @@
 import { getAlertRegistry } from '@/vdb/framework/alert/alert-extensions.js';
 import { getAlertRegistry } from '@/vdb/framework/alert/alert-extensions.js';
 import { DashboardAlertDefinition } from '@/vdb/framework/extension-api/types/alerts.js';
 import { DashboardAlertDefinition } from '@/vdb/framework/extension-api/types/alerts.js';
 import { useQueries, UseQueryOptions } from '@tanstack/react-query';
 import { useQueries, UseQueryOptions } from '@tanstack/react-query';
-import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
+import { createContext, ReactNode, useEffect, useState } from 'react';
 
 
-interface AlertsContextValue {
+export interface AlertsContextValue {
     alertDefs: DashboardAlertDefinition[];
     alertDefs: DashboardAlertDefinition[];
     rawResults: any[];
     rawResults: any[];
     dismissedAlerts: Map<string, number>;
     dismissedAlerts: Map<string, number>;
@@ -11,7 +11,7 @@ interface AlertsContextValue {
     enabledQueries: boolean;
     enabledQueries: boolean;
 }
 }
 
 
-const AlertsContext = createContext<AlertsContextValue | undefined>(undefined);
+export const AlertsContext = createContext<AlertsContextValue | undefined>(undefined);
 
 
 export function AlertsProvider({ children }: { children: ReactNode }) {
 export function AlertsProvider({ children }: { children: ReactNode }) {
     const initialDelayMs = 5_000;
     const initialDelayMs = 5_000;
@@ -50,11 +50,3 @@ export function AlertsProvider({ children }: { children: ReactNode }) {
         </AlertsContext.Provider>
         </AlertsContext.Provider>
     );
     );
 }
 }
-
-export function useAlertsContext() {
-    const context = useContext(AlertsContext);
-    if (!context) {
-        throw new Error('useAlertsContext must be used within AlertsProvider');
-    }
-    return context;
-}