diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 000000000..875583458 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,202 @@ +# Cursor Rules for Medusa2 Starter + +This directory contains comprehensive cursor rules for the Medusa2 starter project, designed to ensure consistent code quality, architectural patterns, and development practices across the entire codebase. + +## 📁 Rule Organization + +Our cursor rules are organized following best practices for maintainability and clarity: + +``` +.cursor/rules/ +├── medusa-backend.mdc # Backend API, modules, workflows +├── medusa-admin.mdc # Admin UI components and patterns +├── typescript-patterns.mdc # TypeScript conventions +├── remix-hook-form-migration.mdc # Form migration patterns +└── README.md # This file +``` + +## 🎯 Rule Categories + +### 1. **Medusa Backend** (`medusa-backend.mdc`) +**Scope**: `apps/medusa/src/api/**/*`, `apps/medusa/src/modules/**/*`, `apps/medusa/src/workflows/**/*` + +Covers: +- API endpoint patterns and structure +- Module development (models, services, migrations) +- Workflow and step implementation +- Database patterns and migrations +- Type definitions for backend APIs +- Security and validation patterns +- Performance optimization +- Testing strategies + +**Key Patterns**: +- Workflow-first architecture for business logic +- Consistent API response structures +- Proper error handling and rollback mechanisms +- Type-safe service resolution from container + +### 2. **Medusa Admin** (`medusa-admin.mdc`) +**Scope**: `apps/medusa/src/admin/**/*` + +Covers: +- React component architecture and composition +- Custom hooks with TanStack Query +- Form handling with React Hook Form +- State management patterns +- UI component usage (Medusa UI) +- Routing and navigation +- Performance optimization +- Accessibility best practices + +**Key Patterns**: +- Controlled form components with proper typing +- Consistent list item and sidebar components +- Declarative state management +- Proper error handling with toast notifications + +### 3. **TypeScript Patterns** (`typescript-patterns.mdc`) +**Scope**: `**/*.ts`, `**/*.tsx` + +Covers: +- Strict type safety practices +- Interface and type definitions +- Generic patterns and constraints +- Utility type usage +- Error handling types +- React component typing +- Module declarations +- Testing type utilities + +**Key Patterns**: +- Branded types for ID safety +- Result pattern for error handling +- Proper generic constraints +- Type-only imports + +### 4. **Form Migration** (`remix-hook-form-migration.mdc`) +**Scope**: Form-related files during migration + +Covers: +- Migration from remix-validated-form to @lambdacurry/forms +- Yup to Zod schema conversion +- React Hook Form integration patterns +- Error handling updates +- Response structure changes + +## 🚀 Usage Guidelines + +### Automatic Application +Most rules are set to `alwaysApply: true` and will automatically activate based on file patterns (globs). This ensures consistent application across the codebase. + +### Manual Application +For specific contexts or when working on particular features, you can manually attach rules using Cursor's rule selection interface. + +### Rule Priority +When multiple rules apply to the same file: +1. More specific rules (narrower globs) take precedence +2. Feature-specific rules override general patterns +3. TypeScript patterns apply broadly but defer to framework-specific rules + +## 🎨 Best Practices Enforced + +### Code Quality +- ✅ Strict TypeScript usage with no `any` types +- ✅ Comprehensive error handling +- ✅ Consistent naming conventions +- ✅ Proper component composition +- ✅ Type-safe API interactions + +### Architecture +- ✅ Workflow-based backend operations +- ✅ Modular component design +- ✅ Separation of concerns +- ✅ Consistent state management +- ✅ Proper abstraction layers + +### Performance +- ✅ Optimized React components with memo/useMemo +- ✅ Efficient database queries +- ✅ Proper caching strategies +- ✅ Lazy loading and code splitting + +### Security +- ✅ Input validation with Zod schemas +- ✅ Authenticated request handling +- ✅ Proper error message sanitization +- ✅ Type-safe API boundaries + +## 🔧 Maintenance + +### Regular Updates +These rules should be updated when: +- Framework versions change (Medusa, React, etc.) +- New architectural patterns are established +- Team conventions evolve +- New best practices emerge + +### Testing Rules +Periodically test rules with: +- Diverse code generation prompts +- Edge case scenarios +- New feature development +- Refactoring operations + +### Quality Assurance +Monitor generated code for: +- Adherence to established patterns +- Proper error handling +- Type safety compliance +- Performance considerations + +## 📚 Related Documentation + +- [Medusa v2 Documentation](https://docs.medusajs.com/v2) +- [React Hook Form Guide](https://react-hook-form.com/) +- [TanStack Query Documentation](https://tanstack.com/query/latest) +- [Medusa UI Components](https://ui.medusajs.com/) +- [TypeScript Handbook](https://www.typescriptlang.org/docs/) + +## 🤝 Contributing + +When adding or modifying rules: + +1. **Follow the established structure** with proper YAML frontmatter +2. **Include concrete examples** for both correct and incorrect patterns +3. **Test thoroughly** with various prompts and scenarios +4. **Update this README** when adding new rule categories +5. **Consider rule interactions** and potential conflicts + +### Rule Quality Checklist +- [ ] Clear description and scope definition +- [ ] Proper glob patterns for file targeting +- [ ] Concrete code examples with explanations +- [ ] Edge case handling +- [ ] Integration with existing rules +- [ ] Performance considerations +- [ ] Security implications + +## 🎯 Goals + +These cursor rules aim to: + +1. **Accelerate Development**: Reduce decision fatigue with clear patterns +2. **Ensure Consistency**: Maintain uniform code quality across the team +3. **Prevent Common Mistakes**: Catch anti-patterns before they enter the codebase +4. **Facilitate Onboarding**: Help new team members understand established conventions +5. **Support Scalability**: Ensure patterns work well as the codebase grows + +## 📈 Success Metrics + +Effective cursor rules should result in: +- Faster feature development +- Fewer code review comments on patterns/style +- More consistent codebase architecture +- Reduced debugging time +- Improved code maintainability + +--- + +*Last updated: May 29, 2025* +*For questions or suggestions, please reach out to the development team.* + diff --git a/.cursor/rules/medusa-admin.mdc b/.cursor/rules/medusa-admin.mdc new file mode 100644 index 000000000..048cd8257 --- /dev/null +++ b/.cursor/rules/medusa-admin.mdc @@ -0,0 +1,646 @@ +--- +description: Medusa v2 admin UI development patterns and best practices +globs: ["apps/medusa/src/admin/**/*"] +alwaysApply: true +--- + +# Medusa v2 Admin UI Development Rules + +## Overview + +This document outlines the architectural patterns and best practices for Medusa v2 admin UI development, including React components, hooks, forms, and state management patterns. + +## Core Principles + +1. **Component Composition**: Build reusable, composable React components +2. **Type Safety**: Use TypeScript strictly throughout the admin UI +3. **Consistent UI Patterns**: Follow Medusa UI design system conventions +4. **Declarative State Management**: Use React Hook Form and TanStack Query +5. **Accessibility First**: Ensure all components are accessible by default + +## Component Architecture + +### Component Structure +``` +src/admin/ +├── components/ # Reusable UI components +│ ├── inputs/ # Form input components +│ │ └── ControlledFields/ +│ ├── Sidebar/ # Navigation components +│ └── [ComponentName]/ +├── editor/ # Feature-specific components +│ ├── components/ +│ ├── hooks/ +│ └── providers/ +├── hooks/ # Shared custom hooks +├── routes/ # Page components and routing +└── sdk.ts # API client configuration +``` + +### Component Patterns + +#### Controlled Form Components +```typescript +import { Control, FieldValues, Path, UseFormSetError } from 'react-hook-form'; +import { Input } from '@medusajs/ui'; + +interface ControlledInputProps { + name: Path; + control: Control; + rules?: object; + onChange?: (value: string) => void; + labelClassName?: string; +} + +export const ControlledInput = ({ + name, + control, + rules, + onChange, + ...props +}: ControlledInputProps) => { + return ( + ( + { + field.onChange(evt); + onChange?.(evt.target.value); + }} + /> + )} + /> + ); +}; +``` + +#### List Item Components +```typescript +import { FC, MouseEventHandler } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Badge, DropdownMenu, IconButton, Text } from '@medusajs/ui'; +import { EllipsisHorizontal, Trash } from '@medusajs/icons'; + +interface ListItemProps { + item: ResourceType; + index: number; + onEdit?: (item: ResourceType) => void; + onDelete?: (item: ResourceType) => void; + onDuplicate?: (item: ResourceType) => void; +} + +export const ResourceListItem: FC = ({ + item, + index, + onEdit, + onDelete, + onDuplicate, +}) => { + const navigate = useNavigate(); + + const handleEditClick = () => { + onEdit?.(item); + }; + + const handleDeleteClick: MouseEventHandler = async (event) => { + event.stopPropagation(); + onDelete?.(item); + }; + + return ( +
+
+
+ + {item.name || 'Untitled'} + +
+
+ + {item.status === 'draft' && Draft} + + + + + + + + + + Edit + Duplicate + + + Delete + + + +
+ ); +}; +``` + +#### Sidebar Components +```typescript +import { FC, PropsWithChildren } from 'react'; +import clsx from 'clsx'; + +interface SidebarProps extends PropsWithChildren { + side: 'left' | 'right'; + isOpen: boolean; + toggle: () => void; + open: () => void; + close: () => void; + className?: string; +} + +export const Sidebar: FC = ({ + side, + isOpen, + toggle, + open, + close, + className, + children, +}) => { + return ( +
+ {children} +
+ ); +}; +``` + +## Custom Hooks Patterns + +### API Mutation Hooks +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { sdk } from '../sdk'; +import { QUERY_KEYS } from './keys'; + +export const useAdminCreateResource = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + return sdk.admin.resource.create(data); + }, + mutationKey: QUERY_KEYS.RESOURCES, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RESOURCES }); + }, + }); +}; + +export const useAdminUpdateResource = () => { + const queryClient = useQueryClient(); + + return useMutation< + UpdateResourceResponse, + Error, + { id: string; data: UpdateResourceInput } + >({ + mutationFn: async ({ id, data }) => { + return sdk.admin.resource.update(id, data); + }, + mutationKey: QUERY_KEYS.RESOURCES, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RESOURCES }); + }, + }); +}; + +export const useAdminDeleteResource = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + return sdk.admin.resource.delete(id); + }, + mutationKey: QUERY_KEYS.RESOURCES, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RESOURCES }); + }, + }); +}; +``` + +### Query Hooks +```typescript +import { useQuery } from '@tanstack/react-query'; +import { sdk } from '../sdk'; + +export const RESOURCES_QUERY_KEY = ['resources']; + +export const useAdminListResources = (query: ListResourcesQuery) => { + return useQuery({ + queryKey: [...RESOURCES_QUERY_KEY, query], + queryFn: async () => { + return sdk.admin.resource.list(query); + }, + }); +}; + +export const useAdminFetchResource = (id: string) => { + return useQuery({ + queryKey: [...RESOURCES_QUERY_KEY, id], + queryFn: async () => { + const response = await sdk.admin.resource.retrieve(id); + return response.resource; + }, + enabled: !!id, + }); +}; +``` + +### Context Hooks +```typescript +import { createContext, useContext, useState, ReactNode } from 'react'; + +interface SidebarContextType { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +const SidebarContext = createContext(undefined); + +export const SidebarProvider = ({ children }: { children: ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + + const open = () => setIsOpen(true); + const close = () => setIsOpen(false); + const toggle = () => setIsOpen(!isOpen); + + return ( + + {children} + + ); +}; + +export const useSidebar = () => { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error('useSidebar must be used within a SidebarProvider'); + } + return context; +}; +``` + +## Form Handling Patterns + +### Form Provider Setup +```typescript +import { FormProvider, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const formSchema = z.object({ + title: z.string().min(1, 'Title is required'), + meta_title: z.string().optional(), + meta_description: z.string().optional(), + meta_image_url: z.string().url().optional(), + status: z.enum(['draft', 'published']).default('draft'), +}); + +type FormValues = z.infer; + +export const ResourceForm = () => { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + title: '', + status: 'draft', + }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await createResource(data); + toast.success('Resource created successfully'); + } catch (error) { + toast.error('Failed to create resource'); + } + }; + + return ( + +
+ + + + + + +
+ ); +}; +``` + +### Delete Confirmation Pattern +```typescript +import { usePrompt } from '@medusajs/ui'; + +export const useDeleteConfirmation = () => { + const prompt = usePrompt(); + + const confirmDelete = async (resourceName: string) => { + return await prompt({ + title: `Delete ${resourceName}`, + description: `Are you sure you want to delete this ${resourceName}?`, + confirmText: 'Yes, delete', + cancelText: 'Cancel', + }); + }; + + return { confirmDelete }; +}; +``` + +## State Management Patterns + +### Query Key Management +```typescript +export const QUERY_KEYS = { + POSTS: ['posts'], + POST_SECTIONS: ['post-sections'], + TEMPLATES: ['templates'], + AUTHORS: ['authors'], + TAGS: ['tags'], +} as const; +``` + +### Error Handling +```typescript +import { toast } from '@medusajs/ui'; + +export const handleAsyncOperation = async ( + operation: () => Promise, + successMessage?: string, + errorMessage?: string +): Promise => { + try { + const result = await operation(); + if (successMessage) { + toast.success(successMessage); + } + return result; + } catch (error) { + console.error('Operation failed:', error); + toast.error(errorMessage || 'Operation failed'); + return null; + } +}; +``` + +## UI Component Patterns + +### Medusa UI Components Usage +```typescript +import { + Button, + Heading, + Text, + Badge, + DropdownMenu, + IconButton, + toast, + usePrompt, +} from '@medusajs/ui'; +import { Plus, EllipsisHorizontal, Trash } from '@medusajs/icons'; + +// Always use Medusa UI components for consistency +// Prefer semantic HTML elements with proper ARIA attributes +// Use Medusa icons for visual consistency +``` + +### Layout Patterns +```typescript +export const AdminLayout = ({ children }: { children: ReactNode }) => { + return ( +
+ +
+
+ {children} +
+
+
+ ); +}; +``` + +## Routing Patterns + +### Route Organization +```typescript +// apps/medusa/src/admin/routes/content/posts/page.tsx +export default function PostsPage() { + return ; +} + +// apps/medusa/src/admin/routes/content/editor/[id]/page.tsx +export default function EditorPage() { + return ; +} +``` + +### Navigation Patterns +```typescript +import { useNavigate } from 'react-router-dom'; + +export const useNavigation = () => { + const navigate = useNavigate(); + + const navigateToEdit = (resourceType: string, id: string) => { + navigate(`/content/${resourceType}/${id}/edit`); + }; + + const navigateToList = (resourceType: string) => { + navigate(`/content/${resourceType}`); + }; + + return { navigateToEdit, navigateToList }; +}; +``` + +## Performance Optimization + +### Component Optimization +```typescript +import { memo, useMemo, useCallback } from 'react'; + +export const OptimizedListItem = memo(({ item, onEdit, onDelete }) => { + const handleEdit = useCallback(() => { + onEdit?.(item); + }, [item, onEdit]); + + const handleDelete = useCallback(() => { + onDelete?.(item); + }, [item, onDelete]); + + const statusBadge = useMemo(() => { + return item.status === 'draft' ? Draft : null; + }, [item.status]); + + return ( +
+ {/* Component content */} + {statusBadge} +
+ ); +}); +``` + +### Query Optimization +```typescript +// Use select to limit data fetching +const { data: posts } = useAdminListPosts({ + select: ['id', 'title', 'status', 'created_at'], + limit: 20, + offset: page * 20, +}); + +// Prefetch related data +const queryClient = useQueryClient(); +const prefetchPostSections = useCallback((postId: string) => { + queryClient.prefetchQuery({ + queryKey: [...QUERY_KEYS.POST_SECTIONS, { post_id: postId }], + queryFn: () => sdk.admin.postSections.list({ post_id: postId }), + }); +}, [queryClient]); +``` + +## Testing Patterns + +### Component Testing +```typescript +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ResourceListItem } from './ResourceListItem'; + +describe('ResourceListItem', () => { + const queryClient = new QueryClient(); + + const renderWithProviders = (component: ReactElement) => { + return render( + + {component} + + ); + }; + + it('should render resource name', () => { + const mockItem = { id: '1', name: 'Test Resource', status: 'draft' }; + + renderWithProviders( + + ); + + expect(screen.getByText('Test Resource')).toBeInTheDocument(); + }); + + it('should call onEdit when clicked', () => { + const mockOnEdit = jest.fn(); + const mockItem = { id: '1', name: 'Test Resource', status: 'draft' }; + + renderWithProviders( + + ); + + fireEvent.click(screen.getByRole('button')); + expect(mockOnEdit).toHaveBeenCalledWith(mockItem); + }); +}); +``` + +### Hook Testing +```typescript +import { renderHook, act } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useAdminCreateResource } from './resource-mutations'; + +describe('useAdminCreateResource', () => { + it('should create resource successfully', async () => { + const queryClient = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useAdminCreateResource(), { wrapper }); + + await act(async () => { + await result.current.mutateAsync({ + name: 'Test Resource', + status: 'draft', + }); + }); + + expect(result.current.isSuccess).toBe(true); + }); +}); +``` + +## Common Anti-Patterns to Avoid + +❌ **Don't**: Use inline styles or CSS-in-JS +✅ **Do**: Use Tailwind CSS classes and Medusa UI components + +❌ **Don't**: Fetch data directly in components +✅ **Do**: Use custom hooks with TanStack Query + +❌ **Don't**: Mutate state directly +✅ **Do**: Use React Hook Form for form state and TanStack Query for server state + +❌ **Don't**: Create deeply nested component hierarchies +✅ **Do**: Use composition and context for state sharing + +❌ **Don't**: Skip error boundaries and error handling +✅ **Do**: Implement proper error handling with toast notifications + +❌ **Don't**: Use any types or skip TypeScript +✅ **Do**: Define strict interfaces and use proper typing + +❌ **Don't**: Forget accessibility attributes +✅ **Do**: Include proper ARIA labels, roles, and keyboard navigation + +## Dependencies + +Required packages for Medusa v2 admin development: +- `@medusajs/ui`: Official UI component library +- `@medusajs/icons`: Official icon library +- `@tanstack/react-query`: Server state management +- `react-hook-form`: Form state management +- `@hookform/resolvers`: Form validation resolvers +- `zod`: Schema validation +- `clsx`: Conditional class names +- `react-router-dom`: Client-side routing + diff --git a/.cursor/rules/medusa-backend.mdc b/.cursor/rules/medusa-backend.mdc new file mode 100644 index 000000000..e31f9f3ea --- /dev/null +++ b/.cursor/rules/medusa-backend.mdc @@ -0,0 +1,306 @@ +--- +description: Medusa v2 backend development patterns and best practices +globs: ["apps/medusa/src/api/**/*", "apps/medusa/src/modules/**/*", "apps/medusa/src/workflows/**/*", "apps/medusa/src/links/**/*"] +alwaysApply: true +--- + +# Medusa v2 Backend Development Rules + +## Overview + +This document outlines the architectural patterns and best practices for Medusa v2 backend development, including API endpoints, modules, workflows, and data models. + +## Core Principles + +1. **Workflow-First Architecture**: Use workflows for all complex business logic +2. **Type Safety**: Leverage TypeScript strictly throughout the backend +3. **Modular Design**: Organize code into focused, reusable modules +4. **Consistent API Patterns**: Follow Medusa v2 conventions for all endpoints + +## API Endpoint Patterns + +### Route Structure +```typescript +// apps/medusa/src/api/admin/[resource]/[id]/route.ts +import type { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework/http'; +import { workflowName } from '../../../workflows/workflow-name'; + +export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const id = req.params.id; + + const { result } = await workflowName(req.scope).run({ + input: { id }, + }); + + res.status(200).json({ resource: result }); +}; +``` + +### Required Patterns +- Always use `AuthenticatedMedusaRequest` and `MedusaResponse` types +- Extract parameters from `req.params` +- Use workflows for business logic, never inline complex operations +- Return consistent response structure: `{ resource: result }` +- Use appropriate HTTP status codes (200, 201, 400, 404, 500) + +### Error Handling +```typescript +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + try { + const { result } = await createResourceWorkflow(req.scope).run({ + input: req.body, + }); + + res.status(201).json({ resource: result }); + } catch (error) { + res.status(400).json({ + error: error.message || 'Failed to create resource' + }); + } +}; +``` + +## Module Development + +### Module Structure +``` +src/modules/[module-name]/ +├── index.ts # Module definition and exports +├── models/ # Data models +│ ├── [entity].ts +│ └── index.ts +├── services/ # Business logic services +│ ├── [entity].ts +│ └── index.ts +├── migrations/ # Database migrations +└── types.ts # Module-specific types +``` + +### Model Patterns +```typescript +import { model } from '@medusajs/framework/utils'; + +export const PostSection = model.define('post_section', { + id: model.id().primaryKey(), + name: model.text(), + layout: model.enum(['full_width', 'two_column', 'grid']), + blocks: model.json(), + status: model.enum(['draft', 'published']).default('draft'), + sort_order: model.number().default(0), + post_id: model.text(), + created_at: model.dateTime().default('now'), + updated_at: model.dateTime().default('now'), +}); +``` + +### Service Patterns +```typescript +import { MedusaService } from '@medusajs/framework/utils'; + +class PostSectionService extends MedusaService({ + PostSection, +}) { + async createPostSection(data: CreatePostSectionInput) { + return await this.create(data); + } + + async updatePostSection(id: string, data: UpdatePostSectionInput) { + return await this.update(id, data); + } + + async deletePostSection(id: string) { + return await this.delete(id); + } +} + +export default PostSectionService; +``` + +## Workflow Development + +### Workflow Structure +```typescript +import { createWorkflow, WorkflowResponse } from '@medusajs/framework/workflows'; +import { createPostSectionStep } from './steps/create-post-section'; + +export const createPostSectionWorkflow = createWorkflow( + 'create-post-section', + (input: CreatePostSectionWorkflowInput) => { + const postSection = createPostSectionStep(input); + + return new WorkflowResponse(postSection); + } +); +``` + +### Step Patterns +```typescript +import { createStep, StepResponse } from '@medusajs/framework/workflows'; + +export const createPostSectionStep = createStep( + 'create-post-section-step', + async (input: CreatePostSectionInput, { container }) => { + const postSectionService = container.resolve('postSectionService'); + + const postSection = await postSectionService.createPostSection(input); + + return new StepResponse(postSection, postSection.id); + }, + async (id: string, { container }) => { + const postSectionService = container.resolve('postSectionService'); + await postSectionService.deletePostSection(id); + } +); +``` + +### Required Workflow Patterns +- Always include compensation logic in steps +- Use descriptive workflow and step names +- Return `StepResponse` with both result and compensation data +- Resolve services from container, never import directly +- Handle errors gracefully with proper rollback + +## Type Definitions + +### API Types +```typescript +export interface AdminPageBuilderCreatePostSectionBody { + name: string; + layout: 'full_width' | 'two_column' | 'grid'; + blocks: Record; + post_id: string; + sort_order?: number; +} + +export interface AdminPageBuilderCreatePostSectionResponse { + section: PostSection; +} +``` + +### Workflow Types +```typescript +export interface CreatePostSectionWorkflowInput { + name: string; + layout: string; + blocks: Record; + post_id: string; + sort_order: number; +} +``` + +## Database Patterns + +### Migration Structure +```typescript +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240101000000 extends Migration { + async up(): Promise { + this.addSql(` + CREATE TABLE "post_section" ( + "id" text PRIMARY KEY, + "name" text NOT NULL, + "layout" text NOT NULL, + "blocks" jsonb NOT NULL DEFAULT '{}', + "status" text NOT NULL DEFAULT 'draft', + "sort_order" integer NOT NULL DEFAULT 0, + "post_id" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() + ); + `); + } + + async down(): Promise { + this.addSql('DROP TABLE "post_section";'); + } +} +``` + +## Security & Validation + +### Input Validation +```typescript +import { z } from 'zod'; + +export const createPostSectionSchema = z.object({ + name: z.string().min(1, 'Name is required'), + layout: z.enum(['full_width', 'two_column', 'grid']), + blocks: z.record(z.any()).default({}), + post_id: z.string().min(1, 'Post ID is required'), + sort_order: z.number().int().min(0).default(0), +}); +``` + +### Authentication +- Always use `AuthenticatedMedusaRequest` for admin endpoints +- Validate user permissions before executing workflows +- Never expose internal service methods directly + +## Performance Considerations + +### Query Optimization +- Use select queries to limit returned fields +- Implement pagination for list endpoints +- Use database indexes for frequently queried fields +- Avoid N+1 queries in relationships + +### Caching +- Cache frequently accessed data +- Invalidate cache on data mutations +- Use Redis for session and temporary data storage + +## Testing Patterns + +### Unit Tests +```typescript +describe('PostSectionService', () => { + it('should create a post section', async () => { + const service = new PostSectionService(); + const input = { + name: 'Test Section', + layout: 'full_width', + blocks: {}, + post_id: 'post_123', + }; + + const result = await service.createPostSection(input); + + expect(result.name).toBe(input.name); + expect(result.layout).toBe(input.layout); + }); +}); +``` + +### Integration Tests +- Test complete workflow execution +- Verify database state changes +- Test error scenarios and rollbacks +- Validate API response formats + +## Common Anti-Patterns to Avoid + +❌ **Don't**: Put business logic directly in API routes +✅ **Do**: Use workflows for all business operations + +❌ **Don't**: Import services directly in workflows +✅ **Do**: Resolve services from container + +❌ **Don't**: Skip compensation logic in workflow steps +✅ **Do**: Always implement proper rollback mechanisms + +❌ **Don't**: Use any types or skip validation +✅ **Do**: Define strict TypeScript interfaces and validate inputs + +❌ **Don't**: Create endpoints without authentication +✅ **Do**: Always use AuthenticatedMedusaRequest for admin routes + +## Dependencies + +Required packages for Medusa v2 backend development: +- `@medusajs/framework`: Core framework utilities +- `@medusajs/types`: Type definitions +- `@mikro-orm/core`: Database ORM +- `zod`: Schema validation +- `@types/node`: Node.js type definitions + diff --git a/.cursor/rules/typescript-patterns.mdc b/.cursor/rules/typescript-patterns.mdc index 59e087d44..df496c5ea 100644 --- a/.cursor/rules/typescript-patterns.mdc +++ b/.cursor/rules/typescript-patterns.mdc @@ -1,511 +1,455 @@ --- -description: TypeScript development patterns and best practices for the Medusa monorepo -globs: - - "**/*.ts" - - "**/*.tsx" +description: TypeScript patterns and conventions for the Medusa2 starter project +globs: ["**/*.ts", "**/*.tsx"] alwaysApply: true --- -# TypeScript Development Patterns +# TypeScript Patterns and Conventions -You are an expert in TypeScript, modern JavaScript, and type-safe development practices. +## Overview -## Core TypeScript Principles +This document outlines TypeScript-specific patterns, conventions, and best practices for the Medusa2 starter project. -- Use strict TypeScript configuration -- Prefer type inference over explicit typing when clear -- Use union types and discriminated unions effectively -- Implement proper type guards and validation -- Leverage generic types for reusability -- Use `as const` for immutable data structures -- Prefer interfaces over type aliases for object shapes +## Core Principles -## Type Definitions +1. **Strict Type Safety**: Use TypeScript's strict mode and avoid `any` types +2. **Explicit Interfaces**: Define clear interfaces for all data structures +3. **Generic Constraints**: Use proper generic constraints for reusable components +4. **Utility Types**: Leverage TypeScript utility types for type transformations -### Interface Design +## Type Definition Patterns + +### Interface Definitions ```typescript // Use interfaces for object shapes interface User { - readonly id: string - name: string - email: string - createdAt: Date - updatedAt: Date + readonly id: string; + name: string; + email: string; + status: 'active' | 'inactive'; + createdAt: Date; + updatedAt: Date; } -// Use generic interfaces for reusability +// Use type aliases for unions and computed types +type UserStatus = 'active' | 'inactive'; +type UserWithoutTimestamps = Omit; +``` + +### API Response Types +```typescript +// Consistent API response structure interface ApiResponse { - data: T - success: boolean - message?: string + data: T; + success: boolean; + message?: string; } -// Extend interfaces for specialization -interface AdminUser extends User { - role: "admin" | "super_admin" - permissions: Permission[] +interface PaginatedResponse { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; } -``` -### Union Types and Discriminated Unions -```typescript -// Use discriminated unions for type safety -type PaymentStatus = - | { status: "pending"; pendingReason: string } - | { status: "completed"; completedAt: Date } - | { status: "failed"; error: string } - -// Type guards for discriminated unions -function isCompletedPayment( - payment: PaymentStatus -): payment is Extract { - return payment.status === "completed" -} +// Example usage +type UsersResponse = ApiResponse; +type PaginatedUsersResponse = PaginatedResponse; ``` -### Generic Types +### Form Types ```typescript -// Generic utility types -type Optional = Omit & Partial> -type RequiredFields = T & Required> - -// Generic function types -type AsyncFunction = (...args: T) => Promise - -// Generic class types -class Repository { - async findById(id: string): Promise { - // Implementation - } - - async create(data: Omit): Promise { - // Implementation - } -} +// Form input types derived from entity types +type CreateUserInput = Omit; +type UpdateUserInput = Partial; + +// Form validation schemas should match these types +const createUserSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email format'), + status: z.enum(['active', 'inactive']).default('active'), +}) satisfies z.ZodType; ``` -## Advanced Type Patterns +## Generic Patterns -### Conditional Types +### Component Props with Generics ```typescript -// Conditional types for API responses -type ApiResult = T extends string - ? { message: T } - : T extends Error - ? { error: T } - : { data: T } - -// Mapped types for form validation -type ValidationErrors = { - [K in keyof T]?: string[] -} +interface DataTableProps { + data: T[]; + columns: ColumnDef[]; + onRowClick?: (row: T) => void; + loading?: boolean; +} + +export const DataTable = >({ + data, + columns, + onRowClick, + loading = false, +}: DataTableProps) => { + // Component implementation +}; +``` -// Template literal types -type EventName = `${T}:created` | `${T}:updated` | `${T}:deleted` -type UserEvents = EventName<"user"> // "user:created" | "user:updated" | "user:deleted" +### Hook Generics +```typescript +interface UseApiOptions { + onSuccess?: (data: T) => void; + onError?: (error: Error) => void; +} + +export const useApi = ( + endpoint: string, + options?: UseApiOptions +) => { + // Hook implementation with proper typing + return useQuery({ + queryKey: [endpoint], + queryFn: () => fetchData(endpoint), + onSuccess: options?.onSuccess, + onError: options?.onError, + }); +}; ``` -### Utility Types +## Utility Type Patterns + +### Common Utility Types ```typescript -// Custom utility types -type DeepPartial = { - [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] -} +// Make specific fields optional +type PartialBy = Omit & Partial>; -type NonNullable = T extends null | undefined ? never : T +// Make specific fields required +type RequiredBy = T & Required>; -type PickByType = { - [K in keyof T as T[K] extends U ? K : never]: T[K] -} +// Deep readonly +type DeepReadonly = { + readonly [P in keyof T]: T[P] extends object ? DeepReadonly : T[P]; +}; -// Usage examples -type UserStringFields = PickByType // { name: string; email: string } -type PartialUser = DeepPartial +// Example usage +type UserWithOptionalEmail = PartialBy; +type UserWithRequiredStatus = RequiredBy, 'status'>; ``` -## Type Guards and Validation - -### Runtime Type Checking +### Branded Types ```typescript -// Type guards for runtime validation -function isString(value: unknown): value is string { - return typeof value === "string" -} +// Use branded types for IDs to prevent mixing +type UserId = string & { readonly brand: unique symbol }; +type PostId = string & { readonly brand: unique symbol }; -function isUser(value: unknown): value is User { - return ( - typeof value === "object" && - value !== null && - "id" in value && - "name" in value && - "email" in value && - isString((value as any).id) && - isString((value as any).name) && - isString((value as any).email) - ) -} +const createUserId = (id: string): UserId => id as UserId; +const createPostId = (id: string): PostId => id as PostId; -// Assertion functions -function assertIsUser(value: unknown): asserts value is User { - if (!isUser(value)) { - throw new Error("Value is not a valid User") - } -} +// This prevents accidentally passing a UserId where PostId is expected ``` -### Zod Integration +## Error Handling Types + +### Result Pattern ```typescript -import { z } from "zod" - -// Define schemas with Zod -const UserSchema = z.object({ - id: z.string().uuid(), - name: z.string().min(1), - email: z.string().email(), - createdAt: z.date(), - updatedAt: z.date(), -}) - -// Infer types from schemas -type User = z.infer - -// Validation with proper error handling -function validateUser(data: unknown): User { - const result = UserSchema.safeParse(data) - - if (!result.success) { - throw new Error(`Invalid user data: ${result.error.message}`) +type Result = + | { success: true; data: T } + | { success: false; error: E }; + +const processUser = async (id: string): Promise> => { + try { + const user = await fetchUser(id); + return { success: true, data: user }; + } catch (error) { + return { success: false, error: error as Error }; } - - return result.data -} +}; ``` -## Error Handling Patterns - ### Custom Error Types ```typescript -// Base error class abstract class AppError extends Error { - abstract readonly code: string - abstract readonly statusCode: number - - constructor(message: string, public readonly context?: Record) { - super(message) - this.name = this.constructor.name - } + abstract readonly code: string; + abstract readonly statusCode: number; } -// Specific error types class ValidationError extends AppError { - readonly code = "VALIDATION_ERROR" - readonly statusCode = 400 + readonly code = 'VALIDATION_ERROR'; + readonly statusCode = 400; constructor( message: string, - public readonly errors: Record + public readonly field: string ) { - super(message) + super(message); } } class NotFoundError extends AppError { - readonly code = "NOT_FOUND" - readonly statusCode = 404 -} -``` - -### Result Pattern -```typescript -// Result type for error handling -type Result = - | { success: true; data: T } - | { success: false; error: E } - -// Helper functions -function success(data: T): Result { - return { success: true, data } -} - -function failure(error: E): Result { - return { success: false, error } -} - -// Usage in functions -async function fetchUser(id: string): Promise> { - try { - const user = await userRepository.findById(id) - return user ? success(user) : failure(new NotFoundError("User not found")) - } catch (error) { - return failure(new NotFoundError("User not found")) + readonly code = 'NOT_FOUND'; + readonly statusCode = 404; + + constructor(resource: string, id: string) { + super(`${resource} with id ${id} not found`); } } ``` -## Async Patterns +## React Component Typing -### Promise Utilities +### Component Props ```typescript -// Timeout wrapper -function withTimeout( - promise: Promise, - timeoutMs: number -): Promise { - return Promise.race([ - promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("Timeout")), timeoutMs) - ), - ]) -} +// Use interfaces for component props +interface ButtonProps { + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + loading?: boolean; + children: React.ReactNode; + onClick?: () => void; +} + +// Use React.FC sparingly, prefer explicit typing +export const Button = ({ + variant = 'primary', + size = 'medium', + disabled = false, + loading = false, + children, + onClick, +}: ButtonProps) => { + // Component implementation +}; +``` -// Retry logic -async function retry( - fn: () => Promise, - maxAttempts: number = 3, - delay: number = 1000 -): Promise { - let lastError: Error - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await fn() - } catch (error) { - lastError = error as Error - - if (attempt === maxAttempts) { - throw lastError - } - - await new Promise(resolve => setTimeout(resolve, delay * attempt)) - } +### Ref Forwarding +```typescript +interface InputProps { + label: string; + error?: string; + placeholder?: string; +} + +export const Input = React.forwardRef( + ({ label, error, placeholder, ...props }, ref) => { + return ( +
+ + + {error && {error}} +
+ ); } - - throw lastError! -} +); + +Input.displayName = 'Input'; ``` -### Async Iterators +### Event Handlers ```typescript -// Async generator for pagination -async function* paginateResults( - fetchPage: (offset: number, limit: number) => Promise, - limit: number = 20 -): AsyncGenerator { - let offset = 0 - let hasMore = true - - while (hasMore) { - const results = await fetchPage(offset, limit) - - if (results.length === 0) { - hasMore = false - } else { - yield results - offset += limit - hasMore = results.length === limit - } - } +interface FormProps { + onSubmit: (data: FormData) => void; + onChange: (field: string, value: string) => void; } -// Usage -for await (const batch of paginateResults(fetchUsers)) { - await processBatch(batch) -} +export const Form = ({ onSubmit, onChange }: FormProps) => { + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + onSubmit(formData); + }; + + const handleInputChange = (event: React.ChangeEvent) => { + onChange(event.target.name, event.target.value); + }; + + return ( +
+ +
+ ); +}; ``` -## Functional Programming Patterns +## Module Declaration Patterns -### Higher-Order Functions +### Ambient Declarations ```typescript -// Memoization -function memoize( - fn: (...args: T) => R -): (...args: T) => R { - const cache = new Map() - - return (...args: T): R => { - const key = JSON.stringify(args) - - if (cache.has(key)) { - return cache.get(key)! - } - - const result = fn(...args) - cache.set(key, result) - return result +// types/global.d.ts +declare global { + interface Window { + ENV: { + API_URL: string; + NODE_ENV: string; + }; } } -// Debounce -function debounce( - fn: (...args: T) => void, - delay: number -): (...args: T) => void { - let timeoutId: NodeJS.Timeout - - return (...args: T) => { - clearTimeout(timeoutId) - timeoutId = setTimeout(() => fn(...args), delay) +// Module augmentation +declare module '@medusajs/ui' { + interface ButtonProps { + customProp?: string; } } ``` -### Pipe and Compose +### Type-only Imports ```typescript -// Pipe function for data transformation -function pipe(...fns: Array<(arg: T) => T>) { - return (value: T): T => fns.reduce((acc, fn) => fn(acc), value) -} +// Use type-only imports when importing only types +import type { User, CreateUserInput } from './types'; +import type { ComponentProps } from 'react'; -// Usage -const processUser = pipe( - (user: User) => ({ ...user, name: user.name.trim() }), - (user: User) => ({ ...user, email: user.email.toLowerCase() }), - (user: User) => ({ ...user, updatedAt: new Date() }) -) +// Regular imports for values +import { createUser } from './api'; +import { Button } from '@medusajs/ui'; ``` -## Module Patterns +## Configuration Types -### Dependency Injection +### Environment Variables ```typescript -// Service container pattern -interface ServiceContainer { - get(token: string): T - register(token: string, factory: () => T): void -} - -class Container implements ServiceContainer { - private services = new Map() - private factories = new Map any>() - - register(token: string, factory: () => T): void { - this.factories.set(token, factory) - } +interface EnvironmentConfig { + readonly NODE_ENV: 'development' | 'production' | 'test'; + readonly API_URL: string; + readonly DATABASE_URL: string; + readonly REDIS_URL?: string; +} + +const config: EnvironmentConfig = { + NODE_ENV: process.env.NODE_ENV as EnvironmentConfig['NODE_ENV'], + API_URL: process.env.API_URL!, + DATABASE_URL: process.env.DATABASE_URL!, + REDIS_URL: process.env.REDIS_URL, +}; + +// Validate required environment variables at startup +const validateConfig = (config: EnvironmentConfig): void => { + const required: (keyof EnvironmentConfig)[] = ['NODE_ENV', 'API_URL', 'DATABASE_URL']; - get(token: string): T { - if (this.services.has(token)) { - return this.services.get(token) - } - - const factory = this.factories.get(token) - if (!factory) { - throw new Error(`Service not found: ${token}`) + for (const key of required) { + if (!config[key]) { + throw new Error(`Missing required environment variable: ${key}`); } - - const service = factory() - this.services.set(token, service) - return service } -} +}; ``` -### Factory Pattern -```typescript -// Abstract factory -interface PaymentProcessor { - processPayment(amount: number): Promise -} +## Testing Types -class PaymentProcessorFactory { - static create(provider: "stripe" | "paypal"): PaymentProcessor { - switch (provider) { - case "stripe": - return new StripeProcessor() - case "paypal": - return new PayPalProcessor() - default: - throw new Error(`Unknown payment provider: ${provider}`) - } - } -} +### Test Utilities +```typescript +// Test helper types +type MockedFunction any> = jest.MockedFunction; + +interface TestUser extends User { + password?: string; // Only for testing +} + +const createTestUser = (overrides?: Partial): TestUser => ({ + id: 'test-user-id', + name: 'Test User', + email: 'test@example.com', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); ``` -## Testing Patterns +## Common Anti-Patterns to Avoid -### Type Testing +❌ **Don't**: Use `any` type ```typescript -// Type-only tests -type AssertEqual = T extends U ? (U extends T ? true : false) : false -type AssertTrue = T - -// Test type assertions -type Test1 = AssertTrue> -type Test2 = AssertTrue["data"], User>> +// Bad +const processData = (data: any) => { + return data.someProperty; +}; ``` -### Mock Types +✅ **Do**: Use proper typing ```typescript -// Mock implementations for testing -type MockFunction any> = jest.MockedFunction - -interface MockRepository { - findById: MockFunction<(id: string) => Promise> - create: MockFunction<(data: Omit) => Promise> - update: MockFunction<(id: string, data: Partial) => Promise> - delete: MockFunction<(id: string) => Promise> +// Good +interface DataInput { + someProperty: string; } -// Factory for creating mocks -function createMockRepository(): MockRepository { - return { - findById: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - } +const processData = (data: DataInput) => { + return data.someProperty; +}; +``` + +❌ **Don't**: Use function declarations for components +```typescript +// Bad +function MyComponent(props: Props) { + return
{props.children}
; } ``` -## Performance Considerations +✅ **Do**: Use const assertions for components +```typescript +// Good +const MyComponent = (props: Props) => { + return
{props.children}
; +}; +``` -### Type-Level Performance +❌ **Don't**: Ignore strict TypeScript settings ```typescript -// Avoid deep recursion in types -type DeepReadonly = T extends any[] - ? ReadonlyArray> - : T extends object - ? { readonly [K in keyof T]: DeepReadonly } - : T - -// Use branded types for performance -type UserId = string & { readonly brand: unique symbol } -type ProductId = string & { readonly brand: unique symbol } - -function createUserId(id: string): UserId { - return id as UserId +// Bad - tsconfig.json +{ + "strict": false, + "noImplicitAny": false } ``` -## Common Anti-Patterns to Avoid - -- Don't use `any` type unless absolutely necessary -- Avoid function overloads when union types suffice -- Don't ignore TypeScript errors with `@ts-ignore` -- Avoid deep nesting in type definitions -- Don't use `Function` type (use specific function signatures) -- Avoid mutation of readonly types -- Don't use `object` type (use `Record` or specific interfaces) -- Avoid circular type dependencies +✅ **Do**: Use strict TypeScript settings +```typescript +// Good - tsconfig.json +{ + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitReturns": true +} +``` -## Configuration +## TypeScript Configuration -### TSConfig Best Practices +### Recommended tsconfig.json ```json { "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, "strict": true, - "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", "exactOptionalPropertyTypes": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false - } + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "build"] } ``` -Remember: TypeScript is a tool for developer productivity and code safety. Use it to catch errors at compile time, improve code documentation, and enable better IDE support. Always prefer type safety over convenience. +## Dependencies + +TypeScript-related packages: +- `typescript`: ^5.0.0 +- `@types/react`: Latest +- `@types/react-dom`: Latest +- `@types/node`: Latest +- `zod`: For runtime type validation diff --git a/apps/medusa/medusa-config.ts b/apps/medusa/medusa-config.ts index 5b83b3fda..268e89b96 100644 --- a/apps/medusa/medusa-config.ts +++ b/apps/medusa/medusa-config.ts @@ -76,6 +76,10 @@ module.exports = defineConfig({ cacheModule, eventBusModule, workflowEngineModule, + { + resolve: './src/modules/page-builder', + options: {}, + }, ], admin: { backendUrl: process.env.ADMIN_BACKEND_URL, diff --git a/apps/medusa/package.json b/apps/medusa/package.json index 457575b47..c7b85464d 100644 --- a/apps/medusa/package.json +++ b/apps/medusa/package.json @@ -33,21 +33,23 @@ }, "dependencies": { "@lambdacurry/medusa-product-reviews": "1.2.0", - "@medusajs/admin-sdk": "2.7.0", - "@medusajs/cli": "2.7.0", - "@medusajs/framework": "2.7.0", - "@medusajs/js-sdk": "2.7.0", - "@medusajs/medusa": "2.7.0", - "@medusajs/types": "2.7.0", + "@medusajs/admin-sdk": "2.8.2", + "@medusajs/cli": "2.8.2", + "@medusajs/framework": "2.8.2", + "@medusajs/js-sdk": "2.8.2", + "@medusajs/medusa": "2.8.2", + "@medusajs/types": "2.8.2", "@mikro-orm/core": "6.4.3", "@mikro-orm/knex": "6.4.3", "@mikro-orm/migrations": "6.4.3", "@mikro-orm/postgresql": "6.4.3", "awilix": "^8.0.1", - "pg": "^8.13.0" + "pg": "^8.13.0", + "usehooks-ts": "^3.1.1" }, "devDependencies": { - "@medusajs/test-utils": "2.7.0", + "@lambdacurry/page-builder-sdk": "0.0.1", + "@medusajs/test-utils": "2.8.2", "@mikro-orm/cli": "6.4.3", "@mikro-orm/core": "6.4.3", "@mikro-orm/migrations": "6.4.3", diff --git a/apps/medusa/src/admin/components/Breadcrumbs.tsx b/apps/medusa/src/admin/components/Breadcrumbs.tsx new file mode 100644 index 000000000..cd46ecca6 --- /dev/null +++ b/apps/medusa/src/admin/components/Breadcrumbs.tsx @@ -0,0 +1,41 @@ +import { clx } from '@medusajs/ui'; +import { Link } from 'react-router-dom'; +import { TriangleRightMini } from '@medusajs/icons'; + +export const Breadcrumbs = ({ crumbs }: { crumbs: { label: string; path: string }[] }) => { + return ( +
    + {crumbs.map((crumb, index) => { + const isLast = index === crumbs.length - 1; + const isSingle = crumbs.length === 1; + + return ( +
  1. + {!isLast && crumb.path ? ( + + {crumb.label} + + ) : ( +
    + {!isSingle && isLast && ...} + + {crumb.label} + +
    + )} + {!isLast && ( + + + + )} +
  2. + ); + })} +
