diff --git a/README.md b/README.md index e6339b7..36ba8af 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,16 @@ Checkout our [Storybook Documentation](https://lambda-curry.github.io/forms/?pat ## Features -- **Controlled Components**: All form components are controlled and work seamlessly with react-hook-form +- **Controlled Form Components**: All form components are controlled and work seamlessly with react-hook-form +- **EditableTable Component**: Advanced inline-editing table with validation, auto-save, and URL state persistence - **Medusa UI Integration**: Built specifically for Medusa Admin and Medusa UI design system - **TypeScript Support**: Full TypeScript support with proper type definitions -- **Storybook Documentation**: Comprehensive documentation and examples +- **Modular Architecture**: Import only what you need with tree-shakeable exports +- **Storybook Documentation**: Comprehensive documentation and interactive examples ## Components +### Controlled Form Components - `ControlledInput` - Text input with validation - `ControlledTextArea` - Multi-line text input - `ControlledSelect` - Dropdown selection @@ -20,6 +23,9 @@ Checkout our [Storybook Documentation](https://lambda-curry.github.io/forms/?pat - `ControlledDatePicker` - Date selection - `ControlledCurrencyInput` - Currency input with formatting +### Data Table Components +- `EditableTable` - Powerful inline-editing table with real-time validation, auto-save, URL state persistence, sorting, filtering, and more. See [EditableTable Documentation](./packages/medusa-forms/src/editable-table/README.md) for details. + ## Getting Started Step 1: Install dependencies diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx new file mode 100644 index 0000000..dec1722 --- /dev/null +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -0,0 +1,1807 @@ +import { EditableTable, ErrorState } from '@lambdacurry/medusa-forms/editable-table'; +import type { CellActionsHandlerGetter, EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; +import { Button, Toaster, TooltipProvider } from '@medusajs/ui'; +import type { Meta } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NuqsAdapter } from 'nuqs/adapters/react'; +import { useCallback, useMemo, useState } from 'react'; +import { z } from 'zod'; + +const meta = { + title: 'Medusa Forms/Editable Table', + component: EditableTable, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` +A powerful, feature-rich table component with inline editing capabilities for tabular data. + +## Features +- **Inline Editing**: Edit data directly in table cells +- **Real-time Validation**: Immediate feedback with Zod schema validation +- **Auto-save**: Debounced saving with visual status indicators +- **URL State Persistence**: Table state (search, sort, pagination) persists in URL +- **Column Management**: Sorting, filtering, pinning, and resizing +- **Multiple Cell Types**: Text, number, autocomplete, badge +- **Performance Optimized**: Handles large datasets efficiently + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 1000 * 60 * 5, + }, + }, + }); + + return ( + + + +
+ +
+ +
+
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; + +// Username regex pattern for Zod validation +const USERNAME_REGEX = /^[a-z0-9_]+$/; + +// ============================================================================ +// Story 1: Simple Validation Example +// ============================================================================ + +export const SimpleValidationExample = { + name: '1. Simple Validation', + render: () => { + interface SimpleProduct extends Record { + id: string; + name: string; + price: number; + stock: number; + } + + const [data, setData] = useState([ + { id: '1', name: 'Laptop', price: 999, stock: 15 }, + { id: '2', name: 'Mouse', price: 29, stock: 50 }, + { id: '3', name: 'Keyboard', price: 79, stock: 30 }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text', required: true }, + { name: 'Price', key: 'price', type: 'number', cellProps: { min: 0, step: 0.01 } }, + { name: 'Stock', key: 'stock', type: 'number', cellProps: { min: 0 } }, + ], + [], + ); + + // Simple inline validation + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key === 'name' && (!value || String(value).length < 2)) { + return Promise.resolve('Name must be at least 2 characters'); + } + if ((_key === 'price' || _key === 'stock') && (value === null || Number(value) < 0)) { + return Promise.resolve('Must be a positive number'); + } + return Promise.resolve(null); + }; + }, []); + + // Simple inline save + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => + prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as SimpleProduct) : item)), + ); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +The simplest EditableTable implementation with basic inline validation. + +**Key Features:** +- Synchronous inline validation functions +- Simple length and numeric checks (name min 2 chars, price/stock positive) +- Direct state updates with 300ms simulated save delay +- No external dependencies (no Zod, no async validation) + +**Use this pattern when:** +- You have simple validation rules +- No need for complex schemas or async validation +- Quick prototyping + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 2: Zod Validation Example +// ============================================================================ + +export const ZodValidationExample = { + name: '2. Zod Schema Validation', + render: () => { + interface User extends Record { + id: string; + email: string; + age: number; + username: string; + } + + const [data, setData] = useState([ + { id: '1', email: 'john@example.com', age: 25, username: 'john_doe' }, + { id: '2', email: 'jane@example.com', age: 30, username: 'jane_smith' }, + { id: '3', email: 'bob@example.com', age: 28, username: 'bob_j' }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Email', key: 'email', type: 'text', required: true }, + { name: 'Username', key: 'username', type: 'text', required: true }, + { name: 'Age', key: 'age', type: 'number', cellProps: { min: 18, max: 120 } }, + ], + [], + ); + + // Zod validation schemas (memoized) + const schemas = useMemo( + () => ({ + email: z.string().email('Invalid email format').min(5, 'Email too short'), + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(20, 'Username too long') + .regex(USERNAME_REGEX, 'Username can only contain lowercase letters, numbers, and underscores'), + age: z.coerce.number().int('Age must be a whole number').min(18, 'Must be 18+').max(120, 'Invalid age'), + }), + [], + ); + + const getValidateHandler = useCallback( + (_key: string) => { + return ({ value }: { value: unknown }) => { + const schema = schemas[_key as keyof typeof schemas]; + if (!schema) return Promise.resolve(null); + + const result = schema.safeParse(value); + if (!result.success) { + return Promise.resolve(result.error.errors[0]?.message || 'Invalid value'); + } + return Promise.resolve(null); + }; + }, + [schemas], + ); + + const getSaveHandler: CellActionsHandlerGetter = useCallback((key: string) => { + return async ({ value, data }) => { + // Note: Validation is called automatically before save - don't call it manually + await new Promise((resolve) => setTimeout(resolve, 400)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as User) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Schema-based validation using Zod for robust type-safe validation. + +**Features Demonstrated:** +- Synchronous Zod schema validation for each field +- Complex validation rules (email format, username regex patterns) +- Range validation (age 18-120) with type coercion +- Detailed, user-friendly error messages from Zod +- 400ms simulated save delay + +**Use this pattern when:** +- You need robust, synchronous validation +- Type safety is important +- Complex validation rules (regex, formats, ranges) +- Reusable validation logic across your app + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 3: Async Operations Example +// ============================================================================ + +export const AsyncOperationsExample = { + name: '3. Async Validation & Save', + render: () => { + interface Product extends Record { + id: string; + sku: string; + name: string; + category: string; + } + + const [data, setData] = useState([ + { id: '1', sku: 'LAPTOP-001', name: 'Gaming Laptop', category: 'Electronics' }, + { id: '2', sku: 'MOUSE-002', name: 'Wireless Mouse', category: 'Electronics' }, + { id: '3', sku: 'DESK-003', name: 'Standing Desk', category: 'Furniture' }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'SKU', key: 'sku', type: 'text', required: true }, + { name: 'Product Name', key: 'name', type: 'text', required: true }, + { name: 'Category', key: 'category', type: 'autocomplete', required: true }, + ], + [], + ); + + // Async validation (simulates API check) + const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { + return async ({ value, table }) => { + if (_key === 'sku') { + // Simulate API call to check SKU uniqueness + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Use table instance instead of data state to avoid re-renders + const allRows = table.getCoreRowModel().rows; + const skuExists = allRows.some((row) => row.original.sku === value); + if (skuExists && String(value).length > 0) { + // In real app, check against server data + return 'SKU validation completed'; + } + } + + if (!value || String(value).trim() === '') { + return 'This field is required'; + } + + return null; + }; + }, []); // No dependencies - use table instance instead + + // Async save (simulates API call) + const getSaveHandler: CellActionsHandlerGetter = useCallback((key: string) => { + return async ({ value, data }) => { + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Simulate occasional API errors + if (Math.random() > 0.9) { + return 'Network error - please retry'; + } + + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; + }; + }, []); + + // Async options (simulates API fetch) + const getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]> = useCallback( + (key: string) => { + return async ({ value }) => { + if (key === 'category') { + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 300)); + + const searchTerm = String(value || '').toLowerCase(); + const categories = ['Electronics', 'Furniture', 'Office Supplies', 'Home & Garden', 'Sports', 'Automotive']; + + return categories + .filter((cat) => cat.toLowerCase().includes(searchTerm)) + .map((cat) => ({ label: cat, value: cat })); + } + return []; + }; + }, + [], + ); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Demonstrates async operations for validation, saving, and fetching options. + +**Async Patterns:** +- **Validation**: Simulates API check (SKU uniqueness with 500ms delay) +- **Save**: Simulates API call with error handling (800ms delay) +- **Options**: Simulates fetching categories from server (300ms delay) + +**Features:** +- Async validation with loading indicators +- Error simulation for retry testing (10% failure rate) +- Autocomplete with async data fetching +- Loading indicators during operations + +**Use this pattern when:** +- Validation requires server checks +- Data must be saved to an API +- Autocomplete options come from server + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 4: Calculated Values (Badge Columns) +// ============================================================================ + +export const CalculatedValuesExample = { + name: '4. Calculated Values & Badges', + render: () => { + interface OrderItem extends Record { + id: string; + product: string; + quantity: number; + price: number; + status: 'pending' | 'shipped' | 'delivered'; + } + + const [data, setData] = useState([ + { id: '1', product: 'Laptop', quantity: 2, price: 999, status: 'shipped' }, + { id: '2', product: 'Mouse', quantity: 5, price: 29, status: 'delivered' }, + { id: '3', product: 'Keyboard', quantity: 3, price: 79, status: 'pending' }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product', key: 'product', type: 'text', required: true }, + { name: 'Quantity', key: 'quantity', type: 'number', cellProps: { min: 1 } }, + { name: 'Price', key: 'price', type: 'number', cellProps: { min: 0, step: 0.01 } }, + { + name: 'Total', + key: 'total', + type: 'badge', + calculateValue: (_key, data) => { + const quantity = Number(data.quantity) || 0; + const price = Number(data.price) || 0; + const total = quantity * price; + return { + status: 'active', + title: `$${total.toFixed(2)}`, + }; + }, + }, + { + name: 'Status', + key: 'status', + type: 'badge', + calculateValue: (_key, data) => ({ + status: data.status === 'delivered' ? 'inactive' : 'active', + title: String(data.status), + }), + }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if ((_key === 'quantity' || _key === 'price') && Number(value) <= 0) { + return 'Must be greater than 0'; + } + if (_key === 'product' && String(value).length < 2) { + return 'Product name too short'; + } + return null; + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as OrderItem) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Badge columns with calculated values based on other fields. + +**Calculated Fields:** +- **Total**: Automatically calculated from quantity × price (displayed as green badge) +- **Status**: Read-only display of order status (green for pending/shipped, red for delivered) + +**Key Concepts:** +- \`calculateValue\` returns \`{ status: 'active' | 'inactive', title: string }\` for badges +- Badge columns are read-only StatusBadge components +- Values update automatically when dependencies change +- Perfect for derived data and status indicators +- 300ms simulated save delay + +**Use this pattern when:** +- Display computed values (totals, aggregates) +- Show status indicators with color coding +- Present read-only derived data + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 5: Cross-Field Validation (Table Instance) +// ============================================================================ + +export const CrossFieldValidationExample = { + name: '5. Cross-Field Validation', + render: () => { + interface InventoryItem extends Record { + id: string; + sku: string; + name: string; + min_stock: number; + current_stock: number; + } + + const [data, setData] = useState([ + { id: '1', sku: 'ITEM-001', name: 'Widget A', min_stock: 10, current_stock: 50 }, + { id: '2', sku: 'ITEM-002', name: 'Widget B', min_stock: 5, current_stock: 20 }, + { id: '3', sku: 'ITEM-003', name: 'Widget C', min_stock: 15, current_stock: 15 }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'SKU', key: 'sku', type: 'text', required: true }, + { name: 'Product Name', key: 'name', type: 'text', required: true }, + { name: 'Min Stock', key: 'min_stock', type: 'number', cellProps: { min: 0 } }, + { name: 'Current Stock', key: 'current_stock', type: 'number', cellProps: { min: 0 } }, + { + name: 'Stock Status', + key: 'stock_status', + type: 'badge', + calculateValue: (_key, data) => { + const current = Number(data.current_stock) || 0; + const min = Number(data.min_stock) || 0; + if (current < min) return { status: 'inactive', title: 'Low Stock' }; + if (current < min * 1.5) return { status: 'warning', title: 'Warning' }; + return { status: 'active', title: 'Good' }; + }, + }, + ], + [], + ); + + // Cross-field validation using table instance + const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { + return async ({ value, data, table }) => { + if (!value || String(value).trim() === '') { + return 'Required field'; + } + + // Validate SKU uniqueness across all rows + if (_key === 'sku') { + const allRows = table.getCoreRowModel().rows; + const duplicate = allRows.find((row) => row.original.sku === value && row.original.id !== data.id); + if (duplicate) { + return 'SKU must be unique'; + } + } + + // Validate current_stock >= min_stock + if (_key === 'current_stock') { + const minStock = Number(data.min_stock) || 0; + if (Number(value) < minStock) { + return `Stock cannot be less than minimum (${minStock})`; + } + } + + // Validate min_stock <= current_stock + if (_key === 'min_stock') { + const currentStock = Number(data.current_stock) || 0; + if (Number(value) > currentStock) { + return `Minimum cannot exceed current stock (${currentStock})`; + } + } + + return null; + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 400)); + setData((prev) => + prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as InventoryItem) : item)), + ); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Cross-field validation using the table instance to access other rows and fields. + +**Validation Rules:** +- **SKU uniqueness**: Checks across all rows using table.getCoreRowModel() +- **Stock relationship**: Current stock must be >= minimum stock +- **Bidirectional validation**: Both fields validate against each other + +**Badge Column:** +- **Stock Status**: Visual indicator based on stock levels (red = Low Stock, warning = Warning, green = Good) +- Calculated from relationship between current_stock and min_stock +- Returns \`{ status: 'inactive' | 'warning' | 'active', title: string }\` + +**Table Instance Usage:** +- Access all rows: \`table.getCoreRowModel().rows\` +- Access current row data: \`data\` parameter +- Check field relationships within same row +- Validate uniqueness across rows +- 400ms simulated save delay + +**Use this pattern when:** +- Validate uniqueness constraints +- Check relationships between fields in the same row +- Enforce business rules across rows + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 6: Dynamic Columns with Custom Hook +// ============================================================================ + +export const DynamicColumnsExample = { + name: '6. Dynamic Columns (Stock Locations)', + render: () => { + interface StockLocationData extends Record { + id: string; + sku: string; + product: string; + [key: `location_${string}`]: number; + } + + // Simulate stock locations from API + const stockLocations = [ + { id: 'loc-1', name: 'Warehouse A', code: 'WH-A' }, + { id: 'loc-2', name: 'Warehouse B', code: 'WH-B' }, + { id: 'loc-3', name: 'Store NYC', code: 'NYC' }, + ]; + + const [data, setData] = useState([ + { id: '1', sku: 'PROD-001', product: 'Widget', location_loc1: 100, location_loc2: 50, location_loc3: 25 }, + { id: '2', sku: 'PROD-002', product: 'Gadget', location_loc1: 75, location_loc2: 30, location_loc3: 10 }, + { id: '3', sku: 'PROD-003', product: 'Device', location_loc1: 200, location_loc2: 100, location_loc3: 50 }, + ]); + + // Custom hook pattern for column definitions + const useStockColumnsDefinition = () => { + return useMemo(() => { + const baseColumns: EditableTableColumnDefinition[] = [ + { name: 'SKU', key: 'sku', type: 'text', required: true }, + { name: 'Product', key: 'product', type: 'text', required: true }, + ]; + + // Dynamically generate location columns + const locationColumns = stockLocations.map((location) => ({ + name: location.name, + key: `location_${location.id.replace('loc-', 'loc')}`, + type: 'number' as const, + placeholder: '0', + cellProps: { min: 0, max: 999999 }, + })) as EditableTableColumnDefinition[]; + + // Calculate total column + const totalColumn: EditableTableColumnDefinition = { + name: 'Total Stock', + key: 'total', + type: 'badge', + calculateValue: (_key, data) => { + const total = stockLocations.reduce((sum, loc) => { + const locationKey = `location_${loc.id.replace('loc-', 'loc')}`; + return sum + (Number(data[locationKey]) || 0); + }, 0); + return { + status: 'active', + title: `${total} units`, + }; + }, + }; + + return [...baseColumns, ...locationColumns, totalColumn]; + }, []); + }; + + const columns = useStockColumnsDefinition(); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key.startsWith('location_') && Number(value) < 0) { + return 'Stock cannot be negative'; + } + if ((_key === 'sku' || _key === 'product') && (!value || String(value).length < 2)) { + return 'Must be at least 2 characters'; + } + return null; + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 400)); + setData((prev) => + prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as StockLocationData) : item)), + ); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Dynamic column generation based on runtime data (stock locations). + +**Dynamic Column Pattern:** +- Custom hook (\`useStockColumnsDefinition\`) generates columns +- Columns created from array of locations (could be from API) +- Calculated badge column sums all location stocks and displays total +- Memoized with \`useMemo\` for performance + +**Key Concepts:** +- Base columns (SKU, Product) + dynamically generated location columns +- Column keys generated programmatically from location data +- Badge column aggregates dynamic fields (returns \`{ status: 'active', title: 'X units' }\`) +- Type-safe with generics and proper TypeScript indexing + +**Use this pattern when:** +- Columns depend on runtime data +- Number of columns varies (e.g., locations, time periods) +- Calculated columns aggregate dynamic fields + +**Implementation Details:** +- 400ms simulated save delay +- Always memoize dynamic columns to prevent re-renders + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 7: Dynamic Column Filters +// ============================================================================ + +export const DynamicColumnFiltersExample = { + name: '7. Dynamic Column Filters', + render: () => { + interface RegionalStock extends Record { + id: string; + sku: string; + product: string; + region_east: number; + region_west: number; + region_north: number; + region_south: number; + } + + const [data, setData] = useState([ + { + id: '1', + sku: 'PROD-001', + product: 'Widget A', + region_east: 150, + region_west: 80, + region_north: 20, + region_south: 5, + }, + { + id: '2', + sku: 'PROD-002', + product: 'Widget B', + region_east: 0, + region_west: 100, + region_north: 50, + region_south: 30, + }, + { + id: '3', + sku: 'PROD-003', + product: 'Gadget C', + region_east: 200, + region_west: 0, + region_north: 150, + region_south: 0, + }, + { + id: '4', + sku: 'PROD-004', + product: 'Device D', + region_east: 50, + region_west: 45, + region_north: 0, + region_south: 90, + }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'SKU', key: 'sku', type: 'text', required: true, enableSorting: true }, + { name: 'Product', key: 'product', type: 'text', required: true, enableSorting: true }, + { + name: 'East Region', + key: 'region_east', + type: 'number', + cellProps: { min: 0 }, + enableFiltering: true, + calculateFilterValue: (value) => { + const qty = Number(value); + if (qty === 0) return 'Out of Stock'; + if (qty < 50) return 'Low (<50)'; + if (qty < 100) return 'Medium (50-99)'; + return 'High (100+)'; + }, + }, + { + name: 'West Region', + key: 'region_west', + type: 'number', + cellProps: { min: 0 }, + enableFiltering: true, + calculateFilterValue: (value) => { + const qty = Number(value); + if (qty === 0) return 'Out of Stock'; + if (qty < 50) return 'Low (<50)'; + if (qty < 100) return 'Medium (50-99)'; + return 'High (100+)'; + }, + }, + { + name: 'North Region', + key: 'region_north', + type: 'number', + cellProps: { min: 0 }, + enableFiltering: true, + calculateFilterValue: (value) => { + const qty = Number(value); + if (qty === 0) return 'Out of Stock'; + if (qty < 50) return 'Low (<50)'; + if (qty < 100) return 'Medium (50-99)'; + return 'High (100+)'; + }, + }, + { + name: 'South Region', + key: 'region_south', + type: 'number', + cellProps: { min: 0 }, + enableFiltering: true, + calculateFilterValue: (value) => { + const qty = Number(value); + if (qty === 0) return 'Out of Stock'; + if (qty < 50) return 'Low (<50)'; + if (qty < 100) return 'Medium (50-99)'; + return 'High (100+)'; + }, + }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key.startsWith('region_') && Number(value) < 0) { + return 'Stock cannot be negative'; + } + return null; + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => + prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as RegionalStock) : item)), + ); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Dynamic column filters for grouping similar columns under a single URL parameter. + +**Filter Categories:** +- Out of Stock (0 units) +- Low (<50 units) +- Medium (50-99 units) +- High (100+ units) + +**Key Features:** +- \`calculateFilterValue\` converts numeric values to filterable categories +- \`dynamicColumnFilters\` groups region columns: ['region_*'] +- Clean URL format: \`?cf_region=region_east:Low,region_west:High\` +- Works with async-loaded columns +- 300ms simulated save delay + +**Use this pattern when:** +- Multiple similar columns need filtering +- Columns are created from API data +- Want readable filter categories instead of raw values +- Need clean URL state management + +**Benefits:** +- Single multi-parser handles all matching columns +- Better performance than individual parsers per column +- Clean, readable URLs with grouped filters + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 8: Table Instance - Dynamic Options +// ============================================================================ + +export const TableInstanceOptionsExample = { + name: '8. Table Instance in Options', + render: () => { + interface TeamMember extends Record { + id: string; + name: string; + role: string; + department: string; + manager: string; + } + + const [data, setData] = useState([ + { id: '1', name: 'Alice Johnson', role: 'Developer', department: 'Engineering', manager: '' }, + { id: '2', name: 'Bob Smith', role: 'Designer', department: 'Design', manager: 'Alice Johnson' }, + { id: '3', name: 'Charlie Brown', role: 'Developer', department: 'Engineering', manager: 'Alice Johnson' }, + { id: '4', name: 'Diana Prince', role: 'Manager', department: 'Engineering', manager: '' }, + { id: '5', name: 'Eve Davis', role: 'Developer', department: 'Engineering', manager: 'Diana Prince' }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Name', key: 'name', type: 'text', required: true }, + { name: 'Role', key: 'role', type: 'autocomplete', required: true }, + { name: 'Department', key: 'department', type: 'autocomplete', required: true }, + { name: 'Manager', key: 'manager', type: 'autocomplete', required: false }, + ], + [], + ); + + const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { + return async ({ value, data }) => { + await Promise.resolve(); + if ((_key === 'name' || _key === 'role' || _key === 'department') && !value) { + return 'Required field'; + } + + // Can't be your own manager + if (_key === 'manager' && value === data.name) { + return 'Cannot be your own manager'; + } + + return null; + }; + }, []); + + const getSaveHandler: CellActionsHandlerGetter = useCallback((key: string) => { + return async ({ value, data }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as TeamMember) : item))); + return null; + }; + }, []); + + // Use table instance to provide context-aware options + const getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]> = useCallback( + (_key: string) => { + return async ({ value, data, table }) => { + const searchTerm = String(value || '').toLowerCase(); + + if (_key === 'role') { + const allRows = table.getCoreRowModel().rows; + const uniqueRoles = new Set(allRows.map((row) => row.original.role).filter(Boolean)); + + return Array.from(uniqueRoles) + .filter((role) => String(role).toLowerCase().includes(searchTerm)) + .sort() + .map((role) => ({ label: String(role), value: role })); + } + + if (_key === 'department') { + const allRows = table.getCoreRowModel().rows; + const uniqueDepts = new Set(allRows.map((row) => row.original.department).filter(Boolean)); + + return Array.from(uniqueDepts) + .filter((dept) => String(dept).toLowerCase().includes(searchTerm)) + .sort() + .map((dept) => ({ label: String(dept), value: dept })); + } + + if (_key === 'manager') { + const allRows = table.getCoreRowModel().rows; + // Get all potential managers (excluding self) + const potentialManagers = allRows + .map((row) => row.original.name) + .filter((name) => name !== data.name && String(name).toLowerCase().includes(searchTerm)); + + return potentialManagers.sort().map((name) => ({ label: String(name), value: name })); + } + + return []; + }; + }, + [], + ); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Using table instance in getOptionsHandler to provide context-aware autocomplete options. + +**Dynamic Options from Table Data:** +- **Role**: Unique roles from all existing team members +- **Department**: Unique departments from table data +- **Manager**: All team members except the current person + +**Table Instance Methods:** +- \`table.getCoreRowModel().rows\` - Access all rows +- \`table.getFilteredRowModel().rows\` - Access filtered rows +- \`row.original\` - Access row data + +**Benefits:** +- Options automatically update as data changes +- No need for separate state management +- Context-aware suggestions (e.g., exclude self from manager options) +- Prevents invalid selections +- 300ms simulated save delay + +**Use this pattern when:** +- Options come from existing table data +- Need to filter options based on current row +- Want to ensure data consistency +- Autocomplete from user-entered values + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 9: Loading State +// ============================================================================ + +export const LoadingState = { + name: '9. Loading State', + render: () => { + interface Product extends Record { + id: string; + name: string; + sku: string; + price: number; + stock: number; + } + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text' }, + { name: 'SKU', key: 'sku', type: 'text' }, + { name: 'Price', key: 'price', type: 'number' }, + { name: 'Stock', key: 'stock', type: 'number' }, + { name: 'Category', key: 'category', type: 'text' }, + ], + [], + ); + + return ( + async () => null} + getSaveHandler={() => async () => null} + getOptionsHandler={() => async () => []} + loading={true} + showControls={true} + showPagination={true} + /> + ); + }, + parameters: { + docs: { + description: { + story: ` +Loading skeleton state displayed while data is being fetched. + +**Features:** +- Animated skeleton rows (shimmer effect) +- Matches table structure with proper column count +- Smooth loading animation +- Maintains layout consistency + +**Use case:** +Show this state while fetching data from API. + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 10: Empty State +// ============================================================================ + +export const EmptyState = { + name: '10. Empty State', + render: () => { + interface Product extends Record { + id: string; + name: string; + sku: string; + price: number; + stock: number; + } + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text' }, + { name: 'SKU', key: 'sku', type: 'text' }, + { name: 'Price', key: 'price', type: 'number' }, + { name: 'Stock', key: 'stock', type: 'number' }, + ], + [], + ); + + return ( + async () => null} + getSaveHandler={() => async () => null} + getOptionsHandler={() => async () => []} + loading={false} + showControls={true} + showPagination={true} + /> + ); + }, + parameters: { + docs: { + description: { + story: ` +Empty state displayed when no data is available. + +**Use Cases:** +- No data loaded yet +- All items have been deleted +- Search/filter returned no results +- Fresh table with no entries + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 11: Row Selection +// ============================================================================ + +export const RowSelectionExample = { + name: '11. Row Selection', + render: () => { + interface Product extends Record { + id: string; + name: string; + price: number; + stock: number; + } + + const [data, setData] = useState([ + { id: '1', name: 'Laptop', price: 999, stock: 15 }, + { id: '2', name: 'Mouse', price: 29, stock: 50 }, + { id: '3', name: 'Keyboard', price: 79, stock: 30 }, + ]); + + const [rowSelection, setRowSelection] = useState>({}); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text', required: true, enableSorting: true }, + { name: 'Price', key: 'price', type: 'number', cellProps: { min: 0, step: 0.01 }, enableSorting: true }, + { name: 'Stock', key: 'stock', type: 'number', cellProps: { min: 0 }, enableSorting: true }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key === 'name' && (!value || String(value).length < 2)) { + return 'Name must be at least 2 characters'; + } + if ((_key === 'price' || _key === 'stock') && Number(value) < 0) { + return 'Must be a positive number'; + } + return Promise.resolve(null); + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + const selectedCount = Object.keys(rowSelection).filter((id) => rowSelection[id]).length; + const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); + + const handleBulkDelete = useCallback(() => { + const selectedProducts = data.filter((product) => selectedIds.includes(product.id)); + const productNames = selectedProducts.map((p) => p.name).join(', '); + + if ( + confirm( + `Are you sure you want to delete ${selectedCount} ${selectedCount === 1 ? 'product' : 'products'}?\n\n${productNames}`, + ) + ) { + setData((prev) => prev.filter((product) => !selectedIds.includes(product.id))); + setRowSelection({}); + } + }, [selectedIds, selectedCount, data]); + + return ( +
+ {selectedCount > 0 && ( +
+
+

+ {selectedCount} {selectedCount === 1 ? 'row' : 'rows'} selected +

+ +
+
+ )} + +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +Row selection enables users to select individual rows or all rows using checkboxes, with support for bulk operations. + +**Features:** +- **Checkbox Column**: Appears on the left for selecting rows +- **Select All**: Header checkbox selects/deselects all rows on current page +- **Selection State**: Managed via \`rowSelection\` prop (object mapping row IDs to boolean) +- **Bulk Actions Bar**: Appears when rows are selected, showing count and action buttons +- **Bulk Delete**: Delete multiple selected rows at once with confirmation + +**Key Props:** +- \`enableRowSelection={true}\` - Enables the checkbox column +- \`rowSelection\` - State object: \`{ [rowId]: boolean }\` +- \`onRowSelectionChange\` - Callback when selection changes + +**Bulk Operations Pattern:** +- Get selected IDs: \`Object.keys(rowSelection).filter(id => rowSelection[id])\` +- Filter data by selected IDs to get selected items +- Perform operations on selected items (delete, update, export, etc.) +- Clear selection after operation: \`setRowSelection({})\` + +**Use Cases:** +- Bulk delete operations +- Bulk status updates +- Export selected data +- Multi-select workflows +- Row management interfaces + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 12: Actions Column +// ============================================================================ + +export const ActionsColumnExample = { + name: '12. Actions Column', + render: () => { + interface Product extends Record { + id: string; + name: string; + price: number; + stock: number; + } + + const [data, setData] = useState([ + { id: '1', name: 'Laptop', price: 999, stock: 15 }, + { id: '2', name: 'Mouse', price: 29, stock: 50 }, + { id: '3', name: 'Keyboard', price: 79, stock: 30 }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text', required: true, enableSorting: true }, + { name: 'Price', key: 'price', type: 'number', cellProps: { min: 0, step: 0.01 }, enableSorting: true }, + { name: 'Stock', key: 'stock', type: 'number', cellProps: { min: 0 }, enableSorting: true }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key === 'name' && (!value || String(value).length < 2)) { + return 'Name must be at least 2 characters'; + } + if ((_key === 'price' || _key === 'stock') && Number(value) < 0) { + return 'Must be a positive number'; + } + return Promise.resolve(null); + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + const handleView = useCallback((item: Product) => { + alert(`Viewing: ${item.name}\nPrice: $${item.price}\nStock: ${item.stock}`); + }, []); + + const handleDelete = useCallback((item: Product) => { + if (confirm(`Are you sure you want to delete "${item.name}"?`)) { + setData((prev) => prev.filter((product) => product.id !== item.id)); + } + }, []); + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Actions column provides a dropdown menu (⋯) with row-specific actions like view and delete. + +**Features:** +- **Actions Column**: Dropdown menu appears on the right side of each row +- **View Action**: Opens a detail view (in this example, shows an alert) +- **Delete Action**: Removes the row after confirmation +- **Icons**: Uses Eye icon for view, Trash icon for delete + +**Key Props:** +- \`onView\` - Handler called when view action is clicked (receives row data) +- \`onDelete\` - Handler called when delete action is clicked (receives row data) + +**Use Cases:** +- Quick row actions (view, edit, delete) +- Navigation to detail pages +- Row management operations +- Context menu for row-specific actions + +**Note:** Actions column only appears when at least one of \`onView\` or \`onDelete\` is provided. + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 13: Custom Column Sizes +// ============================================================================ + +export const CustomColumnSizesExample = { + name: '13. Custom Column Sizes', + render: () => { + interface Product extends Record { + id: string; + sku: string; + name: string; + description: string; + price: number; + stock: number; + category: string; + } + + const [data, setData] = useState([ + { + id: '1', + sku: 'PROD-001', + name: 'Gaming Laptop', + description: 'High-performance laptop for gaming and professional work', + price: 1299.99, + stock: 15, + category: 'Electronics', + }, + { + id: '2', + sku: 'PROD-002', + name: 'Wireless Mouse', + description: 'Ergonomic wireless mouse with long battery life', + price: 29.99, + stock: 50, + category: 'Accessories', + }, + { + id: '3', + sku: 'PROD-003', + name: 'Mechanical Keyboard', + description: 'RGB mechanical keyboard with customizable keys', + price: 149.99, + stock: 30, + category: 'Accessories', + }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + // Custom narrow width for SKU (number type defaults to 120px, but we override) + { name: 'SKU', key: 'sku', type: 'text', minWidth: 100, maxWidth: 150, enableSorting: true }, + // Default width (text type defaults to 200px via getDefaultColumnSizing) + { name: 'Product Name', key: 'name', type: 'text', required: true, enableSorting: true }, + // Custom wide width for description + { name: 'Description', key: 'description', type: 'text', minWidth: 300, maxWidth: 500, enableSorting: true }, + // Custom width for price (number type defaults to 120px, but we make it wider) + { + name: 'Price', + key: 'price', + type: 'number', + minWidth: 140, + maxWidth: 180, + cellProps: { min: 0, step: 0.01 }, + enableSorting: true, + }, + // Default width for stock (number type defaults to 120px) + { name: 'Stock', key: 'stock', type: 'number', cellProps: { min: 0 }, enableSorting: true }, + // Custom width for category (text type defaults to 200px, but we make it narrower) + { name: 'Category', key: 'category', type: 'text', minWidth: 150, maxWidth: 200, enableSorting: true }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if ((_key === 'name' || _key === 'sku') && (!value || String(value).trim() === '')) { + return 'This field is required'; + } + if ((_key === 'price' || _key === 'stock') && Number(value) < 0) { + return 'Must be a positive number'; + } + return Promise.resolve(null); + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + return ( +
+
+

+ Column Sizing Examples: +

+
    +
  • + SKU: Custom narrow (100-150px) - narrower than default text width (200px) +
  • +
  • + Product Name: Default width (200px) - uses getDefaultColumnSizing('text') +
  • +
  • + Description: Custom wide (300-500px) - wider for long text content +
  • +
  • + Price: Custom width (140-180px) - wider than default number width (120px) +
  • +
  • + Stock: Default width (120px) - uses getDefaultColumnSizing('number') +
  • +
  • + Category: Custom width (150-200px) - narrower than default text width +
  • +
+
+ +
+ ); + }, + parameters: { + docs: { + description: { + story: ` +Custom column sizing allows you to control the width of table columns for optimal layout and readability. + +**Features:** +- **Default Sizing**: Uses \`getDefaultColumnSizing(type)\` when \`minWidth\` is not specified + - Number columns: 120px + - Badge columns: 100px + - Select/Autocomplete columns: 180px + - Text columns: 200px +- **Custom minWidth**: Set minimum column width +- **Custom maxWidth**: Set maximum column width (defaults to max of minWidth and 380px) +- **Flexible Layout**: Columns adapt to content while respecting size constraints + +**Key Props:** +- \`minWidth?: number\` - Minimum column width in pixels +- \`maxWidth?: number\` - Maximum column width in pixels +- If \`minWidth\` is not provided, uses \`getDefaultColumnSizing(columnDef.type)\` + +**Use Cases:** +- Narrow columns for short data (IDs, codes, status badges) +- Wide columns for long text content (descriptions, notes) +- Consistent sizing across similar column types +- Responsive layouts that adapt to content + +**Implementation Notes:** +- Default sizes are provided via \`getDefaultColumnSizing()\` function +- \`maxSize\` is calculated as: \`Math.max(maxWidth || 0, Math.max(minSize, 380))\` +- Columns can expand beyond minWidth if content requires it, up to maxWidth + `, + }, + }, + }, +}; + +// ============================================================================ +// Story 14: Error State +// ============================================================================ + +export const ErrorStateExample = { + name: '14. Error State', + render: () => { + interface Product extends Record { + id: string; + name: string; + price: number; + stock: number; + } + + const [hasError, setHasError] = useState(true); + + const handleRetry = useCallback(() => { + setHasError(false); + // In a real app, you would retry fetching data here + setTimeout(() => { + setHasError(true); + }, 2000); + }, []); + + const [data, setData] = useState([ + { id: '1', name: 'Laptop', price: 999, stock: 15 }, + { id: '2', name: 'Mouse', price: 29, stock: 50 }, + { id: '3', name: 'Keyboard', price: 79, stock: 30 }, + ]); + + const columns: EditableTableColumnDefinition[] = useMemo( + () => [ + { name: 'Product Name', key: 'name', type: 'text', required: true, enableSorting: true }, + { name: 'Price', key: 'price', type: 'number', cellProps: { min: 0, step: 0.01 }, enableSorting: true }, + { name: 'Stock', key: 'stock', type: 'number', cellProps: { min: 0 }, enableSorting: true }, + ], + [], + ); + + const getValidateHandler = useCallback((_key: string) => { + return ({ value }: { value: unknown }) => { + if (_key === 'name' && (!value || String(value).length < 2)) { + return 'Name must be at least 2 characters'; + } + if ((_key === 'price' || _key === 'stock') && Number(value) < 0) { + return 'Must be a positive number'; + } + return Promise.resolve(null); + }; + }, []); + + const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 300)); + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; + }; + }, []); + + const getOptionsHandler = useCallback(() => { + return async () => []; + }, []); + + // Show error state when there's an error + if (hasError) { + return ( + + ); + } + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +Error state displayed when data loading fails or an error occurs. + +**Features:** +- **Error Icon**: Alert circle icon indicating an error +- **Error Message**: Clear, user-friendly error message +- **Retry Button**: Allows users to retry the failed operation +- **Customizable**: Title, message, and retry handler can be customized + +**ErrorState Component Props:** +- \`title: string\` - Error title/heading +- \`message: string\` - Detailed error message +- \`onRetry?: () => void\` - Handler for retry button click +- \`showRetry?: boolean\` - Show/hide retry button (default: true) + +**Use Cases:** +- API request failures +- Network errors +- Data loading errors +- Permission errors +- Server errors + +**Implementation Pattern:** +\`\`\`tsx +if (error) { + return ( + + ); +} +\`\`\` + +**Best Practices:** +- Provide clear, actionable error messages +- Always include a retry option when the error is recoverable +- Use appropriate error messages for different error types +- Consider logging errors for debugging + `, + }, + }, + }, +}; diff --git a/packages/medusa-forms/README.md b/packages/medusa-forms/README.md new file mode 100644 index 0000000..5988ec3 --- /dev/null +++ b/packages/medusa-forms/README.md @@ -0,0 +1,60 @@ +# @lambdacurry/medusa-forms + +Form and data management components for Medusa Admin applications. + +## Installation + +```bash +npm install @lambdacurry/medusa-forms +``` + +**Peer dependencies:** See [Requirements](#requirements) below. + +## Components + +- **Controlled Form Components** - React Hook Form integrated inputs, selects, checkboxes, etc. +- **EditableTable** - Inline-editing table with validation, auto-save, sorting, filtering, and URL state persistence +- **UI Components** - Low-level form components + +## Documentation + +📚 **[Interactive Examples & Full Documentation](https://lambda-curry.github.io/forms)** + +Component-specific guides: +- [EditableTable Complete Guide](./src/editable-table/README.md) - Setup, providers, examples, troubleshooting + +## Quick Start + +```typescript +// Controlled form components +import { ControlledInput, ControlledSelect } from '@lambdacurry/medusa-forms/controlled'; + +// EditableTable +import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; + +// UI components +import { Input, Select } from '@lambdacurry/medusa-forms/ui'; +``` + +See [Storybook](https://lambda-curry.github.io/forms) for live examples. + +## Requirements + +**All components:** +- React 18.3+ or 19.0+ +- @medusajs/ui 4.0.0+ +- @medusajs/icons 2.0.0+ +- react-hook-form 7.49.0+ + +**EditableTable additional:** +- @tanstack/react-query 5.0.0+ +- @tanstack/react-table 8.21.0+ +- nuqs 2.6.0+ +- use-debounce 10.0.0+ +- lucide-react 0.263.0+ + +See [EditableTable docs](./src/editable-table/README.md#installation--peer-dependencies) for detailed setup. + +## License + +MIT diff --git a/packages/medusa-forms/package.json b/packages/medusa-forms/package.json index 0a08a81..90189ae 100644 --- a/packages/medusa-forms/package.json +++ b/packages/medusa-forms/package.json @@ -1,11 +1,36 @@ { "name": "@lambdacurry/medusa-forms", - "version": "0.2.8", + "version": "0.3.0-alpha.6", + "description": "Comprehensive form and data management component library for Medusa Admin with controlled form components and EditableTable", + "keywords": [ + "medusa", + "medusa-ui", + "react", + "forms", + "react-hook-form", + "table", + "editable-table", + "inline-editing", + "typescript" + ], + "homepage": "https://lambda-curry.github.io/forms", + "repository": { + "type": "git", + "url": "https://github.com/lambda-curry/forms.git", + "directory": "packages/medusa-forms" + }, + "bugs": { + "url": "https://github.com/lambda-curry/forms/issues" + }, + "license": "MIT", + "author": "Lambda Curry", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "types": "./dist/types/index.d.ts", "files": [ - "dist" + "dist", + "README.md", + "src/editable-table/README.md" ], "exports": { ".": { @@ -37,6 +62,16 @@ "types": "./dist/types/ui/index.d.ts", "default": "./dist/cjs/ui/index.cjs" } + }, + "./editable-table": { + "import": { + "types": "./dist/types/editable-table/index.d.ts", + "default": "./dist/esm/editable-table/index.js" + }, + "require": { + "types": "./dist/types/editable-table/index.d.ts", + "default": "./dist/cjs/editable-table/index.cjs" + } } }, "scripts": { @@ -47,15 +82,25 @@ "type-check": "tsc --noEmit" }, "peerDependencies": { + "@medusajs/icons": "^2.0.0", "@medusajs/ui": "^4.0.0", + "@tanstack/react-query": "^5.0.0", + "@tanstack/react-table": "^8.20.0", + "lucide-react": "^0.263.0", + "nuqs": "^2.6.0", "react": "^18.3.0 || ^19.0.0", - "react-hook-form": "^7.49.0" + "react-hook-form": "^7.49.0", + "use-debounce": "^10.0.0" }, "dependencies": { "@hookform/error-message": "^2.0.1" }, "devDependencies": { + "@medusajs/icons": "^2.0.0", "@medusajs/ui": "^4.0.0", + "@tanstack/react-query": "^5.62.15", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.8.3", "@types/glob": "^8.1.0", "@types/react": "^19.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -63,10 +108,13 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "glob": "^11.0.0", + "lucide-react": "^0.469.0", + "nuqs": "^2.6.0", "react": "^19.0.0", "react-hook-form": "^7.49.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", + "use-debounce": "^10.0.4", "vite": "^5.4.11", "vite-plugin-dts": "^4.4.0", "vite-tsconfig-paths": "^5.1.4" diff --git a/packages/medusa-forms/src/editable-table/README.md b/packages/medusa-forms/src/editable-table/README.md new file mode 100644 index 0000000..b8a50a7 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/README.md @@ -0,0 +1,1824 @@ +# EditableTable Component + +## Overview + +The `EditableTable` is a powerful, feature-rich React component built for the Medusa2 admin interface that provides inline editing capabilities for tabular data. It combines the flexibility of TanStack Table with real-time validation, auto-save functionality, and URL state persistence to create an efficient data management experience. + +## Installation & Peer Dependencies + +### Required Peer Dependencies + +The EditableTable component requires the following peer dependencies to be installed in your project: + +```bash +yarn add @medusajs/ui@^4.0.0 \ + @medusajs/icons@^2.0.0 \ + @tanstack/react-query@^5.0.0 \ + @tanstack/react-table@^8.21.0 \ + lucide-react@^0.263.0 \ + nuqs@^2.6.0 \ + react@^18.3.0 \ + react-hook-form@^7.49.0 \ + use-debounce@^10.0.0 +``` + +| Package | Version | Purpose | +|---------|---------|---------| +| `@medusajs/ui` | ^4.0.0 | Medusa UI design system components (Table, Tooltip, Input, Select, etc.) | +| `@medusajs/icons` | ^2.0.0 | Icon components used throughout the table | +| `@tanstack/react-query` | ^5.0.0 | Data fetching and caching for autocomplete cells | +| `@tanstack/react-table` | ^8.21.0 | Core table functionality (sorting, filtering, pagination) | +| `lucide-react` | ^0.263.0 | Additional icon components | +| `nuqs` | ^2.6.0 | URL state management for table state persistence | +| `react` | ^18.3.0 or ^19.0.0 | React framework | +| `react-hook-form` | ^7.49.0 | Form state management (not directly used by EditableTable but required) | +| `use-debounce` | ^10.0.0 | Debounced validation and save operations | + +### Optional Dependencies + +For virtual scrolling support with large datasets: +```bash +yarn add @tanstack/react-virtual@^3.10.0 +``` + +## NuqsAdapter Setup + +The `EditableTable` component uses `nuqs` for URL state persistence (search, filters, pagination, sorting). Since Medusa Admin uses React Router v6, you need to wrap your page content with the `NuqsAdapter` from `nuqs/adapters/react-router/v6`. + +> **Note:** The adapter required depends on your React framework. For Medusa Admin (React Router v6), use `nuqs/adapters/react-router/v6`. For other frameworks, see the [official nuqs adapters documentation](https://nuqs.dev/docs/adapters). + +### Medusa Admin Setup + +In Medusa Admin, add the `NuqsAdapter` at the page level when creating custom pages: + +```tsx +import { NuqsAdapter } from 'nuqs/adapters/react-router/v6'; +import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; +import { defineRouteConfig } from '@medusajs/admin-sdk'; +import { Buildings } from '@medusajs/icons'; + +const MyCustomPage = () => { + return ( + + + + ); +}; + +export const config = defineRouteConfig({ + label: 'My Custom Page', + icon: Buildings, +}); + +export default MyCustomPage; +``` + +**Why needed:** The `NuqsAdapter` provides the routing context that `nuqs` requires to synchronize table state (search, filters, pagination, sorting) with the URL query parameters. + +**Error if missing:** +``` +[nuqs] nuqs requires an adapter to work with your framework. +``` + +For other frameworks or standalone usage, refer to the [official nuqs adapters documentation](https://nuqs.dev/docs/adapters). + +## Key Features + +- **Inline Editing**: Edit data directly in table cells without navigation +- **Real-time Validation**: Immediate feedback with Zod schema validation +- **Auto-save**: Debounced saving with visual status indicators +- **URL State Persistence**: Table state (search, sort, pagination) persists in URL +- **Column Management**: Sorting, filtering, pinning, and resizing +- **Loading States**: Built-in skeleton loading with customizable row/column counts +- **Tooltip Support**: Column headers can display helpful tooltips +- **Pagination**: Configurable pagination with customizable page sizes +- **Modular Architecture**: Separated concerns with dedicated components +- **Performance Optimized**: Handles 50+ rows efficiently with virtualization support +- **Accessibility**: Full keyboard navigation and screen reader support +- **Type Safety**: End-to-end TypeScript support with strict typing +- **Stable Rendering**: Prevents unnecessary re-renders and state resets + +## Architecture + +### Core Components + +``` +EditableTable/ +├── components/ +│ ├── EditableTable.tsx # Main table component (orchestrator) +│ ├── EditableTableContent.tsx # Table content with headers and rows +│ ├── EditableTableControls.tsx # Table controls (search, filters, etc.) +│ ├── EditableTablePagination.tsx # Pagination component +│ ├── TableSkeleton.tsx # Loading skeleton component +│ ├── TooltipColumnHeader.tsx # Column header with tooltip support +│ ├── LoadingStates.tsx # Loading state components +│ ├── cells/ +│ │ ├── cells.tsx # Cell type implementations +│ │ └── CellStatusIndicator.tsx # Visual status indicators +│ └── editables/ +│ └── InputCell.tsx # Editable input cell component +├── hooks/ +│ ├── useEditableTable.ts # Main table logic hook +│ ├── useEditableTableColumns.tsx # Column definitions +│ ├── useEditableTableUrlState.ts # URL state management +│ └── useEditableCellActions.ts # Cell action handlers +├── types/ +│ ├── cells.ts # Type definitions for cells +│ ├── columns.ts # Column type definitions +│ └── utils.ts # Utility types +└── columnHelpers.tsx # Column utility functions +``` + +### Technology Stack + +- **TanStack Table v8**: Core table functionality with sorting, filtering, and pagination +- **TanStack Virtual v3**: Performance optimization for large datasets (available but not implemented by default) +- **nuqs**: URL state management with type safety +- **Zod**: Schema validation for real-time field validation +- **Medusa UI**: Design system components (Table, Button, Select, Tooltip, etc.) +- **use-debounce**: Debounced operations for validation and save +- **Lucide React**: Icons for UI elements (sorting, pagination, etc.) + +## Usage Example + +### Basic Implementation + +```tsx +import { EditableTable } from '../EditableTable/components/EditableTable'; +import type { EditableTableColumnDefinition } from '../EditableTable/types/cells'; + +// Define your data type +interface MyData { + id: string; + name: string; + quantity: number; + status: 'active' | 'inactive'; +} + +// Define column configuration +const columns: EditableTableColumnDefinition[] = [ + { + name: 'Name', + key: 'name', + type: 'text', + placeholder: 'Enter name', + required: true, + }, + { + name: 'Quantity', + key: 'quantity', + type: 'number', + placeholder: '0', + cellProps: { min: 0, max: 999999 }, + }, +]; + +// Validation handlers +const getValidateHandler = (key: string) => { + return async ({ value }) => { + // Your validation logic + if (key === 'name' && !value) { + return 'Name is required'; + } + return null; + }; +}; + +// Save handlers +const getSaveHandler = (key: string) => { + return async ({ value, data, table }) => { + // Your save logic + await updateRecord(data.id, { [key]: value }); + + // Access table instance for advanced operations + // - Get all rows: table.getCoreRowModel().rows + // - Get filtered rows: table.getFilteredRowModel().rows + // - Get specific row data: table.getRowData(rowIndex) + + return null; + }; +}; + +// Options handlers (for autocomplete columns) +const getOptionsHandler = (key: string) => { + return async ({ value }) => { + // Return options for autocomplete fields + if (key === 'category') { + const searchTerm = String(value || '').toLowerCase(); + const categories = await fetchCategories(searchTerm); + return categories.map(cat => ({ label: cat.name, value: cat.id })); + } + return []; + }; +}; + +// Component usage +export const MyEditableTable = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + + return ( + + data={data} + editableColumns={columns} + getValidateHandler={getValidateHandler} + getSaveHandler={getSaveHandler} + getOptionsHandler={getOptionsHandler} + loading={loading} + showControls={true} + showPagination={true} + enableGlobalFilter={true} + enableSorting={true} + enablePagination={true} + tableId="my-table" // Optional: for URL state persistence + /> + ); +}; +``` + +## Column Types + +### Text Column +```tsx +{ + name: 'Title', + key: 'title', + type: 'text', + placeholder: 'Enter title', + required: true, +} +``` + +### Number Column +```tsx +{ + name: 'Quantity', + key: 'quantity', + type: 'number', + placeholder: '0', + cellProps: { + min: 0, + max: 999999, + step: 1, + }, +} +``` + +### Badge Column (Read-only with Status) +```tsx +{ + name: 'Status', + key: 'status', + type: 'badge', + calculateValue: (key, data) => ({ + status: data.isActive ? 'active' : 'inactive', + title: data.isActive ? 'Active' : 'Inactive', + }), +} +``` + +### Autocomplete Column +```tsx +{ + name: 'Category', + key: 'category', + type: 'autocomplete', + placeholder: 'Search categories...', + required: false, +} +``` + +**Features:** +- Debounced search as user types +- Async option loading via `getOptionsHandler` +- Dropdown with filtered suggestions +- Keyboard navigation support +- Can show additional metadata in tooltips (e.g., "used by X items") + +**Options Handler Example:** +```tsx +const getOptionsHandler = (key: string) => { + return async ({ value }) => { + if (key === 'category') { + const searchTerm = String(value || '').toLowerCase(); + const categories = ['Electronics', 'Clothing', 'Home & Garden', 'Sports']; + + return categories + .filter((cat) => cat.toLowerCase().includes(searchTerm)) + .map((cat) => ({ label: cat, value: cat })); + } + return []; + }; +}; +``` + +### Select Column (Future Enhancement) +```tsx +{ + name: 'Status', + key: 'status', + type: 'select', + options: [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + ], +} +``` + +## Loading States + +The EditableTable includes built-in loading state support with skeleton components: + +### Basic Loading State + +```tsx + +``` + +### Custom Skeleton Configuration + +The skeleton automatically adapts to your column count, but you can customize it: + +```tsx +// The skeleton will show the same number of columns as your editableColumns +const columns = [ + { name: 'Name', key: 'name', type: 'text' }, + { name: 'Email', key: 'email', type: 'text' }, + { name: 'Status', key: 'status', type: 'badge' }, +]; // 3 columns = 3 skeleton columns + + +``` + +## Tooltip Support + +Add helpful tooltips to column headers to provide additional context: + +### Basic Tooltip Implementation + +```tsx +const getTooltipContent = (columnKey: string, columnName: string) => { + const tooltips = { + sku: 'Stock Keeping Unit - unique identifier for inventory items', + serial_number: 'Optional unique serial number for tracking individual items', + condition_description: 'Detailed description of the item\'s current condition', + }; + + return tooltips[columnKey] || null; +}; + + +``` + +### Advanced Tooltip with JSX Content + +```tsx +const getTooltipContent = (columnKey: string, columnName: string) => { + if (columnKey === 'location_levels') { + return ( +
+ Stock Quantities +

