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,462 @@
---
name: building-admin-dashboard-customizations
description: Load automatically when planning, researching, or implementing Medusa Admin dashboard UI (widgets, custom pages, forms, tables, data loading, navigation). REQUIRED for all admin UI work in ALL modes (planning, implementation, exploration). Contains design patterns, component usage, and data loading patterns that MCP servers don't provide.
---
# Medusa Admin Dashboard Customizations
Build custom UI extensions for the Medusa Admin dashboard using the Admin SDK and Medusa UI components.
**Note:** "UI Routes" are custom admin pages, different from backend API routes (which use building-with-medusa skill).
## When to Apply
**Load this skill for ANY admin UI development task, including:**
- Creating widgets for product/order/customer pages
- Building custom admin pages
- Implementing forms and modals
- Displaying data with tables or lists
- Adding navigation between pages
**Also load these skills when:**
- **building-with-medusa:** Building backend API routes that the admin UI calls
- **building-storefronts:** If working on storefront instead of admin dashboard
## CRITICAL: Load Reference Files When Needed
**The quick reference below is NOT sufficient for implementation.** You MUST load relevant reference files before writing code for that component.
**Load these references based on what you're implementing:**
- **Creating widgets?** → MUST load `references/data-loading.md` first
- **Building forms/modals?** → MUST load `references/forms.md` first
- **Displaying data in tables/lists?** → MUST load `references/display-patterns.md` first
- **Selecting from large datasets?** → MUST load `references/table-selection.md` first
- **Adding navigation?** → MUST load `references/navigation.md` first
- **Styling components?** → MUST load `references/typography.md` first
**Minimum requirement:** Load at least 1-2 reference files relevant to your specific task before implementing.
## When to Use This Skill vs MedusaDocs MCP Server
**⚠️ CRITICAL: This skill should be consulted FIRST for planning and implementation.**
**Use this skill for (PRIMARY SOURCE):**
- **Planning** - Understanding how to structure admin UI features
- **Component patterns** - Widgets, pages, forms, tables, modals
- **Design system** - Typography, colors, spacing, semantic classes
- **Data loading** - Critical separate query pattern, cache invalidation
- **Best practices** - Correct vs incorrect patterns (e.g., display queries on mount)
- **Critical rules** - What NOT to do (common mistakes like conditional display queries)
**Use MedusaDocs MCP server for (SECONDARY SOURCE):**
- Specific component prop signatures after you know which component to use
- Available widget zones list
- JS SDK method details
- Configuration options reference
**Why skills come first:**
- Skills contain critical patterns like separate display/modal queries that MCP doesn't emphasize
- Skills show correct vs incorrect patterns; MCP shows what's possible
- Planning requires understanding patterns, not just API reference
## Critical Setup Rules
### SDK Client Configuration
**CRITICAL:** Always use exact configuration - different values cause errors:
```tsx
// src/admin/lib/client.ts
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: import.meta.env.VITE_BACKEND_URL || "/",
debug: import.meta.env.DEV,
auth: {
type: "session",
},
})
```
### pnpm Users ONLY
**CRITICAL:** Install peer dependencies BEFORE writing any code:
```bash
# Find exact version from dashboard
pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard
# Install that exact version
pnpm add @tanstack/react-query@[exact-version]
# If using navigation (Link component)
pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard
pnpm add react-router-dom@[exact-version]
```
**npm/yarn users:** DO NOT install these packages - already available.
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|----------|----------|--------|--------|
| 1 | Data Loading | CRITICAL | `data-` |
| 2 | Design System | CRITICAL | `design-` |
| 3 | Data Display | HIGH (includes CRITICAL price rule) | `display-` |
| 4 | Typography | HIGH | `typo-` |
| 5 | Forms & Modals | MEDIUM | `form-` |
| 6 | Selection Patterns | MEDIUM | `select-` |
## Quick Reference
### 1. Data Loading (CRITICAL)
- `data-sdk-always` - **ALWAYS use Medusa JS SDK for ALL API requests** - NEVER use regular fetch() (missing auth headers causes errors)
- `data-sdk-method-choice` - Use existing SDK methods for built-in endpoints (`sdk.admin.product.list()`), use `sdk.client.fetch()` for custom routes
- `data-display-on-mount` - Display queries MUST load on mount (no enabled condition based on UI state)
- `data-separate-queries` - Separate display queries from modal/form queries
- `data-invalidate-display` - Invalidate display queries after mutations, not just modal queries
- `data-loading-states` - Always show loading states (Spinner), not empty states
- `data-pnpm-install-first` - pnpm users MUST install @tanstack/react-query BEFORE coding
### 2. Design System (CRITICAL)
- `design-semantic-colors` - Always use semantic color classes (bg-ui-bg-base, text-ui-fg-subtle), never hardcoded
- `design-spacing` - Use px-6 py-4 for section padding, gap-2 for lists, gap-3 for items
- `design-button-size` - Always use size="small" for buttons in widgets and tables
- `design-medusa-components` - Always use Medusa UI components (Container, Button, Text), not raw HTML
### 3. Data Display (HIGH)
- `display-price-format` - **CRITICAL**: Prices from Medusa are stored as-is ($49.99 = 49.99, NOT in cents). Display them directly - NEVER divide by 100
### 4. Typography (HIGH)
- `typo-text-component` - Always use Text component from @medusajs/ui, never plain span/p tags
- `typo-labels` - Use `<Text size="small" leading="compact" weight="plus">` for labels/headings
- `typo-descriptions` - Use `<Text size="small" leading="compact" className="text-ui-fg-subtle">` for descriptions
- `typo-no-heading-widgets` - Never use Heading for small sections in widgets (use Text instead)
### 5. Forms & Modals (MEDIUM)
- `form-focusmodal-create` - Use FocusModal for creating new entities
- `form-drawer-edit` - Use Drawer for editing existing entities
- `form-disable-pending` - Always disable actions during mutations (disabled={mutation.isPending})
- `form-show-loading` - Show loading state on submit button (isLoading={mutation.isPending})
### 6. Selection Patterns (MEDIUM)
- `select-small-datasets` - Use Select component for 2-10 options (statuses, types, etc.)
- `select-large-datasets` - Use DataTable with FocusModal for large datasets (products, categories, etc.)
- `select-search-config` - Must pass search configuration to useDataTable to avoid "search not enabled" error
## Critical Data Loading Pattern
**ALWAYS follow this pattern - never load display data conditionally:**
```tsx
// ✅ CORRECT - Separate queries with proper responsibilities
const RelatedProductsWidget = ({ data: product }) => {
const [modalOpen, setModalOpen] = useState(false)
// Display query - loads on mount
const { data: displayProducts } = useQuery({
queryFn: () => fetchSelectedProducts(selectedIds),
queryKey: ["related-products-display", product.id],
// No 'enabled' condition - loads immediately
})
// Modal query - loads when needed
const { data: modalProducts } = useQuery({
queryFn: () => sdk.admin.product.list({ limit: 10, offset: 0 }),
queryKey: ["products-selection"],
enabled: modalOpen, // OK for modal-only data
})
// Mutation with proper invalidation
const updateProduct = useMutation({
mutationFn: updateFunction,
onSuccess: () => {
// Invalidate display data query to refresh UI
queryClient.invalidateQueries({ queryKey: ["related-products-display", product.id] })
// Also invalidate the entity query
queryClient.invalidateQueries({ queryKey: ["product", product.id] })
// Note: No need to invalidate modal selection query
},
})
return (
<Container>
{/* Display uses displayProducts */}
{displayProducts?.map(p => <div key={p.id}>{p.title}</div>)}
<FocusModal open={modalOpen} onOpenChange={setModalOpen}>
{/* Modal uses modalProducts */}
</FocusModal>
</Container>
)
}
// ❌ WRONG - Single query with conditional loading
const BrokenWidget = ({ data: product }) => {
const [modalOpen, setModalOpen] = useState(false)
const { data } = useQuery({
queryFn: () => sdk.admin.product.list(),
enabled: modalOpen, // ❌ Display breaks on page refresh!
})
// Trying to display from modal query
const displayItems = data?.filter(item => ids.includes(item.id)) // No data until modal opens
return <div>{displayItems?.map(...)}</div> // Empty on mount!
}
```
**Why this matters:**
- On page refresh, modal is closed, so conditional query doesn't run
- User sees empty state instead of their data
- Display depends on modal interaction (broken UX)
## Common Mistakes Checklist
Before implementing, verify you're NOT doing these:
**Data Loading:**
- [ ] Using regular fetch() instead of Medusa JS SDK (causes missing auth header errors)
- [ ] Not using existing SDK methods for built-in endpoints (e.g., using sdk.client.fetch("/admin/products") instead of sdk.admin.product.list())
- [ ] Loading display data conditionally based on modal/UI state
- [ ] Using a single query for both display and modal
- [ ] Forgetting to invalidate display queries after mutations
- [ ] Not handling loading states (showing empty instead of spinner)
- [ ] pnpm users: Not installing @tanstack/react-query before coding
**Design System:**
- [ ] Using hardcoded colors instead of semantic classes
- [ ] Forgetting size="small" on buttons in widgets
- [ ] Not using px-6 py-4 for section padding
- [ ] Using raw HTML elements instead of Medusa UI components
**Data Display:**
- [ ] **CRITICAL**: Dividing prices by 100 when displaying (prices are stored as-is: $49.99 = 49.99, NOT in cents)
**Typography:**
- [ ] Using plain span/p tags instead of Text component
- [ ] Not using weight="plus" for labels
- [ ] Not using text-ui-fg-subtle for descriptions
- [ ] Using Heading in small widget sections
**Forms:**
- [ ] Using Drawer for creating (should use FocusModal)
- [ ] Using FocusModal for editing (should use Drawer)
- [ ] Not disabling buttons during mutations
- [ ] Not showing loading state on submit
**Selection:**
- [ ] Using DataTable for <10 items (overkill)
- [ ] Using Select for >10 items (poor UX)
- [ ] Not configuring search in useDataTable (causes error)
## Reference Files Available
Load these for detailed patterns:
```
references/data-loading.md - useQuery/useMutation patterns, cache invalidation
references/forms.md - FocusModal/Drawer patterns, validation
references/table-selection.md - Complete DataTable selection pattern
references/display-patterns.md - Lists, tables, cards for entities
references/typography.md - Text component patterns
references/navigation.md - Link, useNavigate, useParams patterns
```
Each reference contains:
- Step-by-step implementation guides
- Correct vs incorrect code examples
- Common mistakes and solutions
- Complete working examples
## Integration with Backend
**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()**
Admin UI connects to backend API routes using the SDK:
```tsx
import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]"
// ✅ CORRECT - Built-in endpoint: Use existing SDK method
const { data: product } = useQuery({
queryKey: ["product", productId],
queryFn: () => sdk.admin.product.retrieve(productId),
})
// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch()
const { data: reviews } = useQuery({
queryKey: ["reviews", product.id],
queryFn: () => sdk.client.fetch(`/admin/products/${product.id}/reviews`),
})
// ❌ WRONG - Using regular fetch
const { data } = useQuery({
queryKey: ["reviews", product.id],
queryFn: () => fetch(`http://localhost:9000/admin/products/${product.id}/reviews`),
// ❌ Error: Missing Authorization header!
})
// Mutation to custom backend route
const createReview = useMutation({
mutationFn: (data) => sdk.client.fetch("/admin/reviews", {
method: "POST",
body: data
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reviews", product.id] })
toast.success("Review created")
},
})
```
**Why the SDK is required:**
- Admin routes need `Authorization` and session cookie headers
- Store routes need `x-publishable-api-key` header
- SDK handles all required headers automatically
- Regular fetch() without headers → authentication/authorization errors
- Using existing SDK methods provides better type safety
**When to use what:**
- **Built-in endpoints**: Use existing SDK methods (`sdk.admin.product.list()`, `sdk.store.product.list()`)
- **Custom endpoints**: Use `sdk.client.fetch()` for your custom API routes
**For implementing backend API routes**, load the `building-with-medusa` skill.
## Widget vs UI Route
**Widgets** extend existing admin pages:
```tsx
// src/admin/widgets/custom-widget.tsx
import { defineWidgetConfig } from "@medusajs/admin-sdk"
import { DetailWidgetProps } from "@medusajs/framework/types"
const MyWidget = ({ data }: DetailWidgetProps<HttpTypes.AdminProduct>) => {
return <Container>Widget content</Container>
}
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default MyWidget
```
**UI Routes** create new admin pages:
```tsx
// src/admin/routes/custom-page/page.tsx
import { defineRouteConfig } from "@medusajs/admin-sdk"
const CustomPage = () => {
return <div>Page content</div>
}
export const config = defineRouteConfig({
label: "Custom Page",
})
export default CustomPage
```
## Common Issues & Solutions
**"Cannot find module" errors (pnpm users):**
- Install peer dependencies BEFORE coding
- Use exact versions from dashboard
**"No QueryClient set" error:**
- pnpm: Install @tanstack/react-query
- npm/yarn: Remove incorrectly installed package
**"DataTable.Search not enabled":**
- Must pass search configuration to useDataTable
**Widget not refreshing:**
- Invalidate display queries, not just modal queries
- Include all dependencies in query keys
**Display empty on refresh:**
- Display query has conditional `enabled` based on UI state
- Remove condition - display data must load on mount
## Next Steps - Testing Your Implementation
**After successfully implementing a feature, always provide these next steps to the user:**
### 1. Start the Development Server
If the server isn't already running, start it:
```bash
npm run dev # or pnpm dev / yarn dev
```
### 2. Access the Admin Dashboard
Open your browser and navigate to:
- **Admin Dashboard:** http://localhost:9000/app
Log in with your admin credentials.
### 3. Navigate to Your Custom UI
**For Widgets:**
Navigate to the page where your widget is displayed. Common widget zones:
- **Product widgets:** Go to Products → Select a product → Your widget appears in the zone you configured (e.g., `product.details.after`)
- **Order widgets:** Go to Orders → Select an order → Your widget appears in the configured zone
- **Customer widgets:** Go to Customers → Select a customer → Your widget appears in the configured zone
**For UI Routes (Custom Pages):**
- Look for your custom page in the admin sidebar/navigation (based on the `label` you configured)
- Or navigate directly to: `http://localhost:9000/app/[your-route-path]`
### 4. Test Functionality
Depending on what was implemented, test:
- **Forms:** Try creating/editing entities, verify validation and error messages
- **Tables:** Test pagination, search, sorting, and row selection
- **Data display:** Verify data loads correctly and refreshes after mutations
- **Modals:** Open FocusModal/Drawer, test form submission, verify data updates
- **Navigation:** Click links and verify routing works correctly
### Format for Presenting Next Steps
Always present next steps in a clear, actionable format after implementation:
```markdown
## Implementation Complete
The [feature name] has been successfully implemented. Here's how to see it:
### Start the Development Server
[command based on package manager]
### Access the Admin Dashboard
Open http://localhost:9000/app in your browser and log in.
### View Your Custom UI
**For Widgets:**
1. Navigate to [specific admin page, e.g., "Products"]
2. Select [an entity, e.g., "any product"]
3. Scroll to [zone location, e.g., "the bottom of the page"]
4. You'll see your "[widget name]" widget
**For UI Routes:**
1. Look for "[page label]" in the admin navigation
2. Or navigate directly to http://localhost:9000/app/[route-path]
### What to Test
1. [Specific test case 1]
2. [Specific test case 2]
3. [Specific test case 3]
```

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 |