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..92ad938f7 --- /dev/null +++ b/.cursor/rules/medusa-admin.mdc @@ -0,0 +1,759 @@ +--- +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 }); + }, + }); +}; + +export const useAdminCreatePostSection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + return sdk.admin.pageBuilder.createPostSection(data); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; +``` + +### 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; + +// Always invalidate related queries in mutations +export const useAdminCreatePostSection = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + return sdk.admin.pageBuilder.createPostSection(data); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; +``` + +### 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); + }); +}); +``` + +## Verification Checklist + +Before submitting admin UI code, ensure: + +### State Management +- [ ] All mutations invalidate related query keys using `QUERY_KEYS` +- [ ] Query keys are centralized and use consistent naming +- [ ] Loading and error states are properly handled +- [ ] Optimistic updates are implemented where appropriate + +### Component Patterns +- [ ] Event handlers use `stopPropagation()` for nested actions +- [ ] Components use proper TypeScript generics for reusability +- [ ] Form validation uses Zod schemas with proper error handling +- [ ] Accessibility attributes (ARIA labels, roles) are included + +### Performance +- [ ] Components use `memo()` for expensive renders +- [ ] Event handlers are wrapped in `useCallback()` when needed +- [ ] Heavy computations use `useMemo()` +- [ ] Query data is properly selected to minimize re-renders + +### Error Handling +- [ ] All async operations have try-catch blocks +- [ ] User-friendly error messages are displayed via toast +- [ ] Network errors are handled gracefully +- [ ] Form validation errors are displayed inline + +## 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 + +## Common Anti-Patterns to Avoid + +❌ **Don't**: Fetch data directly in components +```typescript +// Bad - direct API calls in components +const MyComponent = () => { + const [data, setData] = useState(null); + useEffect(() => { + fetch('/api/data').then(res => setData(res)); + }, []); +}; +``` + +✅ **Do**: Use custom hooks with TanStack Query +```typescript +// Good - use query hooks +const MyComponent = () => { + const { data, isLoading } = useAdminListResources(); +}; +``` + +❌ **Don't**: Skip event.stopPropagation() in nested actions +```typescript +// Bad - events bubble up unintentionally +const handleDelete = async () => { + await deleteItem(id); // Parent onClick also fires +}; +``` + +✅ **Do**: Use stopPropagation for nested actions +```typescript +// Good - prevent event bubbling +const handleDelete = async (event: MouseEvent) => { + event.stopPropagation(); + await deleteItem(id); +}; +``` + +❌ **Don't**: Forget to invalidate query cache +```typescript +// Bad - stale data after mutations +const { mutate } = useMutation({ + mutationFn: createResource, + // Missing onSuccess invalidation +}); +``` + +✅ **Do**: Always invalidate related queries +```typescript +// Good - fresh data after mutations +const { mutate } = useMutation({ + mutationFn: createResource, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.RESOURCES }); + }, +}); +``` + +### Component Files +- Use PascalCase for component files: `PostSectionListItem.tsx` +- Co-locate related files in feature directories +- Separate concerns: components, hooks, types, and tests + +#### Hook Files +- Group by functionality: `post-sections-mutations.ts`, `post-sections-queries.ts` +- Use descriptive names: `useAdminCreatePostSection` +- Export related hooks from index files + +#### Type Files +- Define interfaces close to usage +- Use barrel exports for shared types +- Prefer type-only imports: `import type { User } from './types'` + +#### Test Files +- Mirror source structure: `components/__tests__/Button.test.tsx` +- Use descriptive test names +- Group related tests in describe blocks diff --git a/.cursor/rules/medusa-backend.mdc b/.cursor/rules/medusa-backend.mdc new file mode 100644 index 000000000..621be347e --- /dev/null +++ b/.cursor/rules/medusa-backend.mdc @@ -0,0 +1,393 @@ +--- +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 + +### Workflow Testing +```typescript +import { createPostWorkflow } from '../create-post'; + +describe('createPostWorkflow', () => { + it('should create post successfully', async () => { + const input = { + post: { + title: 'Test Post', + status: 'draft', + author_id: 'author-1', + }, + }; + + const { result } = await createPostWorkflow(container).run({ input }); + + expect(result).toMatchObject({ + id: expect.any(String), + title: 'Test Post', + status: 'draft', + }); + }); + + it('should handle validation errors', async () => { + const input = { post: { title: '' } }; // Invalid input + + await expect( + createPostWorkflow(container).run({ input }) + ).rejects.toThrow('Title is required'); + }); +}); +``` + +### 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 + +## Verification Checklist + +Before submitting backend code, ensure: + +### API Endpoints +- [ ] All endpoints use workflow pattern for business logic +- [ ] Proper middleware validation with Zod schemas +- [ ] Consistent response structure with proper status codes +- [ ] Error handling with appropriate HTTP status codes + +### Workflows +- [ ] Each workflow has a single responsibility +- [ ] Steps are atomic and can be rolled back +- [ ] Proper error handling and compensation logic +- [ ] Input/output types are properly defined + +### Database +- [ ] Migrations are reversible and tested +- [ ] Indexes are added for query performance +- [ ] Foreign key constraints are properly defined +- [ ] Soft deletes are used where appropriate + +### Security +- [ ] Input validation on all endpoints +- [ ] Authentication checks where required +- [ ] Proper error message sanitization +- [ ] Rate limiting considerations + +## Common Anti-Patterns to Avoid + +❌ **Don't**: Put business logic directly in API routes +```typescript +// Bad - business logic in route handler +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const data = req.validatedBody; + const postSection = await req.scope.resolve('postSectionService').create(data); + // Missing validation, error handling, events + res.json({ section: postSection }); +}; +``` + +✅ **Do**: Use workflows for business logic +```typescript +// Good - workflow handles business logic +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const { result } = await createPostSectionWorkflow(req.scope).run({ + input: { section: req.validatedBody } + }); + res.json({ section: result }); +}; +``` + +❌ **Don't**: Skip input validation +```typescript +// Bad - no validation +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const data = req.body; // Unvalidated input + // Process data... +}; +``` + +✅ **Do**: Use Zod middleware validation +```typescript +// Good - proper validation +const createSchema = z.object({ + name: z.string().min(1), + status: z.enum(['draft', 'published']).default('draft'), +}); + +export const validateCreate = validateAndTransformBody(createSchema); +``` + +## 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/package.json b/apps/medusa/package.json index c7b85464d..70cb78c7f 100644 --- a/apps/medusa/package.json +++ b/apps/medusa/package.json @@ -32,6 +32,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@lambdacurry/medusa-product-reviews": "1.2.0", "@medusajs/admin-sdk": "2.8.2", "@medusajs/cli": "2.8.2", diff --git a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx index 6a263a1e3..72b9bd4a3 100644 --- a/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx +++ b/apps/medusa/src/admin/components/inputs/ControlledFields/ControlledInput.tsx @@ -30,6 +30,7 @@ export const ControlledInput = ({ name, rules, onChange, { if (onChange) { diff --git a/apps/medusa/src/admin/components/organisms/editor/sidebar-container.tsx b/apps/medusa/src/admin/components/organisms/editor/sidebar-container.tsx deleted file mode 100644 index 13dd5e26b..000000000 --- a/apps/medusa/src/admin/components/organisms/editor/sidebar-container.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { XMark } from '@medusajs/icons'; -import { Drawer, IconButton, clx } from '@medusajs/ui'; -import { PropsWithChildren } from 'react'; -import { useEditorSidebar } from '../../../hooks/editor/use-editor-sidebar'; - -type DrawerSidebarContainerProps = PropsWithChildren & { - title?: string; - side?: 'left' | 'right'; -}; - -const DrawerSidebarContainer = ({ title, children, side = 'left' }: DrawerSidebarContainerProps) => { - const { sections: left, settings: right, toggleLeft, toggleRight } = useEditorSidebar(); - - const isOpen = side === 'left' ? left.drawer : right.drawer; - const toggle = side === 'left' ? () => toggleLeft('drawer') : () => toggleRight('drawer'); - - return ( - - -
- {title} - - - -
-
{children}
-
-
- ); -}; - -const StaticSidebarContainer = ({ children, side = 'left' }: PropsWithChildren & { side?: 'left' | 'right' }) => { - const { sections: left, settings: right } = useEditorSidebar(); - const isOpen = side === 'left' ? left.static : right.static; - - return ( -
- {children} -
- ); -}; - -export const SidebarContainer = { - Drawer: DrawerSidebarContainer, - Static: StaticSidebarContainer, -}; diff --git a/apps/medusa/src/admin/editor/components/editor/EditorBreadcrumbs.tsx b/apps/medusa/src/admin/editor/components/editor/EditorBreadcrumbs.tsx index 88de37ce0..cfbecd2ca 100644 --- a/apps/medusa/src/admin/editor/components/editor/EditorBreadcrumbs.tsx +++ b/apps/medusa/src/admin/editor/components/editor/EditorBreadcrumbs.tsx @@ -1,5 +1,6 @@ import { Breadcrumbs } from '../../../components/Breadcrumbs'; import { usePost } from '../../hooks/use-post'; +import { usePostSection } from '../../hooks/use-post-section'; type Crumb = { label: string; @@ -8,6 +9,7 @@ type Crumb = { export const PostEditorBreadcrumbs = () => { const { post } = usePost(); + const { section } = usePostSection(); const crumbs: Crumb[] = [ { @@ -20,9 +22,16 @@ export const PostEditorBreadcrumbs = () => { }, { label: post.title as string, - path: post.id as string, + path: `/content/editor/${post.id}`, }, ]; + if (section) { + crumbs.push({ + label: section.title as string, + path: `/content/editor/${post.id}/section/${section.id}`, + }); + } + return ; }; diff --git a/apps/medusa/src/admin/editor/components/editor/PostContextProvider.tsx b/apps/medusa/src/admin/editor/components/editor/PostContextProvider.tsx index 2cc65c42c..30128e5fc 100644 --- a/apps/medusa/src/admin/editor/components/editor/PostContextProvider.tsx +++ b/apps/medusa/src/admin/editor/components/editor/PostContextProvider.tsx @@ -1,6 +1,6 @@ import { Post, PostStatus } from '@lambdacurry/page-builder-types'; import { PropsWithChildren, createContext } from 'react'; -import { useForm, UseFormReturn } from 'react-hook-form'; +import { UseFormReturn, useForm } from 'react-hook-form'; import { useAdminUpdatePost } from '../../../hooks/posts-mutations'; export type PostFormValues = { @@ -9,7 +9,7 @@ export type PostFormValues = { status: PostStatus; meta_title?: string; meta_description?: string; - meta_image?: string; + meta_image_url?: string; }; const buildDefaultValues = (post: Post): PostFormValues => { @@ -19,7 +19,7 @@ const buildDefaultValues = (post: Post): PostFormValues => { status: post.status ?? 'draft', meta_title: '', meta_description: '', - meta_image: '', + meta_image_url: '', }; }; diff --git a/apps/medusa/src/admin/editor/components/editor/PostEditorLayout.tsx b/apps/medusa/src/admin/editor/components/editor/PostEditorLayout.tsx index 62ef379fd..e60f21238 100644 --- a/apps/medusa/src/admin/editor/components/editor/PostEditorLayout.tsx +++ b/apps/medusa/src/admin/editor/components/editor/PostEditorLayout.tsx @@ -1,6 +1,5 @@ -import { TooltipProvider } from '@medusajs/ui'; import { PropsWithChildren } from 'react'; -import { SectionsSidebar } from './SectionsSidebar'; +import { PostSectionsSidebar } from './PostSectionsSidebar'; import { MainContent } from './MainContent'; import { PostSettingsSidebar } from './PostSettingsSidebar'; @@ -8,12 +7,10 @@ type PostEditorLayoutProps = PropsWithChildren; export const PostEditorLayout = ({ children }: PostEditorLayoutProps) => { return ( - -
- - {children} - -
-
+
+ + {children} + +
); }; diff --git a/apps/medusa/src/admin/editor/components/editor/PostSectionContextProvider.tsx b/apps/medusa/src/admin/editor/components/editor/PostSectionContextProvider.tsx new file mode 100644 index 000000000..388dc6a93 --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/PostSectionContextProvider.tsx @@ -0,0 +1,67 @@ +import { ContentBlock, PostSection, PostSectionLayout, PostSectionStatus } from '@lambdacurry/page-builder-types'; +import { PropsWithChildren, createContext } from 'react'; +import { UseFormReturn, useForm } from 'react-hook-form'; +import { useAdminUpdatePostSection } from '../../../hooks/post-sections-mutations'; + +export type PostSectionFormValues = { + title: string; + status: PostSectionStatus; + layout: PostSectionLayout; + blocks: ContentBlock[]; +}; + +const buildDefaultValues = (section: PostSection): PostSectionFormValues => { + return { + title: section.title ?? '', + status: section.status ?? 'draft', + layout: section.layout ?? ('full_width' as const), + blocks: [], + }; +}; + +export const PostSectionContext = createContext<{ + section?: PostSection; + form?: UseFormReturn; + save?: () => Promise; +}>({ + section: undefined, + form: undefined, + save: undefined, +}); + +export interface PostSectionContextProviderProps extends PropsWithChildren { + section: PostSection | undefined; +} + +export const PostSectionContextProvider = ({ children, section }: PostSectionContextProviderProps) => { + if (!section) { + return null; + } + + return {children}; +}; + +export const PostSectionContextSubProvider = ({ children, section }: PropsWithChildren<{ section: PostSection }>) => { + const { mutateAsync } = useAdminUpdatePostSection(); + + const defaultValues = buildDefaultValues(section); + + const form = useForm({ + defaultValues, + }); + + const handleSubmit = async (data: PostSectionFormValues) => { + const { section: updatedSection } = await mutateAsync({ + id: section?.id as string, + data, + }); + + form.reset(buildDefaultValues(updatedSection)); + }; + + return ( + + {children} + + ); +}; diff --git a/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebar.tsx b/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebar.tsx new file mode 100644 index 000000000..03e3b7ba5 --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebar.tsx @@ -0,0 +1,26 @@ +import { useParams } from 'react-router-dom'; +import { Sidebar } from '../../../components/Sidebar'; +import { usePostSectionsSidebar } from '../../../routes/content/editor/providers/PostSectionsSidebarContext'; +import { usePostSection } from '../../hooks/use-post-section'; +import { PostSectionsSidebarContent } from './PostSectionsSidebarContent'; +import { PostSectionEditorSidebarContent } from './post-section/PostSectionEditorSidebarContent'; + +export const PostSectionsSidebar = () => { + const { section } = usePostSection(); + const { isOpen, open, close, toggle } = usePostSectionsSidebar(); + + return ( + <> + + + + + ); +}; + +const PostSectionEditorContent = ({ className }: { className?: string }) => { + return ; +}; diff --git a/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebarContent.tsx b/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebarContent.tsx new file mode 100644 index 000000000..d055122d1 --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/PostSectionsSidebarContent.tsx @@ -0,0 +1,158 @@ +import { + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { PostSection, PostSectionLayout } from '@lambdacurry/page-builder-types'; +import { Plus } from '@medusajs/icons'; +import { Button, DropdownMenu, Heading, toast } from '@medusajs/ui'; +import { useEffect, useState } from 'react'; +import { FormProvider } from 'react-hook-form'; +import { ControlledInput } from '../../../components/inputs/ControlledFields/ControlledInput'; +import { useAdminCreatePostSection } from '../../../hooks/post-sections-mutations'; +import { useAdminReorderPostSections } from '../../../hooks/posts-mutations'; +import { usePost } from '../../hooks/use-post'; +import { PostSectionListItem } from './post-section/PostSectionListItem'; + +export const PostSectionsSidebarContent = ({ className }: { className?: string }) => { + const { post } = usePost(); + return ( +
+ + + +
+ ); +}; + +const PostTitleForm = ({ className }: { className?: string }) => { + const { form } = usePost(); + + return ( +
+ + + Page Title + + + +
+ ); +}; + +const SectionsMenu = ({ sections: _sections }: { sections: PostSection[] }) => { + const { post } = usePost(); + + const [sections, setSections] = useState(_sections); + + useEffect(() => { + setSections(_sections); + }, [_sections]); + + const { mutateAsync: reorderSections } = useAdminReorderPostSections(); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + const oldIndex = sections.findIndex((section) => section.id === active.id); + const newIndex = sections.findIndex((section) => section.id === over.id); + + // Create a new array with the reordered sections + const reorderedSections = arrayMove(post?.sections ?? [], oldIndex, newIndex); + + setSections(reorderedSections); + + await reorderSections({ + id: post?.id as string, + data: { + section_ids: reorderedSections.map((section) => section.id), + }, + }); + } + }; + + return ( +
+ + Sections + + {!sections.length &&

No sections yet

} + + section.id)} strategy={verticalListSortingStrategy}> + + + +
+ ); +}; + +const CreateSectionButton = () => { + const { post } = usePost(); + + const createSection = useAdminCreatePostSection(); + + const handleCreateSection = async (layout: PostSectionLayout) => { + if (!post?.id) return; + + try { + const result = await createSection.mutateAsync({ + title: `New ${layout.replace('_', ' ')} section`, + layout, + blocks: {}, + post_id: post.id, + sort_order: post.sections?.length || 0, + }); + + if (result.section) toast.success('Section created successfully'); + } catch (error) { + console.error('Failed to create section:', error); + } + }; + + return ( + + + + + + + handleCreateSection('full_width')}> + Full Width Layout + + + handleCreateSection('two_column')}> + Two Column Layout + + + handleCreateSection('grid')}> + Grid Layout + + + + ); +}; diff --git a/apps/medusa/src/admin/editor/components/editor/PostSettingsSidebar.tsx b/apps/medusa/src/admin/editor/components/editor/PostSettingsSidebar.tsx index 8d37bed3d..89cc7aa97 100644 --- a/apps/medusa/src/admin/editor/components/editor/PostSettingsSidebar.tsx +++ b/apps/medusa/src/admin/editor/components/editor/PostSettingsSidebar.tsx @@ -1,12 +1,12 @@ import { useNavigate } from 'react-router-dom'; import { Sidebar } from '../../../components/Sidebar'; -import { useSettingsSidebar } from '../../../routes/content/editor/providers/SettingsSidebarContext'; +import { usePostSettingsSidebar } from '../../../routes/content/editor/providers/PostSettingsSidebarContext'; import { usePost } from '../../hooks/use-post'; import { PostDetailsForm } from './PostDetailsForm'; export const PostSettingsSidebar = () => { const { post } = usePost(); - const { isOpen, open, close, toggle } = useSettingsSidebar(); + const { isOpen, open, close, toggle } = usePostSettingsSidebar(); const navigate = useNavigate(); if (!post) return null; diff --git a/apps/medusa/src/admin/editor/components/editor/SectionsSidebar.tsx b/apps/medusa/src/admin/editor/components/editor/SectionsSidebar.tsx deleted file mode 100644 index 873e956dc..000000000 --- a/apps/medusa/src/admin/editor/components/editor/SectionsSidebar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Plus, SquareTwoStackSolid } from '@medusajs/icons'; -import { Button, DropdownMenu, Label } from '@medusajs/ui'; -import { FormProvider } from 'react-hook-form'; -import { Sidebar } from '../../../components/Sidebar'; -import { ControlledInput } from '../../../components/inputs/ControlledFields/ControlledInput'; -import { useSectionsSidebar } from '../../../routes/content/editor/providers/SectionsSidebarContext'; -import { usePost } from '../../hooks/use-post'; -import { INavItem, NavItem } from './nav-item'; - -export const SectionsSidebar = () => { - const { post } = usePost(); - const { isOpen, open, close, toggle } = useSectionsSidebar(); - - const navItems: INavItem[] = (post?.sections ?? []).map((section) => ({ - icon: , - label: section.name, - to: `/content/editor/${post?.id}/sections/${section.id}`, - })); - - return ( - <> - - - - - ); -}; - -const PostTitleForm = ({ className }: { className?: string }) => { - const { form } = usePost(); - - return ( -
- - - -
- ); -}; - -const SectionsMenu = ({ navItems }: { navItems: INavItem[] }) => { - return ( - <> - - {!navItems.length &&

No sections yet

} - - - ); -}; - -const CreateSectionButton = () => { - return ( - - - - - - - Full Width Layout - - Two Column Layout - - Grid Layout - - - ); -}; diff --git a/apps/medusa/src/admin/editor/components/editor/SectionsSidebarToggleButton.tsx b/apps/medusa/src/admin/editor/components/editor/SectionsSidebarToggleButton.tsx index 5ca128aa2..cf1255e76 100644 --- a/apps/medusa/src/admin/editor/components/editor/SectionsSidebarToggleButton.tsx +++ b/apps/medusa/src/admin/editor/components/editor/SectionsSidebarToggleButton.tsx @@ -1,9 +1,9 @@ import { SidebarLeft } from '@medusajs/icons'; import { IconButton } from '@medusajs/ui'; -import { useSectionsSidebar } from '../../../routes/content/editor/providers/SectionsSidebarContext'; +import { usePostSectionsSidebar } from '../../../routes/content/editor/providers/PostSectionsSidebarContext'; export const SectionsSidebarToggleButton = () => { - const { toggle } = useSectionsSidebar(); + const { toggle } = usePostSectionsSidebar(); return ( toggle()} size="small"> diff --git a/apps/medusa/src/admin/editor/components/editor/SettingsSidebarToggleButton.tsx b/apps/medusa/src/admin/editor/components/editor/SettingsSidebarToggleButton.tsx index 326689e94..926242450 100644 --- a/apps/medusa/src/admin/editor/components/editor/SettingsSidebarToggleButton.tsx +++ b/apps/medusa/src/admin/editor/components/editor/SettingsSidebarToggleButton.tsx @@ -1,9 +1,9 @@ -import { useSettingsSidebar } from '../../../routes/content/editor/providers/SettingsSidebarContext'; +import { usePostSettingsSidebar } from '../../../routes/content/editor/providers/PostSettingsSidebarContext'; import { IconButton } from '@medusajs/ui'; import { CogSixTooth } from '@medusajs/icons'; export const SettingsSidebarToggleButton = () => { - const { toggle } = useSettingsSidebar(); + const { toggle } = usePostSettingsSidebar(); return ( toggle()} size="small"> diff --git a/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionDeleteButton.tsx b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionDeleteButton.tsx new file mode 100644 index 000000000..e5cc29aae --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionDeleteButton.tsx @@ -0,0 +1,51 @@ +import { PostSection } from '@lambdacurry/page-builder-types'; +import { useAdminDeletePostSection } from '../../../../hooks/post-sections-mutations'; +import { Button, usePrompt } from '@medusajs/ui'; +import { FC, PropsWithChildren } from 'react'; + +export interface PostSectionDeleteButtonProps extends PropsWithChildren { + postSection: PostSection; + onDelete?: (postSection: PostSection) => void; + onCancel?: () => void; + className?: string; + size?: React.ComponentProps['size']; +} + +export const PostSectionDeleteButton: FC = ({ + postSection, + onDelete, + onCancel, + children, + className, +}) => { + const { mutateAsync: deletePostSection } = useAdminDeletePostSection(); + const prompt = usePrompt(); + + const handleClick = async () => { + const confirmed = await prompt({ + title: 'Delete section', + description: 'Are you sure you want to delete this section?', + confirmText: 'Yes, delete', + cancelText: 'Cancel', + }); + + if (confirmed) { + await deletePostSection(postSection.id); + onDelete?.(postSection); + return; + } + + onCancel?.(); + return; + }; + + if (!postSection) { + return null; + } + + return ( + + ); +}; diff --git a/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionEditorSidebarContent.tsx b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionEditorSidebarContent.tsx new file mode 100644 index 000000000..a2eb29522 --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionEditorSidebarContent.tsx @@ -0,0 +1,78 @@ +import { ArrowLeft } from '@medusajs/icons'; +import { Button, IconButton, Tabs } from '@medusajs/ui'; +import { FormProvider } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { ControlledInput } from '../../../../components/inputs/ControlledFields/ControlledInput'; +import { usePostSection } from '../../../hooks/use-post-section'; +import { ControlledSelect } from '../../../../components/inputs/ControlledFields/ControlledSelect'; +import { SelectTrigger, SelectValue } from '../../../../components/inputs/Field/Select'; +import { SelectItem } from '../../../../components/inputs/Field/Select'; +import { SelectContent } from '../../../../components/inputs/Field/Select'; + +export const PostSectionEditorSidebarContent = ({ className }: { className?: string }) => { + return ( +
+
+ +
+ ); +}; + +const Header = () => { + const navigate = useNavigate(); + + return ( +
+ navigate('../..')}> + + + + +
+ ); +}; + +const SaveButton = () => { + const { save, form } = usePostSection(); + return ( + + ); +}; + +const PostSectionEditorForm = () => { + const { section, form } = usePostSection(); + + if (!section || !form) return null; + + return ( + + + + + + + + Full Width + Two Column + Grid + + +
+ + + Content + Styles + + + Content Form Here! + + + Styles Form Here! + + +
+
+ ); +}; diff --git a/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionListItem.tsx b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionListItem.tsx new file mode 100644 index 000000000..ff90a72cf --- /dev/null +++ b/apps/medusa/src/admin/editor/components/editor/post-section/PostSectionListItem.tsx @@ -0,0 +1,147 @@ +import { PostSection } from '@lambdacurry/page-builder-types'; +import { DotsSix, EllipsisHorizontal, Trash } from '@medusajs/icons'; +import { Badge, DropdownMenu, IconButton, Text, toast, usePrompt } from '@medusajs/ui'; +import clsx from 'clsx'; +import { FC, MouseEventHandler } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + useAdminDeletePostSection, + useAdminUpdatePostSection, + useAdminDuplicatePostSection, +} from '../../../../hooks/post-sections-mutations'; +import { usePost } from '../../../hooks/use-post'; +import { editSectionPath } from '../../../utils'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; + +export interface PostSectionListItemProps { + index: number; + section: PostSection; +} + +export const PostSectionListItem: FC = ({ section }) => { + const { post } = usePost(); + const prompt = usePrompt(); + const { mutateAsync: deletePostSection } = useAdminDeletePostSection(); + const { mutateAsync: duplicatePostSection } = useAdminDuplicatePostSection(); + const { mutateAsync: updatePostSection } = useAdminUpdatePostSection(); + const navigate = useNavigate(); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleEditClick = () => { + navigate(editSectionPath({ postId: post.id, sectionId: section.id })); + }; + + const handleDuplicateClick: MouseEventHandler = async (event) => { + event.stopPropagation(); + try { + await duplicatePostSection(section.id); + toast.success('Section duplicated successfully'); + } catch (error) { + toast.error('Failed to duplicate section'); + console.error('Failed to duplicate section:', error); + } + }; + + const handlePublishClick: MouseEventHandler = async (event) => { + event.stopPropagation(); + + await updatePostSection({ + id: section.id, + data: { + status: 'published', + }, + }); + }; + + const handleUnpublishClick: MouseEventHandler = async (event) => { + event.stopPropagation(); + + await updatePostSection({ + id: section.id, + data: { + status: 'draft', + }, + }); + }; + + const handleDeleteClick: MouseEventHandler = async (event) => { + event.stopPropagation(); + const confirmed = await prompt({ + title: 'Delete section', + description: 'Are you sure you want to delete this section?', + confirmText: 'Yes, delete', + cancelText: 'Cancel', + }); + + if (confirmed) { + await deletePostSection(section.id); + return; + } + }; + + return ( +
+
+
+ + + +
+ +
+ + {section.title || 'Untitled'} + +
+
+ + {section.status === 'draft' && Draft} + + + + + + + + + + Edit + + {section.status === 'published' && ( + Unpublish + )} + + {section.status === 'draft' && Publish} + + Duplicate + + + + + Delete + + + +
+ ); +}; diff --git a/apps/medusa/src/admin/editor/hooks/use-post-section.tsx b/apps/medusa/src/admin/editor/hooks/use-post-section.tsx new file mode 100644 index 000000000..3c7c82f54 --- /dev/null +++ b/apps/medusa/src/admin/editor/hooks/use-post-section.tsx @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { PostSectionContext } from '../components/editor/PostSectionContextProvider'; + +export const usePostSection = () => { + const { section, form, save } = useContext(PostSectionContext); + + return { section, form, save }; +}; diff --git a/apps/medusa/src/admin/editor/utils.ts b/apps/medusa/src/admin/editor/utils.ts new file mode 100644 index 000000000..7f5a7d6cb --- /dev/null +++ b/apps/medusa/src/admin/editor/utils.ts @@ -0,0 +1,3 @@ +export const editSectionPath = ({ postId, sectionId }: { postId: string; sectionId: string }) => { + return `/content/editor/${postId}/section/${sectionId}`; +}; diff --git a/apps/medusa/src/admin/hooks/editor/use-editor-sidebar.tsx b/apps/medusa/src/admin/hooks/editor/use-editor-sidebar.tsx deleted file mode 100644 index 287a61f9f..000000000 --- a/apps/medusa/src/admin/hooks/editor/use-editor-sidebar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useContext } from 'react'; -import { - PostEditorContext, - EditorSidebarContextType, -} from '../../routes/content/editor/providers/editor-sidebar-context'; - -export const useEditorSidebar = (): EditorSidebarContextType => { - const context = useContext(PostEditorContext); - - if (!context) { - throw new Error('useEditorSidebar must be used within a EditorSidebarProvider'); - } - - return context; -}; diff --git a/apps/medusa/src/admin/hooks/keys.ts b/apps/medusa/src/admin/hooks/keys.ts new file mode 100644 index 000000000..15f8c07c3 --- /dev/null +++ b/apps/medusa/src/admin/hooks/keys.ts @@ -0,0 +1,4 @@ +export const QUERY_KEYS = { + POST_SECTIONS: ['post-sections'], + POSTS: ['posts'], +}; diff --git a/apps/medusa/src/admin/hooks/post-sections-mutations.ts b/apps/medusa/src/admin/hooks/post-sections-mutations.ts new file mode 100644 index 000000000..1180fad41 --- /dev/null +++ b/apps/medusa/src/admin/hooks/post-sections-mutations.ts @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { + AdminPageBuilderCreatePostSectionBody, + AdminPageBuilderCreatePostSectionResponse, + AdminPageBuilderDeletePostSectionResponse, + AdminPageBuilderUpdatePostSectionBody, + AdminPageBuilderUpdatePostSectionResponse, + AdminPageBuilderDuplicatePostSectionResponse, +} from '@lambdacurry/page-builder-types'; + +import { sdk } from '../sdk'; +import { QUERY_KEYS } from './keys'; + +export const useAdminCreatePostSection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (data) => { + return sdk.admin.pageBuilder.createPostSection(data); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; + +export const useAdminUpdatePostSection = () => { + const queryClient = useQueryClient(); + return useMutation< + AdminPageBuilderUpdatePostSectionResponse, + Error, + { id: string; data: AdminPageBuilderUpdatePostSectionBody } + >({ + mutationFn: async ({ id, data }) => { + const { ...rest } = data; + return sdk.admin.pageBuilder.updatePostSection(id, rest); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; + +export const useAdminDeletePostSection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + return sdk.admin.pageBuilder.deletePostSection(id); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; + +export const useAdminDuplicatePostSection = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id) => { + return sdk.admin.pageBuilder.duplicatePostSection(id); + }, + mutationKey: QUERY_KEYS.POST_SECTIONS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POST_SECTIONS }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; diff --git a/apps/medusa/src/admin/hooks/post-sections-queries.ts b/apps/medusa/src/admin/hooks/post-sections-queries.ts new file mode 100644 index 000000000..d79a9df20 --- /dev/null +++ b/apps/medusa/src/admin/hooks/post-sections-queries.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import type { + AdminPageBuilderListPostSectionsQuery, + AdminPageBuilderListPostSectionsResponse, + AdminPageBuilderRetrievePostSectionResponse, + PostSection, +} from '@lambdacurry/page-builder-types'; + +import { sdk } from '../sdk'; + +export const POST_SECTIONS_QUERY_KEY = ['post-sections']; + +export const useAdminListPostSections = (query: AdminPageBuilderListPostSectionsQuery) => { + return useQuery({ + queryKey: [...POST_SECTIONS_QUERY_KEY, query], + queryFn: async () => { + return sdk.admin.pageBuilder.listPostSections(query); + }, + }); +}; + +export const useAdminFetchPostSection = (id: string) => { + return useQuery({ + queryKey: [...POST_SECTIONS_QUERY_KEY, id], + queryFn: async () => { + const result = await sdk.admin.pageBuilder.retrievePostSection(id); + + return result?.section; + }, + }); +}; diff --git a/apps/medusa/src/admin/hooks/posts-mutations.ts b/apps/medusa/src/admin/hooks/posts-mutations.ts index c3b28fb5c..d8947c2ac 100644 --- a/apps/medusa/src/admin/hooks/posts-mutations.ts +++ b/apps/medusa/src/admin/hooks/posts-mutations.ts @@ -6,11 +6,12 @@ import type { AdminPageBuilderDuplicatePostResponse, AdminPageBuilderUpdatePostBody, AdminPageBuilderUpdatePostResponse, + AdminPageBuilderReorderSectionsBody, + AdminPageBuilderReorderSectionsResponse, } from '@lambdacurry/page-builder-types'; import { sdk } from '../sdk'; - -const QUERY_KEY = ['posts']; +import { QUERY_KEYS } from './keys'; export const useAdminCreatePost = () => { const queryClient = useQueryClient(); @@ -18,9 +19,9 @@ export const useAdminCreatePost = () => { mutationFn: async (data) => { return sdk.admin.pageBuilder.createPost(data); }, - mutationKey: QUERY_KEY, + mutationKey: QUERY_KEYS.POSTS, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); }, }); }; @@ -32,9 +33,9 @@ export const useAdminUpdatePost = () => { const { ...rest } = data; return sdk.admin.pageBuilder.updatePost(id, rest); }, - mutationKey: QUERY_KEY, + mutationKey: QUERY_KEYS.POSTS, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); }, }); }; @@ -45,9 +46,9 @@ export const useAdminDeletePost = () => { mutationFn: async (id: string) => { return sdk.admin.pageBuilder.deletePost(id); }, - mutationKey: QUERY_KEY, + mutationKey: QUERY_KEYS.POSTS, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); }, }); }; @@ -58,9 +59,27 @@ export const useAdminDuplicatePost = () => { mutationFn: async (id: string) => { return sdk.admin.pageBuilder.duplicatePost(id); }, - mutationKey: QUERY_KEY, + mutationKey: QUERY_KEYS.POSTS, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); + }, + }); +}; + +export const useAdminReorderPostSections = () => { + const queryClient = useQueryClient(); + return useMutation< + AdminPageBuilderReorderSectionsResponse, + Error, + { id: string; data: AdminPageBuilderReorderSectionsBody } + >({ + mutationFn: async ({ id, data }) => { + return sdk.admin.pageBuilder.reorderPostSections(id, data); + }, + + mutationKey: QUERY_KEYS.POSTS, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.POSTS }); }, }); }; diff --git a/apps/medusa/src/admin/routes/content/editor/[id]/page.tsx b/apps/medusa/src/admin/routes/content/editor/[id]/page.tsx index 74f0da02d..81bb5ec60 100644 --- a/apps/medusa/src/admin/routes/content/editor/[id]/page.tsx +++ b/apps/medusa/src/admin/routes/content/editor/[id]/page.tsx @@ -5,8 +5,8 @@ import { EditorTopbar } from '../../../../editor/components/editor/EditorTopBar' import { PostEditorLayout } from '../../../../editor/components/editor/PostEditorLayout'; import { PostContextProvider } from '../../../../editor/components/editor/PostContextProvider'; import { useAdminFetchPost } from '../../../../hooks/posts-queries'; -import { SectionsSidebarProvider } from '../providers/SectionsSidebarContext'; -import { SettingsSidebarProvider } from '../providers/SettingsSidebarContext'; +import { PostSectionsSidebarProvider } from '../providers/PostSectionsSidebarContext'; +import { PostSettingsSidebarProvider } from '../providers/PostSettingsSidebarContext'; import { EditorSidebarProvider } from '../providers/editor-sidebar-provider'; const PostDetailsPage = () => { @@ -17,8 +17,8 @@ const PostDetailsPage = () => { return ( - - + + @@ -32,8 +32,8 @@ const PostDetailsPage = () => { - - + + ); diff --git a/apps/medusa/src/admin/routes/content/editor/[id]/section/[section_id]/page.tsx b/apps/medusa/src/admin/routes/content/editor/[id]/section/[section_id]/page.tsx new file mode 100644 index 000000000..2dd00b788 --- /dev/null +++ b/apps/medusa/src/admin/routes/content/editor/[id]/section/[section_id]/page.tsx @@ -0,0 +1,43 @@ +import { TooltipProvider } from '@medusajs/ui'; +import { useParams } from 'react-router-dom'; +import { EditorModal } from '../../../../../../editor/components/editor/EditorModal'; +import { EditorTopbar } from '../../../../../../editor/components/editor/EditorTopBar'; +import { PostContextProvider } from '../../../../../../editor/components/editor/PostContextProvider'; +import { PostEditorLayout } from '../../../../../../editor/components/editor/PostEditorLayout'; +import { PostSectionContextProvider } from '../../../../../../editor/components/editor/PostSectionContextProvider'; +import { useAdminFetchPostSection } from '../../../../../../hooks/post-sections-queries'; +import { useAdminFetchPost } from '../../../../../../hooks/posts-queries'; +import { PostSectionsSidebarProvider } from '../../../providers/PostSectionsSidebarContext'; +import { PostSettingsSidebarProvider } from '../../../providers/PostSettingsSidebarContext'; + +const PostDetailsPage = () => { + const { id, section_id } = useParams(); + const { data: post } = useAdminFetchPost(id as string); + const { data: section } = useAdminFetchPostSection(section_id as string); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PostDetailsPage; diff --git a/apps/medusa/src/admin/routes/content/editor/providers/SectionsSidebarContext.tsx b/apps/medusa/src/admin/routes/content/editor/providers/PostSectionsSidebarContext.tsx similarity index 53% rename from apps/medusa/src/admin/routes/content/editor/providers/SectionsSidebarContext.tsx rename to apps/medusa/src/admin/routes/content/editor/providers/PostSectionsSidebarContext.tsx index 195921f0b..40a624eda 100644 --- a/apps/medusa/src/admin/routes/content/editor/providers/SectionsSidebarContext.tsx +++ b/apps/medusa/src/admin/routes/content/editor/providers/PostSectionsSidebarContext.tsx @@ -1,34 +1,34 @@ import { createContext, PropsWithChildren, useContext } from 'react'; import { useToggleState } from '@medusajs/ui'; -export const SectionsSidebarContext = createContext({ +export interface PostSectionsSidebarContextType { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +export const PostSectionsSidebarContext = createContext({ isOpen: false, open: () => {}, close: () => {}, toggle: () => {}, }); -export const useSectionsSidebar = () => { - const context = useContext(SectionsSidebarContext); +export const usePostSectionsSidebar = () => { + const context = useContext(PostSectionsSidebarContext); if (!context) { throw new Error('useSectionsSidebar must be used within a SectionsSidebarProvider'); } return context; }; -export interface EditorSidebarContextType { - isOpen: boolean; - open: () => void; - close: () => void; - toggle: () => void; -} - -export const SectionsSidebarProvider = ({ children }: PropsWithChildren) => { +export const PostSectionsSidebarProvider = ({ children }: PropsWithChildren) => { const [isOpen, open, close, toggle] = useToggleState(true); return ( - + {children} - + ); }; diff --git a/apps/medusa/src/admin/routes/content/editor/providers/PostSettingsSidebarContext.tsx b/apps/medusa/src/admin/routes/content/editor/providers/PostSettingsSidebarContext.tsx new file mode 100644 index 000000000..4fd3fc959 --- /dev/null +++ b/apps/medusa/src/admin/routes/content/editor/providers/PostSettingsSidebarContext.tsx @@ -0,0 +1,34 @@ +import { createContext, PropsWithChildren, useContext } from 'react'; +import { useToggleState } from '@medusajs/ui'; + +export const usePostSettingsSidebar = () => { + const context = useContext(PostSettingsSidebarContext); + if (!context) { + throw new Error('usePostSettingsSidebar must be used within a PostSettingsSidebarProvider'); + } + return context; +}; + +export interface PostSettingsSidebarContextType { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +export const PostSettingsSidebarContext = createContext({ + isOpen: false, + open: () => {}, + close: () => {}, + toggle: () => {}, +}); + +export const PostSettingsSidebarProvider = ({ children }: PropsWithChildren) => { + const [isOpen, open, close, toggle] = useToggleState(false); + + return ( + + {children} + + ); +}; diff --git a/apps/medusa/src/admin/routes/content/editor/providers/SettingsSidebarContext.tsx b/apps/medusa/src/admin/routes/content/editor/providers/SettingsSidebarContext.tsx deleted file mode 100644 index 6faf21e3c..000000000 --- a/apps/medusa/src/admin/routes/content/editor/providers/SettingsSidebarContext.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from 'react'; -import { useToggleState } from '@medusajs/ui'; - -export const useSettingsSidebar = () => { - const context = useContext(SettingsSidebarContext); - if (!context) { - throw new Error('useSettingsSidebar must be used within a SettingsSidebarProvider'); - } - return context; -}; - -export interface EditorSidebarContextType { - isOpen: boolean; - open: () => void; - close: () => void; - toggle: () => void; -} - -export const SettingsSidebarContext = createContext({ - isOpen: false, - open: () => {}, - close: () => {}, - toggle: () => {}, -}); - -export const SettingsSidebarProvider = ({ children }: PropsWithChildren) => { - const [isOpen, open, close, toggle] = useToggleState(false); - - return ( - - {children} - - ); -}; diff --git a/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/middlewares.ts b/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/middlewares.ts new file mode 100644 index 000000000..d55e0060d --- /dev/null +++ b/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/middlewares.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { validateAndTransformBody, type MiddlewareRoute } from '@medusajs/framework'; + +const reorderSectionsSchema = z.object({ + section_ids: z.array(z.string()), +}); + +export type ReorderSectionsBody = z.infer; + +export const validateReorderSections = validateAndTransformBody(reorderSectionsSchema); + +export const adminReorderSectionsRoutesMiddlewares: MiddlewareRoute[] = [ + { + matcher: '/admin/content/posts/:id/reorder-sections', + method: 'POST', + middlewares: [validateReorderSections], + }, +]; diff --git a/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/route.ts b/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/route.ts new file mode 100644 index 000000000..c4b1c0698 --- /dev/null +++ b/apps/medusa/src/api/admin/content/posts/[id]/reorder-sections/route.ts @@ -0,0 +1,17 @@ +import type { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework/http'; +import { reorderPostSectionsWorkflow } from 'src/workflows/reorder-post-sections'; +import { ReorderSectionsBody } from './middlewares'; + +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const id = req.params.id; + const { section_ids } = req.validatedBody; + + const { result } = await reorderPostSectionsWorkflow(req.scope).run({ + input: { + post_id: id, + section_ids, + }, + }); + + res.status(200).json({ post: result }); +}; diff --git a/apps/medusa/src/api/admin/content/posts/route.ts b/apps/medusa/src/api/admin/content/posts/route.ts index e0db28b2a..77dadb1f9 100644 --- a/apps/medusa/src/api/admin/content/posts/route.ts +++ b/apps/medusa/src/api/admin/content/posts/route.ts @@ -12,6 +12,10 @@ export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) pagination: req.queryConfig?.pagination || { skip: 0, take: 10 }, }); + posts.forEach((post) => { + post.sections?.sort((a, b) => (a?.sort_order ?? 0) - (b?.sort_order ?? 0)); + }); + res.status(200).json({ posts: posts, count: metadata.count, diff --git a/apps/medusa/src/api/admin/content/sections/[id]/duplicate/route.ts b/apps/medusa/src/api/admin/content/sections/[id]/duplicate/route.ts new file mode 100644 index 000000000..d46b99a04 --- /dev/null +++ b/apps/medusa/src/api/admin/content/sections/[id]/duplicate/route.ts @@ -0,0 +1,14 @@ +import type { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework/http'; +import { duplicatePostSectionWorkflow } from '../../../../../../workflows/duplicate-post-section'; + +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const id = req.params.id; + + const { result } = await duplicatePostSectionWorkflow(req.scope).run({ + input: { + id, + }, + }); + + res.status(200).json({ section: result }); +}; diff --git a/apps/medusa/src/api/admin/content/sections/[id]/route.ts b/apps/medusa/src/api/admin/content/sections/[id]/route.ts new file mode 100644 index 000000000..fcb4f0d6f --- /dev/null +++ b/apps/medusa/src/api/admin/content/sections/[id]/route.ts @@ -0,0 +1,51 @@ +import type { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework/http'; +import { updatePostSectionWorkflow } from '../../../../../workflows/update-post-section'; +import { deletePostSectionWorkflow } from '../../../../../workflows/delete-post-section'; +import type { UpdatePostSectionStepInput } from '../../../../../workflows/types'; + +export const PUT = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const id = req.params.id; + + const data: UpdatePostSectionStepInput = { + ...(req.validatedBody as UpdatePostSectionStepInput), + id, + }; + + console.log('PUT', id, data); + + const { result } = await updatePostSectionWorkflow(req.scope).run({ + input: { + section: data, + }, + }); + + res.status(200).json({ section: result }); +}; + +export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const query = req.scope.resolve('query'); + + const { data: sections } = await query.graph({ + entity: 'post_section', + fields: req.queryConfig?.fields || ['*'], + filters: { id: req.params.id }, + }); + + const section = sections[0]; + + res.status(200).json({ + section: section, + }); +}; + +export const DELETE = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const id = req.params.id; + + const { result } = await deletePostSectionWorkflow(req.scope).run({ + input: { + id, + }, + }); + + res.status(200).json({ id: result.id, object: 'post_section', deleted: true }); +}; diff --git a/apps/medusa/src/api/admin/content/sections/middlewares.ts b/apps/medusa/src/api/admin/content/sections/middlewares.ts new file mode 100644 index 000000000..b7fd23ee1 --- /dev/null +++ b/apps/medusa/src/api/admin/content/sections/middlewares.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { validateAndTransformBody, type MiddlewareRoute } from '@medusajs/framework'; + +const createPostSectionSchema = z.object({ + title: z.string(), + status: z.enum(['draft', 'published']).default('draft'), + layout: z.enum(['full_width', 'two_column', 'grid']).default('full_width'), + sort_order: z.number().optional(), + blocks: z.any().optional(), + post_id: z.string().optional(), + post_template_id: z.string().optional(), +}); + +const updatePostSectionSchema = createPostSectionSchema.partial(); + +export const validateCreatePostSection = validateAndTransformBody(createPostSectionSchema); +export const validateUpdatePostSection = validateAndTransformBody(updatePostSectionSchema); + +export const adminSectionRoutesMiddlewares: MiddlewareRoute[] = [ + { + matcher: '/admin/content/sections', + method: 'POST', + middlewares: [validateCreatePostSection], + }, + { + matcher: '/admin/content/sections/:id', + method: 'PUT', + middlewares: [validateUpdatePostSection], + }, +]; diff --git a/apps/medusa/src/api/admin/content/sections/route.ts b/apps/medusa/src/api/admin/content/sections/route.ts new file mode 100644 index 000000000..74a48a71f --- /dev/null +++ b/apps/medusa/src/api/admin/content/sections/route.ts @@ -0,0 +1,34 @@ +import type { AuthenticatedMedusaRequest, MedusaResponse } from '@medusajs/framework/http'; +import { createPostSectionWorkflow } from '../../../../workflows/create-post-section'; +import type { CreatePostSectionStepInput } from '../../../../workflows/types'; + +export const GET = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const query = req.scope.resolve('query'); + + const { data: sections, metadata = { count: 0, skip: 0, take: 0 } } = await query.graph({ + entity: 'post_section', + fields: req.queryConfig?.fields || ['*'], + filters: req.filterableFields || {}, + pagination: req.queryConfig?.pagination || { skip: 0, take: 10 }, + }); + + res.status(200).json({ + sections: sections, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }); +}; + +export const POST = async (req: AuthenticatedMedusaRequest, res: MedusaResponse) => { + const { result } = await createPostSectionWorkflow(req.scope).run({ + input: { + section: { + ...req.validatedBody, + blocks: req.validatedBody.blocks || {}, + }, + }, + }); + + res.status(200).json({ section: result }); +}; diff --git a/apps/medusa/src/api/middlewares.ts b/apps/medusa/src/api/middlewares.ts index 8d7f46b15..07bf6bd9d 100644 --- a/apps/medusa/src/api/middlewares.ts +++ b/apps/medusa/src/api/middlewares.ts @@ -1,8 +1,12 @@ -import { defineMiddlewares } from '@medusajs/framework' -import { adminPostRoutesMiddlewares } from './admin/content/posts/middlewares' -import { adminPostItemRoutesMiddlewares } from './admin/content/posts/[id]/middlewares' +import { defineMiddlewares } from '@medusajs/framework'; +import { adminPostRoutesMiddlewares } from './admin/content/posts/middlewares'; +import { adminPostItemRoutesMiddlewares } from './admin/content/posts/[id]/middlewares'; +import { adminSectionRoutesMiddlewares } from './admin/content/sections/middlewares'; +import { adminReorderSectionsRoutesMiddlewares } from './admin/content/posts/[id]/reorder-sections/middlewares'; export default defineMiddlewares([ ...adminPostRoutesMiddlewares, ...adminPostItemRoutesMiddlewares, -]) + ...adminSectionRoutesMiddlewares, + ...adminReorderSectionsRoutesMiddlewares, +]); diff --git a/apps/medusa/src/modules/page-builder/index.ts b/apps/medusa/src/modules/page-builder/index.ts index 37320abc9..aed109b40 100644 --- a/apps/medusa/src/modules/page-builder/index.ts +++ b/apps/medusa/src/modules/page-builder/index.ts @@ -8,6 +8,9 @@ export const pageBuilderModuleEvents = Object.freeze({ POST_CREATED: 'post.created', POST_UPDATED: 'post.updated', POST_DELETED: 'post.deleted', + SECTION_CREATED: 'section.created', + SECTION_UPDATED: 'section.updated', + SECTION_DELETED: 'section.deleted', }); export default Module(PAGE_BUILDER_MODULE, { diff --git a/apps/medusa/src/modules/page-builder/migrations/.snapshot-page-builder.json b/apps/medusa/src/modules/page-builder/migrations/.snapshot-page-builder.json index 14c5ec289..80106b38d 100644 --- a/apps/medusa/src/modules/page-builder/migrations/.snapshot-page-builder.json +++ b/apps/medusa/src/modules/page-builder/migrations/.snapshot-page-builder.json @@ -215,14 +215,32 @@ ], "mappedType": "enum" }, - "seo": { - "name": "seo", - "type": "jsonb", + "meta_title": { + "name": "meta_title", + "type": "text", "unsigned": false, "autoincrement": false, "primary": false, "nullable": true, - "mappedType": "json" + "mappedType": "text" + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "meta_image_url": { + "name": "meta_image_url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" }, "published_at": { "name": "published_at", @@ -839,27 +857,6 @@ "nullable": false, "mappedType": "text" }, - "type": { - "name": "type", - "type": "text", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "enumItems": [ - "button_list", - "cta", - "header", - "hero", - "product_carousel", - "product_grid", - "image_gallery", - "raw_html", - "rich_text", - "blog_list" - ], - "mappedType": "enum" - }, "status": { "name": "status", "type": "text", @@ -875,8 +872,8 @@ ], "mappedType": "enum" }, - "name": { - "name": "name", + "title": { + "name": "title", "type": "text", "unsigned": false, "autoincrement": false, @@ -884,61 +881,38 @@ "nullable": false, "mappedType": "text" }, - "content": { - "name": "content", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "json" - }, - "settings": { - "name": "settings", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": false, - "mappedType": "json" - }, - "styles": { - "name": "styles", - "type": "jsonb", - "unsigned": false, - "autoincrement": false, - "primary": false, - "nullable": true, - "mappedType": "json" - }, - "is_reusable": { - "name": "is_reusable", - "type": "boolean", + "layout": { + "name": "layout", + "type": "text", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "default": "false", - "mappedType": "boolean" + "default": "'full_width'", + "enumItems": [ + "full_width", + "two_column", + "grid" + ], + "mappedType": "enum" }, - "usage_count": { - "name": "usage_count", + "sort_order": { + "name": "sort_order", "type": "integer", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "default": "1", "mappedType": "integer" }, - "sort_order": { - "name": "sort_order", - "type": "integer", + "blocks": { + "name": "blocks", + "type": "jsonb", "unsigned": false, "autoincrement": false, "primary": false, "nullable": false, - "mappedType": "integer" + "mappedType": "json" }, "post_id": { "name": "post_id", diff --git a/apps/medusa/src/modules/page-builder/migrations/Migration20250528213548.ts b/apps/medusa/src/modules/page-builder/migrations/Migration20250528213548.ts new file mode 100644 index 000000000..086c4701c --- /dev/null +++ b/apps/medusa/src/modules/page-builder/migrations/Migration20250528213548.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250528213548 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "page_builder_post" drop column if exists "seo";`); + + this.addSql(`alter table if exists "page_builder_post" add column if not exists "meta_title" text null, add column if not exists "meta_description" text null, add column if not exists "meta_image_url" text null;`); + + this.addSql(`alter table if exists "page_builder_post_section" drop column if exists "type", drop column if exists "content", drop column if exists "settings", drop column if exists "styles", drop column if exists "is_reusable", drop column if exists "usage_count";`); + + this.addSql(`alter table if exists "page_builder_post_section" add column if not exists "layout" text check ("layout" in ('full_width', 'two_column', 'grid')) not null default 'full_width', add column if not exists "blocks" jsonb not null;`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "page_builder_post" drop column if exists "meta_title", drop column if exists "meta_description", drop column if exists "meta_image_url";`); + + this.addSql(`alter table if exists "page_builder_post" add column if not exists "seo" jsonb null;`); + + this.addSql(`alter table if exists "page_builder_post_section" drop column if exists "layout";`); + + this.addSql(`alter table if exists "page_builder_post_section" add column if not exists "type" text check ("type" in ('button_list', 'cta', 'header', 'hero', 'product_carousel', 'product_grid', 'image_gallery', 'raw_html', 'rich_text', 'blog_list')) not null, add column if not exists "settings" jsonb not null, add column if not exists "styles" jsonb null, add column if not exists "is_reusable" boolean not null default false, add column if not exists "usage_count" integer not null default 1;`); + this.addSql(`alter table if exists "page_builder_post_section" rename column "blocks" to "content";`); + } + +} diff --git a/apps/medusa/src/modules/page-builder/migrations/Migration20250529215257.ts b/apps/medusa/src/modules/page-builder/migrations/Migration20250529215257.ts new file mode 100644 index 000000000..c7ecc5f95 --- /dev/null +++ b/apps/medusa/src/modules/page-builder/migrations/Migration20250529215257.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20250529215257 extends Migration { + + override async up(): Promise { + this.addSql(`alter table if exists "page_builder_post_section" rename column "name" to "title";`); + } + + override async down(): Promise { + this.addSql(`alter table if exists "page_builder_post_section" rename column "title" to "name";`); + } + +} diff --git a/apps/medusa/src/modules/page-builder/models/post-section.ts b/apps/medusa/src/modules/page-builder/models/post-section.ts index f1fc774f3..415bcb66e 100644 --- a/apps/medusa/src/modules/page-builder/models/post-section.ts +++ b/apps/medusa/src/modules/page-builder/models/post-section.ts @@ -7,7 +7,7 @@ export const PostSectionModel = model.define( { id: model.id({ prefix: 'postsec' }).primaryKey(), status: model.enum(['draft', 'published', 'archived']).default('draft'), - name: model.text(), + title: model.text(), layout: model.enum(['full_width', 'two_column', 'grid']).default('full_width'), sort_order: model.number(), blocks: model.json(), diff --git a/apps/medusa/src/workflows/create-post-section.ts b/apps/medusa/src/workflows/create-post-section.ts new file mode 100644 index 000000000..27421bcec --- /dev/null +++ b/apps/medusa/src/workflows/create-post-section.ts @@ -0,0 +1,25 @@ +import { transform } from '@medusajs/framework/workflows-sdk'; +import { emitEventStep } from '@medusajs/medusa/core-flows'; +import { WorkflowResponse, createWorkflow } from '@medusajs/workflows-sdk'; + +import type { CreatePostSectionWorkflowInput } from './types'; +import { pageBuilderModuleEvents } from '../modules/page-builder'; +import { createPostSectionStep } from './steps/create-post-section'; + +export const createPostSectionWorkflow = createWorkflow( + 'create-post-section-workflow', + (input: CreatePostSectionWorkflowInput) => { + const section = createPostSectionStep(input.section); + + const emitData = transform({ section }, ({ section }) => { + return { + eventName: pageBuilderModuleEvents.SECTION_CREATED, + data: { id: section.id }, + }; + }); + + emitEventStep(emitData); + + return new WorkflowResponse(section); + }, +); diff --git a/apps/medusa/src/workflows/delete-post-section.ts b/apps/medusa/src/workflows/delete-post-section.ts new file mode 100644 index 000000000..b70cc8c22 --- /dev/null +++ b/apps/medusa/src/workflows/delete-post-section.ts @@ -0,0 +1,25 @@ +import { transform } from '@medusajs/framework/workflows-sdk'; +import { emitEventStep } from '@medusajs/medusa/core-flows'; +import { WorkflowResponse, createWorkflow } from '@medusajs/workflows-sdk'; + +import type { DeletePostSectionWorkflowInput } from './types'; +import { pageBuilderModuleEvents } from '../modules/page-builder'; +import { deletePostSectionStep } from './steps/delete-post-section'; + +export const deletePostSectionWorkflow = createWorkflow( + 'delete-post-section-workflow', + (input: DeletePostSectionWorkflowInput) => { + const result = deletePostSectionStep(input.id); + + const emitData = transform({ result }, ({ result }) => { + return { + eventName: pageBuilderModuleEvents.SECTION_DELETED, + data: { id: result.id }, + }; + }); + + emitEventStep(emitData); + + return new WorkflowResponse(result); + }, +); diff --git a/apps/medusa/src/workflows/duplicate-post-section.ts b/apps/medusa/src/workflows/duplicate-post-section.ts new file mode 100644 index 000000000..e6f4b6c9a --- /dev/null +++ b/apps/medusa/src/workflows/duplicate-post-section.ts @@ -0,0 +1,25 @@ +import { transform } from '@medusajs/framework/workflows-sdk'; +import { emitEventStep } from '@medusajs/medusa/core-flows'; +import { WorkflowResponse, createWorkflow } from '@medusajs/workflows-sdk'; + +import type { DuplicatePostSectionWorkflowInput } from './types'; +import { pageBuilderModuleEvents } from '../modules/page-builder'; +import { duplicatePostSectionStep } from './steps/duplicate-post-section'; + +export const duplicatePostSectionWorkflow = createWorkflow( + 'duplicate-post-section-workflow', + (input: DuplicatePostSectionWorkflowInput) => { + const section = duplicatePostSectionStep(input.id); + + const emitData = transform({ section }, ({ section }) => { + return { + eventName: pageBuilderModuleEvents.SECTION_CREATED, + data: { id: section.id }, + }; + }); + + emitEventStep(emitData); + + return new WorkflowResponse(section); + }, +); diff --git a/apps/medusa/src/workflows/reorder-post-sections.ts b/apps/medusa/src/workflows/reorder-post-sections.ts new file mode 100644 index 000000000..91afe8c35 --- /dev/null +++ b/apps/medusa/src/workflows/reorder-post-sections.ts @@ -0,0 +1,25 @@ +import { transform } from '@medusajs/framework/workflows-sdk'; +import { emitEventStep } from '@medusajs/medusa/core-flows'; +import { WorkflowResponse, createWorkflow } from '@medusajs/workflows-sdk'; + +import type { ReorderPostSectionsWorkflowInput } from './types'; +import { pageBuilderModuleEvents } from '../modules/page-builder'; +import { reorderPostSectionsStep } from './steps/reorder-post-sections'; + +export const reorderPostSectionsWorkflow = createWorkflow( + 'reorder-post-sections-workflow', + (input: ReorderPostSectionsWorkflowInput) => { + const post = reorderPostSectionsStep(input); + + const emitData = transform({ post }, ({ post }) => { + return { + eventName: pageBuilderModuleEvents.POST_UPDATED, + data: { id: post.id }, + }; + }); + + emitEventStep(emitData); + + return new WorkflowResponse(post); + }, +); diff --git a/apps/medusa/src/workflows/steps/create-post-section.ts b/apps/medusa/src/workflows/steps/create-post-section.ts new file mode 100644 index 000000000..ee901241a --- /dev/null +++ b/apps/medusa/src/workflows/steps/create-post-section.ts @@ -0,0 +1,35 @@ +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; + +import type { CreatePostSectionStepInput } from '../types'; +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; + +export const createPostSectionStepId = 'create-post-section-step'; + +export const createPostSectionStep = createStep( + createPostSectionStepId, + async (data: CreatePostSectionStepInput, { container }) => { + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + const createData: CreatePostSectionStepInput = { + ...data, + status: data.status || 'draft', + layout: data.layout || 'full_width', + }; + + const section = await pageBuilderService.createPostSections(createData); + + return new StepResponse(section, { + sectionId: section.id, + }); + }, + async (data, { container }) => { + if (!data) return; + + const { sectionId } = data; + + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + await pageBuilderService.deletePostSections(sectionId); + }, +); diff --git a/apps/medusa/src/workflows/steps/delete-post-section.ts b/apps/medusa/src/workflows/steps/delete-post-section.ts new file mode 100644 index 000000000..f1e0c10ce --- /dev/null +++ b/apps/medusa/src/workflows/steps/delete-post-section.ts @@ -0,0 +1,14 @@ +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; + +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; + +export const deletePostSectionStepId = 'delete-post-section-step'; + +export const deletePostSectionStep = createStep(deletePostSectionStepId, async (id: string, { container }) => { + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + await pageBuilderService.deletePostSections(id); + + return new StepResponse({ id }); +}); diff --git a/apps/medusa/src/workflows/steps/duplicate-post-section.ts b/apps/medusa/src/workflows/steps/duplicate-post-section.ts new file mode 100644 index 000000000..2ce7ca021 --- /dev/null +++ b/apps/medusa/src/workflows/steps/duplicate-post-section.ts @@ -0,0 +1,48 @@ +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; + +export const duplicatePostSectionStepId = 'duplicate-post-section-step'; + +export const duplicatePostSectionStep = createStep( + duplicatePostSectionStepId, + async (id: string, { container }) => { + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + // Get the original section + const originalSection = await pageBuilderService.retrievePostSection(id, { + relations: ['post'], + }); + + // Get the last section's sort order to place the new one at the end + const sections = await pageBuilderService.listPostSections({ + post_id: originalSection.post?.id, + }); + + const lastSortOrder = sections.length > 0 ? Math.max(...sections.map((s) => s.sort_order || 0)) + 1 : 0; + + // Create a new section with the same data, but always as draft + const newSection = await pageBuilderService.createPostSections({ + title: `${originalSection.title} (copy)`, + status: 'draft', + layout: originalSection.layout, + blocks: originalSection.blocks, + post_id: originalSection.post?.id, + post_template_id: originalSection.post_template_id, + sort_order: lastSortOrder, + }); + + return new StepResponse(newSection, { + sectionId: newSection.id, + }); + }, + async (data, { container }) => { + if (!data) return; + + const { sectionId } = data; + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + // Delete the created section if workflow fails + await pageBuilderService.deletePostSections(sectionId); + }, +); diff --git a/apps/medusa/src/workflows/steps/duplicate-post.ts b/apps/medusa/src/workflows/steps/duplicate-post.ts index 5ded7251b..aa126b82b 100644 --- a/apps/medusa/src/workflows/steps/duplicate-post.ts +++ b/apps/medusa/src/workflows/steps/duplicate-post.ts @@ -1,25 +1,24 @@ -import { StepResponse, createStep } from '@medusajs/workflows-sdk' +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; -import type { DuplicatePostStepInput } from '../types' -import { PAGE_BUILDER_MODULE } from '../../modules/page-builder' -import type PageBuilderService from '../../modules/page-builder/service' +import type { DuplicatePostStepInput } from '../types'; +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; -export const duplicatePostStepId = 'duplicate-post-step' +export const duplicatePostStepId = 'duplicate-post-step'; export const duplicatePostStep = createStep( duplicatePostStepId, async (data: DuplicatePostStepInput, { container }) => { - const pageBuilderService = - container.resolve(PAGE_BUILDER_MODULE) + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); // Get the existing post to duplicate - const existingPost = await pageBuilderService.retrievePost(data.id) + const existingPost = await pageBuilderService.retrievePost(data.id); // Create a new title with "(copy)" suffix - const newTitle = `${existingPost.title || 'Untitled'} (copy)` + const newTitle = `${existingPost.title || 'Untitled'} (copy)`; // Create a new handle or make it null to generate a new one - const handle = existingPost.handle ? `${existingPost.handle}-copy` : null + const handle = existingPost.handle ? `${existingPost.handle}-copy` : null; // Create a new post with the copied data const newPost = await pageBuilderService.createPosts({ @@ -30,22 +29,23 @@ export const duplicatePostStep = createStep( status: 'draft', // Always start as draft type: existingPost.type, content_mode: existingPost.content_mode, - seo: existingPost.seo ? { ...existingPost.seo } : undefined, + meta_title: existingPost.meta_title, + meta_description: existingPost.meta_description, + meta_image_url: existingPost.meta_image_url, is_home_page: false, // Never copy home page status - }) + }); return new StepResponse(newPost, { originalId: existingPost.id, newId: newPost.id, - }) + }); }, async (data, { container }) => { - if (!data) return + if (!data) return; - const pageBuilderService = - container.resolve(PAGE_BUILDER_MODULE) + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); // Delete the created post if workflow fails - await pageBuilderService.deletePosts(data.newId) + await pageBuilderService.deletePosts(data.newId); }, -) +); diff --git a/apps/medusa/src/workflows/steps/reorder-post-sections.ts b/apps/medusa/src/workflows/steps/reorder-post-sections.ts new file mode 100644 index 000000000..eeb307de1 --- /dev/null +++ b/apps/medusa/src/workflows/steps/reorder-post-sections.ts @@ -0,0 +1,36 @@ +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; + +export const reorderPostSectionsStepId = 'reorder-post-sections-step'; + +export type ReorderPostSectionsStepInput = { + post_id: string; + section_ids: string[]; +}; + +export const reorderPostSectionsStep = createStep( + reorderPostSectionsStepId, + async (data: ReorderPostSectionsStepInput, { container }) => { + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + // Update sort_order for each section + await Promise.all( + data.section_ids.map((sectionId: string, index: number) => + pageBuilderService.updatePostSections({ + id: sectionId, + sort_order: index, + }), + ), + ); + + // Fetch the updated post with sections + const post = await pageBuilderService.retrievePost(data.post_id, { + relations: ['sections'], + }); + + return new StepResponse(post, { + postId: post.id, + }); + }, +); diff --git a/apps/medusa/src/workflows/steps/update-post-section.ts b/apps/medusa/src/workflows/steps/update-post-section.ts new file mode 100644 index 000000000..c45a3abc6 --- /dev/null +++ b/apps/medusa/src/workflows/steps/update-post-section.ts @@ -0,0 +1,20 @@ +import { StepResponse, createStep } from '@medusajs/workflows-sdk'; + +import type { UpdatePostSectionStepInput } from '../types'; +import { PAGE_BUILDER_MODULE } from '../../modules/page-builder'; +import type PageBuilderService from '../../modules/page-builder/service'; + +export const updatePostSectionStepId = 'update-post-section-step'; + +export const updatePostSectionStep = createStep( + updatePostSectionStepId, + async (data: UpdatePostSectionStepInput, { container }) => { + const pageBuilderService = container.resolve(PAGE_BUILDER_MODULE); + + const section = await pageBuilderService.updatePostSections(data); + + return new StepResponse(section, { + sectionId: section.id, + }); + }, +); diff --git a/apps/medusa/src/workflows/types.ts b/apps/medusa/src/workflows/types.ts new file mode 100644 index 000000000..cd498c2ce --- /dev/null +++ b/apps/medusa/src/workflows/types.ts @@ -0,0 +1,77 @@ +import type { Post, PostSectionLayout, PostSectionStatus } from '@lambdacurry/page-builder-types'; + +export type CreatePostSectionStepInput = { + name: string; + layout?: PostSectionLayout; + sort_order?: number; + blocks?: any; + post_id?: string; + post_template_id?: string; + status?: PostSectionStatus; +}; + +export type UpdatePostSectionStepInput = Partial & { + id: string; +}; + +export type CreatePostSectionWorkflowInput = { + section: CreatePostSectionStepInput; +}; + +export type UpdatePostSectionWorkflowInput = { + section: UpdatePostSectionStepInput; +}; + +export type DeletePostSectionWorkflowInput = { + id: string; +}; + +export type ReorderPostSectionsWorkflowInput = { + post_id: string; + section_ids: string[]; +}; + +type PostInput = Omit< + Post, + 'id' | 'created_at' | 'updated_at' | 'featured_image' | 'root' | 'sections' | 'authors' | 'tags' +>; + +// Post types +export type CreatePostStepInput = Partial & { + featured_image_id?: string; + authors?: string[]; + tags?: string[]; +}; + +export type UpdatePostStepInput = { + id: string; + sections?: string[]; +} & Partial; + +export type DeletePostStepInput = { + id: string; +}; + +export type CreatePostWorkflowInput = { + post: CreatePostStepInput; +}; + +export type UpdatePostWorkflowInput = { + post: UpdatePostStepInput; +}; + +export type DeletePostWorkflowInput = { + id: string; +}; + +export type DuplicatePostStepInput = { + id: string; +}; + +export type DuplicatePostWorkflowInput = { + id: string; +}; + +export type DuplicatePostSectionWorkflowInput = { + id: string; +}; diff --git a/apps/medusa/src/workflows/types/index.ts b/apps/medusa/src/workflows/types/index.ts deleted file mode 100644 index 843aec5fa..000000000 --- a/apps/medusa/src/workflows/types/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Post } from '@lambdacurry/page-builder-types'; - -type PostInput = Omit< - Post, - 'id' | 'created_at' | 'updated_at' | 'featured_image' | 'root' | 'sections' | 'authors' | 'tags' ->; - -// Post types -export type CreatePostStepInput = Partial & { - featured_image_id?: string; - authors?: string[]; - tags?: string[]; -}; - -export type UpdatePostStepInput = { - id: string; - sections?: string[]; -} & Partial; - -export type DeletePostStepInput = { - id: string; -}; - -export type CreatePostWorkflowInput = { - post: CreatePostStepInput; -}; - -export type UpdatePostWorkflowInput = { - post: UpdatePostStepInput; -}; - -export type DeletePostWorkflowInput = { - id: string; -}; - -export type DuplicatePostStepInput = { - id: string; -}; - -export type DuplicatePostWorkflowInput = { - id: string; -}; diff --git a/apps/medusa/src/workflows/update-post-section.ts b/apps/medusa/src/workflows/update-post-section.ts new file mode 100644 index 000000000..8387e2f65 --- /dev/null +++ b/apps/medusa/src/workflows/update-post-section.ts @@ -0,0 +1,25 @@ +import { transform } from '@medusajs/framework/workflows-sdk'; +import { emitEventStep } from '@medusajs/medusa/core-flows'; +import { WorkflowResponse, createWorkflow } from '@medusajs/workflows-sdk'; + +import type { UpdatePostSectionWorkflowInput } from './types'; +import { pageBuilderModuleEvents } from '../modules/page-builder'; +import { updatePostSectionStep } from './steps/update-post-section'; + +export const updatePostSectionWorkflow = createWorkflow( + 'update-post-section-workflow', + (input: UpdatePostSectionWorkflowInput) => { + const section = updatePostSectionStep(input.section); + + const emitData = transform({ section }, ({ section }) => { + return { + eventName: pageBuilderModuleEvents.SECTION_UPDATED, + data: { id: section.id }, + }; + }); + + emitEventStep(emitData); + + return new WorkflowResponse(section); + }, +); diff --git a/packages/page-builder-sdk/src/sdk/admin/admin-page-builder.ts b/packages/page-builder-sdk/src/sdk/admin/admin-page-builder.ts index 013075a95..1a76cfd92 100644 --- a/packages/page-builder-sdk/src/sdk/admin/admin-page-builder.ts +++ b/packages/page-builder-sdk/src/sdk/admin/admin-page-builder.ts @@ -8,6 +8,17 @@ import type { AdminPageBuilderUpdatePostBody, AdminPageBuilderUpdatePostResponse, AdminPageBuilderDuplicatePostResponse, + AdminPageBuilderListPostSectionsQuery, + AdminPageBuilderListPostSectionsResponse, + AdminPageBuilderCreatePostSectionBody, + AdminPageBuilderCreatePostSectionResponse, + AdminPageBuilderUpdatePostSectionBody, + AdminPageBuilderUpdatePostSectionResponse, + AdminPageBuilderDeletePostSectionResponse, + AdminPageBuilderDuplicatePostSectionResponse, + AdminPageBuilderReorderSectionsBody, + AdminPageBuilderReorderSectionsResponse, + AdminPageBuilderRetrievePostSectionResponse, } from '@lambdacurry/page-builder-types'; export class AdminPageBuilderResource { @@ -45,4 +56,53 @@ export class AdminPageBuilderResource { method: 'POST', }); } + + async listPostSections(query: AdminPageBuilderListPostSectionsQuery) { + return this.client.fetch('/admin/content/sections', { + method: 'GET', + query, + }); + } + + async retrievePostSection(id: string) { + return this.client.fetch(`/admin/content/sections/${id}`, { + method: 'GET', + }); + } + + async createPostSection(data: AdminPageBuilderCreatePostSectionBody) { + return this.client.fetch('/admin/content/sections', { + method: 'POST', + body: data, + }); + } + + async updatePostSection(id: string, data: AdminPageBuilderUpdatePostSectionBody) { + return this.client.fetch(`/admin/content/sections/${id}`, { + method: 'PUT', + body: data, + }); + } + + async deletePostSection(id: string) { + return this.client.fetch(`/admin/content/sections/${id}`, { + method: 'DELETE', + }); + } + + async duplicatePostSection(id: string) { + return this.client.fetch(`/admin/content/sections/${id}/duplicate`, { + method: 'POST', + }); + } + + async reorderPostSections(postId: string, data: AdminPageBuilderReorderSectionsBody) { + return this.client.fetch( + `/admin/content/posts/${postId}/reorder-sections`, + { + method: 'POST', + body: data, + }, + ); + } } diff --git a/packages/page-builder-sdk/src/types/index.ts b/packages/page-builder-sdk/src/types/index.ts index 72b77b2cd..129fc2a54 100644 --- a/packages/page-builder-sdk/src/types/index.ts +++ b/packages/page-builder-sdk/src/types/index.ts @@ -1,2 +1,3 @@ -export * from './product-reviews' -export * from './product-review-stats' +export * from './product-reviews'; +export * from './product-review-stats'; +export * from './post-sections'; diff --git a/packages/page-builder-sdk/src/types/post-sections.ts b/packages/page-builder-sdk/src/types/post-sections.ts new file mode 100644 index 000000000..df04564ee --- /dev/null +++ b/packages/page-builder-sdk/src/types/post-sections.ts @@ -0,0 +1,19 @@ +import type { + AdminPageBuilderListPostSectionsQuery, + AdminPageBuilderListPostSectionsResponse, + AdminPageBuilderCreatePostSectionBody, + AdminPageBuilderCreatePostSectionResponse, + AdminPageBuilderUpdatePostSectionBody, + AdminPageBuilderUpdatePostSectionResponse, + AdminPageBuilderDeletePostSectionResponse, +} from '@lambdacurry/page-builder-types'; + +export type { + AdminPageBuilderListPostSectionsQuery, + AdminPageBuilderListPostSectionsResponse, + AdminPageBuilderCreatePostSectionBody, + AdminPageBuilderCreatePostSectionResponse, + AdminPageBuilderUpdatePostSectionBody, + AdminPageBuilderUpdatePostSectionResponse, + AdminPageBuilderDeletePostSectionResponse, +}; diff --git a/packages/page-builder-types/src/admins.d.ts b/packages/page-builder-types/src/admins.d.ts index 4566ab640..0c818f117 100644 --- a/packages/page-builder-types/src/admins.d.ts +++ b/packages/page-builder-types/src/admins.d.ts @@ -1,5 +1,5 @@ import { PostContentMode, PostStatus, PostType } from './common'; -import type { Post } from './models'; +import type { Post, PostSection } from './models'; // Response Types export interface PaginatedResponse { @@ -43,7 +43,9 @@ export type AdminPageBuilderCreatePostBody = { status?: PostStatus; type?: PostType; content_mode?: PostContentMode; - seo?: Record; + meta_title?: string; + meta_description?: string; + meta_image_url?: string; is_home_page?: boolean; }; @@ -59,7 +61,9 @@ export type AdminPageBuilderUpdatePostBody = { status?: PostStatus; type?: PostType; content_mode?: PostContentMode; - seo?: Record; + meta_title?: string; + meta_description?: string; + meta_image_url?: string; is_home_page?: boolean; }; @@ -76,3 +80,60 @@ export interface AdminPageBuilderDeletePostResponse { export interface AdminPageBuilderDuplicatePostResponse { post: Post; } + +export type AdminPageBuilderListPostSectionsQuery = { + limit?: number; + offset?: number; + fields?: string[]; + expand?: string[]; + order?: string; +}; + +export type AdminPageBuilderListPostSectionsResponse = { + sections: PostSection[]; + count: number; + offset: number; + limit: number; +}; + +export type AdminPageBuilderCreatePostSectionBody = { + title: string; + layout?: PostSectionLayout; + sort_order?: number; + blocks?: any; + post_id?: string; + post_template_id?: string; + status?: 'draft' | 'published' | 'archived'; +}; + +export type AdminPageBuilderCreatePostSectionResponse = { + section: PostSection; +}; + +export type AdminPageBuilderUpdatePostSectionBody = Partial; + +export type AdminPageBuilderRetrievePostSectionResponse = { + section: PostSection; +}; + +export type AdminPageBuilderUpdatePostSectionResponse = { + section: PostSection; +}; + +export type AdminPageBuilderDeletePostSectionResponse = { + id: string; + object: 'post_section'; + deleted: boolean; +}; + +export type AdminPageBuilderDuplicatePostSectionResponse = { + section: PostSection; +}; + +export type AdminPageBuilderReorderSectionsBody = { + section_ids: string[]; +}; + +export interface AdminPageBuilderReorderSectionsResponse { + post: Post; +} diff --git a/packages/page-builder-types/src/api/admin/page-builder.ts b/packages/page-builder-types/src/api/admin/page-builder.ts new file mode 100644 index 000000000..f02831c4e --- /dev/null +++ b/packages/page-builder-types/src/api/admin/page-builder.ts @@ -0,0 +1,51 @@ +import type { PostSection } from '../../models'; +import type { + AdminPageBuilderListPostSectionsQuery, + AdminPageBuilderListPostSectionsResponse, + AdminPageBuilderCreatePostSectionBody, + AdminPageBuilderCreatePostSectionResponse, + AdminPageBuilderUpdatePostSectionBody, + AdminPageBuilderUpdatePostSectionResponse, + AdminPageBuilderDeletePostSectionResponse, + AdminPageBuilderDuplicatePostSectionResponse, +} from '../../admins'; + +export interface AdminPageBuilderEndpoints { + listPostSections: { + GET: { + query: AdminPageBuilderListPostSectionsQuery; + response: AdminPageBuilderListPostSectionsResponse; + }; + }; + createPostSection: { + POST: { + body: AdminPageBuilderCreatePostSectionBody; + response: AdminPageBuilderCreatePostSectionResponse; + }; + }; + updatePostSection: { + POST: { + params: { + id: string; + }; + body: AdminPageBuilderUpdatePostSectionBody; + response: AdminPageBuilderUpdatePostSectionResponse; + }; + }; + deletePostSection: { + DELETE: { + params: { + id: string; + }; + response: AdminPageBuilderDeletePostSectionResponse; + }; + }; + duplicatePostSection: { + POST: { + params: { + id: string; + }; + response: AdminPageBuilderDuplicatePostSectionResponse; + }; + }; +} diff --git a/packages/page-builder-types/src/common.d.ts b/packages/page-builder-types/src/common.d.ts index b57fef775..612d02b30 100644 --- a/packages/page-builder-types/src/common.d.ts +++ b/packages/page-builder-types/src/common.d.ts @@ -2,38 +2,42 @@ * Common type declarations for page builder */ -export type PostStatus = 'draft' | 'published' | 'archived' +export type PostStatus = 'draft' | 'published' | 'archived'; -export type PostType = 'page' | 'post' +export type PostType = 'page' | 'post'; -export type PostContentMode = 'basic' | 'advanced' +export type PostContentMode = 'basic' | 'advanced'; + +export type PostSectionStatus = 'draft' | 'published' | 'archived'; + +export type PostSectionLayout = 'full_width' | 'two_column' | 'grid'; // These would be defined in the implementation file -export declare const postStatuses: readonly PostStatus[] -export declare const postTypes: readonly PostType[] -export declare const postContentModes: readonly PostContentMode[] +export declare const postStatuses: readonly PostStatus[]; +export declare const postTypes: readonly PostType[]; +export declare const postContentModes: readonly PostContentMode[]; export interface SortOptions { - sort?: string - order?: 'ASC' | 'DESC' + sort?: string; + order?: 'ASC' | 'DESC'; } export interface PaginationOptions { - limit?: number - offset?: number + limit?: number; + offset?: number; } export interface FilterOptions { - q?: string - [key: string]: unknown + q?: string; + [key: string]: unknown; } -export type QueryOptions = SortOptions & PaginationOptions & FilterOptions +export type QueryOptions = SortOptions & PaginationOptions & FilterOptions; export interface FindConfig extends QueryOptions { - select?: (keyof T)[] - relations?: string[] + select?: (keyof T)[]; + relations?: string[]; where?: { [K in keyof T]?: T[K] | T[K][] } & { - [key: string]: unknown - } + [key: string]: unknown; + }; } diff --git a/packages/page-builder-types/src/models.d.ts b/packages/page-builder-types/src/models.d.ts index 1e22ad161..5847b170c 100644 --- a/packages/page-builder-types/src/models.d.ts +++ b/packages/page-builder-types/src/models.d.ts @@ -2,91 +2,101 @@ * Type declarations for page builder models */ -import type { PostContentMode, PostStatus, PostType } from './common' +import type { PostContentMode, PostSectionLayout, PostSectionStatus, PostStatus, PostType } from './common'; export interface Base { - id: string - created_at: string - updated_at: string | undefined + id: string; + created_at: string; + updated_at: string | undefined; } export interface Post extends Base { - title: string - handle?: string | null - excerpt?: string | null - content?: Record | null - status: PostStatus - type: PostType - content_mode: PostContentMode - seo?: Record | null - is_home_page: boolean - published_at?: string | null - archived_at?: string | null - featured_image_id?: string - featured_image?: Image - authors?: PostAuthor[] - tags?: PostTag[] - sections?: PostSection[] - root_id?: string - root?: PostTemplate + title: string; + handle?: string | null; + excerpt?: string | null; + content?: Record | null; + status: PostStatus; + type: PostType; + content_mode: PostContentMode; + meta_title?: string | null; + meta_description?: string | null; + meta_image_url?: string | null; + is_home_page: boolean; + published_at?: string | null; + archived_at?: string | null; + featured_image_id?: string; + featured_image?: Image; + authors?: PostAuthor[]; + tags?: PostTag[]; + sections?: PostSection[]; + root_id?: string; + root?: Post; } export interface Image extends Base { - url: string - alt?: string - width?: number - height?: number - mime_type?: string - file_size?: number - metadata?: Record + url: string; + alt?: string; + width?: number; + height?: number; + mime_type?: string; + file_size?: number; + metadata?: Record; } export interface NavigationItem extends Base { - title: string - url: string - parent_id?: string - parent?: NavigationItem - children?: NavigationItem[] + title: string; + url: string; + parent_id?: string; + parent?: NavigationItem; + children?: NavigationItem[]; } export interface PostAuthor extends Base { - name: string - bio?: string - posts?: Post[] + name: string; + bio?: string; + posts?: Post[]; +} + +export interface ContentBlock { + id: string; + type: string; + styles: Record; + content: Record; } export interface PostSection extends Base { - name: string - data?: Record - order: number - post_id?: string - post?: Post - parent_section_id?: string - parent_section?: PostSection - child_sections?: PostSection[] + title: string; + layout: PostSectionLayout; + blocks: ContentBlock[]; + sort_order: number; + post_id?: string; + status: PostSectionStatus; } export interface PostTag extends Base { - name: string - posts?: Post[] + name: string; + posts?: Post[]; } export interface PostTemplate extends Base { - name: string - data?: Record - posts?: Post[] + title: string; + status: PostStatus; + type: PostType; + description?: string | null; + sort_order: number; + sections?: PostSection[]; } export interface SiteSettings extends Base { - site_name: string - site_url?: string - logo_id?: string - logo?: Image - favicon_id?: string - favicon?: Image - social_links?: Record - navigation?: Record - custom_css?: string - custom_js?: string - meta_defaults?: Record + site_name: string; + site_url?: string; + logo_id?: string; + logo?: Image; + favicon_id?: string; + favicon?: Image; + social_links?: Record; + navigation?: Record; + custom_css?: string; + custom_js?: string; + meta_defaults?: Record; } diff --git a/yarn.lock b/yarn.lock index 6e67e2cfc..b38125f88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1532,7 +1532,7 @@ __metadata: languageName: node linkType: hard -"@dnd-kit/core@npm:^6.1.0": +"@dnd-kit/core@npm:^6.1.0, @dnd-kit/core@npm:^6.3.1": version: 6.3.1 resolution: "@dnd-kit/core@npm:6.3.1" dependencies: @@ -1546,6 +1546,32 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/modifiers@npm:^9.0.0": + version: 9.0.0 + resolution: "@dnd-kit/modifiers@npm:9.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/ca8cc9da8296df10774d779c1611074dc327ccc3c49041c102111c98c7f2b2b73b6af5209c0eef6b2fe978ac63dc2a985efa87c85a8d786577304bd2e64cee1d + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + "@dnd-kit/sortable@npm:^8.0.0": version: 8.0.0 resolution: "@dnd-kit/sortable@npm:8.0.0" @@ -21790,6 +21816,10 @@ __metadata: version: 0.0.0-use.local resolution: "medusa@workspace:apps/medusa" dependencies: + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/modifiers": "npm:^9.0.0" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@lambdacurry/medusa-product-reviews": "npm:1.2.0" "@lambdacurry/page-builder-sdk": "npm:0.0.1" "@medusajs/admin-sdk": "npm:2.8.2"