Initial commit
This commit is contained in:
462
.agents/skills/building-admin-dashboard-customizations/SKILL.md
Normal file
462
.agents/skills/building-admin-dashboard-customizations/SKILL.md
Normal 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]
|
||||
```
|
||||
@@ -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