Files
2026-03-07 11:07:45 -03:00

13 KiB

Table Selection Pattern

Contents

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:

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.

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 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:

// 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

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:

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

<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

const { data, isLoading } = useQuery({
  queryFn: () => sdk.admin.productCategory.list({ limit, offset }),
  queryKey: ["categories", limit, offset],
})

For Regions Selection

const { data, isLoading } = useQuery({
  queryFn: () => sdk.admin.region.list({ limit, offset }),
  queryKey: ["regions", limit, offset],
})

For Custom Endpoints

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.