# 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) => { 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(initialState) const [searchValue, setSearchValue] = useState("") const [pagination, setPagination] = useState({ 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 (
Related Products
{selectedProducts.length === 0 ? ( No related products selected ) : (
{selectedProducts.map((p) => (
{p.title}
))}
)}
) } const columnHelper = createDataTableColumnHelper() 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 `` for product titles and labels - Use `` for descriptions - Container headers can use `` 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
``` ## 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.