Files
suplement/.agents/skills/building-admin-dashboard-customizations/references/table-selection.md
2026-03-07 11:07:45 -03:00

407 lines
13 KiB
Markdown

# Table Selection Pattern
## Contents
- [Pre-Implementation Requirements for pnpm](#pre-implementation-requirements-for-pnpm)
- [Complete Widget Example: Related Products Selection](#complete-widget-example-related-products-selection)
- [Key Implementation Details](#key-implementation-details)
- [Pattern Variations](#pattern-variations)
- [Important Notes](#important-notes)
This is a complete reference implementation for selecting from large datasets (Products, Categories, Regions, etc.) in Medusa Admin customizations. This pattern uses FocusModal with DataTable for an optimal UX.
## Pre-Implementation Requirements for pnpm
**⚠️ pnpm Users**: This example requires the following packages. Install them BEFORE implementing:
- `@tanstack/react-query` - for useQuery and useMutation
- `react-router-dom` - for Link component (optional, can be removed if not needed)
Check and install with exact versions:
```bash
pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard
pnpm add @tanstack/react-query@[exact-version]
pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard
pnpm add react-router-dom@[exact-version]
```
See the main SKILL.md for full pnpm setup instructions.
## Complete Widget Example: Related Products Selection
```tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import {
Container,
Heading,
Button,
toast,
FocusModal,
Text,
DataTable,
DataTableRowSelectionState,
DataTablePaginationState,
createDataTableColumnHelper,
useDataTable,
} from "@medusajs/ui"
import { HttpTypes } from "@medusajs/types"
import { useMemo, useState } from "react"
import { DetailWidgetProps } from "@medusajs/framework/types"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { sdk } from "../lib/client"
import { PencilSquare } from "@medusajs/icons"
const ProductRelatedProductsWidget = ({
data: product
}: DetailWidgetProps<HttpTypes.AdminProduct>) => {
const [open, setOpen] = useState(false)
const queryClient = useQueryClient()
// Parse existing related products from metadata
const initialIds = useMemo(() => {
if (product?.metadata?.related_product_ids) {
try {
const ids = JSON.parse(product.metadata.related_product_ids as string)
return Array.isArray(ids) ? ids : []
} catch {
return []
}
}
return []
}, [product?.metadata?.related_product_ids])
// Initialize selection state with existing related products
const initialState = useMemo(() => {
return initialIds.reduce((acc, id) => {
acc[id] = true
return acc
}, {} as DataTableRowSelectionState)
}, [initialIds])
const [rowSelection, setRowSelection] = useState<DataTableRowSelectionState>(initialState)
const [searchValue, setSearchValue] = useState("")
const [pagination, setPagination] = useState<DataTablePaginationState>({
pageIndex: 0,
pageSize: 10
})
// IMPORTANT: Separate queries for display and modal selection
// Query 1: Fetch selected products for display (loads on mount)
const { data: displayProducts } = useQuery({
queryFn: async () => {
if (initialIds.length === 0) return { products: [] }
// Fetch specific products by IDs for display
const response = await sdk.admin.product.list({
id: initialIds, // Fetch only the selected products
limit: initialIds.length,
})
return response
},
queryKey: ["related-products-display", initialIds],
enabled: initialIds.length > 0, // Only fetch if there are IDs
})
// Query 2: Fetch products for modal selection (only when modal is open)
const limit = pagination.pageSize
const offset = pagination.pageIndex * limit
const { data: modalProducts, isLoading } = useQuery({
queryFn: () => sdk.admin.product.list({
limit,
offset,
q: searchValue || undefined,
}),
queryKey: ["products-selection", limit, offset, searchValue],
keepPreviousData: true, // Smooth pagination
enabled: open, // Only load when modal is open
})
// Mutation to update the product metadata
const updateProduct = useMutation({
mutationFn: (relatedProductIds: string[]) => {
return sdk.admin.product.update(product.id, {
metadata: {
...product.metadata,
related_product_ids: JSON.stringify(relatedProductIds),
},
})
},
onSuccess: () => {
// Invalidate queries to refresh the display
queryClient.invalidateQueries({ queryKey: ["product", product.id] })
queryClient.invalidateQueries({ queryKey: ["related-products-display"] })
toast.success("Success", {
description: "Related products updated successfully",
dismissLabel: "Close",
})
setOpen(false)
},
onError: (error) => {
console.error("Error saving related products:", error)
toast.error("Error", {
description: "Failed to update related products",
dismissLabel: "Close",
})
},
})
// Get selected product IDs
const selectedProductIds = useMemo(() => Object.keys(rowSelection), [rowSelection])
// Use display query for showing selected products
const selectedProducts = displayProducts?.products || []
const handleSubmit = () => {
updateProduct.mutate(selectedProductIds)
}
const columns = useColumns()
// Use modalProducts for the selection table
const availableProducts = useMemo(() => {
if (!modalProducts?.products) return []
return modalProducts.products.filter(p => p.id !== product.id)
}, [modalProducts?.products, product.id])
const table = useDataTable({
data: availableProducts,
columns,
getRowId: (row) => row.id,
rowCount: modalProducts?.count || 0,
isLoading,
rowSelection: {
state: rowSelection,
onRowSelectionChange: setRowSelection,
},
search: {
state: searchValue,
onSearchChange: setSearchValue,
},
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Related Products</Heading>
<Button
size="small"
variant="secondary"
onClick={() => setOpen(true)}
>
<PencilSquare />
</Button>
</div>
<div className="px-6 py-4">
{selectedProducts.length === 0 ? (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
No related products selected
</Text>
) : (
<div className="flex flex-col gap-y-2">
{selectedProducts.map((p) => (
<div key={p.id} className="flex items-center justify-between">
<Text size="small" leading="compact" weight="plus">
{p.title}
</Text>
</div>
))}
</div>
)}
</div>
<FocusModal open={open} onOpenChange={setOpen}>
<FocusModal.Content>
<div className="flex h-full flex-col overflow-hidden">
<FocusModal.Header />
<FocusModal.Body className="flex items-start justify-center">
<div className="w-full max-w-3xl">
<div className="flex flex-col gap-y-6">
<DataTable instance={table}>
<DataTable.Toolbar>
<div className="flex gap-2">
<DataTable.Search placeholder="Search products..." />
</div>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
</div>
</div>
</FocusModal.Body>
<FocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</FocusModal.Close>
<Button
size="small"
onClick={handleSubmit}
isLoading={updateProduct.isPending}
>
Save
</Button>
</div>
</FocusModal.Footer>
</div>
</FocusModal.Content>
</FocusModal>
</Container>
)
}
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminProduct>()
const useColumns = () => {
return useMemo(() => [
columnHelper.select(),
columnHelper.accessor("title", {
header: "Title",
}),
columnHelper.accessor("status", {
header: "Status",
}),
columnHelper.accessor("created_at", {
header: "Created",
cell: ({ getValue }) => new Date(getValue()).toLocaleDateString(),
}),
], [])
}
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default ProductRelatedProductsWidget
```
## Key Implementation Details
### 1. Typography
- Use `<Text size="small" leading="compact" weight="plus">` for product titles and labels
- Use `<Text size="small" leading="compact" className="text-ui-fg-subtle">` for descriptions
- Container headers can use `<Heading>` component
- See [typography.md](typography.md) for complete typography guidelines
### 2. State Management
- Use `DataTableRowSelectionState` for tracking selected rows
- Initialize with existing selections from metadata
- Use `DataTablePaginationState` with both `pageIndex` and `pageSize`
### 3. Data Loading Pattern - CRITICAL
**Always use separate queries for display vs modal selection:**
```tsx
// Display query - loads on mount, fetches specific items
const { data: displayProducts } = useQuery({
queryFn: () => sdk.admin.product.list({
id: initialIds, // Fetch specific products by IDs
}),
queryKey: ["related-products-display", initialIds],
enabled: initialIds.length > 0,
})
// Modal query - loads when modal opens, paginated
const { data: modalProducts } = useQuery({
queryFn: () => sdk.admin.product.list({
limit, offset, q: searchValue,
}),
queryKey: ["products-selection", limit, offset, searchValue],
enabled: open, // Only when modal is open
keepPreviousData: true,
})
```
**Why this pattern?**
- Display data loads immediately on mount
- Modal data only loads when needed
- Prevents "No data" on page refresh
- Handles ID-based references properly
### 4. Updating with useMutation and Cache Invalidation
```tsx
const updateProduct = useMutation({
mutationFn: (payload) => sdk.admin.product.update(id, payload),
onSuccess: () => {
// Invalidate BOTH the product and display queries
queryClient.invalidateQueries({ queryKey: ["product", id] })
queryClient.invalidateQueries({ queryKey: ["related-products-display"] })
toast.success("Updated successfully")
// Note: No need to invalidate ["products-selection"] - that's modal data
},
})
```
### 5. DataTable Configuration
Always provide all required configurations:
```tsx
const table = useDataTable({
data: data?.products || [],
columns,
getRowId: (row) => row.id,
rowCount: data?.count || 0,
isLoading,
rowSelection: { /* config */ },
search: { /* config */ },
pagination: { /* config */ },
})
```
### 6. Component Structure
```tsx
<DataTable instance={table}>
<DataTable.Toolbar>
<div className="flex gap-2">
<DataTable.Search placeholder="Search..." />
</div>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
```
## Pattern Variations
### For Categories Selection
```tsx
const { data, isLoading } = useQuery({
queryFn: () => sdk.admin.productCategory.list({ limit, offset }),
queryKey: ["categories", limit, offset],
})
```
### For Regions Selection
```tsx
const { data, isLoading } = useQuery({
queryFn: () => sdk.admin.region.list({ limit, offset }),
queryKey: ["regions", limit, offset],
})
```
### For Custom Endpoints
```tsx
const { data, isLoading } = useQuery({
queryFn: () => sdk.client.fetch("/admin/custom-endpoint", {
query: { limit, offset, search: searchValue }
}),
queryKey: ["custom-data", limit, offset, searchValue],
})
```
## Important Notes
1. **Package Manager Considerations**:
- **pnpm users**: MUST install `@tanstack/react-query` and `react-router-dom` BEFORE implementing (see Pre-Implementation Requirements above)
- **npm/yarn users**: DO NOT install these packages - they're already available through dashboard
2. **Always use keepPreviousData: true** for pagination to prevent UI flicker
3. **Search is server-side** - Pass the search value in the query function
4. **Metadata updates replace the entire object** - Spread existing metadata when updating
5. **Use proper query key dependencies** - Include all parameters that affect the data
This pattern provides a consistent, performant way to handle selection from large datasets in Medusa Admin customizations.