Current inventory levels at each location

+
    +
  • • Min: 0 items
  • +
  • • Max: 999,999 items
  • +
+
+ ); + } + return null; +}; +``` + +## Pagination + +The table includes a comprehensive pagination system: + +### Default Pagination + +```tsx + +``` + +### Custom Page Size Options + +```tsx +// The pagination component supports custom page sizes +// Default options: [20, 30, 40, 50, 100] +// This is handled internally by the pagination component +``` + +### Pagination Features + +- **Page Size Selection**: Users can choose how many rows to display +- **Navigation Controls**: First, Previous, Next, Last page buttons +- **Page Information**: Shows current page and total pages +- **URL Persistence**: Page state is saved in URL parameters + +## Validation System + +### Schema-based Validation with Zod + +```tsx +import { z } from 'zod'; + +const mySchema = { + name: z.string().min(2, 'Name must be at least 2 characters'), + quantity: z.coerce.number().min(0, 'Quantity cannot be negative'), + email: z.string().email('Invalid email format'), +}; + +const getValidateHandler = (key: string) => { + return async ({ value }) => { + const schema = mySchema[key]; + if (!schema) return null; + + const result = schema.safeParse(value); + if (!result.success) { + return result.error.errors[0]?.message || 'Invalid value'; + } + return null; + }; +}; +``` + +### Real-time Validation Flow + +1. **User Input**: User types in a cell +2. **Debounced Validation**: Validation runs after 300ms delay +3. **Visual Feedback**: Cell shows validation status (error/success) +4. **Error Display**: Error message appears below cell +5. **Save Prevention**: Invalid data cannot be saved + +## Save System + +### Auto-save + +> **⚠️ IMPORTANT**: Validation is called automatically before save. Do NOT call validation manually inside your save handler. + +```tsx +const getSaveHandler = (key: string) => { + return async ({ value, data }) => { + try { + // ✅ Validation is handled automatically by EditableTable + // ❌ DO NOT call validateField() here - it's already done + + // Check if value actually changed (optional optimization) + if (value === data[key]) return null; + + // Perform the save operation + await updateRecord(data.id, { [key]: value }); + + // Return null for success + return null; + } catch (error) { + // Return error message on failure + return error.message || 'Save failed'; + } + }; +}; +``` + +**Save Flow:** +1. User edits cell value +2. **Validation runs automatically** (debounced 300ms) +3. If validation passes, **save handler is called** +4. Save handler performs the save operation +5. Visual indicator shows success or error state + +### Save States and Visual Indicators + +- **Idle**: Default state, no visual indicator +- **Editing**: Orange border while typing +- **Saving**: Gray background during save +- **Saved**: Green border/background for 2 seconds +- **Error**: Red border/background with error message +- **Retry**: Option to retry failed saves + +## Options System + +The `getOptionsHandler` provides autocomplete suggestions for cells of type `autocomplete`. It's called when users type in autocomplete fields. + +### Basic Options Handler + +```tsx +const getOptionsHandler = (key: string) => { + return async ({ value }) => { + const searchTerm = String(value || '').toLowerCase(); + + if (key === 'category') { + // Fetch or filter options based on search term + const categories = ['Electronics', 'Clothing', 'Home & Garden', 'Sports']; + + return categories + .filter(cat => cat.toLowerCase().includes(searchTerm)) + .map(cat => ({ label: cat, value: cat })); + } + + return []; // Return empty array for non-autocomplete fields + }; +}; +``` + +### API-based Options + +```tsx +const getOptionsHandler = (key: string) => { + return async ({ value }) => { + const searchTerm = String(value || '').toLowerCase(); + + if (key === 'supplier') { + // Fetch from API + const response = await fetch(`/api/suppliers?search=${searchTerm}`); + const suppliers = await response.json(); + + return suppliers.map(s => ({ + label: s.name, + value: s.id + })); + } + + return []; + }; +}; +``` + +### Options with Metadata + +You can include additional metadata in options for enhanced tooltips: + +```tsx +const getOptionsHandler = (key: string) => { + return async ({ value }) => { + if (key === 'location') { + const locations = await fetchLocations(value); + + return locations.map(loc => ({ + label: loc.name, + value: loc.id, + // Additional metadata for tooltips + usedBy: loc.inventoryItems?.map(item => ({ + id: item.id, + name: item.name + })) + })); + } + return []; + }; +}; +``` + +### Options Format + +Options must follow this structure: + +```tsx +type Option = { + label: string; // Display text in dropdown + value: unknown; // Value to save when selected + usedBy?: Array<{ // Optional: for tooltip metadata + id: string; + name: string; + }>; +}; +``` + +## Accessing Table Instance in Handlers + +All handler functions (`getValidateHandler`, `getSaveHandler`, and `getOptionsHandler`) receive the table instance as a parameter. This allows you to access table state and data for advanced use cases. + +### Table Instance API + +```tsx +// Handler signature +type EditableCellActionFn = (args: { + meta: EditableTableCellMeta; + data: TData; + value: unknown; + table: EditableTableInstance; // Table instance (always available) +}) => Promise; + +// Available methods on table instance +interface EditableTableInstance { + // Core data access + getCoreRowModel(): { rows: Row[] }; + getFilteredRowModel(): { rows: Row[] }; + getSortedRowModel(): { rows: Row[] }; + getRowData(rowIndex: number): T; + + // State access + getState(): TableState; + getPageCount(): number; + getCanNextPage(): boolean; + getCanPreviousPage(): boolean; + + // All TanStack Table methods available + // See: https://tanstack.com/table/latest/docs/api/core/table +} +``` + +### Use Case: Validation with Table Context + +```tsx +// Validate uniqueness across all rows +const getValidateHandler = (key: string) => { + return async ({ value, data, table }) => { + if (key === 'sku') { + // Check if SKU is unique across all rows + const allRows = table.getCoreRowModel().rows; + const duplicate = allRows.find(row => + row.original.sku === value && row.original.id !== data.id + ); + + if (duplicate) { + return 'SKU must be unique'; + } + } + return null; + }; +}; +``` + +### Use Case: Dynamic Options from Table Data + +```tsx +// Get autocomplete options from existing table data +const getOptionsHandler = (key: string) => { + return async ({ value, data, table }) => { + if (key === 'category') { + const searchTerm = String(value || '').toLowerCase(); + + // Get all unique categories from filtered rows + const filteredRows = table.getFilteredRowModel().rows; + const uniqueCategories = new Set(); + + for (const row of filteredRows) { + const category = row.getValue('category'); + if (category && typeof category === 'string') { + uniqueCategories.add(category); + } + } + + // Convert to option format and filter by search term + return Array.from(uniqueCategories) + .filter(cat => cat.toLowerCase().includes(searchTerm)) + .sort() + .map(cat => ({ label: cat, value: cat })); + } + return []; + }; +}; +``` + +### Use Case: Conditional Save Based on Table State + +```tsx +// Save with context from other rows +const getSaveHandler = (key: string) => { + return async ({ value, data, table }) => { + if (key === 'quantity') { + // Get total quantity across all rows + const allRows = table.getCoreRowModel().rows; + const totalQuantity = allRows.reduce((sum, row) => + sum + (Number(row.getValue('quantity')) || 0), + 0 + ); + + // Validate against total + if (totalQuantity + Number(value) > 10000) { + return 'Total quantity cannot exceed 10,000'; + } + + // Proceed with save + await updateRecord(data.id, { [key]: value }); + return null; + } + return null; + }; +}; +``` + +### Use Case: Cross-Field Validation + +```tsx +// Validate based on related row data +const getValidateHandler = (key: string) => { + return async ({ value, data, table }) => { + if (key === 'max_quantity') { + const minQuantity = data.min_quantity; + + if (Number(value) < Number(minQuantity)) { + return 'Max quantity must be greater than min quantity'; + } + + // Check if any other row has conflicting ranges + const allRows = table.getCoreRowModel().rows; + const conflicts = allRows.filter(row => { + if (row.original.id === data.id) return false; + const otherMin = Number(row.original.min_quantity); + const otherMax = Number(row.original.max_quantity); + const currentMin = Number(minQuantity); + const currentMax = Number(value); + + // Check for overlapping ranges + return (currentMin <= otherMax && currentMax >= otherMin); + }); + + if (conflicts.length > 0) { + return 'Quantity range overlaps with another row'; + } + } + return null; + }; +}; +``` + +### Best Practices + +1. **Never call validation manually in save handler**: Validation is automatic + ```tsx + // ❌ Bad - Manual validation in save handler + const getSaveHandler = (key: string) => { + return async ({ value }) => { + const error = await validateField(key, value); // ❌ Don't do this + if (error) return error; + await save(value); + }; + }; + + // ✅ Good - Let EditableTable handle validation + const getSaveHandler = (key: string) => { + return async ({ value, data }) => { + // Validation already done - just save + await updateRecord(data.id, { [key]: value }); + return null; + }; + }; + ``` + +2. **Use for read-only operations**: The table instance is for reading state, not mutating + ```tsx + // ✅ Good - Read data + const allCategories = table.getCoreRowModel().rows.map(r => r.original.category); + + // ❌ Bad - Don't mutate table state directly + // table.setRowSelection({ ...}); + ``` + +3. **Consider performance**: Accessing all rows can be expensive for large datasets + ```tsx + // ✅ Good - Use filtered rows when possible + const visibleRows = table.getFilteredRowModel().rows; + + // ⚠️ Be cautious - Getting all rows can be slow + const allRows = table.getCoreRowModel().rows; + ``` + +4. **Type safety**: The table instance is properly typed + ```tsx + // TypeScript knows the exact row type + const row: EditableInventoryItemData = table.getRowData(0); + ``` + +5. **Access is always available**: The table parameter is always provided to handlers + ```tsx + // ✅ No need for optional chaining + const totalRows = table.getRowCount(); + ``` + +## URL State Management + +### Persistent Table State + +The table automatically persists the following state in the URL: + +```tsx +type EditableTableUrlState = { + q: string; // Global search query + sort: string; // Sort column (with - prefix for desc) + page: number; // Current page number + pageSize: number; // Items per page +}; +``` + +### Multi-table Support + +```tsx +// Each table can have its own URL namespace + + + +``` + +### Dynamic Column Filters + +For columns created at runtime (e.g., stock locations from API), use dynamic filters to group related columns under a single URL parameter. + +**Problem:** Individual parsers for each column don't work with async-loaded columns. + +**Solution:** Group columns with a pattern using multi-parser format. + +```tsx +// Define columns with matching pattern +const columns = [ + { name: 'SKU', key: 'sku', type: 'text' }, + { + name: 'Location A', + key: 'location_levels.sloc_123', // Matches: location_levels.* + type: 'number', + calculateFilterValue: (value) => { + const qty = Number(value); + return qty === 0 ? 'Out of stock' : qty < 100 ? '< 100' : '100+'; + } + }, + // More location columns... +]; + +// Enable dynamic filters + +``` + +**URL Format:** `?cf_location_levels=sloc_123:100+,sloc_456:Out%20of%20stock` + +**Benefits:** +- Works with async-loaded columns +- Clean, readable URLs +- Single multi-parser handles all matching columns +- Better performance + +**When to use:** +- ✅ Columns created from API data +- ✅ Multiple similar columns +- ✅ Columns which column headers that depend on dynamic data. For example: stock locations + +## Performance and Stability Guidelines + +### Preventing Re-render Issues + +The EditableTable is designed to prevent unnecessary re-renders that can cause cell state resets and poor user experience. Follow these patterns to ensure optimal performance: + +#### 0. **CRITICAL**: Never Depend on Data State in Handlers + +**This is the most common cause of re-render issues and broken status indicators.** + +Handler functions (`getValidateHandler`, `getSaveHandler`, `getOptionsHandler`) must NEVER depend on the `data` state variable. Instead, use the `table` instance parameter provided to the action functions. + +```tsx +// ❌ INCORRECT - Causes re-renders on every data change +const getValidateHandler = useCallback((key: string) => { + return async ({ value }) => { + // Using data state variable - BAD! + const exists = data.some(item => item.sku === value); + return exists ? 'Duplicate SKU' : null; + }; +}, [data]); // ❌ data dependency causes handler to recreate + +// ✅ CORRECT - Use table instance instead +const getValidateHandler = useCallback( + (key: string): EditableCellActionFn => { + return async ({ value, table }) => { + // Use table instance - GOOD! + const allRows = table.getCoreRowModel().rows; + const exists = allRows.some(row => row.original.sku === value); + return exists ? 'Duplicate SKU' : null; + }; + }, + [] // ✅ No dependencies - stable reference +); +``` + +**Why This Matters:** +- When handlers depend on `data`, they recreate every time `data` changes +- Handler recreation causes the entire table to re-render +- Re-renders reset cell states, breaking status indicators (saving, error states) +- Users see flickering and inconsistent behavior + +**Type Safety:** +Always type your handlers for better TypeScript support: + +```tsx +// Type the handler function return value +const getValidateHandler = useCallback( + (key: string): EditableCellActionFn => { + return async ({ value, data, table }) => { + // Handler implementation + // TypeScript will infer correct types for value, data, table + }; + }, + [] // Empty dependencies +); + +const getSaveHandler = useCallback( + (key: string): EditableCellActionFn => { + return async ({ value, data }) => { + // Save implementation + }; + }, + [] // Empty dependencies unless you need external stable references +); + +const getOptionsHandler = useCallback( + (key: string): EditableCellActionFn => { + return async ({ value, table }) => { + // Get options from table instance, not state + const allRows = table.getCoreRowModel().rows; + // ... generate options + }; + }, + [] +); +``` + +#### 1. Memoize Column Definitions in Custom Hooks + +```tsx +// ✅ Correct - Memoize inside the hook +export const useInventoryItemColumnsDefinition = (stockLocations) => { + return useMemo(() => { + const inventoryColumns = [ + // ... column definitions + ]; + return inventoryColumns; + }, [stockLocations]); +}; + +// ❌ Incorrect - Creates new array every render +export const useInventoryItemColumnsDefinition = (stockLocations) => { + return stockLocations.map(location => ({ ...columnDef })); +}; +``` + +#### 2. Stabilize Handler Functions + +```tsx +// ✅ Correct - Use useCallback for stable references +const getSaveHandler = useCallback((key: string) => { + return async ({ value, data }) => { + // Save logic + }; +}, [dependencies]); + +// ❌ Incorrect - New function every render +const getSaveHandler = (key: string) => { + return async ({ value, data }) => { + // Save logic + }; +}; +``` + +#### 3. Memoize Complex Props + +```tsx +// ✅ Correct - Memoize object props +const tableConfig = useMemo(() => ({ + data, + columns, + handlers: { validate: getValidateHandler, save: getSaveHandler } +}), [data, columns, getValidateHandler, getSaveHandler]); + +// ❌ Incorrect - New object every render +const tableConfig = { + data, + columns, + handlers: { validate: getValidateHandler, save: getSaveHandler } +}; +``` + +#### 4. Never Call Hooks Inside Other Hooks + +```tsx +// ❌ Incorrect - Violates Rules of Hooks +const columns = useMemo(() => useInventoryItemColumnsDefinition(stockLocations), [stockLocations]); + +// ✅ Correct - Call hook normally, memoize inside the hook +const columns = useInventoryItemColumnsDefinition(stockLocations); +``` + +### Common Pitfalls to Avoid + +1. **⚠️ CRITICAL: Data Dependencies in Handlers**: NEVER depend on `data` state in handlers - use `table` instance instead +2. **⚠️ CRITICAL: Manual Validation in Save Handler**: NEVER call validation manually in save handler - it's automatic +3. **Unstable Column References**: Always memoize column definitions inside custom hooks +4. **New Function References**: Use `useCallback` for handler functions +5. **Object Recreation**: Memoize complex objects passed as props +6. **Hook Violations**: Never call hooks inside `useMemo`, `useCallback`, or other hooks +7. **Missing Type Annotations**: Type your handlers with `EditableCellActionFn` for better TypeScript support + +## Component Props + +### EditableTable Props + +```tsx +interface EditableTableProps> { + // Required props + data: T[]; + editableColumns: EditableTableColumnDefinition[]; + getValidateHandler: (key: string) => EditableCellActionFn, string | null> | undefined; + getSaveHandler: (key: string) => EditableCellActionFn, string | null> | undefined; + getOptionsHandler: (key: string) => EditableCellActionFn, { label: string; value: unknown }[]> | undefined; + + // Optional configuration + tableId?: string; // For URL state persistence + loading?: boolean; // Shows skeleton when true + className?: string; // Additional CSS classes + + // UI Controls + showControls?: boolean; // Show table controls (default: true) + showPagination?: boolean; // Show pagination (default: true) + showInfo?: boolean; // Show table info (default: true) + + // Table Features + enableGlobalFilter?: boolean; // Enable global search + enableColumnFilters?: boolean; // Enable column-specific filters + enableSorting?: boolean; // Enable column sorting + enablePagination?: boolean; // Enable pagination + enableColumnPinning?: boolean; // Enable column pinning + enableColumnVisibility?: boolean; // Enable column show/hide + enableRowSelection?: boolean; // Enable row selection + + // Event Handlers + onView?: (item: T) => void; // Row view handler + onDelete?: (item: T) => void; // Row delete handler + rowSelection?: Record; // Row selection state + onRowSelectionChange?: (rowSelection: Record) => void; + + // Advanced Features + getTooltipContent?: (columnKey: string, columnName: string) => string | React.ReactNode | null; + initialState?: Partial; // Initial table state +} +``` + +### Column Definition Props + +```tsx +interface EditableTableColumnDefinition { + // Basic properties + name: string; // Display name + key: keyof T; // Data key + type: EditableColumnType; // Cell type ('text' | 'number' | 'badge' | 'select' | 'autocomplete') + + // Optional configuration + description?: string | null; // Column description + placeholder?: string; // Input placeholder + required?: boolean; // Is field required + + // Layout + minWidth?: number; // Minimum column width + maxWidth?: number; // Maximum column width + + // Features + enableSorting?: boolean; // Enable sorting for this column + enableFiltering?: boolean; // Enable filtering for this column + enableHiding?: boolean; // Allow hiding this column + isPinnable?: boolean; // Allow pinning this column + + // Dependencies and validation + dependsOn?: string[]; // Fields this column depends on + cellProps?: Record; // Props passed to cell component + + // Value calculation + calculateValue?: (key: keyof T, data: Record) => unknown; + getFieldKey?: (key: string) => string; // Custom field key transformation +} +``` + +## Real-World Example: Inventory Table + +The `EditableInventoryTable` serves as a comprehensive implementation example, demonstrating all the patterns and features of the EditableTable component. + +### Implementation Overview + +```tsx +// my-medusa-app/src/admin/components/EditableInventoryTable/EditableInventoryTable.tsx +export const EditableInventoryTable = () => { + // Data fetching + const { data: inventoryData } = useAdminListInventoryItems(); + const { data: stockLocationData } = useAdminListStockLocations(); + const { data: productConditionsData } = useAdminListProductConditions(); + + // Data transformation + const inventoryItems = useMemo( + () => mapInventoryItemToEditableItems( + inventoryData?.inventory_items, + stockLocations, + productConditionsData?.product_conditions, + ), + [inventoryData, stockLocations, productConditionsData], + ); + + // Column definitions + const columns = useInventoryItemColumnsDefinition(stockLocations); + + // Action handlers + const getValidateHandler = useInventoryCellValidateHandlers(); + const getSaveHandler = useInventoryCellSaveHandlers(); + + return ( + + data={inventoryItems} + editableColumns={columns} + getValidateHandler={getValidateHandler} + getSaveHandler={getSaveHandler} + /> + ); +}; +``` + +### Data Type Definition + +```tsx +// types.ts +export type EditableInventoryItemData = { + id?: string; + sku?: string; + title?: string; + [location_level: `location_levels.${string}`]: number | undefined; + product_condition_id?: string; + condition?: string; + serial_number?: string; + condition_description?: string; + product_variants?: InventoryItem['variants']; + condition_photos?: ImageFile[]; +}; +``` + +### Dynamic Column Generation + +```tsx +// useInventoryItemColumnsDefinition.tsx +const mapLocationLevelColumnsDefinition = ( + stockLocations: HttpTypes.AdminStockLocation[], +): EditableTableColumnDefinition[] => { + return stockLocations.map((location) => ({ + name: location.name || location.id.slice(-3), + key: createLocationLevelsKey(location.id), // "location_levels.{locationId}" + type: 'number', + placeholder: '0', + required: false, + dependsOn: ['title'], + cellProps: { min: 0, max: 999999, step: 1 }, + })); +}; +``` + +### Validation Schema + +```tsx +// inventorySchema.ts +export const inventorySchema = { + sku: z.string() + .min(3, 'SKU must be at least 3 characters') + .max(50, 'SKU cannot exceed 50 characters') + .regex(/^[A-Z0-9-_]+$/i, 'SKU can only contain letters, numbers, hyphens, and underscores'), + + title: z.string() + .min(2, 'Title must be at least 2 characters') + .max(200, 'Title cannot exceed 200 characters'), + + serial_number: z.string() + .refine((val) => val === '' || val?.length >= 3, 'Serial number must be at least 3 characters') + .optional(), + + 'location_levels.*': z.object({ + location_id: z.string().min(1, 'This location is not available'), + stocked_quantity: z.coerce.number().int().min(0).max(999999).default(0), + }), +}; +``` + +### Save Handler Implementation + +```tsx +// useInventoryCellSaveHandlers.ts +export const useInventoryCellSaveHandlers = (): SaverFn => { + const { mutateAsync: updateInventoryField } = useAdminUpdateInventoryField(); + const { mutateAsync: updateInventoryItemDetailsField } = useAdminUpdateInventoryItemDetailsField(); + const { mutateAsync: updateInventoryLocationLevel } = useAdminUpdateInventoryLocationLevelField(); + + const inventorySavers: EditableCellActionsMap = useMemo(() => ({ + // Basic inventory fields + sku: async ({ value: rawValue, meta, data }) => { + const result = inventorySchema.sku.safeParse(rawValue); + if (!result.success) { + return result.error.errors[0]?.message || 'Invalid SKU'; + } + + const parsedValue = result.data; + if (isSameValue(parsedValue, data, meta.key)) { + return null; // No change needed + } + + await updateInventoryField({ + itemId: data.id as string, + field: 'sku', + value: parsedValue, + }); + + return null; // Success + }, + + // Location levels (dynamic fields) + 'location_levels.*': async ({ value: rawValue, meta, data }) => { + const locationId = meta.key.split('.')[1]; + const result = inventorySchema['location_levels.*'].safeParse({ + stocked_quantity: rawValue, + location_id: locationId, + }); + + if (!result.success) { + return result.error.errors[0]?.message || 'Invalid quantity'; + } + + const parsedValue = result.data; + if (isSameValue(parsedValue.stocked_quantity, data, meta.key)) { + return null; + } + + await updateInventoryLocationLevel({ + itemId: data.id as string, + locationId, + stocked_quantity: parsedValue.stocked_quantity, + }); + + return null; + }, + }), [updateInventoryField, updateInventoryItemDetailsField, updateInventoryLocationLevel]); + + return useCallback((key: string) => { + const handler = inventorySavers[key.includes('location_levels.') ? 'location_levels.*' : key]; + if (!handler) return undefined; + + return async (...args) => { + return await handler(...args).catch((error) => { + toast.error(`An error occurred while saving ${key}`); + return `An error occurred while saving ${key}`; + }); + }; + }, [inventorySavers]); +}; +``` + +### Key Patterns Demonstrated + +1. **Dynamic Column Generation**: Stock location columns are generated dynamically based on available locations +2. **Wildcard Field Handling**: `location_levels.*` pattern handles multiple similar fields +3. **Multi-API Integration**: Different fields save to different API endpoints +4. **Error Handling**: Comprehensive error handling with user feedback +5. **Type Safety**: Full TypeScript integration with proper typing +6. **Performance**: Memoization and efficient re-rendering + +## Advanced Patterns + +### Calculated Values + +```tsx +// Badge columns with calculated display values +{ + name: 'Status', + key: 'status', + type: 'badge', + calculateValue: (key, data) => { + const count = data.items?.length || 0; + return { + status: count > 0 ? 'active' : 'inactive', + title: count > 0 ? `${count} items` : 'No items', + }; + }, +} +``` + +### Custom Cell Props + +```tsx +// Pass custom props to cell components +{ + name: 'Price', + key: 'price', + type: 'number', + cellProps: { + min: 0, + max: 999999, + step: 0.01, + prefix: '$', + suffix: 'USD', + }, +} +``` + +## Future Improvements + +### Developer Experience Enhancements + +1. **Simplified Handler Creation** + ```tsx + // Auto-generate handlers from schema and API functions + const handlers = createHandlersFromSchema(schema, { + save: { sku: updateSku, title: updateTitle }, + validate: schema, + }); + ``` + +### Accessibility Features + +#### Keyboard Navigation + +- **Tab**: Navigate between cells [**DONE**] +- **Enter**: Start editing a cell +- **Escape**: Cancel editing + +#### Screen Reader Support + +- **ARIA Labels**: Proper labeling for all interactive elements +- **Live Regions**: Status updates announced to screen readers +- **Focus Management**: Logical focus flow throughout the table +- **Error Announcements**: Validation errors are announced + + +### Testing Strategy + +#### Unit Tests + +```tsx +// Test cell validation +describe('Cell Validation', () => { + it('should validate required fields', async () => { + const validator = getValidateHandler('name'); + const result = await validator({ value: '', meta: {}, data: {} }); + expect(result).toBe('Name is required'); + }); +}); + +// Test save operations +describe('Cell Save', () => { + it('should save valid data', async () => { + const saver = getSaveHandler('name'); + const result = await saver({ + value: 'New Name', + meta: { key: 'name' }, + data: { id: '1', name: 'Old Name' } + }); + expect(result).toBeNull(); + }); +}); +``` + +### Feature Enhancements + +1. **Advanced Cell Types** + ```tsx + // Rich text editor cell + { type: 'richtext', toolbar: ['bold', 'italic', 'link'] } + + // Date picker cell + { type: 'date', format: 'YYYY-MM-DD', minDate: new Date() } + + // File upload cell + { type: 'file', accept: 'image/*', maxSize: '5MB' } + ``` + +2. **Bulk Operations** (Row selection implemented, bulk actions future enhancement) + ```tsx + // Row selection is available, bulk actions need implementation + + ``` + +3. **Advanced Filtering** (Basic filtering available, advanced filters future enhancement) + ```tsx + // Basic column filtering is available + + ``` + +4. **Column Tooltips** (✅ Implemented) + ```tsx + // Tooltip support for column headers is now available + { + return tooltipMap[columnKey] || null; + }} + /> + ``` + +5. **Pagination** (✅ Implemented) + ```tsx + // Full pagination system with customizable page sizes + + ``` + +### Code Organization Improvements + +1. **Configuration Presets** + ```tsx + // Pre-configured table types + const InventoryTablePreset = createTablePreset({ + validation: inventorySchema, + saveHandlers: inventorySaveHandlers, + columns: inventoryColumns, + }); + + + ``` + +## Building New Tables: Step-by-Step Guide + +When creating a new table similar to `EditableInventoryTable`, follow this structured approach to ensure optimal performance and avoid common pitfalls: + +### Step 1: Define Data Types + +```tsx +// types.ts +export type MyTableData = { + id: string; + name: string; + quantity: number; + status: 'active' | 'inactive'; + // Add other fields as needed +}; +``` + +### Step 2: Create Column Definitions Hook + +```tsx +// useMyTableColumnsDefinition.tsx +import { useMemo } from 'react'; +import type { EditableTableColumnDefinition } from '../EditableTable/types/cells'; + +export const useMyTableColumnsDefinition = ( + dependencies: any[] // Any data needed for dynamic columns +): EditableTableColumnDefinition[] => { + return useMemo(() => { + const columns: EditableTableColumnDefinition[] = [ + { + name: 'Name', + key: 'name', + type: 'text', + placeholder: 'Enter name', + required: true, + dependsOn: [], + }, + { + name: 'Quantity', + key: 'quantity', + type: 'number', + placeholder: '0', + cellProps: { min: 0, max: 999999 }, + dependsOn: ['name'], + }, + // Add more columns as needed + ]; + + return columns; + }, [dependencies]); // ✅ CRITICAL: Memoize with proper dependencies +}; +``` + +### Step 3: Create Validation Schema and Handlers + +```tsx +// myTableSchema.ts +import { z } from 'zod'; + +export const myTableSchema = { + name: z.string().min(2, 'Name must be at least 2 characters'), + quantity: z.coerce.number().min(0, 'Quantity cannot be negative'), + // Add more validation rules +}; + +// useMyTableValidateHandlers.ts +import { useCallback } from 'react'; +import { myTableSchema } from './myTableSchema'; + +export const useMyTableValidateHandlers = () => { + return useCallback((key: string) => { + return async ({ value }) => { + const schema = myTableSchema[key]; + if (!schema) return null; + + const result = schema.safeParse(value); + return result.success ? null : result.error.errors[0]?.message; + }; + }, []); // ✅ CRITICAL: Empty dependency array for stable reference +}; +``` + +### Step 4: Create Save Handlers + +```tsx +// useMyTableSaveHandlers.ts +import { useCallback } from 'react'; +import { useMyTableMutations } from './mutations'; // Your API mutations + +export const useMyTableSaveHandlers = () => { + const { mutateAsync: updateRecord } = useMyTableMutations(); + + return useCallback((key: string) => { + return async ({ value, data }) => { + try { + // Validate before saving + const validationError = await validateField(key, value); + if (validationError) return validationError; + + // Check if value actually changed + if (value === data[key]) return null; + + // Perform save operation + await updateRecord({ id: data.id, [key]: value }); + + return null; // Success + } catch (error) { + return error.message || 'Save failed'; + } + }; + }, [updateRecord]); // ✅ CRITICAL: Include all dependencies +}; +``` + +### Step 5: Create Options Handlers (for autocomplete columns) + +```tsx +// useMyTableOptionsHandlers.ts +import { useCallback } from 'react'; + +export const useMyTableOptionsHandlers = () => { + return useCallback((key: string) => { + return async ({ value }) => { + const searchTerm = String(value || '').toLowerCase(); + + // Return options for autocomplete fields + if (key === 'category') { + const categories = await fetchCategories(searchTerm); + return categories + .filter(cat => cat.name.toLowerCase().includes(searchTerm)) + .map(cat => ({ label: cat.name, value: cat.id })); + } + + if (key === 'supplier') { + const suppliers = await fetchSuppliers(searchTerm); + return suppliers.map(s => ({ label: s.name, value: s.id })); + } + + // Return empty array for non-autocomplete fields + return []; + }; + }, []); // ✅ CRITICAL: Empty dependency array for stable reference +}; +``` + +### Step 6: Create the Main Table Component + +```tsx +// MyTable.tsx +import { useCallback, useMemo, useState } from 'react'; +import { EditableTable } from '../EditableTable/components/EditableTable'; +import { useMyTableColumnsDefinition } from './useMyTableColumnsDefinition'; +import { useMyTableValidateHandlers } from './useMyTableValidateHandlers'; +import { useMyTableSaveHandlers } from './useMyTableSaveHandlers'; +import { useMyTableOptionsHandlers } from './useMyTableOptionsHandlers'; + +export const MyTable = () => { + // Data fetching + const { data, isLoading } = useMyTableData(); + + // Memoized data transformation + const tableData = useMemo(() => + transformDataForTable(data), + [data] + ); + + // Column definitions (memoized inside hook) + const columns = useMyTableColumnsDefinition([/* dependencies */]); + + // Handlers (stable references) + const getValidateHandler = useMyTableValidateHandlers(); + const getSaveHandler = useMyTableSaveHandlers(); + const getOptionsHandler = useMyTableOptionsHandlers(); + + // Action handlers + const handleView = useCallback((item: MyTableData) => { + navigate(`/my-table/${item.id}`); + }, [navigate]); + + const handleDelete = useCallback((item: MyTableData) => { + // Delete logic + }, []); + + // Row selection + const [rowSelection, setRowSelection] = useState>({}); + const handleRowSelectionChange = useCallback((newSelection: Record) => { + setRowSelection(newSelection); + }, []); + + return ( + + data={tableData} + editableColumns={columns} + loading={isLoading} + getValidateHandler={getValidateHandler} + getSaveHandler={getSaveHandler} + getOptionsHandler={getOptionsHandler} + enableRowSelection={true} + rowSelection={rowSelection} + onRowSelectionChange={handleRowSelectionChange} + onView={handleView} + onDelete={handleDelete} + /> + ); +}; +``` + +### Step 7: Performance Checklist + +Before deploying your table, verify these performance requirements: + +- [ ] **⚠️ CRITICAL: Handlers do NOT depend on `data` state** - use `table` instance instead +- [ ] **⚠️ CRITICAL: Save handler does NOT call validation** - validation is automatic +- [ ] **Handlers are typed** with `EditableCellActionFn` +- [ ] **Column definitions are memoized** inside the custom hook +- [ ] **Handler functions use `useCallback`** with empty dependencies `[]` +- [ ] **No hooks are called inside other hooks** (Rules of Hooks) +- [ ] **Complex objects are memoized** with `useMemo` +- [ ] **Data transformations are memoized** to prevent unnecessary recalculations +- [ ] **Action handlers are stable** and don't recreate on every render +- [ ] **Status indicators work correctly** - test editing, saving, and error states + +### Common Issues and Solutions + +| Issue | Symptom | Solution | +|-------|---------|----------| +| Status indicators not showing | Cells reset state during saves | Remove `data` dependency from handlers - use `table` instance | +| Flickering during edits | Indicators appear/disappear rapidly | Handlers depend on `data` - use `table` instance instead | +| Performance issues | Excessive re-renders | Use `useCallback` with empty deps `[]` for handlers | +| Column state resets | Loses focus/state when typing | Memoize column definitions in custom hook | +| Hook violations | React errors in console | Never call hooks inside `useMemo`/`useCallback` | +| Unstable references | Table re-renders unnecessarily | Memoize all complex objects and arrays | + +## Migration Guide + +### From Basic Table to EditableTable + +```tsx +// Before: Basic table + + + + Name + Quantity + + + + {data.map(item => ( + + {item.name} + {item.quantity} + + ))} + +
+ +// After: EditableTable + +``` + +### Adding Validation + +```tsx +// 1. Define schema +const schema = { + name: z.string().min(1, 'Name is required'), + quantity: z.coerce.number().min(0, 'Quantity must be positive'), +}; + +// 2. Create validation handler +const getValidateHandler = (key) => async ({ value }) => { + const result = schema[key]?.safeParse(value); + return result?.success ? null : result.error.errors[0]?.message; +}; + +// 3. Add to table + +``` + +### Adding Save Functionality + +```tsx +// 1. Create save handler +const getSaveHandler = (key) => async ({ value, data }) => { + try { + await updateRecord(data.id, { [key]: value }); + return null; // Success + } catch (error) { + return error.message; // Error + } +}; + +// 2. Add to table + +``` + +## Conclusion + +The EditableTable component represents a sophisticated solution for inline data editing in admin interfaces. It successfully combines: + +- **Developer Experience**: Type-safe, well-documented API with clear patterns +- **User Experience**: Intuitive editing with immediate feedback and error handling +- **Performance**: Optimized for large datasets with efficient rendering +- **Maintainability**: Clean architecture with separation of concerns +- **Extensibility**: Plugin-ready design for future enhancements + +The InventoryTable implementation demonstrates how these patterns can be applied to real-world scenarios, providing a template for future table implementations throughout the Medusa2 admin interface. + +**Key Takeaways:** +- **⚠️ CRITICAL: Never depend on `data` state in handlers** - use `table` instance to avoid re-renders +- **⚠️ CRITICAL: Never call validation manually in save handler** - it's automatic +- **Type all handlers** with `EditableCellActionFn` for type safety +- Use schema-driven validation for consistency and type safety +- Implement proper error handling and user feedback +- Leverage URL state for better user experience +- Design for performance from the start +- Maintain clear separation between data, validation, and save logic +- **Always memoize column definitions inside custom hooks** to prevent re-render issues +- **Use `useCallback` for all handler functions** with empty dependencies `[]` +- **Never call hooks inside other hooks** to avoid React violations +- **Test status indicators** to verify cells maintain state during saves + +## Critical Performance Patterns + +### The "Status Indicator Test" +Before deploying any EditableTable implementation, perform this test: + +1. **Edit a cell** and observe the status indicators +2. **Verify the complete flow**: Editing → Saving → Success/Error +3. **Check for state resets**: Status indicators should not disappear during saves +4. **Test multiple cells**: Only the edited cell should show indicators + +If status indicators don't work correctly, the table has re-render issues that need to be fixed using the patterns outlined in this guide. + +This component serves as a foundation for efficient data management workflows and can be extended to support additional use cases as the admin interface evolves. diff --git a/packages/medusa-forms/src/editable-table/columnHelpers.tsx b/packages/medusa-forms/src/editable-table/columnHelpers.tsx new file mode 100644 index 0000000..ba0ca47 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/columnHelpers.tsx @@ -0,0 +1,59 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { EditableTableCellMeta } from './types/cells'; +import type { EditableColumnType } from './types/columns'; + +export const canSortColumn = (type: EditableColumnType) => ['text', 'number'].includes(type); + +// Get default column sizing based on field type +export function getDefaultColumnSizing(type: EditableColumnType): number { + switch (type) { + case 'number': + return 120; + case 'badge': + return 100; + case 'select': + case 'autocomplete': + return 180; + default: + return 200; + } +} + +// Get filter function based on field type +export function getFilterFunction(type: string) { + switch (type) { + case 'boolean': + return 'equals'; + case 'number': + return 'includesString'; // Use includesString for now, can be customized later + case 'date': + return 'includesString'; // Use includesString for now, can be customized later + default: + return 'includesString'; + } +} + +// Get sorting function based on field type +export function getSortingFunction(type: string) { + switch (type) { + case 'number': + return 'basic'; + case 'date': + return 'datetime'; + case 'boolean': + return 'basic'; + default: + return 'alphanumeric'; + } +} + +// Helper to get column class names based on type +export function getColumnHeaderClassName(colDef: ColumnDef>): string { + const baseClasses = 'flex items-center gap-2 text-left justify-between'; + + const meta = colDef.meta as EditableTableCellMeta; + switch (meta?.type) { + default: + return `${baseClasses}`; + } +} diff --git a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx new file mode 100644 index 0000000..61389f4 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx @@ -0,0 +1,87 @@ +import { Table, clx } from '@medusajs/ui'; +import type { ReactNode } from 'react'; +import { useEditableCellActions } from '../hooks/useEditableCellActions'; +import { useEditableTable } from '../hooks/useEditableTable'; +import type { CellActionsHandlerGetter, EditableTableConfig } from '../types/cells'; +import { EditableTableContent } from './EditableTableContent'; +import { EditableTableControls } from './EditableTableControls'; +import { TableSkeleton } from './TableSkeleton'; + +interface EditableTableProps> extends Omit, 'getCellActions'> { + tableId?: string; + showControls?: boolean; + showPagination?: boolean; + showInfo?: boolean; + className?: string; + loading?: boolean; + getValidateHandler: CellActionsHandlerGetter; + getSaveHandler: CellActionsHandlerGetter; + getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]>; + // Additional actions to render in table controls + additionalActions?: ReactNode; +} + +// Main EditableTable component +export function EditableTable>({ + showControls = true, + showPagination = true, + showInfo = true, + className, + loading = false, + data, + getValidateHandler, + getSaveHandler, + getOptionsHandler, + additionalActions, + ...inputConfig +}: EditableTableProps) { + const getCellActionsFn = useEditableCellActions({ getValidateHandler, getSaveHandler, getOptionsHandler }); + + const { table } = useEditableTable({ + ...inputConfig, + data, + getCellActions: getCellActionsFn, + }); + + // Show skeleton if loading + if (loading) { + const columnCount = inputConfig.editableColumns?.length || 6; + return ; + } + + return ( +
+ {/* Table Controls */} + {showControls && ( + + )} + + {/* Table Container */} +
+ {/* Table Content */} + + + {/* Pagination */} + {showPagination && ( + + )} +
+
+ ); +} diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx new file mode 100644 index 0000000..e56f934 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx @@ -0,0 +1,195 @@ +import { Table, Text, clx } from '@medusajs/ui'; +import { Button } from '@medusajs/ui'; +import { type ColumnDef, type Table as TanStackTable, flexRender } from '@tanstack/react-table'; +import { ArrowDownUp, ArrowDownWideNarrow, ArrowUpWideNarrow } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { getColumnHeaderClassName } from '../columnHelpers'; +import { TooltipColumnHeader } from './TooltipColumnHeader'; + +interface EditableTableContentProps> { + table: TanStackTable; + className?: string; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; +} + +export function EditableTableContent>({ + table, + className, + getTooltipContent, +}: EditableTableContentProps) { + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, headerIndex) => { + // Calculate left offset for pinned columns + let leftOffset = 0; + if (header.column.getIsPinned() === 'left') { + const pinnedColumns = table.getState().columnPinning.left || []; + const currentColumnIndex = pinnedColumns.indexOf(header.column.id); + if (currentColumnIndex > 0) { + // Sum up the widths of all previous pinned columns + for (let i = 0; i < currentColumnIndex; i++) { + const prevColumn = table.getColumn(pinnedColumns[i]); + if (prevColumn) { + leftOffset += prevColumn.getSize(); + } + } + } + } + + return ( + + {header.isPlaceholder ? null : ( +
>), + !header.column.getCanSort() && 'cursor-default', + )} + > + + + {flexRender(header.column.columnDef.header, header.getContext())} + + + + {/* Sort indicator */} + {header.column.getCanSort() && ( +
+ {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? ( + + )} +
+ )} +
+ )} + + {/* Column resizer */} + {header.column.getCanResize() && ( +
+ + )} + +
+ No results found. +
+
+
+ ); +} diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx new file mode 100644 index 0000000..82db6f1 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx @@ -0,0 +1,170 @@ +import { XMarkMini } from '@medusajs/icons'; +import { Button, Input, clx } from '@medusajs/ui'; +import type { Table as TanStackTable } from '@tanstack/react-table'; +import { type ChangeEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import type { EditableTableColumnDefinition } from '../types/cells'; +import { countActiveFilters, getActiveFilters } from '../utils/filterUtils'; +import { FilterChip } from './filters/FilterChip'; +import { FilterDropdown } from './filters/FilterDropdown'; + +interface EditableTableControlsProps> { + table: TanStackTable; + columnDefs: EditableTableColumnDefinition[]; + showGlobalFilter?: boolean; + showColumnFilters?: boolean; + className?: string; + searchDebounceMs?: number; // Configurable debounce time + // Additional action buttons to render on the right side + additionalActions?: ReactNode; +} + +/** + * EditableTableControls - Component for table controls like search and filters + * Includes global search functionality with debouncing and URL persistence + */ +export function EditableTableControls>({ + table, + columnDefs, + showGlobalFilter = false, + showColumnFilters = false, + className, + searchDebounceMs = 1000, // Default 1 second + additionalActions, +}: EditableTableControlsProps) { + // Use a ref to track the input element for uncontrolled behavior + const inputRef = useRef(null); + + // Track which column filter is being edited (for reopening dropdown) + const [editingColumnKey, setEditingColumnKey] = useState(null); + + // Debounced search handler with configurable delay + const debouncedSearch = useDebouncedCallback((value: string) => { + table.setGlobalFilter(value); + }, searchDebounceMs); + + // Get column filters from table state + const columnFilters = table.getState().columnFilters; + + // Get active filters - memoized with proper dependency + const activeFilters = useMemo(() => { + return getActiveFilters(columnFilters); + }, [columnFilters]); + + // Count active filters + const activeFilterCount = useMemo(() => { + return countActiveFilters(columnFilters); + }, [columnFilters]); + + // Handle remove individual filter + const handleRemoveFilter = useCallback( + (columnId: string) => { + const column = table.getColumn(columnId); + column?.setFilterValue(null); // null removes the filter + }, + [table], + ); + + // Handle clear all filters + const handleClearAllFilters = useCallback(() => { + table.resetColumnFilters(); + }, [table]); + + // Initialize input value from table state on mount + + // biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount + useEffect(() => { + if (inputRef.current) { + const currentGlobalFilter = table.getState().globalFilter || ''; + if (inputRef.current.value !== currentGlobalFilter) { + inputRef.current.value = currentGlobalFilter; + } + } + }, []); // Only run on mount + + // Handle search input change + const handleSearchChange = (e: ChangeEvent) => { + const value = e.target.value; + // No need to set state - input is uncontrolled + debouncedSearch(value); // Debounced filter update + }; + + // Early return if no controls are enabled + const hasControls = [showGlobalFilter, showColumnFilters, additionalActions].some(Boolean); + + if (!hasControls) return null; + + return ( +
+ {/* Top Row: Add Filter + Search + Additional Actions */} +
+ {/* Left Side: Add Filter + Search */} +
+ {/* Add Filter Dropdown */} + {showColumnFilters && ( + + )} + + {/* Global Search Filter */} + {showGlobalFilter && ( +
+ +
+ )} +
+ + {/* Right Side: Additional Actions */} + {additionalActions && ( +
{additionalActions}
+ )} +
+ + {/* Active Filters Row */} + {showColumnFilters && activeFilterCount > 0 && ( +
+ {Array.from(activeFilters.entries()).map(([columnId, values]) => { + const columnDef = columnDefs.find((col) => String(col.key) === columnId); + const columnName = columnDef?.name || columnId; + const columnKey = columnDef?.key; + + return ( + { + handleRemoveFilter(columnId); + setEditingColumnKey(null); + }} + onClick={() => { + if (columnKey) { + setEditingColumnKey(columnKey); + } + }} + /> + ); + })} + + {activeFilterCount > 1 && ( + + )} +
+ )} +
+ ); +} diff --git a/packages/medusa-forms/src/editable-table/components/EditableTablePagination.tsx b/packages/medusa-forms/src/editable-table/components/EditableTablePagination.tsx new file mode 100644 index 0000000..78774e5 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTablePagination.tsx @@ -0,0 +1,97 @@ +import { Button, Select } from '@medusajs/ui'; +import type { Table as TanStackTable } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; + +interface EditableTablePaginationProps> { + table: TanStackTable; + pageSizeOptions?: number[]; + labels?: { + rowsPerPage?: string; + firstPage?: string; + previousPage?: string; + nextPage?: string; + lastPage?: string; + }; +} + +const defaultPageSizeOptions = [20, 30, 40, 50, 100]; + +const defaultLabels = { + rowsPerPage: 'Rows per page', + firstPage: 'First page', + previousPage: 'Previous page', + nextPage: 'Next page', + lastPage: 'Last page', +}; + +export function EditableTablePagination>({ + table, + pageSizeOptions = defaultPageSizeOptions, + labels = defaultLabels, +}: EditableTablePaginationProps) { + return ( +
+
+

{labels.rowsPerPage}

+ +
+ +
+
+ + + + +
+
+
+ ); +} diff --git a/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx b/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx new file mode 100644 index 0000000..9c0eebe --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx @@ -0,0 +1,39 @@ +import { Button, Container, Text } from '@medusajs/ui'; +import { AlertCircle, RefreshCw } from 'lucide-react'; + +interface ErrorStateProps { + title: string; + message: string; + onRetry?: () => void; + showRetry?: boolean; +} + +export const ErrorState = ({ title, message, onRetry, showRetry = true }: ErrorStateProps) => { + return ( +
+ +
+
+
+ + {title} + {message} +
+ + {showRetry && ( + + )} +
+
+
+
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx b/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx new file mode 100644 index 0000000..81a227f --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx @@ -0,0 +1,52 @@ +import { Table, clx } from '@medusajs/ui'; + +interface TableSkeletonProps { + columns?: number; + rows?: number; + className?: string; +} + +const SkeletonRow = ({ columns }: { columns: number }) => ( + + {Array.from({ length: columns }).map((_, index) => ( + +
+
+
+ + ))} + +); + +const SkeletonHeader = ({ columns }: { columns: number }) => ( + + + {Array.from({ length: columns }).map((_, index) => ( + +
+ + ))} + + +); + +export const TableSkeleton = ({ columns = 6, rows = 8, className }: TableSkeletonProps) => { + return ( +
+
+
+
+ + + + {Array.from({ length: rows }).map((_, index) => ( + + ))} + +
+
+
+
+
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx b/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx new file mode 100644 index 0000000..a9ba024 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx @@ -0,0 +1,36 @@ +import { InformationCircleSolid } from '@medusajs/icons'; +import { Tooltip } from '@medusajs/ui'; +import type { ReactNode } from 'react'; + +interface TooltipColumnHeaderProps { + children: ReactNode; + columnKey: string; + columnName: string; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; +} + +export const TooltipColumnHeader = ({ + children, + columnKey, + columnName, + getTooltipContent, +}: TooltipColumnHeaderProps) => { + if (!getTooltipContent) { + return <>{children}; + } + + const tooltipContent = getTooltipContent(columnKey, columnName); + + if (!tooltipContent) { + return <>{children}; + } + + return ( +
+ {children} + + + +
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx new file mode 100644 index 0000000..a47aa74 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx @@ -0,0 +1,68 @@ +import { ArrowPath, Check, ExclamationCircle, LockClosedSolid, PencilSquare } from '@medusajs/icons'; +import { Tooltip, clx } from '@medusajs/ui'; +import type { ReactNode } from 'react'; +import type { CellStatus } from '../../types/cells'; + +export const CellStatusIndicator = ({ + status, + error, + className, +}: { + status: CellStatus; + error: string | null; + className?: string; +}) => { + let icon: ReactNode | null = null; + let tooltip: string | undefined; + + switch (status) { + case 'editing': { + icon = ; + tooltip = 'Editing...'; + break; + } + case 'saving': { + icon = ( +
+ ); + tooltip = 'Saving...'; + break; + } + case 'saved': { + icon = ; + tooltip = 'Saved successfully'; + break; + } + case 'error': { + icon = ; + tooltip = error || 'An error occurred'; + break; + } + case 'disabled': { + icon = ; + tooltip = 'Field is disabled'; + break; + } + case 'retry': { + icon = ; + tooltip = error ? `Retry... ${error}` : 'Retry...'; + break; + } + default: + icon = null; + } + + if (!icon) return null; + + return ( +
+ {tooltip ? ( + +
{icon}
+
+ ) : ( + icon + )} +
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/cells/cells.tsx b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx new file mode 100644 index 0000000..059cb3c --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx @@ -0,0 +1,71 @@ +import { StatusBadge } from '@medusajs/ui'; +import type { ComponentProps } from 'react'; +import type { CellContentProps } from '../../types/cells'; +import { AutocompleteCell } from '../editables/AutocompleteCell/AutocompleteCell'; +import { InputCell } from '../editables/InputCell'; + +const PlaceholderCell = () => { + return ( +
+ - +
+ ); +}; + +// Text cell component +const TextCell = ({ meta, value, actions, cellProps }: CellContentProps) => { + return ; +}; + +export type BadgeCellValue = { + status: 'active' | 'inactive'; + title: string; +}; + +// Badge cell component for arrays or multiple values +const BadgeCell = ({ value }: CellContentProps) => { + if (!value?.status) { + return ; + } + + const colorMap: { [key: string]: ComponentProps['color'] } = { + active: 'green', + inactive: 'red', + warning: 'orange', + }; + + const color = colorMap[value.status] ?? colorMap.inactive; + + return ( +
+ {value.title} +
+ ); +}; + +// Number cell component +const NumberCell = ({ meta, value = 0, actions, cellProps }: CellContentProps) => { + return ; +}; + +const ViewOnlyCell = ({ value }: CellContentProps) => { + return {Array.isArray(value) ? value.join(', ') : value?.toString()}; +}; + +// Main cell content component +export const CellContent = (props: CellContentProps) => { + switch (props.meta.type) { + case 'text': + return ; + case 'badge': + return ; + case 'number': + return ; + case 'select': + return ; + case 'autocomplete': + return ; + default: + return -; + } +}; diff --git a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx new file mode 100644 index 0000000..a4112ea --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { Input, clx } from '@medusajs/ui'; +import { + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, + type ReactNode, + type RefObject, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { AutocompleteSuggestion } from './AutocompleteSuggestion'; + +interface AutocompleteProps { + suggestions: string[]; + placeholder?: string; + onSelect?: (value: string) => void; + className?: string; + // Optional controlled props + value?: string; + onChange?: (value: string) => void; + inputRef?: RefObject; + /** Skip internal filtering - suggestions are already filtered */ + preFiltered?: boolean; + /** Control dropdown open state externally */ + open?: boolean; + // Event handlers + onBlur?: (e: FocusEvent) => void; + onFocus?: (e: FocusEvent) => void; + onKeyDown?: (e: KeyboardEvent) => void; + /** Tooltip content for each suggestion - can be string or React component */ + getTooltipContent?: (suggestion: string) => string | ReactNode; +} + +export function Autocomplete({ + suggestions, + placeholder = 'Search...', + onSelect, + className, + value: controlledValue, + onChange: controlledOnChange, + inputRef: externalInputRef, + preFiltered = false, + open: controlledOpen, + onBlur: externalOnBlur, + onFocus: externalOnFocus, + onKeyDown: externalOnKeyDown, + getTooltipContent, +}: AutocompleteProps) { + const [internalValue, setInternalValue] = useState(''); + const [internalIsOpen, setInternalIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); + const internalInputRef = useRef(null); + const listboxRef = useRef(null); + + // Use controlled or internal state + const isControlled = controlledValue !== undefined; + const inputValue = isControlled ? controlledValue : internalValue; + const inputRef = externalInputRef || internalInputRef; + const isOpenControlled = controlledOpen !== undefined; + const isOpen = isOpenControlled ? controlledOpen : internalIsOpen; + const setIsOpen = isOpenControlled ? () => undefined : setInternalIsOpen; + + const filteredSuggestions = useMemo(() => { + if (preFiltered) return suggestions; + if (!inputValue.trim()) return []; + return suggestions.filter((suggestion) => suggestion.toLowerCase().includes(inputValue.toLowerCase())); + }, [inputValue, suggestions, preFiltered]); + + useEffect(() => { + // Skip auto-open when open state is controlled externally + if (isOpenControlled) return; + + if (filteredSuggestions.length > 0) { + setIsOpen(true); + setHighlightedIndex(0); + } else { + setIsOpen(false); + } + }, [filteredSuggestions, isOpenControlled, setIsOpen]); + + const handleSelect = (value: string) => { + if (isControlled) { + controlledOnChange?.(value); + } else { + setInternalValue(value); + } + setIsOpen(false); + onSelect?.(value); + inputRef.current?.focus(); + }; + + const handleInputChange = (e: ChangeEvent) => { + const newValue = e.target.value; + if (isControlled) { + controlledOnChange?.(newValue); + } else { + setInternalValue(newValue); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + // Call external handler first + externalOnKeyDown?.(e); + + if (!isOpen || filteredSuggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + setHighlightedIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev)); + break; + } + case 'ArrowUp': { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + break; + } + case 'Enter': { + e.preventDefault(); + if (filteredSuggestions[highlightedIndex]) { + handleSelect(filteredSuggestions[highlightedIndex]); + } + break; + } + case 'Escape': { + e.preventDefault(); + setIsOpen(false); + break; + } + default: { + break; + } + } + }; + + const handleFocus = (e: FocusEvent) => { + externalOnFocus?.(e); + if (!isOpenControlled && filteredSuggestions.length > 0) { + setIsOpen(true); + } + }; + + const handleBlur = (e: FocusEvent) => { + // Check if the blur is happening because we're clicking inside the dropdown + const relatedTarget = e.relatedTarget as HTMLElement; + if (relatedTarget && listboxRef.current?.contains(relatedTarget)) { + // Don't close if clicking inside dropdown + return; + } + + externalOnBlur?.(e); + if (!isOpenControlled) { + setTimeout(() => setIsOpen(false), 200); + } + }; + + // Update dropdown position when opened + useEffect(() => { + if (isOpen && inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + 4, + left: rect.left, + width: rect.width, + }); + } + }, [isOpen, inputRef]); + + useEffect(() => { + if (isOpen && listboxRef.current) { + const highlightedElement = listboxRef.current.children[highlightedIndex] as HTMLElement; + highlightedElement?.scrollIntoView({ + block: 'nearest', + }); + } + }, [highlightedIndex, isOpen]); + + return ( + <> + + + {isOpen && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.map((suggestion, index) => ( + + ))} +
+ )} + + + {isOpen ? `${filteredSuggestions.length} suggestions available` : 'No suggestions'} + + + ); +} diff --git a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx new file mode 100644 index 0000000..b1c99d8 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx @@ -0,0 +1,99 @@ +import { clx } from '@medusajs/ui'; +import { type ChangeEvent, type ReactNode, type RefObject, useCallback } from 'react'; +import type { CellContentProps } from '../../../types/cells'; +import { getCellStatusClassName, getStatusIndicator } from '../../../utils/cell-status'; +import { CellStatusIndicator } from '../../cells/CellStatusIndicator'; +import { Autocomplete } from './Autocomplete'; +import { useAutocompleteCell } from './hooks'; + +export interface AutocompleteCellProps extends CellContentProps {} + +export const AutocompleteCell = ({ meta, value: defaultValue, actions, cellProps }: AutocompleteCellProps) => { + const { + inputValue, + isDropdownOpen, + filteredOptions, + cellState, + inputRef, + handleInputChange, + handleInputBlur, + handleInputFocus, + handleOptionSelect, + handleKeyDown, + } = useAutocompleteCell(defaultValue, actions, meta); + + const cellStatus = getStatusIndicator({ + isEditing: cellState.isEditing, + isSaving: cellState.isSaving, + canRetrySave: cellState.canRetrySave, + error: cellState.error, + justSaved: cellState.justSaved, + }); + + const showIndicator = cellStatus !== 'idle'; + + // Convert options to suggestions (just labels) + const suggestions = filteredOptions.map((opt) => opt.label); + + // Map selected label back to option + const handleSelect = (selectedLabel: string) => { + const option = filteredOptions.find((opt) => opt.label === selectedLabel); + if (option) { + handleOptionSelect(option); + } + }; + + // Handle change for controlled input + const handleChange = (value: string) => { + handleInputChange({ target: { value } } as ChangeEvent); + }; + + const getTooltipContent = useCallback( + (suggestion: string): ReactNode | null => { + const option = filteredOptions.find((opt) => opt.label === suggestion); + if (option?.usedBy?.length === undefined) { + return null; + } + + return ( +
+ {`${option.usedBy.length} item${option.usedBy.length > 1 ? 's' : ''} using this value:`} +
    + {option.usedBy.map((usedBy) => ( +
  • {usedBy.name}
  • + ))} +
+
+ ); + }, + [filteredOptions], + ); + + return ( +
+ } + preFiltered={true} + open={isDropdownOpen && suggestions.length > 0} + getTooltipContent={getTooltipContent} + placeholder="" + className={clx( + 'txt-compact-small size-full outline-none transition-all duration-200', + 'ring-0 focus:ring-0 focus:outline-none shadow-none focus:shadow-none', + 'truncate focus:text-clip', + getCellStatusClassName(cellStatus), + showIndicator && 'pr-8', + )} + /> + {showIndicator && } +
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx new file mode 100644 index 0000000..693cb09 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Button, Tooltip, clx } from '@medusajs/ui'; +import type { ReactNode } from 'react'; + +const highlightMatch = (text: string, query: string) => { + if (!query.trim()) return text; + + const parts = text.split(new RegExp(`(${query})`, 'gi')); + return ( + <> + {parts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +}; + +interface AutocompleteSuggestionProps { + suggestion: string; + index: number; + highlightedIndex: number; + inputValue: string; + getTooltipContent?: (suggestion: string) => string | ReactNode; + onSelect: (value: string) => void; + onMouseEnter: (index: number) => void; +} + +export const AutocompleteSuggestion = ({ + suggestion, + index, + highlightedIndex, + inputValue, + getTooltipContent, + onSelect, + onMouseEnter, +}: AutocompleteSuggestionProps) => { + const tooltipContent = getTooltipContent?.(suggestion); + + const button = ( + + ); + + return tooltipContent ? ( + + {button} + + ) : ( + button + ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts new file mode 100644 index 0000000..126ea07 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts @@ -0,0 +1,248 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDebounce } from 'use-debounce'; +import { useCellState } from '../../../hooks/useCellState'; +import type { EditableCellActions, EditableTableCellMeta } from '../../../types/cells'; +import { filterOptions, sortOptions } from './utils'; + +export const useAutocompleteOptions = ( + getOptionsFn: ((updatedValue: unknown) => Promise<{ label: string; value: unknown }[]>) | undefined, + inputValue: string | undefined, + meta: EditableTableCellMeta, +) => { + return useQuery<{ label: string; value: unknown }[]>({ + queryKey: ['table.autocomplete-options', meta.key, inputValue], + placeholderData: keepPreviousData, + queryFn: async () => { + if (!getOptionsFn) return []; + + const result = await getOptionsFn(inputValue); + return result; + }, + enabled: !!getOptionsFn, + }); +}; + +export const useAutocompleteCell = ( + defaultValue: string | undefined, + actions: { + save: EditableCellActions['save']; + getOptions: EditableCellActions['getOptions']; + }, + meta: EditableTableCellMeta, +) => { + const cellState = useCellState(); + const [inputValue, setInputValue] = useState(defaultValue || ''); + + // Debounce input value for getOptions calls + const [debouncedInputValue] = useDebounce(inputValue, 300); + + const { data: options = [] } = useAutocompleteOptions(actions.getOptions, debouncedInputValue, meta); + + const sortedOptions = useMemo(() => { + return sortOptions(options); + }, [options]); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // Filter options based on input value (search by label) + const filteredOptions = useMemo(() => { + return filterOptions(sortedOptions, inputValue, 10); + }, [inputValue, sortedOptions]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Reset highlighted index when filtered options change + // biome-ignore lint/correctness/useExhaustiveDependencies: only run when filtered options change + useEffect(() => { + setHighlightedIndex(-1); + }, [filteredOptions]); + + const hasValueChanged = useCallback( + (value: unknown) => { + return defaultValue?.toString() !== value?.toString(); + }, + [defaultValue], + ); + + const _save = useCallback( + async (value: unknown) => { + cellState.setIsEditing(false); + cellState.setIsSaving(true); + + const error = await actions.save(value).catch(() => { + cellState.setCanRetrySave(true); + + return 'An error occurred. Please try again.'; + }); + + cellState.setError(error); + cellState.setIsSaving(false); + }, + [actions, cellState], + ); + + const handleInputChange = useCallback( + (e: ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + + if (!hasValueChanged(newValue)) { + cellState.setIsSaving(false); + cellState.setIsEditing(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + }, + [cellState, hasValueChanged], + ); + + const handleInputBlur = useCallback( + async (e: FocusEvent) => { + // Don't trigger blur save if clicking on dropdown + if (dropdownRef.current?.contains(e.relatedTarget as Node)) { + return; + } + + const newValue = e.target.value; + + if (!hasValueChanged(newValue)) { + cellState.setIsSaving(false); + setIsDropdownOpen(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + + await _save(newValue); + setIsDropdownOpen(false); + }, + [_save, cellState, hasValueChanged], + ); + + const handleInputFocus = useCallback(() => { + setIsDropdownOpen(true); + }, []); + + const handleOptionSelect = useCallback( + async ({ value }: { label: string; value: unknown }) => { + setInputValue(value as string); + setIsDropdownOpen(false); + + if (!hasValueChanged(value as string)) { + cellState.setIsSaving(false); + cellState.setIsEditing(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + + await _save(value); + + // Return focus to input + inputRef.current?.focus(); + }, + [_save, cellState, hasValueChanged], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isDropdownOpen || filteredOptions.length === 0) { + if (e.key === 'ArrowDown') { + setIsDropdownOpen(true); + } + return; + } + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev)); + break; + } + case 'ArrowUp': { + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + } + case 'Enter': { + e.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + handleOptionSelect(filteredOptions[highlightedIndex]); + } else { + // Just close dropdown, let the debounced save handle it + setIsDropdownOpen(false); + } + break; + } + case 'Escape': { + e.preventDefault(); + setIsDropdownOpen(false); + setHighlightedIndex(-1); + break; + } + default: { + break; + } + } + }, + [isDropdownOpen, filteredOptions, highlightedIndex, handleOptionSelect], + ); + + return { + // State + inputValue, + isDropdownOpen, + highlightedIndex, + filteredOptions, + cellState, + + // Refs + inputRef, + dropdownRef, + + // Handlers + handleInputChange, + handleInputBlur, + handleInputFocus, + handleOptionSelect, + handleKeyDown, + setHighlightedIndex, + }; +}; diff --git a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/utils.tsx b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/utils.tsx new file mode 100644 index 0000000..9c51400 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/utils.tsx @@ -0,0 +1,36 @@ +import type { EditableCellActions } from '../../../types/cells'; + +// Sort options with priority: (new) first, (current) second, then alphabetical +export const sortOptions = (options: { label: string; value: unknown }[]) => { + return options.sort((a, b) => { + const aIsCurrent = a.label.endsWith(' (current)'); + const bIsCurrent = b.label.endsWith(' (current)'); + const aIsNew = a.label.endsWith(' (new)'); + const bIsNew = b.label.endsWith(' (new)'); + + // New options first + if (aIsNew && !bIsNew) return -1; + if (!aIsNew && bIsNew) return 1; + + // Current options second + if (aIsCurrent && !bIsCurrent && !bIsNew) return -1; + if (!aIsCurrent && bIsCurrent && !aIsNew) return 1; + + // Everything else alphabetically + return a.label.localeCompare(b.label); + }); +}; + +// Filter options based on input value (search by label) +export const filterOptions = ( + options: Awaited>, + inputValue: string, + maxResults = 10, +) => { + if (!inputValue.trim()) { + return options.slice(0, maxResults); // Show first 10 when empty + } + + const search = inputValue.toLowerCase(); + return options.filter((option) => option.label.toLowerCase().includes(search)).slice(0, maxResults); +}; diff --git a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx new file mode 100644 index 0000000..81db3e3 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -0,0 +1,127 @@ +import { Input, clx } from '@medusajs/ui'; +import { type ChangeEvent, useCallback } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import { useCellState } from '../../hooks/useCellState'; +import type { CellContentProps } from '../../types/cells'; +import { SAVE_DELAY_MS, getCellStatusClassName, getStatusIndicator } from '../../utils/cell-status'; +import { CellStatusIndicator } from '../cells/CellStatusIndicator'; + +const VALID_NUMBER_REGEX = /^\d+(\.\d+)?$/; + +function isValidNumberInput(value: string): boolean { + if (!value || value.trim() === '') return false; + + // Regex explanation: + // ^ - start of string + // \d+ - one or more digits (handles whole numbers like "0", "123") + // (\.\d+)? - optional group: dot followed by one or more digits (handles ".5", ".123") + // $ - end of string + return VALID_NUMBER_REGEX.test(value.trim()); +} + +export const InputCell = ({ + meta, + value: defaultValue, + actions, + cellProps, +}: CellContentProps) => { + const cellState = useCellState(); + const hasValueChanged = useCallback( + (value: string | number | undefined) => { + return defaultValue?.toString() !== value?.toString(); + }, + [defaultValue], + ); + + const _save = useCallback( + async (e: ChangeEvent) => { + cellState.setIsEditing(false); + + if (meta.type === 'number' && !isValidNumberInput(e.target.value)) { + cellState.setError('Please enter a valid number'); + + return; + } + + cellState.setIsSaving(true); + + const error = await actions.save(e.target.value).catch(() => { + cellState.setCanRetrySave(true); + + return 'An error occurred. Please try again.'; + }); + + cellState.setError(error); + cellState.setIsSaving(false); + }, + [actions, cellState, meta.type], + ); + + const debouncedSave = useDebouncedCallback(_save, SAVE_DELAY_MS); + + const onChangeHandler = useCallback( + async (e: ChangeEvent) => { + if (!hasValueChanged(e.target.value)) { + cellState.setIsSaving(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + + await debouncedSave(e); + }, + [debouncedSave, cellState, hasValueChanged], + ); + const onBlurHandler = useCallback( + async (e: ChangeEvent) => { + if (!hasValueChanged(e.target.value)) { + cellState.setIsSaving(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + + debouncedSave.cancel(); + await _save(e); + }, + [debouncedSave, _save, cellState, hasValueChanged], + ); + + const cellStatus = getStatusIndicator({ + isEditing: cellState.isEditing, + isSaving: cellState.isSaving, + canRetrySave: cellState.canRetrySave, + error: cellState.error, + justSaved: cellState.justSaved, + }); + + const showIndicator = cellStatus !== 'idle'; + const showLeftIndicator = showIndicator && meta.type === 'number'; + const showRightIndicator = showIndicator && meta.type === 'text'; + + return ( +
+ {showLeftIndicator && } + + {showRightIndicator && } +
+ ); +}; diff --git a/packages/medusa-forms/src/editable-table/components/filters/FilterChip.tsx b/packages/medusa-forms/src/editable-table/components/filters/FilterChip.tsx new file mode 100644 index 0000000..f3cc3cd --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/filters/FilterChip.tsx @@ -0,0 +1,65 @@ +import { XMarkMini } from '@medusajs/icons'; +import { Button, clx } from '@medusajs/ui'; +import { memo } from 'react'; +import { formatFilterValue } from '../../utils/filterUtils'; + +export interface FilterChipProps { + columnName: string; + values: string[]; + onRemove: () => void; + onClick?: () => void; + className?: string; +} + +/** + * FilterChip - Displays an active column filter with remove button + * Memoized to prevent unnecessary re-renders + */ +export const FilterChip = memo(({ columnName, values, onRemove, onClick, className }) => { + // Format the filter values for display + const displayValue = values.length === 1 ? formatFilterValue(values[0]) : `${values.length} selected`; + + return ( +
+
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } + className={clx('inline-flex items-center gap-1.5', onClick ? 'cursor-pointer' : 'cursor-default', className)} + > + {columnName}: + {displayValue} +
+ +
+ ); +}); + +FilterChip.displayName = 'FilterChip'; diff --git a/packages/medusa-forms/src/editable-table/components/filters/FilterDropdown.tsx b/packages/medusa-forms/src/editable-table/components/filters/FilterDropdown.tsx new file mode 100644 index 0000000..18e8174 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/filters/FilterDropdown.tsx @@ -0,0 +1,312 @@ +import { Funnel } from '@medusajs/icons'; +import { Button, Checkbox, DropdownMenu, Input, Text } from '@medusajs/ui'; +import type { Table } from '@tanstack/react-table'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { EditableTableColumnDefinition } from '../../types/cells'; +import { + EMPTY_FILTER_VALUE, + formatFilterValue, + getFilterValueCounts, + getFilterableColumns, + getUniqueColumnValues, +} from '../../utils/filterUtils'; + +export interface FilterDropdownProps> { + table: Table; + columnDefs: EditableTableColumnDefinition[]; + className?: string; + initialColumn?: keyof T; // Allow opening with a pre-selected column + onEditingColumnChange?: (column: keyof T | null) => void; // Callback to clear editing state +} + +/** + * FilterDropdown - Dropdown for adding column filters + * Memoized to prevent unnecessary re-renders + */ +function FilterDropdownComponent>({ + table, + columnDefs, + className, + initialColumn, + onEditingColumnChange, +}: FilterDropdownProps) { + const [selectedColumn, setSelectedColumn] = useState(null); + const [pendingValues, setPendingValues] = useState>(new Set()); + const [searchValue, setSearchValue] = useState(''); + const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false); + const [isValueMenuOpen, setIsValueMenuOpen] = useState(false); + + // Handle initialColumn changes (when user clicks a filter chip) + useEffect(() => { + if (initialColumn && initialColumn !== selectedColumn) { + // Initialize column selection and pending values + const filter = table.getState().columnFilters.find((f) => f.id === String(initialColumn)); + const existingValues = filter && Array.isArray(filter.value) ? filter.value : []; + + setSelectedColumn(initialColumn); + setPendingValues(new Set(existingValues)); + setSearchValue(''); + setIsValueMenuOpen(true); + + // Clear the editing state after opening + if (onEditingColumnChange) { + onEditingColumnChange(null); + } + } + }, [initialColumn, selectedColumn, table, onEditingColumnChange]); + + // Get filterable columns that are currently visible + const filterableColumns = useMemo(() => { + const columns = getFilterableColumns(columnDefs); + return columns.filter((col) => { + const column = table.getColumn(String(col.key)); + return column?.getIsVisible(); + }); + }, [columnDefs, table]); + + // Get unique values for the selected column (from all rows, not just filtered) + const columnValues = useMemo(() => { + if (!selectedColumn) return []; + + const rows = table.getCoreRowModel().rows; // Get all rows, not just filtered ones + const columnDef = columnDefs.find((col) => col.key === selectedColumn); + return getUniqueColumnValues(rows, selectedColumn, columnDef); + }, [selectedColumn, table, columnDefs]); + + // Get counts for each filter value in the selected column + const filterValueCounts = useMemo(() => { + if (!selectedColumn) return new Map(); + + const rows = table.getCoreRowModel().rows; // Get all rows, not just filtered ones + const columnDef = columnDefs.find((col) => col.key === selectedColumn); + return getFilterValueCounts(rows, selectedColumn, columnDef); + }, [selectedColumn, table, columnDefs]); + + // Filter column values based on search + const filteredValues = useMemo(() => { + if (!searchValue.trim()) return columnValues; + + const search = searchValue.toLowerCase(); + return columnValues.filter((value) => formatFilterValue(value).toLowerCase().includes(search)); + }, [columnValues, searchValue]); + + // Get current filter values for selected column from table state + const currentFilterValues = useMemo(() => { + if (!selectedColumn) return []; + + const filter = table.getState().columnFilters.find((f) => f.id === String(selectedColumn)); + if (!filter) return []; + + return Array.isArray(filter.value) ? filter.value : [filter.value]; + }, [selectedColumn, table]); + + // Initialize pending values when column is selected or changed + const handleColumnSelect = useCallback( + (columnKey: keyof T) => { + setSelectedColumn(columnKey); + + // Initialize pending values with current filter values for this column + const filter = table.getState().columnFilters.find((f) => f.id === String(columnKey)); + const existingValues = filter && Array.isArray(filter.value) ? filter.value : []; + setPendingValues(new Set(existingValues)); + + setSearchValue(''); + setIsColumnMenuOpen(false); + setIsValueMenuOpen(true); + }, + [table], + ); + + // Handle value toggle (only updates local state, not table state) + const handleValueToggle = useCallback((value: string) => { + setPendingValues((prev) => { + const newValues = new Set(prev); + if (newValues.has(value)) { + newValues.delete(value); + } else { + newValues.add(value); + } + return newValues; + }); + }, []); + + // Handle apply (apply pending values to table state and close) + const handleApply = useCallback(() => { + if (!selectedColumn) return; + + const column = table.getColumn(String(selectedColumn)); + if (!column) return; + + // Apply pending values to table state + if (pendingValues.size > 0) { + column.setFilterValue(Array.from(pendingValues)); + } else { + // Clear the filter (null removes it completely) + column.setFilterValue(null); + } + + // Reset state and close dropdown + setSelectedColumn(null); + setIsValueMenuOpen(false); + setIsColumnMenuOpen(false); + setSearchValue(''); + setPendingValues(new Set()); + }, [selectedColumn, table, pendingValues]); + + // Handle cancel (revert to current filter values) + const handleCancel = useCallback(() => { + setSelectedColumn(null); + setIsValueMenuOpen(false); + setIsColumnMenuOpen(false); + setSearchValue(''); + setPendingValues(new Set()); + }, []); + + // Handle value menu close (when user clicks outside) + const handleValueMenuOpenChange = useCallback( + (open: boolean) => { + setIsValueMenuOpen(open); + + // If closing and a column is selected, reset it + if (!open && selectedColumn) { + setSelectedColumn(null); + setSearchValue(''); + setPendingValues(new Set()); + } + }, + [selectedColumn], + ); + + // Get column name for display + const selectedColumnName = useMemo(() => { + if (!selectedColumn) return ''; + return columnDefs.find((col) => col.key === selectedColumn)?.name || ''; + }, [selectedColumn, columnDefs]); + + // Determine if we're clearing an existing filter + const isClearingFilter = useMemo(() => { + return pendingValues.size === 0 && currentFilterValues.length > 0; + }, [pendingValues.size, currentFilterValues.length]); + + // Determine button state and text + const applyButtonText = isClearingFilter ? 'Clear' : 'Apply'; + const applyButtonDisabled = pendingValues.size === 0 && !isClearingFilter; + + return ( +
+ {/* Column Selection Dropdown */} + {!selectedColumn && ( + + + + + + {filterableColumns.length === 0 ? ( +
No filterable columns available
+ ) : ( + filterableColumns.map((col) => ( + handleColumnSelect(col.key)} + className="cursor-pointer" + > + {col.name} + + )) + )} +
+
+ )} + + {/* Value Selection Dropdown */} + {selectedColumn && ( + + + + + + {/* Search Input */} +
+
+ setSearchValue(e.target.value)} + className="pl-8" + size="small" + /> +
+
+ + {/* Value List */} +
+ {filteredValues.length === 0 ? ( +
No values found
+ ) : ( +
+ {filteredValues.map((value) => { + const isChecked = pendingValues.has(value); + const displayValue = formatFilterValue(value); + const isEmptyValue = value === EMPTY_FILTER_VALUE; + const count = filterValueCounts.get(value) || 0; + + return ( +
handleValueToggle(value)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleValueToggle(value); + } + }} + className="flex items-center gap-2 px-3 py-2 hover:bg-ui-bg-base-hover cursor-pointer" + > + handleValueToggle(value)} + onClick={(e) => e.stopPropagation()} + /> +
+ + {displayValue} + + + ({count}) + +
+
+ ); + })} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ )} +
+ ); +} + +// Memoize to prevent unnecessary re-renders +export const FilterDropdown = memo(FilterDropdownComponent) as typeof FilterDropdownComponent; diff --git a/packages/medusa-forms/src/editable-table/hooks/useCellState.ts b/packages/medusa-forms/src/editable-table/hooks/useCellState.ts new file mode 100644 index 0000000..3a8f8eb --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useCellState.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; + +export const useCellState = () => { + const [error, setError] = useState(null); + const [canRetrySave, setCanRetrySave] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSavingState] = useState(false); + const [justSaved, setJustSavedState] = useState(false); + + const setJustSaved = useCallback(() => { + setJustSavedState(true); + setTimeout(() => { + setJustSavedState(false); + }, 2000); + }, []); + + const setIsSaving = useCallback( + (newSaving: boolean) => { + setIsSavingState((prev) => { + const wasSaving = prev; + + if (wasSaving && !newSaving) { + // status just changed from saving to not saving + setJustSaved(); + } + + return newSaving; + }); + }, + [setJustSaved], + ); + + return { error, canRetrySave, isEditing, isSaving, justSaved, setError, setCanRetrySave, setIsEditing, setIsSaving }; +}; diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts new file mode 100644 index 0000000..032cf98 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react'; +import type { + CellActionsHandlerGetter, + EditableCellActionHandler, + EditableCellActions, + GetCellActionsFn, +} from '../types/cells'; + +export const useEditableCellActions = ({ + getValidateHandler, + getSaveHandler, + getOptionsHandler, +}: { + getValidateHandler: CellActionsHandlerGetter; + getSaveHandler: CellActionsHandlerGetter; + getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]>; +}): GetCellActionsFn => { + return useCallback( + ({ meta, data, table }): EditableCellActions => { + const validateHandler = getValidateHandler?.(meta.key) || (() => null); + const saverHandler: EditableCellActionHandler = async (args) => { + const saveHandler = getSaveHandler?.(meta.key); + const validationError = validateHandler ? await validateHandler(args) : null; + + if (validationError) { + return validationError; + } + + if (!saveHandler) { + // Consistent error surface; do not throw in UI flow + return `No save handler available for ${meta.name}`; + } + + return saveHandler(args); + }; + const optionsHandler = + getOptionsHandler?.(meta.key) || (async (): Promise<{ label: string; value: unknown }[]> => []); + + return { + save: async (updatedValue: unknown): Promise => { + return await saverHandler({ + value: updatedValue, + meta, + data, + table, + }); + }, + validate: async (updatedValue: unknown): Promise => { + return await validateHandler({ + value: updatedValue, + meta, + data, + table, + }); + }, + getOptions: async (updatedValue: unknown): Promise<{ label: string; value: unknown }[]> => { + return await optionsHandler({ + meta, + data, + value: updatedValue, + table, + }); + }, + }; + }, + [getValidateHandler, getSaveHandler, getOptionsHandler], + ); +}; diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts new file mode 100644 index 0000000..ef752d3 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts @@ -0,0 +1,226 @@ +import type { Row, RowSelectionState } from '@tanstack/react-table'; +import { + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { useMemo } from 'react'; + +import type { EditableTableConfig, EditableTableInstance } from '../types/cells'; +import { columnFilterFn } from '../utils/filterUtils'; +import { createGlobalFilterFn } from '../utils/searchUtils'; +import { useEditableTableColumns } from './useEditableTableColumns'; +import { useEditableTableUrlState } from './useEditableTableUrlState'; + +// Main hook for EditableTable functionality +export function useEditableTable>(config: EditableTableConfig) { + const { + data, + editableColumns, + enableGlobalFilter = true, + enableColumnFilters = true, + enableSorting = true, + enablePagination = true, + onView, + onDelete, + getCellActions, + dynamicColumnFilters, + } = config; + + // URL state management + const { tableState, updateTableState, resetTableState } = useEditableTableUrlState( + editableColumns, + dynamicColumnFilters, + ); + + // Create column definitions + const columns = useEditableTableColumns({ + columnDefs: editableColumns, + getCellActions, + onView, + onDelete, + enableRowSelection: config.enableRowSelection, + rowSelection: config.rowSelection, + onRowSelectionChange: config.onRowSelectionChange, + }); + + // Create custom global filter function that supports calculated values + const globalFilterFn = useMemo(() => createGlobalFilterFn(editableColumns), [editableColumns]); + + // Create custom column filter function that supports calculated values and empty values + const customColumnFilterFn = useMemo( + () => (row: Row, columnId: string, filterValue: unknown) => + columnFilterFn( + row, + columnId, + filterValue, + editableColumns.find((col) => String(col.key) === columnId), + ), + [editableColumns], + ); + + // Memoize table configuration to prevent re-renders + const tableConfig = useMemo( + () => ({ + data, + columns, + // Use row ID for stable row selection (assumes data has 'id' field) + getRowId: (row: T, index: number) => { + // Try common ID field names + if ('id' in row && typeof row.id === 'string') return row.id; + if ('id' in row && typeof row.id === 'number') return String(row.id); + // Fallback to index if no ID field found (not ideal but required for TanStack Table) + return String(index); + }, + // Core features + getCoreRowModel: getCoreRowModel(), + + // Filtering + getFilteredRowModel: enableColumnFilters ? getFilteredRowModel() : undefined, + enableGlobalFilter, + enableColumnFilters, + globalFilterFn, + // Default column filter function for all columns + defaultColumn: { + filterFn: customColumnFilterFn, + }, + + // Sorting + getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, + enableSorting, + enableMultiSort: true, + + // Pagination + getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, + + // State management + state: { + globalFilter: tableState.globalFilter, + columnFilters: tableState.columnFilters, + sorting: tableState.sorting, + pagination: tableState.pagination, + ...(config.enableRowSelection && config.rowSelection !== undefined + ? { rowSelection: config.rowSelection } + : {}), + }, + + // State update handlers + onGlobalFilterChange: updateTableState.setGlobalFilter, + onColumnFiltersChange: updateTableState.setColumnFilters, + onSortingChange: updateTableState.setSorting, + onPaginationChange: updateTableState.setPagination, + ...(config.enableRowSelection && config.onRowSelectionChange + ? { + onRowSelectionChange: ( + updaterOrValue: RowSelectionState | ((old: RowSelectionState) => RowSelectionState), + ) => { + if (typeof updaterOrValue === 'function') { + const currentSelection = config.rowSelection || {}; + const newSelection = updaterOrValue(currentSelection); + config.onRowSelectionChange?.(newSelection); + } else { + config.onRowSelectionChange?.(updaterOrValue); + } + }, + } + : {}), + + // Manual pagination for server-side pagination (if needed) + manualPagination: false, + manualSorting: false, + manualFiltering: false, + }), + [ + data, + columns, + enableColumnFilters, + enableGlobalFilter, + enableSorting, + enablePagination, + tableState, + updateTableState, + globalFilterFn, + customColumnFilterFn, + config.enableRowSelection, + config.rowSelection, + config.onRowSelectionChange, + ], + ); + + // Create table instance + const table = useReactTable(tableConfig) as EditableTableInstance; + + // Add helper method to get row data + table.getRowData = (rowIndex: number) => { + const row = table.getRowModel().rows[rowIndex]; + return row?.original; + }; + + // Table utilities + const tableUtils = { + // Reset all table state + resetTable: resetTableState, + + // Filtering helpers + setGlobalFilter: (value: string) => { + table.setGlobalFilter(value); + }, + + clearGlobalFilter: () => { + table.setGlobalFilter(''); + }, + + setColumnFilter: (columnId: string, value: unknown) => { + table.getColumn(columnId)?.setFilterValue(value); + }, + + clearColumnFilter: (columnId: string) => { + table.getColumn(columnId)?.setFilterValue(undefined); + }, + + clearAllFilters: () => { + table.resetColumnFilters(); + table.resetGlobalFilter(); + }, + + // Sorting helpers + sortColumn: (columnId: string, desc = false) => { + table.getColumn(columnId)?.toggleSorting(desc); + }, + + clearSorting: () => { + table.resetSorting(); + }, + + // Pagination helpers + goToPage: (pageIndex: number) => { + table.setPageIndex(pageIndex); + }, + + nextPage: () => { + table.nextPage(); + }, + + previousPage: () => { + table.previousPage(); + }, + + setPageSize: (pageSize: number) => { + table.setPageSize(pageSize); + }, + + // Data helpers + getSelectedRows: () => table.getSelectedRowModel().rows, + getFilteredRows: () => table.getFilteredRowModel().rows, + getAllRows: () => table.getCoreRowModel().rows, + }; + + return { + table, + tableState, + updateTableState, + ...tableUtils, + }; +} diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx new file mode 100644 index 0000000..be3ccc6 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -0,0 +1,175 @@ +import { EllipsisHorizontal, Eye, Trash } from '@medusajs/icons'; +import { Checkbox, DropdownMenu } from '@medusajs/ui'; +import type { ColumnDef } from '@tanstack/react-table'; +import { useMemo } from 'react'; +import { canSortColumn, getDefaultColumnSizing, getSortingFunction } from '../columnHelpers'; +import { CellContent } from '../components/cells/cells'; +import type { + EditableTableCellMeta, + EditableTableColumnDefinition, + EditableTableInstance, + GetCellActionsFn, +} from '../types/cells'; + +// Hook to create columns for the EditableTable +export const useEditableTableColumns = >({ + columnDefs, + getCellActions, + onView, + onDelete, + enableRowSelection = false, + rowSelection, + onRowSelectionChange, +}: { + columnDefs: EditableTableColumnDefinition[]; + getCellActions: GetCellActionsFn; + onView?: (item: T) => void; + onDelete?: (item: T) => void; + enableRowSelection?: boolean; + rowSelection?: Record; + onRowSelectionChange?: (rowSelection: Record) => void; +}): ColumnDef[] => { + const columns: ColumnDef[] = useMemo(() => { + const editableColumns = columnDefs.map((columnDef) => _createEditableTableColumn(columnDef, getCellActions)); + const actionsColumn = _createActionsColumn(onView, onDelete); + const checkboxColumn = enableRowSelection ? _createCheckboxColumn(rowSelection, onRowSelectionChange) : null; + + const allColumns: ColumnDef[] = []; + if (checkboxColumn) allColumns.push(checkboxColumn); + allColumns.push(...editableColumns); + if (actionsColumn) allColumns.push(actionsColumn); + + return allColumns as ColumnDef[]; + }, [columnDefs, getCellActions, enableRowSelection, rowSelection, onRowSelectionChange, onView, onDelete]); + + return columns; +}; + +// Create a TanStack Table column definition from EditableTable column definition +function _createEditableTableColumn>( + columnDef: EditableTableColumnDefinition, + getCellActions: GetCellActionsFn, +): ColumnDef { + const defaultSize = getDefaultColumnSizing(columnDef.type); + const minSize = columnDef.minWidth || defaultSize; + const maxSize = Math.max(columnDef.maxWidth || 0, Math.max(minSize, 380)); + const fieldKey = columnDef.getFieldKey?.(columnDef.key) || columnDef.key; + const columnMeta: EditableTableCellMeta = { + name: columnDef.name, + type: columnDef.type, + description: columnDef.description, + key: fieldKey, + }; + + return { + id: columnDef.key, + accessorKey: columnDef.key, + accessorFn: (row) => row[fieldKey], + header: columnDef.name, + size: minSize, + minSize: minSize, + maxSize: maxSize, + enableSorting: (columnDef.enableSorting ?? false) && canSortColumn(columnDef.type), + enableColumnFilter: columnDef.enableFiltering ?? true, + enableHiding: columnDef.enableHiding, + enablePinning: columnDef.isPinnable ?? false, + enableResizing: false, + cell: ({ getValue, row, table }) => { + const value = getValue() as string; + const rowData = row.original; + + const cellActions = getCellActions({ + meta: columnMeta, + data: rowData, + table: table as EditableTableInstance>, + }); + + const calculatedValue = columnDef.calculateValue ? columnDef.calculateValue(fieldKey, rowData) : value; + + return ( + + ); + }, + // Custom sort function for different field types + sortingFn: getSortingFunction(columnDef.type), + meta: { ...columnMeta }, + }; +} + +// Create checkbox column for row selection +function _createCheckboxColumn>( + _rowSelection?: Record, + _onRowSelectionChange?: (rowSelection: Record) => void, +): ColumnDef { + return { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 50, + minSize: 50, + maxSize: 50, + enableSorting: false, + enableColumnFilter: false, + enableHiding: false, + enablePinning: false, + }; +} + +// Create actions column with Medusa DropdownMenu +function _createActionsColumn>( + onView?: (item: T) => void, + onDelete?: (item: T) => void, +): ColumnDef | null { + if (!(onView || onDelete)) return null; + + return { + id: 'actions', + header: '', + size: 50, + minSize: 50, + maxSize: 50, + enableSorting: false, + enableColumnFilter: false, + enableHiding: false, + enablePinning: false, + cell: ({ row }) => { + const item = row.original; + + return ( + + + + + + {onView && ( + onView(item)} className="gap-x-2"> + + View + + )} + {onDelete && ( + onDelete(item)} className="gap-x-2"> + + Delete + + )} + + + ); + }, + }; +} diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts new file mode 100644 index 0000000..bfa1bb2 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts @@ -0,0 +1,320 @@ +import type { ColumnFiltersState, PaginationState, SortingState, Updater } from '@tanstack/react-table'; +import { createParser, parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useCallback, useMemo, useRef } from 'react'; +import type { EditableTableColumnDefinition } from '../types/cells'; +import { + buildDynamicGroupNames, + deserializeColumnFilters, + encodeColumnKeyForUrl, + matchesPattern, + serializeColumnFilters, +} from '../utils/columnFilterStateUtils'; + +// Compact URL state for EditableTable +export type EditableTableUrlState = { + q: string; // Global search/filter + sort: string; // Sort column with optional - prefix for desc + page: number; // Current page + pageSize: number; // Page size + // Column filters are added dynamically as cf_columnKey: string[] + // Dynamic filters are added as multi-parsers (e.g., location_levels: string[]) +}; + +// Default values (not persisted in URL when default) +const DEFAULT_VALUES: EditableTableUrlState = { + q: '', + sort: '', + page: 0, + pageSize: 20, +}; + +// Create parameter keys with optional table ID prefix +function createParameterKeys(tableId = '') { + const prefix = tableId ? `${tableId}_` : ''; + + return { + q: `${prefix}q`, + sort: `${prefix}sort`, + page: `${prefix}page`, + pageSize: `${prefix}pageSize`, + }; +} + +// Serialize sorting to compact format +function serializeSorting(sorting: SortingState): string { + if (sorting.length === 0) return ''; + + const sort = sorting[0]; // Only single column sorting + return sort.desc ? `-${sort.id}` : sort.id; +} + +// Deserialize sorting from compact format +function deserializeSorting(sortStr: string): SortingState { + if (!sortStr.trim()) return []; + + const isDesc = sortStr.startsWith('-'); + const columnId = isDesc ? sortStr.slice(1) : sortStr; + + return [{ id: columnId, desc: isDesc }]; +} + +// Find first non-avatar column for default sorting +function getDefaultSortColumn>( + columnsDef: EditableTableColumnDefinition[], +): string { + // First try core columns + for (const column of columnsDef) { + if (column.enableSorting !== false) { + return String(column.key); + } + } + + // Fallback to first column + return columnsDef.length > 0 ? String(columnsDef[0].key) : ''; +} + +export function useEditableTableUrlState>( + columnsDef: EditableTableColumnDefinition[], + dynamicColumnFilters?: string[], +) { + const paramKeys = createParameterKeys(); + + // Flag to prevent circular updates when we update URL state + const isUpdatingFromUrl = useRef(false); + + // Get default sort column + const defaultSortColumn = useMemo(() => getDefaultSortColumn(columnsDef), [columnsDef]); + + // Build a set of dynamic group names + const dynamicGroupNames = useMemo(() => buildDynamicGroupNames(dynamicColumnFilters), [dynamicColumnFilters]); + + // Build parsers for regular column filters (non-dynamic) + const columnFilterParsers = useMemo(() => { + const parsers: Record>> = {}; + + for (const column of columnsDef) { + if (column.enableFiltering === false) continue; + + const columnKey = String(column.key); + + // Check if this column matches any dynamic pattern + let isDynamic = false; + if (dynamicColumnFilters) { + for (const pattern of dynamicColumnFilters) { + if (matchesPattern(columnKey, pattern)) { + isDynamic = true; + break; + } + } + } + + // Only create cf_* parser if not dynamic + if (!isDynamic) { + const encodedKey = encodeColumnKeyForUrl(columnKey); + const filterKey = `cf_${encodedKey}`; + parsers[filterKey] = parseAsArrayOf(parseAsString).withDefault([]); + } + } + + return parsers; + }, [columnsDef, dynamicColumnFilters]); + + // Build multi-parsers for dynamic column groups (with cf_ prefix) + const dynamicFilterParsers = useMemo(() => { + const parsers: Record>> = {}; + + for (const groupName of dynamicGroupNames) { + // Create a multi-parser for this group with cf_ prefix + parsers[`cf_${groupName}`] = createParser({ + parse: (value: string) => { + // Parse comma-separated values + return value.split(',').filter((v) => v.trim()); + }, + serialize: (value: string[]) => { + // Serialize to comma-separated string + return value.join(','); + }, + }).withDefault([]); + } + + return parsers; + }, [dynamicGroupNames]); + + // Combine base parsers with column filter parsers and dynamic parsers + const allParsers = useMemo( + () => ({ + [paramKeys.q]: parseAsString.withDefault(DEFAULT_VALUES.q), + [paramKeys.sort]: parseAsString.withDefault(defaultSortColumn), + [paramKeys.page]: parseAsInteger.withDefault(DEFAULT_VALUES.page), + [paramKeys.pageSize]: parseAsInteger.withDefault(DEFAULT_VALUES.pageSize), + ...columnFilterParsers, + ...dynamicFilterParsers, + }), + [paramKeys, defaultSortColumn, columnFilterParsers, dynamicFilterParsers], + ); + + // Use nuqs for URL state management + const [urlState, setUrlState] = useQueryStates(allParsers, { + // Clear defaults from URL + clearOnDefault: true, + }); + + // Memoize pagination state separately to prevent circular updates + const paginationState = useMemo( + () => ({ + pageIndex: Number(urlState[paramKeys.page] || DEFAULT_VALUES.page), + pageSize: Number(urlState[paramKeys.pageSize] || DEFAULT_VALUES.pageSize), + }), + [urlState[paramKeys.page], urlState[paramKeys.pageSize], paramKeys.page, paramKeys.pageSize], + ); + + // Extract column filters from URL state - memoized to prevent re-renders + const columnFiltersState = useMemo(() => { + return deserializeColumnFilters(urlState as Record, dynamicColumnFilters); + }, [urlState, dynamicColumnFilters]); + + // Convert URL state to TanStack Table state - memoized with stable references + const tableState = useMemo(() => { + const globalFilter = String(urlState[paramKeys.q] || ''); + const sorting = deserializeSorting(String(urlState[paramKeys.sort] || defaultSortColumn)); + + return { + globalFilter, + columnFilters: columnFiltersState, + sorting, + pagination: paginationState, + }; + }, [ + urlState[paramKeys.q], + urlState[paramKeys.sort], + paramKeys.q, + paramKeys.sort, + defaultSortColumn, + columnFiltersState, + paginationState, + ]); + + // Helper function to handle updater functions + const handleUpdater = useCallback((updaterOrValue: Updater, currentValue: T): T => { + return typeof updaterOrValue === 'function' ? (updaterOrValue as (old: T) => T)(currentValue) : updaterOrValue; + }, []); + + // Update functions for each state property - memoized separately to avoid re-renders + const setGlobalFilter = useCallback( + (updaterOrValue: Updater) => { + const newValue = handleUpdater(updaterOrValue, tableState.globalFilter); + setUrlState({ + [paramKeys.q]: newValue || null, // null removes from URL if empty + }); + }, + [tableState.globalFilter, setUrlState, paramKeys.q, handleUpdater], + ); + + const setSorting = useCallback( + (updaterOrValue: Updater) => { + const newValue = handleUpdater(updaterOrValue, tableState.sorting); + const serialized = serializeSorting(newValue); + setUrlState({ + [paramKeys.sort]: serialized || defaultSortColumn, // fallback to default + }); + }, + [tableState.sorting, setUrlState, paramKeys.sort, handleUpdater, defaultSortColumn], + ); + + const setColumnFilters = useCallback( + (updaterOrValue: Updater) => { + const currentValue = columnFiltersState; + const newValue = handleUpdater(updaterOrValue, currentValue); + + // Serialize both old and new states to determine what changed + const currentSerialized = serializeColumnFilters(currentValue, dynamicColumnFilters); + const newSerialized = serializeColumnFilters(newValue, dynamicColumnFilters); + + // Build updates object: first clear old keys, then set new values + const updates: Record = {}; + + // Clear all old filter keys (set to null) + for (const key of Object.keys(currentSerialized)) { + updates[key] = null; + } + + // Set new filter values + for (const [key, value] of Object.entries(newSerialized)) { + updates[key] = value; + } + + setUrlState(updates); + }, + [columnFiltersState, setUrlState, handleUpdater, dynamicColumnFilters], + ); + + const setPagination = useCallback( + (updaterOrValue: Updater) => { + // Prevent circular updates + if (isUpdatingFromUrl.current) { + return; + } + + const newValue = handleUpdater(updaterOrValue, tableState.pagination); + + // Check if the value actually changed + const hasChanged = + newValue.pageIndex !== tableState.pagination.pageIndex || newValue.pageSize !== tableState.pagination.pageSize; + + // Only update URL if values actually changed + if (hasChanged) { + isUpdatingFromUrl.current = true; + setUrlState({ + [paramKeys.page]: newValue.pageIndex !== undefined ? newValue.pageIndex : null, + [paramKeys.pageSize]: newValue.pageSize || null, + }); + // Reset flag after a short delay to allow URL update to complete + setTimeout(() => { + isUpdatingFromUrl.current = false; + }, 100); + } + }, + [tableState.pagination, setUrlState, paramKeys.page, paramKeys.pageSize, handleUpdater], + ); + + // Stable reference for updateTableState + const updateTableState = useMemo( + () => ({ + setGlobalFilter, + setSorting, + setColumnFilters, + setPagination, + }), + [setGlobalFilter, setSorting, setColumnFilters, setPagination], + ); + + // Reset function + const resetTableState = useCallback(() => { + // Clear all filters (both cf_* and dynamic groups) + const clearFilters: Record = {}; + + // Clear cf_* filters + for (const key of Object.keys(columnFilterParsers)) { + clearFilters[key] = null; + } + + // Clear dynamic filter groups (with cf_ prefix) + for (const groupName of dynamicGroupNames) { + clearFilters[`cf_${groupName}`] = null; + } + + setUrlState({ + [paramKeys.q]: null, + [paramKeys.sort]: defaultSortColumn, + [paramKeys.page]: null, + [paramKeys.pageSize]: null, + ...clearFilters, + }); + }, [setUrlState, paramKeys, defaultSortColumn, columnFilterParsers, dynamicGroupNames]); + + return { + tableState, + updateTableState, + resetTableState, + }; +} diff --git a/packages/medusa-forms/src/editable-table/index.ts b/packages/medusa-forms/src/editable-table/index.ts new file mode 100644 index 0000000..e002a9b --- /dev/null +++ b/packages/medusa-forms/src/editable-table/index.ts @@ -0,0 +1,51 @@ +// Components +export { EditableTable } from './components/EditableTable'; +export { EditableTableContent } from './components/EditableTableContent'; +export { EditableTableControls } from './components/EditableTableControls'; +export { EditableTablePagination } from './components/EditableTablePagination'; +export { TableSkeleton } from './components/TableSkeleton'; +export { TooltipColumnHeader } from './components/TooltipColumnHeader'; +export { ErrorState } from './components/LoadingStates'; +export { CellContent } from './components/cells/cells'; +export { CellStatusIndicator } from './components/cells/CellStatusIndicator'; +export { InputCell } from './components/editables/InputCell'; +export { AutocompleteCell } from './components/editables/AutocompleteCell/AutocompleteCell'; +export { FilterDropdown } from './components/filters/FilterDropdown'; +export { FilterChip } from './components/filters/FilterChip'; + +// Hooks +export { useEditableTable } from './hooks/useEditableTable'; +export { useEditableTableColumns } from './hooks/useEditableTableColumns'; +export { useEditableTableUrlState } from './hooks/useEditableTableUrlState'; +export { useEditableCellActions } from './hooks/useEditableCellActions'; +export { useCellState } from './hooks/useCellState'; + +// Types +export type { + EditableTableCellMeta, + EditableTableColumnDefinition, + EditableTableConfig, + EditableTableInstance, + EditableCellActions, + EditableCellActionHandler, + CellActionsHandlerGetter, + EditableCellActionsMap, + CellState, + CellStatus, + CellContentProps, + EditableTableState, +} from './types/cells'; + +export type { + EditableColumnType, + EditableColumnDefinition, +} from './types/columns'; + +// Utilities +export { + canSortColumn, + getDefaultColumnSizing, + getFilterFunction, + getSortingFunction, + getColumnHeaderClassName, +} from './columnHelpers'; diff --git a/packages/medusa-forms/src/editable-table/types/cells.ts b/packages/medusa-forms/src/editable-table/types/cells.ts new file mode 100644 index 0000000..273cc2f --- /dev/null +++ b/packages/medusa-forms/src/editable-table/types/cells.ts @@ -0,0 +1,156 @@ +import type { SortingState, Table, VisibilityState } from '@tanstack/react-table'; +import type { FunctionComponent, ReactNode } from 'react'; +import type { EditableColumnType } from './columns'; + +// Editable Table field definition +export type BaseEditableCellMeta = { + name: string; + description?: string | null; + type: EditableColumnType; + key: TDataKey; + calculateValue?: (key: TDataKey, data: Record) => string | number | undefined; +}; + +export type TextEditableTableCellMeta = { + type: 'text'; +}; + +export type NumberEditableTableCellMeta = { + type: 'number'; +}; + +export type SelectEditableTableCellMeta = { + type: 'select'; +}; + +export type AutocompleteEditableTableCellMeta = { + type: 'autocomplete'; +}; + +export type BadgeEditableTableCellMeta = { + type: 'badge'; +}; + +// Editable Table field definition +export type EditableTableCellMeta = BaseEditableCellMeta & + ( + | TextEditableTableCellMeta + | NumberEditableTableCellMeta + | SelectEditableTableCellMeta + | AutocompleteEditableTableCellMeta + | BadgeEditableTableCellMeta + ); + +// Column definition types +export type BaseEditableTableColumnDefinition = { + name: string; + key: string; + description?: string | null; + type: EditableColumnType; + minWidth?: number; + maxWidth?: number; + enableSorting?: boolean; + enableFiltering?: boolean; + enableHiding?: boolean; + isPinnable?: boolean; + + // custom column props + required?: boolean; + dependsOn?: string[]; + getFieldKey?: (key: string) => string; + cellProps?: Record; +}; + +export type EditableTableColumnDefinition> = BaseEditableTableColumnDefinition & { + key: keyof T; + /** Calculate the display value for a cell (used for rendering) */ + calculateValue?: (key: keyof T, data: Record) => unknown; + /** + * Calculate the filter value for a cell (used for filtering) + * Transforms complex values into filterable strings + * Example: Array -> "Has items" / "No items", Object -> "Present" / "Absent" + */ + calculateFilterValue?: (value: unknown, key: keyof T, data: Record) => string; +}; + +// Cell content component props +export type CellContentProps = { + meta: EditableTableCellMeta; + value: TValue; + actions: EditableCellActions; + cellProps?: Record; +}; + +// Table state for URL persistence (simplified) +export type EditableTableState = { + globalSearchFilter: string; + columnVisibility: VisibilityState; + sorting: SortingState; +}; + +// Table configuration +export type EditableTableConfig> = { + data: T[]; + editableColumns: EditableTableColumnDefinition[]; + enableGlobalFilter?: boolean; + enableColumnFilters?: boolean; + enableSorting?: boolean; + enablePagination?: boolean; + /** Dynamic column filter patterns (e.g., ['location_levels.*']) for grouping related columns */ + dynamicColumnFilters?: string[]; + enableRowSelection?: boolean; + rowSelection?: Record; + onRowSelectionChange?: (rowSelection: Record) => void; + initialState?: Partial; + onView?: (item: T) => void; + onDelete?: (item: T) => void; + getCellActions: GetCellActionsFn; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; +}; + +// Cell component map type +export type CellComponentMap = Record>; + +// Table instance type for hooks +export type EditableTableInstance> = Table & { + getRowData: (rowIndex: number) => T; +}; + +export type EditableCellActions = { + save: (updatedValue: unknown) => Promise; + validate: (updatedValue: unknown) => Promise; + getOptions: ( + updatedValue: unknown, + ) => Promise<{ label: string; value: unknown; usedBy?: { id: string; name: string }[] }[]>; +}; + +export type GetCellActionsFn = >(args: { + meta: EditableTableCellMeta; + data: TRowData; + table: EditableTableInstance>; +}) => EditableCellActions; + +export type CellState = { + isEditing: boolean; + isSaving: boolean; + canRetrySave: boolean; + error: string | null; + justSaved: boolean; +}; + +export type CellStatus = 'editing' | 'saving' | 'saved' | 'error' | 'disabled' | 'retry' | 'idle'; + +export type EditableCellActionHandler = (args: { + meta: EditableTableCellMeta; + data: Record; + value: unknown; + table: EditableTableInstance>; +}) => Promise | TReturn; + +// biome-ignore lint/suspicious/noExplicitAny: It can be any type +export type CellActionsHandlerGetter = (key: string) => EditableCellActionHandler | undefined; + +export type EditableCellActionsMap = Partial<{ + // biome-ignore lint/suspicious/noExplicitAny: It can be any type + [key: string]: EditableCellActionHandler; +}>; diff --git a/packages/medusa-forms/src/editable-table/types/columns.ts b/packages/medusa-forms/src/editable-table/types/columns.ts new file mode 100644 index 0000000..3d500fe --- /dev/null +++ b/packages/medusa-forms/src/editable-table/types/columns.ts @@ -0,0 +1,51 @@ +import type { IdentifiedColumnDef } from '@tanstack/react-table'; +import type { FlattenType } from './utils'; + +// Column Definition Types +export type EditableColumnType = 'text' | 'number' | 'select' | 'autocomplete' | 'badge'; + +type BaseEditableColumnDefinition> = FlattenType< + Pick, 'maxSize' | 'minSize' | 'size'> & { + id: keyof T; + header: string; + + // custom column props + type: EditableColumnType; + placeholder?: string; + required?: boolean; + dependsOn?: string[]; + getFieldKey?: (key: keyof T) => string; + } +>; + +export type EditableTextColumnDefinition> = BaseEditableColumnDefinition & { + type: 'text'; +}; + +type EditableNumberColumnDefinition> = BaseEditableColumnDefinition & { + type: 'number'; + min?: number; + max?: number; + step?: number; +}; + +type EditableSelectColumnDefinition> = BaseEditableColumnDefinition & { + type: 'select'; + // options: { value: string; label: string }[]; +}; + +type EditableAutocompleteColumnDefinition> = BaseEditableColumnDefinition & { + type: 'autocomplete'; + // loadOptions: (args: Record) => Promise<{ value: string; label: string }[]>; +}; + +type EditableBadgeColumnDefinition> = BaseEditableColumnDefinition & { + type: 'badge'; +}; + +export type EditableColumnDefinition> = + | EditableTextColumnDefinition + | EditableNumberColumnDefinition + | EditableSelectColumnDefinition + | EditableAutocompleteColumnDefinition + | EditableBadgeColumnDefinition; diff --git a/packages/medusa-forms/src/editable-table/types/utils.ts b/packages/medusa-forms/src/editable-table/types/utils.ts new file mode 100644 index 0000000..8e3c844 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/types/utils.ts @@ -0,0 +1,2 @@ +// Utility type to flatten intersection types +export type FlattenType = T extends infer U ? { [K in keyof U]: U[K] } : never; diff --git a/packages/medusa-forms/src/editable-table/utils/cell-status.ts b/packages/medusa-forms/src/editable-table/utils/cell-status.ts new file mode 100644 index 0000000..d947700 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/utils/cell-status.ts @@ -0,0 +1,41 @@ +import { clx } from '@medusajs/ui'; +import type { CellState, CellStatus } from '../types/cells'; + +export const SAVE_DELAY_MS = 2000; + +export const getStatusIndicator = (cellState: CellState): CellStatus => { + if (cellState.isEditing) return 'editing'; + if (cellState.isSaving) return 'saving'; + if (cellState.canRetrySave) return 'retry'; + if (cellState.error) return 'error'; + if (cellState.justSaved) return 'saved'; + + return 'idle'; +}; + +export const getCellStatusClassName = (status: CellStatus) => { + switch (status) { + case 'editing': + return clx( + 'border-2 bg-transparent text-ui-fg-base', + 'hover:bg-ui-bg-subtle focus:bg-ui-bg-subtle', + 'bg-ui-tag-orange-bg/10 border-2 border-ui-tag-orange-border/50', + ); + case 'saving': + return clx( + 'border-2 bg-transparent text-ui-fg-base', + 'hover:bg-ui-bg-subtle focus:bg-ui-bg-subtle', + 'bg-ui-bg-subtle', + ); + case 'saved': + return clx('border-2 border-ui-tag-green-border bg-ui-tag-green-bg text-ui-tag-green-text rounded-md'); + case 'error': + return clx('border-2 border-ui-tag-red-border bg-ui-tag-red-bg text-ui-tag-red-text rounded-md'); + case 'disabled': + return clx( + 'border-2 border-ui-tag-neutral-border bg-ui-tag-neutral-bg text-ui-tag-neutral-text rounded-md opacity-60', + ); + default: + return clx('border-none bg-transparent text-ui-fg-base', 'hover:bg-ui-bg-subtle focus:bg-ui-bg-subtle'); + } +}; diff --git a/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts b/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts new file mode 100644 index 0000000..b67c8e6 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts @@ -0,0 +1,180 @@ +import type { ColumnFiltersState } from '@tanstack/react-table'; + +/** + * Utility functions for managing column filter state and URL serialization + */ + +// Helper to encode column key for URL (replace dots with double underscores) +export function encodeColumnKeyForUrl(columnKey: string): string { + return columnKey.replace(/\./g, '__'); +} + +// Helper to decode column key from URL (replace double underscores with dots) +export function decodeColumnKeyFromUrl(urlKey: string): string { + return urlKey.replace(/__/g, '.'); +} + +// Helper to check if a column key matches a pattern (e.g., "location_levels.*") +export function matchesPattern(columnKey: string, pattern: string): boolean { + if (!pattern.includes('*')) { + return columnKey === pattern; + } + + const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*'); + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(columnKey); +} + +// Extract the group name from a pattern (e.g., "location_levels.*" -> "location_levels") +export function extractGroupName(pattern: string): string { + return pattern.replace(/\.\*$/, ''); +} + +// Extract the column ID from a column key (e.g., "location_levels.sloc_123" -> "sloc_123") +export function extractColumnId(columnKey: string, groupName: string): string { + const prefix = `${groupName}.`; + if (columnKey.startsWith(prefix)) { + return columnKey.slice(prefix.length); + } + return columnKey; +} + +/** + * Serialize column filters to URL parameters + * Returns both cf_* parameters and multi-parser parameters + */ +export function serializeColumnFilters( + filters: ColumnFiltersState, + dynamicColumnFilters?: string[], +): Record { + const result: Record = {}; + const dynamicGroups: Record = {}; + + // Group filters by pattern + for (const filter of filters) { + const columnKey = String(filter.id); + const filterValues = Array.isArray(filter.value) ? filter.value : [filter.value]; + const values = filterValues.filter((v): v is string => typeof v === 'string' && v !== ''); + + if (values.length === 0) continue; + + // Check if this column matches any dynamic pattern + let matchedPattern: string | null = null; + if (dynamicColumnFilters) { + for (const pattern of dynamicColumnFilters) { + if (matchesPattern(columnKey, pattern)) { + matchedPattern = pattern; + break; + } + } + } + + if (matchedPattern) { + // Dynamic filter: use multi-parser format + const groupName = extractGroupName(matchedPattern); + const columnId = extractColumnId(columnKey, groupName); + + // Format: "columnId:filterValue" + if (!dynamicGroups[groupName]) { + dynamicGroups[groupName] = []; + } + for (const value of values) { + dynamicGroups[groupName].push(`${columnId}:${value}`); + } + } else { + // Regular filter: use cf_* format + const encodedKey = encodeColumnKeyForUrl(columnKey); + result[`cf_${encodedKey}`] = values; + } + } + + // Add dynamic groups to result with cf_ prefix + for (const [groupName, entries] of Object.entries(dynamicGroups)) { + result[`cf_${groupName}`] = entries.length > 0 ? entries : null; + } + + return result; +} + +/** + * Deserialize column filters from URL parameters (nuqs state only) + */ +export function deserializeColumnFilters( + urlParams: Record, + dynamicColumnFilters?: string[], +): ColumnFiltersState { + const filters: ColumnFiltersState = []; + + // Build a map of group names for dynamic filters + const dynamicGroupNames = new Set(); + if (dynamicColumnFilters) { + for (const pattern of dynamicColumnFilters) { + dynamicGroupNames.add(extractGroupName(pattern)); + } + } + + // Get filters from urlParams (nuqs state only) + for (const [key, value] of Object.entries(urlParams)) { + if (!(value && Array.isArray(value)) || value.length === 0) continue; + + // Check if this is a cf_* parameter + if (key.startsWith('cf_')) { + const paramName = key.slice(3); // Remove 'cf_' prefix + + // Check if this is a dynamic filter group + if (dynamicGroupNames.has(paramName)) { + // Multi-parser format: "columnId:filterValue" + const groupName = paramName; + const filtersByColumn: Record = {}; + + for (const entry of value) { + if (!entry || typeof entry !== 'string') continue; + + const colonIndex = entry.indexOf(':'); + if (colonIndex === -1) continue; + + const columnId = entry.slice(0, colonIndex); + const filterValue = entry.slice(colonIndex + 1); + + if (columnId && filterValue) { + const fullColumnKey = `${groupName}.${columnId}`; + if (!filtersByColumn[fullColumnKey]) { + filtersByColumn[fullColumnKey] = []; + } + filtersByColumn[fullColumnKey].push(filterValue); + } + } + + // Add each column's filters + for (const [columnKey, filterValues] of Object.entries(filtersByColumn)) { + if (filterValues.length > 0) { + filters.push({ id: columnKey, value: filterValues }); + } + } + } else { + // Regular cf_* format (single column) + const encodedColumnId = paramName; + const columnId = decodeColumnKeyFromUrl(encodedColumnId); // Decode column key + const decodedValues = value.filter((v) => v?.trim()); + if (decodedValues.length > 0) { + filters.push({ id: columnId, value: decodedValues }); + } + } + } + } + + return filters; +} + +/** + * Build a set of dynamic group names from filter patterns + */ +export function buildDynamicGroupNames(dynamicColumnFilters?: string[]): Set { + const groups = new Set(); + if (dynamicColumnFilters) { + for (const pattern of dynamicColumnFilters) { + groups.add(extractGroupName(pattern)); + } + } + return groups; +} diff --git a/packages/medusa-forms/src/editable-table/utils/filterUtils.ts b/packages/medusa-forms/src/editable-table/utils/filterUtils.ts new file mode 100644 index 0000000..d60c5c3 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/utils/filterUtils.ts @@ -0,0 +1,218 @@ +import type { ColumnFilter, Row } from '@tanstack/react-table'; +import type { EditableTableColumnDefinition } from '../types/cells'; + +// Special value to represent empty/null/undefined in filters +export const EMPTY_FILTER_VALUE = '__EMPTY__'; +export const EMPTY_FILTER_LABEL = '(empty)'; + +/** + * Extract unique values from a column, including "empty" values + * Uses calculateFilterValue if available to transform values for filtering + */ +export function getUniqueColumnValues>( + rows: Row[], + columnKey: keyof T, + columnDef?: EditableTableColumnDefinition, +): string[] { + const values = new Set(); + let hasEmpty = false; + + for (const row of rows) { + const data = row.original; + let value: unknown; + + // Use calculateValue if available (for computed columns like badges) + if (columnDef?.calculateValue) { + value = columnDef.calculateValue(columnKey, data); + } else { + value = data[columnKey]; + } + + // Handle empty values + if (value === null || value === undefined || value === '') { + hasEmpty = true; + continue; + } + + // Apply calculateFilterValue if available to transform the value for filtering + let filterValue: string; + if (columnDef?.calculateFilterValue) { + filterValue = columnDef.calculateFilterValue(value, columnKey, data); + } else { + filterValue = String(value); + } + + // Add to set if not empty + if (filterValue.trim()) { + values.add(filterValue); + } else { + hasEmpty = true; + } + } + + // Sort values alphabetically + const sortedValues = Array.from(values).sort((a, b) => a.localeCompare(b)); + + // Add empty option at the end if there are empty values + if (hasEmpty) { + sortedValues.push(EMPTY_FILTER_VALUE); + } + + return sortedValues; +} + +/** + * Get filterable columns from column definitions + */ +export function getFilterableColumns>( + columnDefs: EditableTableColumnDefinition[], +): EditableTableColumnDefinition[] { + return columnDefs.filter((col) => col.enableFiltering !== false); +} + +/** + * Custom column filter function that handles empty values and arrays + * Uses calculateFilterValue if available to transform values for comparison + */ +export function columnFilterFn>( + row: Row, + columnId: string, + filterValue: unknown, + columnDef?: EditableTableColumnDefinition, +): boolean { + if (!Array.isArray(filterValue) || filterValue.length === 0) { + return true; // No filter applied + } + + const data = row.original; + const columnKey = columnId as keyof T; + let cellValue: unknown; + + // Use calculateValue if available + if (columnDef?.calculateValue) { + cellValue = columnDef.calculateValue(columnKey, data); + } else { + cellValue = data[columnKey]; + } + + // Handle empty value filter + const hasEmptyFilter = filterValue.includes(EMPTY_FILTER_VALUE); + const isEmpty = cellValue === null || cellValue === undefined || cellValue === ''; + + if (isEmpty && hasEmptyFilter) { + return true; + } + + if (isEmpty) { + return false; + } + + // Apply calculateFilterValue if available to transform the value for comparison + let stringValue: string; + if (columnDef?.calculateFilterValue) { + stringValue = columnDef.calculateFilterValue(cellValue, columnKey, data); + } else { + stringValue = String(cellValue); + } + + // Filter out empty filter value and check if it matches any filter value (OR logic) + const otherFilters = filterValue.filter((v) => v !== EMPTY_FILTER_VALUE); + + return otherFilters.some((filterVal) => stringValue === String(filterVal)); +} + +/** + * Format filter value for display (handle empty values) + */ +export function formatFilterValue(value: string): string { + return value === EMPTY_FILTER_VALUE ? EMPTY_FILTER_LABEL : value; +} + +/** + * Get active filters from column filters state + */ +export function getActiveFilters(columnFilters: ColumnFilter[]): Map { + const activeFilters = new Map(); + + for (const filter of columnFilters) { + const values = Array.isArray(filter.value) ? filter.value : [filter.value]; + const stringValues = values.filter((v): v is string => typeof v === 'string' && v !== ''); + + if (stringValues.length > 0) { + activeFilters.set(String(filter.id), stringValues); + } + } + + return activeFilters; +} + +/** + * Check if a column has active filters + */ +export function hasColumnFilter(columnFilters: ColumnFilter[], columnId: string): boolean { + const filter = columnFilters.find((f) => f.id === columnId); + if (!filter) return false; + + const values = Array.isArray(filter.value) ? filter.value : [filter.value]; + return values.some((v) => typeof v === 'string' && v !== ''); +} + +/** + * Count total number of active filters + */ +export function countActiveFilters(columnFilters: ColumnFilter[]): number { + return getActiveFilters(columnFilters).size; +} + +/** + * Get count of rows for each filter value in a column + */ +export function getFilterValueCounts>( + rows: Row[], + columnKey: keyof T, + columnDef?: EditableTableColumnDefinition, +): Map { + const counts = new Map(); + let emptyCount = 0; + + for (const row of rows) { + const data = row.original; + let value: unknown; + + // Use calculateValue if available (for computed columns like badges) + if (columnDef?.calculateValue) { + value = columnDef.calculateValue(columnKey, data); + } else { + value = data[columnKey]; + } + + // Handle empty values + if (value === null || value === undefined || value === '') { + emptyCount++; + continue; + } + + // Apply calculateFilterValue if available to transform the value for filtering + let filterValue: string; + if (columnDef?.calculateFilterValue) { + filterValue = columnDef.calculateFilterValue(value, columnKey, data); + } else { + filterValue = String(value); + } + + // Count non-empty values + if (filterValue.trim()) { + const currentCount = counts.get(filterValue) || 0; + counts.set(filterValue, currentCount + 1); + } else { + emptyCount++; + } + } + + // Add empty count if there are empty values + if (emptyCount > 0) { + counts.set(EMPTY_FILTER_VALUE, emptyCount); + } + + return counts; +} diff --git a/packages/medusa-forms/src/editable-table/utils/searchUtils.ts b/packages/medusa-forms/src/editable-table/utils/searchUtils.ts new file mode 100644 index 0000000..b5e9d04 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/utils/searchUtils.ts @@ -0,0 +1,122 @@ +import type { Row } from '@tanstack/react-table'; +import type { EditableTableColumnDefinition } from '../types/cells'; + +/** + * Custom global filter function that supports calculated values + * This function searches through both raw data and calculated column values + */ +export function createGlobalFilterFn>( + columnDefs: EditableTableColumnDefinition[], +) { + return (row: Row, _columnId: string, value: string): boolean => { + const searchValue = value.toLowerCase().trim(); + + if (!searchValue) return true; + + const data = row.original; + + // Search through all column definitions + for (const columnDef of columnDefs) { + const columnKey = String(columnDef.key); + let searchableValue = ''; + + if (columnDef.calculateValue) { + // Handle calculated values + try { + const calculatedValue = columnDef.calculateValue(columnKey, data); + + if (typeof calculatedValue === 'string') { + searchableValue = calculatedValue; + } else if (calculatedValue && typeof calculatedValue === 'object') { + // Handle object values (like BadgeCellValue) + if ('title' in calculatedValue && typeof calculatedValue.title === 'string') { + searchableValue = calculatedValue.title; + } else if ('status' in calculatedValue && typeof calculatedValue.status === 'string') { + searchableValue = calculatedValue.status; + } else { + // Fallback to JSON string representation + searchableValue = JSON.stringify(calculatedValue); + } + } + } catch (_error) { + // If calculation fails, skip this column + continue; + } + } else { + // Handle raw data values + const rawValue = data[columnKey]; + if (rawValue != null) { + searchableValue = String(rawValue); + } + } + + // Check if the searchable value contains the search term + if (searchableValue.toLowerCase().includes(searchValue)) { + return true; + } + } + + return false; + }; +} + +/** + * Get searchable text from a calculated value + */ +export function getSearchableText(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object') { + // Handle common object patterns + if ('title' in value && typeof value.title === 'string') { + return value.title; + } + + if ('label' in value && typeof value.label === 'string') { + return value.label; + } + + if ('name' in value && typeof value.name === 'string') { + return value.name; + } + + if ('status' in value && typeof value.status === 'string') { + return value.status; + } + + // Fallback to JSON representation + return JSON.stringify(value); + } + + return String(value || ''); +} + +/** + * Extract all searchable values from a row for debugging + */ +export function extractSearchableValues>( + row: T, + columnDefs: EditableTableColumnDefinition[], +): Record { + const searchableValues: Record = {}; + + for (const columnDef of columnDefs) { + const columnKey = String(columnDef.key); + + try { + if (columnDef.calculateValue) { + const calculatedValue = columnDef.calculateValue(columnKey, row); + searchableValues[columnKey] = getSearchableText(calculatedValue); + } else { + const rawValue = row[columnKey]; + searchableValues[columnKey] = getSearchableText(rawValue); + } + } catch (_error) { + searchableValues[columnKey] = ''; + } + } + + return searchableValues; +} diff --git a/packages/medusa-forms/vite.config.ts b/packages/medusa-forms/vite.config.ts index 9136e53..cc5904b 100644 --- a/packages/medusa-forms/vite.config.ts +++ b/packages/medusa-forms/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ index: './src/index.ts', 'controlled/index': './src/controlled/index.ts', 'ui/index': './src/ui/index.ts', + 'editable-table/index': './src/editable-table/index.ts', }, formats: ['es', 'cjs'], }, @@ -82,7 +83,14 @@ export default defineConfig({ 'tailwindcss-animate', 'zod', '@hookform/error-message', + '@medusajs/icons', '@medusajs/ui', + '@tanstack/react-query', + '@tanstack/react-table', + '@tanstack/react-virtual', + 'lucide-react', + 'nuqs', + 'use-debounce', ], }, }, diff --git a/yarn.lock b/yarn.lock index d4679a1..8f30596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1670,12 +1670,29 @@ __metadata: languageName: unknown linkType: soft -"@lambdacurry/medusa-forms@npm:*, @lambdacurry/medusa-forms@workspace:packages/medusa-forms": +"@lambdacurry/medusa-forms@npm:*": + version: 0.2.8 + resolution: "@lambdacurry/medusa-forms@npm:0.2.8" + dependencies: + "@hookform/error-message": "npm:^2.0.1" + peerDependencies: + "@medusajs/ui": ^4.0.0 + react: ^18.3.0 || ^19.0.0 + react-hook-form: ^7.49.0 + checksum: 10c0/e6c41a8c91a69a578e7ea66d24f4eb319caa3dab43ea676746edb12f79f0fd5a9be1341315067ae152ff95089a44d967358c2f094e2a291249f9782fcc5f0a9f + languageName: node + linkType: hard + +"@lambdacurry/medusa-forms@workspace:packages/medusa-forms": version: 0.0.0-use.local resolution: "@lambdacurry/medusa-forms@workspace:packages/medusa-forms" dependencies: "@hookform/error-message": "npm:^2.0.1" + "@medusajs/icons": "npm:^2.0.0" "@medusajs/ui": "npm:^4.0.0" + "@tanstack/react-query": "npm:^5.62.15" + "@tanstack/react-table": "npm:^8.21.3" + "@tanstack/react-virtual": "npm:^3.8.3" "@types/glob": "npm:^8.1.0" "@types/react": "npm:^19.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.21.0" @@ -1683,17 +1700,26 @@ __metadata: "@vitejs/plugin-react": "npm:^4.3.4" autoprefixer: "npm:^10.4.20" glob: "npm:^11.0.0" + lucide-react: "npm:^0.469.0" + nuqs: "npm:^2.6.0" react: "npm:^19.0.0" react-hook-form: "npm:^7.49.0" tailwindcss: "npm:^4.0.0" typescript: "npm:^5.7.2" + use-debounce: "npm:^10.0.4" vite: "npm:^5.4.11" vite-plugin-dts: "npm:^4.4.0" vite-tsconfig-paths: "npm:^5.1.4" peerDependencies: + "@medusajs/icons": ^2.0.0 "@medusajs/ui": ^4.0.0 + "@tanstack/react-query": ^5.0.0 + "@tanstack/react-table": ^8.20.0 + lucide-react: ^0.263.0 + nuqs: ^2.6.0 react: ^18.3.0 || ^19.0.0 react-hook-form: ^7.49.0 + use-debounce: ^10.0.0 languageName: unknown linkType: soft @@ -1744,6 +1770,15 @@ __metadata: languageName: node linkType: hard +"@medusajs/icons@npm:^2.0.0": + version: 2.11.1 + resolution: "@medusajs/icons@npm:2.11.1" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + checksum: 10c0/1464d49350d280eeb1e992f35f1dcdbe55c26e304c6ec7a16ebba613f09d099b8d0343f9f6385fb0c8101ba7b372f9cddc7ddad9e6b7bbdbdfe89b993c510687 + languageName: node + linkType: hard + "@medusajs/ui@npm:^4.0.0": version: 4.0.13 resolution: "@medusajs/ui@npm:4.0.13" @@ -4924,6 +4959,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10c0/a1ab9a8bdc09b5b47aa8365d0e0ec40cc2df6437be02853696a0e377321653b0d3ac6f079a8c67d5ddbe9821025584b1fb71d9cc041a6666a96f1fadf2ece15f + languageName: node + linkType: hard + "@storybook/addon-docs@npm:^9.0.6": version: 9.0.6 resolution: "@storybook/addon-docs@npm:9.0.6" @@ -5461,6 +5503,24 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:5.90.5": + version: 5.90.5 + resolution: "@tanstack/query-core@npm:5.90.5" + checksum: 10c0/3b9460cc10d494357a30ddd3138f2a831611d14b5b8ce3587daa17a078d63945fcdf419864d9dc8e1249aa89b512003d2f134977c64ceccdbdfdd79f1f7e0a34 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.62.15": + version: 5.90.5 + resolution: "@tanstack/react-query@npm:5.90.5" + dependencies: + "@tanstack/query-core": "npm:5.90.5" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/b2450259e40afc2aec5e455414f204c511ec98ebbbd25963316ab72b25758722ee424ed51210bd6863f78f03ae414e18571879f9d70a022e11049f3f04ef5ce2 + languageName: node + linkType: hard + "@tanstack/react-table@npm:8.20.5": version: 8.20.5 resolution: "@tanstack/react-table@npm:8.20.5" @@ -5473,6 +5533,30 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-table@npm:^8.21.3": + version: 8.21.3 + resolution: "@tanstack/react-table@npm:8.21.3" + dependencies: + "@tanstack/table-core": "npm:8.21.3" + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 10c0/85d1d0fcb690ecc011f68a5a61c96f82142e31a0270dcf9cbc699a6f36715b1653fe6ff1518302a6d08b7093351fc4cabefd055a7db3cd8ac01e068956b0f944 + languageName: node + linkType: hard + +"@tanstack/react-virtual@npm:^3.8.3": + version: 3.13.12 + resolution: "@tanstack/react-virtual@npm:3.13.12" + dependencies: + "@tanstack/virtual-core": "npm:3.13.12" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/0eda3d5691ec3bf93a1cdaa955f4972c7aa9a5026179622824bb52ff8c47e59ee4634208e52d77f43ffb3ce435ee39a0899d6a81f6316918ce89d68122490371 + languageName: node + linkType: hard + "@tanstack/table-core@npm:8.20.5": version: 8.20.5 resolution: "@tanstack/table-core@npm:8.20.5" @@ -5480,6 +5564,20 @@ __metadata: languageName: node linkType: hard +"@tanstack/table-core@npm:8.21.3": + version: 8.21.3 + resolution: "@tanstack/table-core@npm:8.21.3" + checksum: 10c0/40e3560e6d55e07cc047024aa7f83bd47a9323d21920d4adabba8071fd2d21230c48460b26cedf392588f8265b9edc133abb1b0d6d0adf4dae0970032900a8c9 + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.13.12": + version: 3.13.12 + resolution: "@tanstack/virtual-core@npm:3.13.12" + checksum: 10c0/483f38761b73db05c181c10181f0781c1051be3350ae5c378e65057e5f1fdd6606e06e17dbaad8a5e36c04b208ea1a1344cacd4eca0dcde60f335cf398e4d698 + languageName: node + linkType: hard + "@testing-library/dom@npm:10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0" @@ -9931,6 +10029,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^0.469.0": + version: 0.469.0 + resolution: "lucide-react@npm:0.469.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/0ee78d110550579b848a01446a440f733fd17e1bf1850aa01551b61f415e03e5f5ab7f26b56f1c648edc93856177f5476ff9f8f6f5d80e8fe45913d208f49961 + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" @@ -10361,6 +10468,33 @@ __metadata: languageName: node linkType: hard +"nuqs@npm:^2.6.0": + version: 2.7.2 + resolution: "nuqs@npm:2.7.2" + dependencies: + "@standard-schema/spec": "npm:1.0.0" + peerDependencies: + "@remix-run/react": ">=2" + "@tanstack/react-router": ^1 + next: ">=14.2.0" + react: ">=18.2.0 || ^19.0.0-0" + react-router: ^6 || ^7 + react-router-dom: ^6 || ^7 + peerDependenciesMeta: + "@remix-run/react": + optional: true + "@tanstack/react-router": + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + checksum: 10c0/08f130a9a4d4287d5a7151edc534229a7909de69d54d33ec1e2178759c4e8cec8d5c3d673f3b0a66186297bffd41eb1f88a411b2b69792b0ee2e5b90323e81f7 + languageName: node + linkType: hard + "nyc@npm:^15.1.0": version: 15.1.0 resolution: "nyc@npm:15.1.0" @@ -12546,6 +12680,15 @@ __metadata: languageName: node linkType: hard +"use-debounce@npm:^10.0.4": + version: 10.0.6 + resolution: "use-debounce@npm:10.0.6" + peerDependencies: + react: "*" + checksum: 10c0/f0745de48fc344e6f90ea24384f3c79bf0733d06649827241135847abd67ee8db32f523d490c3276bce9f5a6867194ab1c0187bf6b84cabe1dd679037f4a527e + languageName: node + linkType: hard + "use-sidecar@npm:^1.1.3": version: 1.1.3 resolution: "use-sidecar@npm:1.1.3"