Initial commit

This commit is contained in:
2026-03-07 11:07:45 -03:00
commit 9d523f8b6a
65 changed files with 17311 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
# Data Loading Principles and Patterns
## Contents
- [Fundamental Rules](#fundamental-rules)
- [Think Before You Code Checklist](#think-before-you-code-checklist)
- [Common Mistake vs Correct Pattern](#common-mistake-vs-correct-pattern)
- [Working with Tanstack Query](#working-with-tanstack-query)
- [Fetching Data with useQuery](#fetching-data-with-usequery)
- [Basic Query](#basic-query)
- [Paginated Query](#paginated-query)
- [Query with Dependencies](#query-with-dependencies)
- [Fetching Multiple Items by IDs](#fetching-multiple-items-by-ids)
- [Updating Data with useMutation](#updating-data-with-usemutation)
- [Basic Mutation](#basic-mutation)
- [Mutation with Loading State](#mutation-with-loading-state)
- [Create Mutation](#create-mutation)
- [Delete Mutation](#delete-mutation)
- [Cache Invalidation Guidelines](#cache-invalidation-guidelines)
- [Important Notes about Metadata](#important-notes-about-metadata)
- [Common Patterns](#common-patterns)
- [Pattern: Fetching Data with Pagination](#pattern-fetching-data-with-pagination)
- [Pattern: Search with Debounce](#pattern-search-with-debounce)
- [Pattern: Updating Metadata with useMutation](#pattern-updating-metadata-with-usemutation)
- [Common Issues & Solutions](#common-issues--solutions)
- [Complete Example: Widget with Separate Queries](#complete-example-widget-with-separate-queries)
## Fundamental Rules
1. **ALWAYS use the Medusa JS SDK** - NEVER use regular fetch() for API requests (missing headers causes authentication/authorization errors)
2. **Display data must load on mount** - Any data shown in the widget's main UI must be fetched when the component mounts, not conditionally
3. **Separate concerns** - Modal/form data queries should be independent from display data queries
4. **Handle reference data properly** - When storing IDs/references (in metadata or elsewhere), you must fetch the full entities to display them
5. **Always show loading states** - Users should see loading indicators, not empty states, while data is being fetched
6. **Invalidate the right queries** - After mutations, invalidate the queries that provide display data, not just the modal queries
## Think Before You Code Checklist
Before implementing any widget that displays data:
- [ ] Am I using the Medusa JS SDK for all API requests (not regular fetch)?
- [ ] For built-in endpoints, am I using existing SDK methods (not sdk.client.fetch)?
- [ ] What data needs to be visible immediately?
- [ ] Where is this data stored? (metadata, separate endpoint, related entities)
- [ ] If storing IDs, how will I fetch the full entities for display?
- [ ] Are my display queries separate from interaction queries?
- [ ] Have I added loading states for all data fetches?
- [ ] Which queries need invalidation after updates to refresh the display?
## Common Mistake vs Correct Pattern
### ❌ WRONG - Single query for both display and modal:
```tsx
// This breaks on page refresh!
const { data } = useQuery({
queryFn: () => sdk.admin.product.list(),
enabled: modalOpen, // Display won't work on mount!
})
// Trying to display filtered data from modal query
const displayItems = data?.filter((item) => ids.includes(item.id)) // No data until modal opens
```
**Why this is wrong:**
- On page refresh, modal is closed, so query doesn't run
- User sees empty state instead of their data
- Display depends on modal interaction
### ✅ CORRECT - Separate queries with proper invalidation:
```tsx
// Display data - loads immediately
const { data: displayData } = useQuery({
queryFn: () => fetchDisplayData(),
queryKey: ["display-data", product.id],
// No 'enabled' condition - loads on mount
})
// Modal data - loads when needed
const { data: modalData } = useQuery({
queryFn: () => fetchModalData(),
queryKey: ["modal-data"],
enabled: modalOpen, // OK for modal-only data
})
// Mutation with proper cache invalidation
const updateMutation = useMutation({
mutationFn: updateFunction,
onSuccess: () => {
// Invalidate display data query to refresh UI
queryClient.invalidateQueries({ queryKey: ["display-data", product.id] })
// Also invalidate the entity if it caches the data
queryClient.invalidateQueries({ queryKey: ["product", product.id] })
},
})
```
**Why this is correct:**
- Display query runs immediately on component mount
- Modal query only runs when needed
- Proper invalidation ensures UI updates after changes
- Each query has a clear, separate responsibility
## Using the Medusa JS SDK
**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()**
### Why the SDK is Required
- **Admin routes** require `Authorization` header and session cookie - SDK adds them automatically
- **Store routes** require `x-publishable-api-key` header - SDK adds them automatically
- **Regular fetch()** doesn't include these headers → authentication/authorization errors
- Using existing SDK methods provides better type safety and autocomplete
### When to Use What
```tsx
import { sdk } from "../lib/client"
// ✅ CORRECT - Built-in endpoint: Use existing SDK method
const product = await sdk.admin.product.retrieve(productId, {
fields: "+metadata,+variants.*"
})
// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch()
const reviews = await sdk.client.fetch(`/admin/products/${productId}/reviews`)
// ❌ WRONG - Using regular fetch for ANY endpoint
const response = await fetch(`http://localhost:9000/admin/products/${productId}`)
// ❌ Error: Missing Authorization header!
```
### SDK Method Selection
**For built-in Medusa endpoints:**
- Use existing SDK methods: `sdk.admin.product.list()`, `sdk.store.product.list()`, etc.
- Provides type safety, autocomplete, and proper header handling
- Reference: [Medusa JS SDK Documentation](https://docs.medusajs.com/resources/medusa-js-sdk)
**For custom API routes:**
- Use `sdk.client.fetch()` for your custom endpoints
- SDK still handles all required headers (auth, API keys)
- Pass plain objects to body (SDK handles JSON serialization)
## Working with Tanstack Query
Admin widgets and routes have Tanstack Query pre-configured.
**⚠️ pnpm Users**: You MUST install `@tanstack/react-query` BEFORE using `useQuery` or `useMutation`. Install with exact version from dashboard:
```bash
pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard
pnpm add @tanstack/react-query@[exact-version]
```
**npm/yarn Users**: DO NOT install `@tanstack/react-query` - it's already available through dashboard dependencies.
## Fetching Data with useQuery
### Basic Query
```tsx
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../lib/client"
const { data, isLoading, error } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId, {
fields: "+metadata,+variants.*",
}),
queryKey: ["product", productId],
})
```
### Paginated Query
```tsx
const limit = 15
const offset = pagination.pageIndex * limit
const { data: products } = useQuery({
queryFn: () =>
sdk.admin.product.list({
limit,
offset,
q: searchTerm, // for search
}),
queryKey: ["products", limit, offset, searchTerm],
keepPreviousData: true, // Prevents UI flicker during pagination
})
```
### Query with Dependencies
```tsx
// Only fetch if productId exists
const { data } = useQuery({
queryFn: () => sdk.admin.product.retrieve(productId),
queryKey: ["product", productId],
enabled: !!productId, // Only run when productId is truthy
})
```
### Fetching Multiple Items by IDs
```tsx
// For display - fetch specific items by IDs
const { data: displayProducts } = useQuery({
queryFn: async () => {
if (selectedIds.length === 0) return { products: [] }
const response = await sdk.admin.product.list({
id: selectedIds, // Fetch only the selected products
limit: selectedIds.length,
})
return response
},
queryKey: ["related-products-display", selectedIds],
enabled: selectedIds.length > 0, // Only fetch if there are IDs
})
```
## Updating Data with useMutation
### Basic Mutation
```tsx
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "@medusajs/ui"
const queryClient = useQueryClient()
const updateProduct = useMutation({
mutationFn: (payload) => sdk.admin.product.update(productId, payload),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ["product", productId] })
toast.success("Product updated successfully")
},
onError: (error) => {
toast.error(error.message || "Failed to update product")
},
})
// Usage
const handleSave = () => {
updateProduct.mutate({
metadata: {
...existingMetadata,
new_field: "value",
},
})
}
```
### Mutation with Loading State
```tsx
<Button
onClick={handleSave}
isLoading={updateProduct.isPending}
>
Save
</Button>
```
### Create Mutation
```tsx
const createProduct = useMutation({
mutationFn: (data) => sdk.admin.product.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
toast.success("Product created successfully")
setOpen(false)
},
})
```
### Delete Mutation
```tsx
const deleteProduct = useMutation({
mutationFn: (id) => sdk.admin.product.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
toast.success("Product deleted")
},
})
```
## Cache Invalidation Guidelines
After mutations, invalidate the queries that affect what the user sees:
```tsx
onSuccess: () => {
// Invalidate the entity itself if it stores the data
queryClient.invalidateQueries({ queryKey: ["product", productId] })
// Invalidate display-specific queries
queryClient.invalidateQueries({ queryKey: ["related-products", productId] })
// Don't need to invalidate modal selection queries
// queryClient.invalidateQueries({ queryKey: ["products-list"] }) // Not needed
}
```
**Key Points:**
- Use specific query keys with IDs for targeted invalidation
- Invalidate both the entity and display data queries when needed
- Consider what the user sees and ensure those queries refresh
- Modal/selection queries typically don't need invalidation
## Important Notes about Metadata
- When updating nested objects in metadata, pass the entire object (Medusa doesn't merge nested objects)
- To remove a metadata property, set it to an empty string
- Metadata is stored as JSONB in the database
**Example: Updating Metadata**
```tsx
// ✅ CORRECT - Spread existing metadata
updateProduct.mutate({
metadata: {
...product.metadata,
new_field: "value",
},
})
// ❌ WRONG - Overwrites all metadata
updateProduct.mutate({
metadata: {
new_field: "value", // All other fields lost!
},
})
```
## Common Patterns
### Pattern: Fetching Data with Pagination
```tsx
const limit = 15
const offset = pagination.pageIndex * limit
const { data } = useQuery({
queryFn: () => sdk.admin.product.list({ limit, offset }),
queryKey: ["products", limit, offset],
keepPreviousData: true, // Prevents UI flicker during pagination
})
```
### Pattern: Search with Debounce
```tsx
import { useDebouncedValue } from "@mantine/hooks" // or implement your own
const [search, setSearch] = useState("")
const [debouncedSearch] = useDebouncedValue(search, 300)
const { data } = useQuery({
queryFn: () => sdk.admin.product.list({ q: debouncedSearch }),
queryKey: ["products", debouncedSearch],
})
```
### Pattern: Updating Metadata with useMutation
```tsx
const updateMetadata = useMutation({
mutationFn: (metadata) => sdk.admin.product.update(productId, { metadata }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["product", productId] })
toast.success("Updated successfully")
},
})
```
## Common Issues & Solutions
### Authentication/Authorization errors when fetching data
**Symptoms:**
- API returns 401 Unauthorized or 403 Forbidden
- "Missing x-publishable-api-key header" error
- "Unauthorized" error on admin routes
**Cause:** Using regular `fetch()` instead of the Medusa JS SDK
**Solution:**
```tsx
// ❌ WRONG - Missing required headers
const { data } = useQuery({
queryFn: () => fetch('http://localhost:9000/admin/products').then(r => r.json()),
queryKey: ["products"]
})
// ✅ CORRECT - SDK handles headers automatically
const { data } = useQuery({
queryFn: () => sdk.admin.product.list(),
queryKey: ["products"]
})
// ✅ CORRECT - For custom routes
const { data } = useQuery({
queryFn: () => sdk.client.fetch('/admin/custom-route'),
queryKey: ["custom-data"]
})
```
### "No QueryClient set, use QueryClientProvider to set one"
- **pnpm users**: You forgot to install `@tanstack/react-query` before implementing. Install it now with the exact version from dashboard
- **npm/yarn users**: You incorrectly installed `@tanstack/react-query` - remove it from package.json
- Never wrap your component in QueryClientProvider - it's already provided
### Search not filtering results
- The search happens server-side via the `q` parameter
- Make sure to pass the search value in your queryFn:
```tsx
queryFn: () => sdk.admin.product.list({ q: searchValue })
```
### Metadata updates not working
- Always pass the complete metadata object (partial updates aren't merged)
- To remove a field, set it to an empty string, not null or undefined
### Widget not refreshing after mutation
- Use queryClient.invalidateQueries() with the correct query key
- Ensure your query key includes all dependencies (search, pagination, etc.)
### Data shows empty on page refresh
- Your query has `enabled: modalOpen` or similar condition
- Display data should NEVER be conditionally enabled based on UI state
- Move conditional queries to modals/forms only
## Complete Example: Widget with Separate Queries
```tsx
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useState, useMemo } from "react"
import { Container, Heading, Button, FocusModal, toast } from "@medusajs/ui"
import { sdk } from "../lib/client"
const RelatedProductsWidget = ({ data: product }) => {
const [open, setOpen] = useState(false)
const queryClient = useQueryClient()
// Parse existing related product IDs from metadata
const relatedIds = useMemo(() => {
if (product?.metadata?.related_product_ids) {
try {
const ids = JSON.parse(product.metadata.related_product_ids)
return Array.isArray(ids) ? ids : []
} catch {
return []
}
}
return []
}, [product?.metadata?.related_product_ids])
// Query 1: Fetch selected products for display (loads on mount)
const { data: displayProducts } = useQuery({
queryFn: async () => {
if (relatedIds.length === 0) return { products: [] }
const response = await sdk.admin.product.list({
id: relatedIds,
limit: relatedIds.length,
})
return response
},
queryKey: ["related-products-display", relatedIds],
enabled: relatedIds.length > 0,
})
// Query 2: Fetch products for modal selection (only when modal is open)
const { data: modalProducts, isLoading } = useQuery({
queryFn: () => sdk.admin.product.list({ limit: 10, offset: 0 }),
queryKey: ["products-selection"],
enabled: open, // Only load when modal is open
})
// Mutation to update the product metadata
const updateProduct = useMutation({
mutationFn: (relatedProductIds) => {
return sdk.admin.product.update(product.id, {
metadata: {
...product.metadata,
related_product_ids: JSON.stringify(relatedProductIds),
},
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["product", product.id] })
queryClient.invalidateQueries({ queryKey: ["related-products-display"] })
toast.success("Related products updated")
setOpen(false)
},
})
return (
<Container>
<div className="flex items-center justify-between">
<Heading>Related Products</Heading>
<Button onClick={() => setOpen(true)}>Edit</Button>
</div>
{/* Display current selection */}
<div>
{displayProducts?.products.map((p) => (
<div key={p.id}>{p.title}</div>
))}
</div>
{/* Modal for selection */}
<FocusModal open={open} onOpenChange={setOpen}>
{/* Modal content with selection UI */}
</FocusModal>
</Container>
)
}
```

View File

@@ -0,0 +1,436 @@
# Displaying Entities - Patterns and Components
## Contents
- [When to Use Each Pattern](#when-to-use-each-pattern)
- [DataTable Pattern](#datatable-pattern)
- [Complete DataTable Implementation](#complete-datatable-implementation)
- [DataTable Troubleshooting](#datatable-troubleshooting)
- [Simple List Patterns](#simple-list-patterns)
- [Product/Variant List Item](#productvariant-list-item)
- [Simple Text List (No Thumbnails)](#simple-text-list-no-thumbnails)
- [Compact List (No Cards)](#compact-list-no-cards)
- [Grid Display](#grid-display)
- [Key Design Elements](#key-design-elements)
- [Empty States](#empty-states)
- [Loading States](#loading-states)
- [Conditional Rendering Based on Count](#conditional-rendering-based-on-count)
- [Common Class Patterns](#common-class-patterns)
## When to Use Each Pattern
**Use DataTable when:**
- Displaying potentially many entries (>5-10 items)
- Users need to search, filter, or paginate
- Bulk actions are needed (select multiple, delete, etc.)
- Displaying in a main list view
**Use simple list components when:**
- Displaying a few entries (<5-10 items)
- In a widget or sidebar context
- As a preview or summary
- Space is limited
## DataTable Pattern
**⚠️ pnpm Users**: DataTable examples may use `react-router-dom` for navigation. Install it BEFORE implementing if needed.
### Complete DataTable Implementation
```tsx
import {
DataTable,
DataTableRowSelectionState,
DataTablePaginationState,
createDataTableColumnHelper,
useDataTable,
} from "@medusajs/ui"
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../lib/client"
const columnHelper = createDataTableColumnHelper<HttpTypes.AdminProduct>()
const columns = [
columnHelper.select(), // For row selection
columnHelper.accessor("title", {
header: "Title",
}),
columnHelper.accessor("status", {
header: "Status",
}),
columnHelper.accessor("created_at", {
header: "Created",
cell: ({ getValue }) => new Date(getValue()).toLocaleDateString(),
}),
]
export function ProductTable() {
const [rowSelection, setRowSelection] = useState<DataTableRowSelectionState>(
{}
)
const [searchValue, setSearchValue] = useState("")
const [pagination, setPagination] = useState<DataTablePaginationState>({
pageIndex: 0,
pageSize: 15,
})
const limit = pagination.pageSize
const offset = pagination.pageIndex * limit
// Fetch products with search and pagination
const { data, isLoading } = useQuery({
queryFn: () =>
sdk.admin.product.list({
limit,
offset,
q: searchValue || undefined, // Search query
}),
queryKey: ["products", limit, offset, searchValue],
keepPreviousData: true, // Smooth pagination
})
const table = useDataTable({
data: data?.products || [],
columns,
getRowId: (product) => product.id,
rowCount: data?.count || 0,
isLoading,
rowSelection: {
state: rowSelection,
onRowSelectionChange: setRowSelection,
},
search: {
state: searchValue,
onSearchChange: setSearchValue,
},
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<DataTable instance={table}>
<DataTable.Toolbar>
<div className="flex gap-2">
<DataTable.Search placeholder="Search products..." />
</div>
</DataTable.Toolbar>
<DataTable.Table />
<DataTable.Pagination />
</DataTable>
)
}
```
### DataTable Troubleshooting
**"DataTable.Search was rendered but search is not enabled"**
You must pass search state configuration to useDataTable:
```tsx
search: {
state: searchValue,
onSearchChange: setSearchValue,
}
```
**"Cannot destructure property 'pageIndex' of pagination as it is undefined"**
Always initialize pagination state with both properties:
```tsx
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 15,
})
```
## Simple List Patterns
### Product/Variant List Item
For displaying a small list of products or variants with thumbnails:
```tsx
import { Thumbnail, Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
import { Link } from "react-router-dom"
// Component for displaying a product variant
const ProductVariantItem = ({ variant, link }) => {
const Inner = (
<div className="shadow-elevation-card-rest bg-ui-bg-component rounded-md px-4 py-2 transition-colors">
<div className="flex items-center gap-3">
<div className="shadow-elevation-card-rest rounded-md">
<Thumbnail src={variant.product?.thumbnail} />
</div>
<div className="flex flex-1 flex-col">
<Text size="small" leading="compact" weight="plus">
{variant.title}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{variant.options.map((o) => o.value).join(" ⋅ ")}
</Text>
</div>
<div className="size-7 flex items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted rtl:rotate-180" />
</div>
</div>
</div>
)
if (!link) {
return <div key={variant.id}>{Inner}</div>
}
return (
<Link
to={link}
key={variant.id}
className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md [&:hover>div]:bg-ui-bg-component-hover"
>
{Inner}
</Link>
)
}
// Usage in a widget
const RelatedProductsDisplay = ({ products }) => {
if (products.length > 10) {
// Use DataTable for many items
return <ProductDataTable products={products} />
}
// Use simple list for few items
return (
<div className="flex flex-col gap-2">
{products.map((product) => (
<ProductVariantItem
key={product.id}
variant={product}
link={`/products/${product.id}`}
/>
))}
</div>
)
}
```
### Simple Text List (No Thumbnails)
For entities without images (categories, regions, etc.):
```tsx
import { Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
import { Link } from "react-router-dom"
const SimpleListItem = ({ title, description, link }) => {
const Inner = (
<div className="shadow-elevation-card-rest bg-ui-bg-component rounded-md px-4 py-3 transition-colors">
<div className="flex items-center gap-3">
<div className="flex flex-1 flex-col gap-y-1">
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
{description && (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{description}
</Text>
)}
</div>
<div className="size-7 flex items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted rtl:rotate-180" />
</div>
</div>
</div>
)
if (!link) {
return <div>{Inner}</div>
}
return (
<Link
to={link}
className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md [&:hover>div]:bg-ui-bg-component-hover"
>
{Inner}
</Link>
)
}
// Usage
<div className="flex flex-col gap-2">
{categories.map((cat) => (
<SimpleListItem
key={cat.id}
title={cat.name}
description={cat.description}
link={`/categories/${cat.id}`}
/>
))}
</div>
```
### Compact List (No Cards)
For very compact displays:
```tsx
import { Text } from "@medusajs/ui"
<div className="flex flex-col gap-y-2">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between">
<Text size="small" leading="compact" weight="plus">
{item.title}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{item.metadata}
</Text>
</div>
))}
</div>
```
### Grid Display
For displaying items in a grid:
```tsx
<div className="grid grid-cols-2 gap-4">
{items.map((item) => (
<div
key={item.id}
className="shadow-elevation-card-rest bg-ui-bg-component rounded-md p-4"
>
<div className="flex flex-col gap-y-2">
<Thumbnail src={item.thumbnail} />
<Text size="small" leading="compact" weight="plus">
{item.title}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{item.description}
</Text>
</div>
</div>
))}
</div>
```
## Key Design Elements
### For Product/Variant displays:
- Always show the thumbnail using `<Thumbnail />` component
- Display title with `<Text size="small" leading="compact" weight="plus">`
- Show secondary info with `<Text size="small" leading="compact" className="text-ui-fg-subtle">`
- Use `shadow-elevation-card-rest` for card elevation
- Include hover states with `bg-ui-bg-component-hover`
- Add navigation indicators (arrows) when items are clickable
### For other entities:
- Use similar card patterns but adapt the content
- Keep consistent spacing (`gap-3` for items, `gap-2` for lists)
- Always use the Text component with correct typography patterns
- Maintain visual hierarchy with `weight="plus"` for primary and `text-ui-fg-subtle` for secondary text
## Empty States
Always handle empty states gracefully:
```tsx
{items.length === 0 ? (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
No items to display
</Text>
) : (
<div className="flex flex-col gap-2">
{items.map((item) => (
<ItemDisplay key={item.id} item={item} />
))}
</div>
)}
```
## Loading States
Show loading states while data is being fetched:
```tsx
import { Spinner } from "@medusajs/ui"
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Spinner />
</div>
) : (
<div className="flex flex-col gap-2">
{items.map((item) => (
<ItemDisplay key={item.id} item={item} />
))}
</div>
)}
```
## Conditional Rendering Based on Count
```tsx
const DisplayComponent = ({ items }) => {
// Use DataTable for many items
if (items.length > 10) {
return <ItemsDataTable items={items} />
}
// Use simple list for few items
if (items.length > 0) {
return (
<div className="flex flex-col gap-2">
{items.map((item) => (
<SimpleListItem key={item.id} item={item} />
))}
</div>
)
}
// Empty state
return (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
No items to display
</Text>
)
}
```
## Common Class Patterns
### Card with elevation and hover
```tsx
className="shadow-elevation-card-rest bg-ui-bg-component rounded-md transition-colors hover:bg-ui-bg-component-hover"
```
### Flex container with consistent spacing
```tsx
className="flex flex-col gap-2" // For vertical lists
className="flex items-center gap-3" // For horizontal items
```
### Focus states for interactive elements
```tsx
className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md"
```
### RTL support for directional icons
```tsx
className="text-ui-fg-muted rtl:rotate-180"
```

View File

@@ -0,0 +1,400 @@
# Forms and Modal Patterns
## Contents
- [FocusModal vs Drawer](#focusmodal-vs-drawer)
- [Edit Button Patterns](#edit-button-patterns)
- [Simple Edit Button (top right corner)](#simple-edit-button-top-right-corner)
- [Dropdown Menu with Actions](#dropdown-menu-with-actions)
- [Select Component for Small Datasets](#select-component-for-small-datasets)
- [FocusModal Example](#focusmodal-example)
- [Drawer Example](#drawer-example)
- [Form with Validation and Loading States](#form-with-validation-and-loading-states)
- [Key Form Patterns](#key-form-patterns)
## FocusModal vs Drawer
**FocusModal** - Use for creating new entities:
- Full-screen modal
- More space for complex forms
- Better for multi-step flows
**Drawer** - Use for editing existing entities:
- Side panel that slides in from right
- Quick edits without losing context
- Better for single-field updates
**Rule of thumb:** FocusModal for creating, Drawer for editing.
## Edit Button Patterns
Data displayed in a container should not be editable directly. Instead, use an "Edit" button. This can be:
### Simple Edit Button (top right corner)
```tsx
import { Button } from "@medusajs/ui"
import { PencilSquare } from "@medusajs/icons"
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Section Title</Heading>
<Button
size="small"
variant="secondary"
onClick={() => setOpen(true)}
>
<PencilSquare />
</Button>
</div>
```
### Dropdown Menu with Actions
```tsx
import { EllipsisHorizontal, PencilSquare, Plus, Trash } from "@medusajs/icons"
import { DropdownMenu, IconButton } from "@medusajs/ui"
export function DropdownMenuDemo() {
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item className="gap-x-2">
<PencilSquare className="text-ui-fg-subtle" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Item className="gap-x-2">
<Plus className="text-ui-fg-subtle" />
Add
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item className="gap-x-2">
<Trash className="text-ui-fg-subtle" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)
}
```
## Select Component for Small Datasets
For selecting from 2-10 options (statuses, types, etc.), use the Select component:
```tsx
import { Select } from "@medusajs/ui"
<Select>
<Select.Trigger>
<Select.Value placeholder="Select status" />
</Select.Trigger>
<Select.Content>
{items.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.Content>
</Select>
```
**For larger datasets** (Products, Categories, Regions, etc.), use DataTable with FocusModal for search and pagination. See [table-selection.md](table-selection.md) for the complete pattern.
## FocusModal Example
```tsx
import { FocusModal, Button, Input, Label } from "@medusajs/ui"
import { useState } from "react"
const MyWidget = () => {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState({ title: "" })
const handleSubmit = () => {
// Handle form submission
console.log(formData)
setOpen(false)
}
return (
<>
<Button onClick={() => setOpen(true)}>
Create New
</Button>
<FocusModal open={open} onOpenChange={setOpen}>
<FocusModal.Content>
<div className="flex h-full flex-col overflow-hidden">
<FocusModal.Header>
<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}>
Save
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Label>Title</Label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
{/* More form fields */}
</div>
</FocusModal.Body>
</div>
</FocusModal.Content>
</FocusModal>
</>
)
}
```
## Drawer Example
```tsx
import { Drawer, Button, Input, Label } from "@medusajs/ui"
import { useState } from "react"
const MyWidget = ({ data }) => {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState({ title: data.title })
const handleSubmit = () => {
// Handle form submission
console.log(formData)
setOpen(false)
}
return (
<>
<Button onClick={() => setOpen(true)}>
Edit
</Button>
<Drawer open={open} onOpenChange={setOpen}>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Edit Settings</Drawer.Title>
</Drawer.Header>
<Drawer.Body className="flex-1 overflow-auto p-4">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Label>Title</Label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
{/* More form fields */}
</div>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
Cancel
</Button>
</Drawer.Close>
<Button size="small" onClick={handleSubmit}>
Save
</Button>
</div>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
</>
)
}
```
## Form with Validation and Loading States
```tsx
import { FocusModal, Button, Input, Label, Text, toast } from "@medusajs/ui"
import { useState } from "react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { sdk } from "../lib/client"
const CreateProductWidget = () => {
const [open, setOpen] = useState(false)
const [formData, setFormData] = useState({
title: "",
description: "",
})
const [errors, setErrors] = useState({})
const queryClient = useQueryClient()
const createProduct = useMutation({
mutationFn: (data) => sdk.admin.product.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
toast.success("Product created successfully")
setOpen(false)
setFormData({ title: "", description: "" })
setErrors({})
},
onError: (error) => {
toast.error(error.message || "Failed to create product")
},
})
const handleSubmit = () => {
// Validate
const newErrors = {}
if (!formData.title) newErrors.title = "Title is required"
if (!formData.description) newErrors.description = "Description is required"
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors)
return
}
createProduct.mutate(formData)
}
return (
<>
<Button onClick={() => setOpen(true)}>
Create Product
</Button>
<FocusModal open={open} onOpenChange={setOpen}>
<FocusModal.Content>
<div className="flex h-full flex-col overflow-hidden">
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button
size="small"
variant="secondary"
disabled={createProduct.isPending}
>
Cancel
</Button>
</FocusModal.Close>
<Button
size="small"
onClick={handleSubmit}
isLoading={createProduct.isPending}
>
Save
</Button>
</div>
</FocusModal.Header>
<FocusModal.Body className="flex-1 overflow-auto">
<div className="flex flex-col gap-y-4">
<div className="flex flex-col gap-y-2">
<Label>Title *</Label>
<Input
value={formData.title}
onChange={(e) => {
setFormData({ ...formData, title: e.target.value })
setErrors({ ...errors, title: undefined })
}}
/>
{errors.title && (
<Text size="small" className="text-ui-fg-error">
{errors.title}
</Text>
)}
</div>
<div className="flex flex-col gap-y-2">
<Label>Description *</Label>
<Input
value={formData.description}
onChange={(e) => {
setFormData({ ...formData, description: e.target.value })
setErrors({ ...errors, description: undefined })
}}
/>
{errors.description && (
<Text size="small" className="text-ui-fg-error">
{errors.description}
</Text>
)}
</div>
</div>
</FocusModal.Body>
</div>
</FocusModal.Content>
</FocusModal>
</>
)
}
```
## Key Form Patterns
### Always Disable Actions During Mutations
```tsx
<Button
disabled={mutation.isPending}
onClick={handleAction}
>
Action
</Button>
```
### Show Loading State on Submit Button
```tsx
<Button
isLoading={mutation.isPending}
onClick={handleSubmit}
>
Save
</Button>
```
### Clear Form After Success
```tsx
onSuccess: () => {
setFormData(initialState)
setErrors({})
setOpen(false)
}
```
### Validate Before Submitting
```tsx
const handleSubmit = () => {
const errors = validateForm(formData)
if (Object.keys(errors).length > 0) {
setErrors(errors)
return
}
mutation.mutate(formData)
}
```
### Clear Field Errors on Input Change
```tsx
<Input
value={formData.field}
onChange={(e) => {
setFormData({ ...formData, field: e.target.value })
setErrors({ ...errors, field: undefined }) // Clear error
}}
/>
```

View File

@@ -0,0 +1,496 @@
# Navigation and Routing
## Contents
- [Pre-Implementation Requirements for pnpm](#pre-implementation-requirements-for-pnpm)
- [Basic Navigation with Link Component](#basic-navigation-with-link-component)
- [Programmatic Navigation](#programmatic-navigation)
- [Accessing Route Parameters](#accessing-route-parameters)
- [Linking to Built-in Admin Pages](#linking-to-built-in-admin-pages)
- [Navigation from Widgets](#navigation-from-widgets)
- [Common Navigation Patterns](#common-navigation-patterns)
## Pre-Implementation Requirements for pnpm
**⚠️ pnpm Users**: Navigation requires `react-router-dom`. Install BEFORE implementing:
```bash
pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard
pnpm add react-router-dom@[exact-version]
```
**npm/yarn Users**: DO NOT install - already available through dashboard dependencies.
## Basic Navigation with Link Component
Use the `Link` component for internal navigation in widgets and custom pages:
```tsx
import { Link } from "react-router-dom"
import { Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
// Link to a custom page
<Link
to="/custom/my-page"
className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md [&:hover>div]:bg-ui-bg-component-hover"
>
<div className="shadow-elevation-card-rest bg-ui-bg-component rounded-md px-4 py-3 transition-colors">
<div className="flex items-center gap-3">
<Text size="small" leading="compact" weight="plus">
Go to Custom Page
</Text>
<div className="size-7 flex items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted rtl:rotate-180" />
</div>
</div>
</div>
</Link>
```
### Link with Dynamic ID
```tsx
// Link to product details
<Link to={`/products/${product.id}`}>
<Text size="small" leading="compact" weight="plus">
{product.title}
</Text>
</Link>
```
### Button-styled Link
```tsx
import { Button } from "@medusajs/ui"
import { Link } from "react-router-dom"
<Button asChild size="small" variant="secondary">
<Link to="/custom/my-page">
View Details
</Link>
</Button>
```
## Programmatic Navigation
Use `useNavigate` for navigation after actions (e.g., after creating an entity):
```tsx
import { useNavigate } from "react-router-dom"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { toast, Button } from "@medusajs/ui"
import { sdk } from "../lib/client"
const CreateProductWidget = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const createProduct = useMutation({
mutationFn: (data) => sdk.admin.product.create(data),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["products"] })
toast.success("Product created successfully")
// Navigate to the new product's page
navigate(`/products/${result.product.id}`)
},
onError: (error) => {
toast.error(error.message || "Failed to create product")
},
})
const handleCreate = () => {
createProduct.mutate({
title: "New Product",
// ... other fields
})
}
return (
<Button onClick={handleCreate} isLoading={createProduct.isPending}>
Create and View Product
</Button>
)
}
```
### Navigate with State
Pass data to the destination page:
```tsx
navigate("/custom/review", {
state: { productId: product.id, productTitle: product.title }
})
// Access in destination page
import { useLocation } from "react-router-dom"
const ReviewPage = () => {
const location = useLocation()
const { productId, productTitle } = location.state || {}
return <div>Reviewing: {productTitle}</div>
}
```
### Navigate Back
```tsx
const navigate = useNavigate()
<Button onClick={() => navigate(-1)}>
Go Back
</Button>
```
## Accessing Route Parameters
In custom pages, access URL parameters with `useParams`:
```tsx
// Custom page at: /custom/products/:id
import { useParams } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { sdk } from "../lib/client"
import { Container, Heading } from "@medusajs/ui"
const ProductDetailsPage = () => {
const { id } = useParams() // Get :id from URL
const { data: product, isLoading } = useQuery({
queryFn: () => sdk.admin.product.retrieve(id, {
fields: "+metadata,+variants.*",
}),
queryKey: ["product", id],
enabled: !!id, // Only fetch if ID exists
})
if (isLoading) return <div>Loading...</div>
return (
<Container>
<Heading>{product?.title}</Heading>
{/* Product details */}
</Container>
)
}
export default ProductDetailsPage
```
### Multiple Parameters
```tsx
// Route: /custom/orders/:orderId/items/:itemId
const { orderId, itemId } = useParams()
const { data } = useQuery({
queryFn: () => sdk.client.fetch(`/admin/orders/${orderId}/items/${itemId}`),
queryKey: ["order-item", orderId, itemId],
enabled: !!orderId && !!itemId,
})
```
### Query Parameters
Use `useSearchParams` for query string parameters:
```tsx
import { useSearchParams } from "react-router-dom"
const ProductsPage = () => {
const [searchParams, setSearchParams] = useSearchParams()
const status = searchParams.get("status") // Get ?status=published
const page = searchParams.get("page") // Get ?page=2
const { data } = useQuery({
queryFn: () => sdk.admin.product.list({
status,
offset: (parseInt(page) || 0) * 15,
}),
queryKey: ["products", status, page],
})
// Update query params
const handleFilterChange = (newStatus: string) => {
setSearchParams({ status: newStatus, page: "0" })
}
return (
<div>
<Button onClick={() => handleFilterChange("published")}>
Published Only
</Button>
{/* Products list */}
</div>
)
}
```
## Linking to Built-in Admin Pages
Link to standard Medusa admin pages:
```tsx
import { Link } from "react-router-dom"
// Product details
<Link to={`/products/${productId}`}>View Product</Link>
// Order details
<Link to={`/orders/${orderId}`}>View Order</Link>
// Customer details
<Link to={`/customers/${customerId}`}>View Customer</Link>
// Product categories
<Link to="/categories">View Categories</Link>
// Settings
<Link to="/settings">Settings</Link>
// Custom field in settings
<Link to="/settings/custom-field-name">Custom Settings</Link>
```
### Common Built-in Routes
```tsx
const ADMIN_ROUTES = {
products: "/products",
productDetails: (id: string) => `/products/${id}`,
orders: "/orders",
orderDetails: (id: string) => `/orders/${id}`,
customers: "/customers",
customerDetails: (id: string) => `/customers/${id}`,
categories: "/categories",
inventory: "/inventory",
pricing: "/pricing",
settings: "/settings",
}
// Usage
<Link to={ADMIN_ROUTES.productDetails(product.id)}>
View Product
</Link>
```
## Navigation from Widgets
### Pattern: View All Link
Add a "View All" link from a widget to a custom page:
```tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { Container, Heading, Button, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
import { DetailWidgetProps } from "@medusajs/framework/types"
const RelatedProductsWidget = ({ data: product }) => {
// ... widget logic
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Related Products</Heading>
<div className="flex items-center gap-x-2">
<Button size="small" variant="secondary" onClick={() => setOpen(true)}>
Edit
</Button>
<Button asChild size="small" variant="transparent">
<Link to={`/custom/products/${product.id}/related`}>
View All
</Link>
</Button>
</div>
</div>
{/* Widget content */}
</Container>
)
}
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default RelatedProductsWidget
```
### Pattern: List Item Navigation
Make list items clickable to navigate:
```tsx
import { Thumbnail, Text } from "@medusajs/ui"
import { TriangleRightMini } from "@medusajs/icons"
import { Link } from "react-router-dom"
const ProductListItem = ({ product }) => {
return (
<Link
to={`/products/${product.id}`}
className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md [&:hover>div]:bg-ui-bg-component-hover"
>
<div className="shadow-elevation-card-rest bg-ui-bg-component rounded-md px-4 py-2 transition-colors">
<div className="flex items-center gap-3">
<Thumbnail src={product.thumbnail} />
<div className="flex flex-1 flex-col">
<Text size="small" leading="compact" weight="plus">
{product.title}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{product.status}
</Text>
</div>
<div className="size-7 flex items-center justify-center">
<TriangleRightMini className="text-ui-fg-muted rtl:rotate-180" />
</div>
</div>
</div>
</Link>
)
}
```
## Common Navigation Patterns
### Pattern: Back to List
Navigate back to list after viewing details:
```tsx
import { useNavigate } from "react-router-dom"
import { ArrowLeft } from "@medusajs/icons"
import { IconButton } from "@medusajs/ui"
const DetailsPage = () => {
const navigate = useNavigate()
return (
<div>
<div className="flex items-center gap-x-2 mb-4">
<IconButton onClick={() => navigate("/custom/products")}>
<ArrowLeft />
</IconButton>
<Heading>Product Details</Heading>
</div>
{/* Details content */}
</div>
)
}
```
### Pattern: Breadcrumb Navigation
```tsx
import { Link } from "react-router-dom"
import { Text } from "@medusajs/ui"
const Breadcrumbs = ({ product }) => {
return (
<div className="flex items-center gap-x-2">
<Link to="/products">
<Text size="small" className="text-ui-fg-subtle hover:text-ui-fg-base">
Products
</Text>
</Link>
<Text size="small" className="text-ui-fg-muted">/</Text>
<Link to={`/products/${product.id}`}>
<Text size="small" className="text-ui-fg-subtle hover:text-ui-fg-base">
{product.title}
</Text>
</Link>
<Text size="small" className="text-ui-fg-muted">/</Text>
<Text size="small" weight="plus">Details</Text>
</div>
)
}
```
### Pattern: Tab Navigation
Navigate between different views using tabs:
```tsx
import { useSearchParams, Link } from "react-router-dom"
import { Tabs } from "@medusajs/ui"
const ProductTabs = () => {
const [searchParams] = useSearchParams()
const activeTab = searchParams.get("tab") || "details"
return (
<Tabs value={activeTab}>
<Tabs.List>
<Tabs.Trigger value="details" asChild>
<Link to="?tab=details">Details</Link>
</Tabs.Trigger>
<Tabs.Trigger value="variants" asChild>
<Link to="?tab=variants">Variants</Link>
</Tabs.Trigger>
<Tabs.Trigger value="media" asChild>
<Link to="?tab=media">Media</Link>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="details">
{/* Details content */}
</Tabs.Content>
<Tabs.Content value="variants">
{/* Variants content */}
</Tabs.Content>
<Tabs.Content value="media">
{/* Media content */}
</Tabs.Content>
</Tabs>
)
}
```
### Pattern: Action with Navigation
Perform an action then navigate:
```tsx
const deleteProduct = useMutation({
mutationFn: (id) => sdk.admin.product.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] })
toast.success("Product deleted")
navigate("/products") // Navigate to list after deletion
},
})
```
### Pattern: Conditional Navigation
Navigate based on state or data:
```tsx
const handleComplete = () => {
if (hasErrors) {
toast.error("Please fix errors first")
return
}
if (isDraft) {
navigate(`/custom/products/${id}/publish`)
} else {
navigate("/products")
}
}
```
## Important Notes
1. **pnpm users**: Must install `react-router-dom` with exact version from dashboard
2. **npm/yarn users**: Do NOT install `react-router-dom` - already available
3. **Always use relative paths** starting with `/` for internal navigation
4. **Use Link for navigation links** - better for SEO and accessibility
5. **Use navigate for programmatic navigation** - after actions or based on logic
6. **Always handle loading states** when fetching route parameter-based data
7. **Clean up on unmount** when using listeners or subscriptions in routes
8. **Maintain focus management** for accessibility when navigating

View File

@@ -0,0 +1,407 @@
# 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.

View File

@@ -0,0 +1,210 @@
# Typography Guidelines
## Contents
- [Core Typography Pattern](#core-typography-pattern)
- [Typography Rules](#typography-rules)
- [Complete Examples](#complete-examples)
- [Text Color Classes](#text-color-classes)
- [Common Patterns](#common-patterns)
- [Quick Reference](#quick-reference)
## Core Typography Pattern
Use the `Text` component from `@medusajs/ui` for all text elements. Follow these specific patterns:
### Headings/Labels
Use this pattern for section headings, field labels, or any primary text:
```tsx
<Text size="small" leading="compact" weight="plus">
{labelText}
</Text>
```
### Body/Descriptions
Use this pattern for descriptions, helper text, or secondary information:
```tsx
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{descriptionText}
</Text>
```
## Typography Rules
- **Never use** `<Heading>` component for small sections within widgets/containers
- **Always use** `size="small"` and `leading="compact"` for consistency
- **Use** `weight="plus"` for labels and headings
- **Use** `className="text-ui-fg-subtle"` for secondary/descriptive text
- **For larger headings** (page titles, container headers), use the `<Heading>` component
## Complete Examples
### Widget Section with Label and Description
```tsx
import { Text } from "@medusajs/ui"
// In a container or widget:
<div className="flex flex-col gap-y-2">
<Text size="small" leading="compact" weight="plus">
Product Settings
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
Configure how this product appears in your store
</Text>
</div>
```
### List Item with Title and Subtitle
```tsx
<div className="flex flex-col gap-y-1">
<Text size="small" leading="compact" weight="plus">
Premium T-Shirt
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
Size: Large Color: Blue
</Text>
</div>
```
### Container Header (Use Heading)
```tsx
import { Container, Heading } from "@medusajs/ui"
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">Related Products</Heading>
</div>
{/* ... */}
</Container>
```
### Empty State Message
```tsx
<Text size="small" leading="compact" className="text-ui-fg-subtle">
No related products selected
</Text>
```
### Form Field Label
```tsx
<div className="flex flex-col gap-y-2">
<Text size="small" leading="compact" weight="plus">
Display Name
</Text>
<Input {...props} />
</div>
```
### Error Message
```tsx
<Text size="small" className="text-ui-fg-error">
This field is required
</Text>
```
### Badge or Status Text
```tsx
<div className="flex items-center gap-x-2">
<Text size="small" leading="compact" weight="plus">
Status:
</Text>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
Active
</Text>
</div>
```
## Text Color Classes
Medusa UI provides semantic color classes:
- `text-ui-fg-base` - Default text color (rarely needed, it's the default)
- `text-ui-fg-subtle` - Secondary/muted text
- `text-ui-fg-muted` - Even more muted
- `text-ui-fg-disabled` - Disabled state
- `text-ui-fg-error` - Error messages
- `text-ui-fg-success` - Success messages
- `text-ui-fg-warning` - Warning messages
## Common Patterns
### Two-Column Layout
```tsx
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-y-1">
<Text size="small" leading="compact" className="text-ui-fg-subtle">
Category
</Text>
<Text size="small" leading="compact" weight="plus">
Clothing
</Text>
</div>
<div className="flex flex-col gap-y-1">
<Text size="small" leading="compact" className="text-ui-fg-subtle">
Status
</Text>
<Text size="small" leading="compact" weight="plus">
Published
</Text>
</div>
</div>
```
### Inline Label-Value Pair
```tsx
<div className="flex items-center gap-x-2">
<Text size="small" leading="compact" className="text-ui-fg-subtle">
SKU:
</Text>
<Text size="small" leading="compact" weight="plus">
SHIRT-001
</Text>
</div>
```
### Card with Title and Metadata
```tsx
<div className="flex flex-col gap-y-2">
<Text size="small" leading="compact" weight="plus">
Premium Cotton T-Shirt
</Text>
<div className="flex items-center gap-x-2 text-ui-fg-subtle">
<Text size="small" leading="compact">
$29.99
</Text>
<Text size="small" leading="compact">
</Text>
<Text size="small" leading="compact">
In stock
</Text>
</div>
</div>
```
## Quick Reference
| Use Case | Pattern |
|----------|---------|
| Section headings | `weight="plus"` |
| Primary text | `weight="plus"` |
| Labels | `weight="plus"` |
| Descriptions | `className="text-ui-fg-subtle"` |
| Helper text | `className="text-ui-fg-subtle"` |
| Metadata | `className="text-ui-fg-subtle"` |
| Errors | `className="text-ui-fg-error"` |
| Empty states | `className="text-ui-fg-subtle"` |
| Large headers | `<Heading>` component |