+ ); +}; diff --git a/apps/medusa/src/admin/components/Sidebar.tsx b/apps/medusa/src/admin/components/Sidebar.tsx new file mode 100644 index 000000000..2c40f6a22 --- /dev/null +++ b/apps/medusa/src/admin/components/Sidebar.tsx @@ -0,0 +1,116 @@ +import { XMark } from '@medusajs/icons'; +import { Drawer, Heading, IconButton, clx } from '@medusajs/ui'; +import { PropsWithChildren } from 'react'; +import { useMediaQuery } from '../hooks/use-media-query'; + +const DrawerSidebarContainer = ({ + title, + children, + side = 'left', + isOpen, + toggle, +}: { title?: string; children: React.ReactNode; side?: 'left' | 'right'; isOpen: boolean; toggle: () => void }) => { + return ( + + +
+ {title} + + + +
+
{children}
+
+
+ ); +}; + +const StaticSidebarContainer = ({ + header, + toggle, + children, + side = 'left', + className, + isOpen, +}: PropsWithChildren & { + header?: { + title: string; + showCloseButton?: boolean; + }; + side?: 'left' | 'right'; + isOpen: boolean; + className?: string; + toggle: () => void; + showCloseButton?: boolean; +}) => { + return ( +
+ {header && ( +
+
+ {header?.title} + {header?.showCloseButton && ( + toggle()}> + + + )} +
+
+ )} + {children} +
+ ); +}; + +export const SidebarContainer = { + Drawer: DrawerSidebarContainer, + Static: StaticSidebarContainer, +}; + +export interface SidebarProps extends PropsWithChildren { + side: 'left' | 'right'; + className?: string; + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; + showCloseButton?: boolean; + header?: { + title: string; + showCloseButton?: boolean; + }; +} + +export const Sidebar = ({ children, side, className, isOpen, toggle, header }: SidebarProps) => { + const isLargeScreen = useMediaQuery('(min-width: 768px)'); + + return ( + <> + + {children} + + + + {children} + + + ); +}; diff --git a/apps/medusa/src/admin/components/action-menu.tsx b/apps/medusa/src/admin/components/action-menu.tsx new file mode 100644 index 000000000..3444f24b8 --- /dev/null +++ b/apps/medusa/src/admin/components/action-menu.tsx @@ -0,0 +1,100 @@ +import { + DropdownMenu, + IconButton, + clx, +} from "@medusajs/ui" +import { EllipsisHorizontal } from "@medusajs/icons" +import { Link } from "react-router-dom" + +export type Action = { + icon: React.ReactNode + label: string + disabled?: boolean +} & ( + | { + to: string + onClick?: never + } + | { + onClick: () => void + to?: never + } +) + +export type ActionGroup = { + actions: Action[] +} + +export type ActionMenuProps = { + groups: ActionGroup[] +} + +export const ActionMenu = ({ groups }: ActionMenuProps) => { + return ( + + + + + + + + {groups.map((group, index) => { + if (!group.actions.length) { + return null + } + + const isLast = index === groups.length - 1 + + return ( + + {group.actions.map((action, index) => { + if (action.onClick) { + return ( + { + e.stopPropagation() + action.onClick() + }} + className={clx( + "[&_svg]:text-ui-fg-subtle flex items-center gap-x-2", + { + "[&_svg]:text-ui-fg-disabled": action.disabled, + } + )} + > + {action.icon} + {action.label} + + ) + } + + return ( +
+ + e.stopPropagation()}> + {action.icon} + {action.label} + + +
+ ) + })} + {!isLast && } +
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/components/header.tsx b/apps/medusa/src/admin/components/header.tsx new file mode 100644 index 000000000..253b44bc5 --- /dev/null +++ b/apps/medusa/src/admin/components/header.tsx @@ -0,0 +1,66 @@ +import { Heading, Button, Text } from "@medusajs/ui" +import React, { Fragment } from "react" +import { Link, LinkProps } from "react-router-dom" +import { ActionMenu, ActionMenuProps } from "./action-menu" + +export type HeadingProps = { + title: string + subtitle?: string + actions?: ( + { + type: "button", + props: React.ComponentProps + link?: LinkProps + } | + { + type: "action-menu" + props: ActionMenuProps + } | + { + type: "custom" + children: React.ReactNode + } + )[] +} + +export const Header = ({ + title, + subtitle, + actions = [], +}: HeadingProps) => { + return ( +
+
+ {title} + {subtitle && ( + + {subtitle} + + )} +
+ {actions.length > 0 && ( +
+ {actions.map((action, index) => ( + + {action.type === "button" && ( + + )} + {action.type === "action-menu" && ( + + )} + {action.type === "custom" && action.children} + + ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCheckbox.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCheckbox.tsx new file mode 100644 index 000000000..8b17102a7 --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCheckbox.tsx @@ -0,0 +1,42 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { FieldCheckbox, type FieldCheckboxProps } from '../Field/FieldCheckbox'; + +type Props = Omit & + Omit & { + name: Path; + rules?: Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; + }; + +export const ControlledCheckbox = ({ name, rules, onChange, ...props }: Props) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + >, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => ( + { + if (onChange) onChange(checked); + field.onChange(checked); + }} + /> + )} + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCurrencyInput.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCurrencyInput.tsx new file mode 100644 index 000000000..5fb02f7fc --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledCurrencyInput.tsx @@ -0,0 +1,37 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { CurrencyInput, type Props as CurrencyInputProps } from '../Field/CurrencyInput'; + +type Props = CurrencyInputProps & + Omit & { + name: Path; + }; + +export const ControlledCurrencyInput = ({ name, rules, ...props }: Props) => { + const { control } = useFormContext(); + + return ( + + control={control} + name={name} + rules={rules as Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => { + return ( + ) => { + field.onChange(e.target.value.replace(/[^0-9.-]+/g, '')); + }} + /> + ); + }} + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledDatePicker.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledDatePicker.tsx new file mode 100644 index 000000000..d7f666f0c --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledDatePicker.tsx @@ -0,0 +1,26 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { DatePickerInput, type Props as DatePickerProps } from '../Field/DatePicker'; + +type Props = DatePickerProps & + Omit & { + name: Path; + }; + +export const ControlledDatePicker = ({ name, rules, ...props }: Props) => { + const { control } = useFormContext(); + return ( + + control={control} + name={name} + rules={rules as Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => } + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx new file mode 100644 index 000000000..72b9bd4a3 --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx @@ -0,0 +1,45 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { Input, type Props as InputProps } from '../Field/Input'; + +type Props = InputProps & + Omit & { + name: Path; + rules?: Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; + } & React.ComponentProps & + Omit, 'render'>; + +export const ControlledInput = ({ name, rules, onChange, ...props }: Props) => { + const { + control, + formState: { errors }, + } = useFormContext(); + + return ( + >, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => ( + { + if (onChange) { + onChange(evt); + } + field.onChange(evt); + }} + /> + )} + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSearchableSelect.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSearchableSelect.tsx new file mode 100644 index 000000000..930b6aeee --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSearchableSelect.tsx @@ -0,0 +1,36 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { SearchableSelect, type Props as SearchableSelectProps } from '../Field/SearchableSelect'; + +type Props = SearchableSelectProps & + Omit & { + name: Path; + }; + +export const ControlledSearchableSelect = ({ name, rules, onChange, ...props }: Props) => { + const { control } = useFormContext(); + + return ( + + control={control} + name={name} + rules={rules as Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => ( + { + field.onChange(a, b); + onChange?.(a, b); + }} + /> + )} + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSelect.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSelect.tsx new file mode 100644 index 000000000..fc97b3a5f --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledSelect.tsx @@ -0,0 +1,52 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { Select, type Props as SelectProps } from '../Field/Select'; + +type Props = SelectProps & + Omit & { + name: Path; + children: React.ReactNode; + onBlur?: () => void; + onChange?: (value: unknown) => void; + }; + +export const ControlledSelect = ({ + name, + rules, + children, + onChange, + onBlur, + ...props +}: Props) => { + const { control } = useFormContext(); + return ( + + control={control} + name={name} + rules={rules as Omit>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>} + render={({ field }) => { + const handleChange = (value: unknown) => { + if (typeof onChange === 'function') onChange(value); + field.onChange(value); + }; + + const handleBlur = () => { + if (typeof onBlur === 'function') onBlur(); + field.onBlur(); + }; + + return ( + + ); + }} + /> + ); +}; diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledTextArea.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledTextArea.tsx new file mode 100644 index 000000000..692f44594 --- /dev/null +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledTextArea.tsx @@ -0,0 +1,28 @@ +import { + Controller, + type ControllerProps, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from 'react-hook-form'; +import { TextArea, type Props as TextAreaProps } from '../Field/TextArea'; + +type Props = TextAreaProps & + Omit & { + name: Path; + rules?: RegisterOptions>; + } & React.ComponentProps & + Omit, 'render'>; + +export const ControlledTextArea = ({ name, rules, ...props }: Props) => { + const { control } = useFormContext(); + return ( + + control={control} + name={name} + rules={rules} + render={({ field }) =>