Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
Reference in New Issue
Block a user