From 9f2dc113ebedd70a83b16264d4eaf2646eb83922 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 12:51:14 -0600 Subject: [PATCH 01/38] feat: Introduce EditableTable component with advanced features - Added EditableTable component for inline editing with validation, auto-save, and URL state persistence. - Enhanced README.md to include new features and components. - Updated package.json to version 0.2.9 and added new dependencies for EditableTable functionality. - Introduced comprehensive documentation for EditableTable, including usage examples and peer dependencies. - Implemented various utility functions and hooks to support EditableTable operations. This update significantly enhances the data management capabilities of the Medusa Forms library. --- README.md | 10 +- .../medusa-forms/EditableTable.stories.tsx | 579 ++++++ packages/medusa-forms/README.md | 60 + packages/medusa-forms/package.json | 54 +- .../medusa-forms/src/editable-table/README.md | 1645 +++++++++++++++++ .../src/editable-table/columnHelpers.tsx | 58 + .../components/EditableTable.tsx | 104 ++ .../components/EditableTableContent.tsx | 200 ++ .../components/EditableTableControls.tsx | 200 ++ .../components/EditableTablePagination.tsx | 97 + .../components/LoadingStates.tsx | 47 + .../components/TableSkeleton.tsx | 52 + .../components/TooltipColumnHeader.tsx | 35 + .../components/cells/CellStatusIndicator.tsx | 61 + .../editable-table/components/cells/cells.tsx | 70 + .../AutocompleteCell/Autocomplete.tsx | 227 +++ .../AutocompleteCell/AutocompleteCell.tsx | 99 + .../AutocompleteSuggestion.tsx | 75 + .../editables/AutocompleteCell/hooks.ts | 231 +++ .../editables/AutocompleteCell/utils.tsx | 36 + .../components/editables/InputCell.tsx | 129 ++ .../components/filters/FilterChip.tsx | 65 + .../components/filters/FilterDropdown.tsx | 312 ++++ .../src/editable-table/hooks/useCellState.ts | 34 + .../hooks/useEditableCellActions.ts | 57 + .../editable-table/hooks/useEditableTable.ts | 197 ++ .../hooks/useEditableTableColumns.tsx | 175 ++ .../hooks/useEditableTableUrlState.ts | 320 ++++ .../medusa-forms/src/editable-table/index.ts | 51 + .../src/editable-table/types/cells.ts | 156 ++ .../src/editable-table/types/columns.ts | 51 + .../src/editable-table/types/utils.ts | 2 + .../src/editable-table/utils/cell-status.ts | 41 + .../utils/columnFilterStateUtils.ts | 180 ++ .../src/editable-table/utils/filterUtils.ts | 218 +++ .../src/editable-table/utils/searchUtils.ts | 122 ++ packages/medusa-forms/vite.config.ts | 8 + yarn.lock | 130 ++ 38 files changed, 6183 insertions(+), 5 deletions(-) create mode 100644 apps/docs/src/medusa-forms/EditableTable.stories.tsx create mode 100644 packages/medusa-forms/README.md create mode 100644 packages/medusa-forms/src/editable-table/README.md create mode 100644 packages/medusa-forms/src/editable-table/columnHelpers.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/EditableTable.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/EditableTablePagination.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/LoadingStates.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/cells/cells.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts create mode 100644 packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/utils.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/filters/FilterChip.tsx create mode 100644 packages/medusa-forms/src/editable-table/components/filters/FilterDropdown.tsx create mode 100644 packages/medusa-forms/src/editable-table/hooks/useCellState.ts create mode 100644 packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts create mode 100644 packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts create mode 100644 packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx create mode 100644 packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts create mode 100644 packages/medusa-forms/src/editable-table/index.ts create mode 100644 packages/medusa-forms/src/editable-table/types/cells.ts create mode 100644 packages/medusa-forms/src/editable-table/types/columns.ts create mode 100644 packages/medusa-forms/src/editable-table/types/utils.ts create mode 100644 packages/medusa-forms/src/editable-table/utils/cell-status.ts create mode 100644 packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts create mode 100644 packages/medusa-forms/src/editable-table/utils/filterUtils.ts create mode 100644 packages/medusa-forms/src/editable-table/utils/searchUtils.ts 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..85553a8 --- /dev/null +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -0,0 +1,579 @@ +import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; +import type { EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; +import { 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 { useState } from 'react'; + +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 on field changes +- **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, and more +- **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; + +// Regex patterns defined at top level for performance +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const PHONE_REGEX = /^\d{3}-\d{4}$/; + +// Mock data types +interface Product extends Record { + id: string; + name: string; + sku: string; + price: number; + stock: number; + category: string; + status: 'active' | 'inactive' | 'draft'; +} + +interface InventoryItem extends Record { + id: string; + location: string; + item_name: string; + quantity: number; + min_quantity: number; + supplier: string; +} + +// Mock data generators +const mockProducts: Product[] = Array.from({ length: 50 }, (_, i) => ({ + id: `prod-${i + 1}`, + name: `Product ${i + 1}`, + sku: `SKU-${String(i + 1).padStart(4, '0')}`, + price: Math.floor(Math.random() * 500) + 10, + stock: Math.floor(Math.random() * 200), + category: ['Electronics', 'Clothing', 'Home & Garden', 'Sports'][Math.floor(Math.random() * 4)] || 'Electronics', + status: (['active', 'inactive', 'draft'] as const)[Math.floor(Math.random() * 3)] || 'active', +})); + +const mockInventory: InventoryItem[] = Array.from({ length: 30 }, (_, i) => ({ + id: `inv-${i + 1}`, + location: `Warehouse ${String.fromCharCode(65 + (i % 5))}`, + item_name: `Item ${i + 1}`, + quantity: Math.floor(Math.random() * 500), + min_quantity: Math.floor(Math.random() * 50), + supplier: ['Supplier A', 'Supplier B', 'Supplier C', 'Supplier D'][Math.floor(Math.random() * 4)] || 'Supplier A', +})); + +// Product columns +const productColumns: EditableTableColumnDefinition[] = [ + { + name: 'Product Name', + key: 'name', + type: 'text', + required: true, + enableSorting: true, + enableFiltering: true, + }, + { + name: 'SKU', + key: 'sku', + type: 'text', + required: true, + enableSorting: true, + enableFiltering: true, + }, + { + name: 'Price', + key: 'price', + type: 'number', + required: true, + enableSorting: true, + cellProps: { min: 0, step: 0.01 }, + }, + { + name: 'Stock', + key: 'stock', + type: 'number', + required: true, + enableSorting: true, + cellProps: { min: 0 }, + }, + { + name: 'Category', + key: 'category', + type: 'autocomplete', + enableFiltering: true, + }, + { + name: 'Status', + key: 'status', + type: 'badge', + enableFiltering: true, + calculateValue: (key, data) => data[key], + }, +]; + +// Inventory columns +const inventoryColumns: EditableTableColumnDefinition[] = [ + { + name: 'Location', + key: 'location', + type: 'autocomplete', + required: true, + enableFiltering: true, + }, + { + name: 'Item Name', + key: 'item_name', + type: 'text', + required: true, + enableSorting: true, + enableFiltering: true, + }, + { + name: 'Quantity', + key: 'quantity', + type: 'number', + required: true, + enableSorting: true, + cellProps: { min: 0 }, + }, + { + name: 'Min Quantity', + key: 'min_quantity', + type: 'number', + required: true, + enableSorting: true, + cellProps: { min: 0 }, + }, + { + name: 'Supplier', + key: 'supplier', + type: 'autocomplete', + required: true, + enableFiltering: true, + }, +]; + +// Basic Product Table +export const BasicProductTable = { + name: 'Basic Product Table', + render: () => { + const [data, setData] = useState(mockProducts); + + const validateProductField = (key: string, value: unknown) => { + const valueStr = String(value); + const valueNum = Number(value); + + if (key === 'name' && (!value || valueStr.length < 3)) { + return 'Product name must be at least 3 characters'; + } + if (key === 'sku' && (!value || valueStr.length < 4)) { + return 'SKU must be at least 4 characters'; + } + if ((key === 'price' || key === 'stock') && (value === null || value === undefined || valueNum < 0)) { + return 'Value must be greater than or equal to 0'; + } + return null; + }; + + const getValidateHandler = (key: string) => { + return async ({ value }: { value: unknown }) => validateProductField(key, value); + }; + + const getSaveHandler = (key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Update data + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + + return null; // Success + }; + }; + + const getOptionsHandler = (key: string) => { + return async ({ value }: { value: unknown }) => { + await new Promise((resolve) => setTimeout(resolve, 200)); + + const searchTerm = String(value || '').toLowerCase(); + + if (key === 'category') { + const categories = ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books', 'Toys']; + return categories + .filter((cat) => cat.toLowerCase().includes(searchTerm)) + .map((cat) => ({ label: cat, value: cat })); + } + + return []; + }; + }; + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +A basic product table demonstrating core EditableTable functionality: + +**Features Demonstrated:** +- Inline text and number editing +- Real-time validation (min length, non-negative numbers) +- Auto-save with debouncing +- Global search across all columns +- Column sorting +- Column filtering +- Pagination +- Autocomplete for category selection +- Visual status indicators for cell states + +**Interactions:** +- Click any cell to edit inline +- Changes are validated and auto-saved after a brief delay +- Use the search bar to filter products globally +- Click column headers to sort +- Use the filter dropdowns to filter by specific columns +- Navigate pages using pagination controls + `, + }, + }, + }, +}; + +// Inventory Management Table +export const InventoryManagementTable = { + name: 'Inventory Management', + render: () => { + const [data, setData] = useState(mockInventory); + + const validateInventoryField = (key: string, value: unknown, data: Record) => { + const valueNum = Number(value); + const valueStr = String(value); + + if (!value || valueStr.trim() === '') { + return 'This field is required'; + } + if ((key === 'quantity' || key === 'min_quantity') && valueNum < 0) { + return 'Quantity cannot be negative'; + } + const minQty = data.min_quantity as number; + if (key === 'quantity' && minQty && valueNum < minQty) { + return `Quantity cannot be less than minimum (${minQty})`; + } + return null; + }; + + const getValidateHandler = (key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => + validateInventoryField(key, value, data); + }; + + const getSaveHandler = (key: string) => { + return async ({ value, data }: { value: unknown; data: Record }) => { + await new Promise((resolve) => setTimeout(resolve, 600)); + setData((prev) => + prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as InventoryItem) : item)), + ); + return null; + }; + }; + + const getOptionsHandler = (key: string) => { + return async ({ value }: { value: unknown }) => { + await new Promise((resolve) => setTimeout(resolve, 150)); + const searchTerm = String(value || '').toLowerCase(); + + if (key === 'location') { + const locations = ['Warehouse A', 'Warehouse B', 'Warehouse C', 'Warehouse D', 'Warehouse E']; + return locations + .filter((loc) => loc.toLowerCase().includes(searchTerm)) + .map((loc) => ({ label: loc, value: loc })); + } + + if (key === 'supplier') { + const suppliers = [ + 'Supplier A', + 'Supplier B', + 'Supplier C', + 'Supplier D', + 'Global Suppliers Inc', + 'Direct Wholesale', + ]; + return suppliers + .filter((sup) => sup.toLowerCase().includes(searchTerm)) + .map((sup) => ({ label: sup, value: sup })); + } + + return []; + }; + }; + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +An inventory management table with complex validation rules: + +**Advanced Features:** +- **Cross-field validation**: Quantity must be >= min_quantity +- **Autocomplete fields**: Location and supplier with async search +- **Conditional validation**: Different rules for different field types +- **Required field validation**: All fields must have values + +**Business Rules:** +- Quantities cannot be negative +- Current quantity must meet or exceed minimum quantity threshold +- Locations and suppliers are selected from autocomplete dropdowns +- All fields are required + `, + }, + }, + }, +}; + +// Simple Text Table +export const SimpleTextTable = { + name: 'Simple Text Table', + render: () => { + interface SimpleData extends Record { + id: string; + name: string; + email: string; + phone: string; + } + + const simpleData: SimpleData[] = [ + { id: '1', name: 'John Doe', email: 'john@example.com', phone: '555-0001' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', phone: '555-0002' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', phone: '555-0003' }, + { id: '4', name: 'Alice Brown', email: 'alice@example.com', phone: '555-0004' }, + { id: '5', name: 'Charlie Davis', email: 'charlie@example.com', phone: '555-0005' }, + ]; + + const [data, setData] = useState(simpleData); + + const columns: EditableTableColumnDefinition[] = [ + { name: 'Name', key: 'name', type: 'text', required: true, enableSorting: true }, + { name: 'Email', key: 'email', type: 'text', required: true, enableSorting: true }, + { name: 'Phone', key: 'phone', type: 'text', required: true }, + ]; + + const validateContactField = (key: string, value: unknown) => { + const valueStr = String(value); + + if (!value || valueStr.trim() === '') { + return 'This field is required'; + } + if (key === 'email' && !EMAIL_REGEX.test(valueStr)) { + return 'Invalid email format'; + } + if (key === 'phone' && !PHONE_REGEX.test(valueStr)) { + return 'Phone must be in format: XXX-XXXX'; + } + return null; + }; + + const getValidateHandler = (key: string) => { + return async ({ value }: { value: unknown }) => validateContactField(key, value); + }; + + const getSaveHandler = (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 SimpleData) : item))); + return null; + }; + }; + + const getOptionsHandler = () => { + return async () => []; + }; + + return ( + + ); + }, + parameters: { + docs: { + description: { + story: ` +A simple contact table demonstrating format validation: + +**Validation Rules:** +- **Email**: Must match standard email format +- **Phone**: Must match XXX-XXXX format +- **All fields**: Required + +**Simplified Configuration:** +- No pagination (small dataset) +- Text-only fields +- Format-specific validation +- Real-time feedback on validation errors + `, + }, + }, + }, +}; + +// Loading State +export const LoadingState = { + name: 'Loading State', + render: () => { + const columns: EditableTableColumnDefinition[] = [ + { name: '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: ` +The loading skeleton state displayed while data is being fetched. + +**Features:** +- Animated skeleton rows +- Matches table structure with proper column widths +- Smooth loading animation +- Maintains layout consistency + `, + }, + }, + }, +}; + +// Empty State +export const EmptyState = { + name: 'Empty State', + render: () => { + const columns: EditableTableColumnDefinition[] = [ + { name: '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: ` +The empty state displayed when no data is available. + +**Use Cases:** +- No data loaded yet +- All items have been deleted +- Search/filter returned no results + `, + }, + }, + }, +}; diff --git a/packages/medusa-forms/README.md b/packages/medusa-forms/README.md new file mode 100644 index 0000000..e9d937b --- /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, 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..2f1d74b 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.2.9", + "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.21.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.10.8", "@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..1f674e3 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/README.md @@ -0,0 +1,1645 @@ +# 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 +``` + +## Storybook Setup Requirements + +> **Prerequisites:** Make sure you have installed all [peer dependencies](#installation--peer-dependencies) before setting up Storybook stories. + +The `EditableTable` component requires several context providers to function correctly in Storybook (or any standalone environment). Below are the required providers and the errors you'll encounter if they're missing: + +### Required Providers + +#### 1. NuqsAdapter (URL State Management) +**Package:** `nuqs` (peer dependency) +**Error if missing:** +``` +[nuqs] nuqs requires an adapter to work with your framework. +``` + +**Why needed:** The EditableTable uses `nuqs` for URL state persistence (search, filters, pagination, sorting). In Storybook, which doesn't have a framework router, you need the React adapter. + +**Setup:** +```typescript +import { NuqsAdapter } from 'nuqs/adapters/react'; +``` + +#### 2. QueryClientProvider (React Query) +**Package:** `@tanstack/react-query` (peer dependency) +**Error if missing:** +``` +No QueryClient set, use QueryClientProvider to set one +``` + +**Why needed:** The EditableTable's autocomplete cells and async operations depend on React Query for data fetching and caching. + +**Setup:** +```typescript +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, +}); +``` + +#### 3. TooltipProvider (Medusa UI) +**Package:** `@medusajs/ui` (peer dependency) +**Error if missing:** +``` +`Tooltip` must be used within `TooltipProvider` +``` + +**Why needed:** The EditableTable uses tooltips for column headers and cell status indicators. Medusa UI's Tooltip component requires a provider. + +**Setup:** +```typescript +import { TooltipProvider } from '@medusajs/ui'; +``` + +#### 4. Toaster (Medusa UI - Optional but Recommended) +**Package:** `@medusajs/ui` (peer dependency) +**Why needed:** For displaying toast notifications for validation errors, save confirmations, etc. + +**Setup:** +```typescript +import { Toaster } from '@medusajs/ui'; +``` + +### Complete Storybook Decorator Example + +Here's the complete decorator setup for EditableTable stories with all required imports: + +```typescript +// Component imports +import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; +import type { EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; + +// Provider imports +import { Toaster, TooltipProvider } from '@medusajs/ui'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NuqsAdapter } from 'nuqs/adapters/react'; + +// Storybook imports +import type { Meta } from '@storybook/react-vite'; + +// React imports +import { useState } from 'react'; + +const meta = { + title: 'Your Stories/Editable Table', + component: EditableTable, + decorators: [ + (Story) => { + // Initialize React Query client + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }, + }, + }); + + return ( + + + +
+ +
+ +
+
+
+ ); + }, + ], +} satisfies Meta; + +export default meta; +``` + +**Key Imports Explained:** +- `EditableTable` - Main component from medusa-forms package +- `Toaster`, `TooltipProvider` - From `@medusajs/ui` peer dependency +- `QueryClient`, `QueryClientProvider` - From `@tanstack/react-query` peer dependency +- `NuqsAdapter` - From `nuqs` peer dependency (React adapter for standalone usage) + +### Provider Hierarchy + +The providers must be nested in this specific order (outermost to innermost): + +``` +NuqsAdapter ← URL state management + └─ QueryClientProvider ← React Query context + └─ TooltipProvider ← Medusa UI tooltips + └─ Your Content + └─ Toaster ← Toast notifications (sibling to content) +``` + +### Troubleshooting + +| Error Message | Missing Provider | Package | Solution | +|---------------|------------------|---------|----------| +| `nuqs requires an adapter` | `NuqsAdapter` | `nuqs` | Wrap in `` from `nuqs/adapters/react` | +| `No QueryClient set` | `QueryClientProvider` | `@tanstack/react-query` | Wrap in `` | +| `Tooltip must be used within TooltipProvider` | `TooltipProvider` | `@medusajs/ui` | Wrap in `` from `@medusajs/ui` | +| Toast notifications not appearing | `Toaster` | `@medusajs/ui` | Add `` component from `@medusajs/ui` | +| Icons not rendering | `@medusajs/icons` | `@medusajs/icons` | Ensure peer dependency is installed | +| Table functionality broken | `@tanstack/react-table` | `@tanstack/react-table` | Ensure peer dependency is installed | + +### Production Usage + +In a production Medusa Admin application, these providers are typically already set up at the app level: +- Next.js or React Router provides the routing context for `nuqs` +- React Query is configured globally +- Medusa UI providers are included in the app shell + +You only need to configure these providers explicitly in isolated environments like Storybook or standalone demos. + +## 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; + }; +}; + +// Component usage +export const MyEditableTable = () => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + + return ( + + data={data} + editableColumns={columns} + getValidateHandler={getValidateHandler} + getSaveHandler={getSaveHandler} + 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', + }), +} +``` + +### Select Column (Future Enhancement) +```tsx +{ + name: 'Category', + key: 'category', + type: 'select', + options: [ + { value: 'electronics', label: 'Electronics' }, + { value: 'clothing', label: 'Clothing' }, + ], +} +``` + +## 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 + +```tsx +const getSaveHandler = (key: string) => { + return async ({ value, data, meta }) => { + 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 the save operation + await updateRecord(data.id, { [key]: value }); + + // Return null for success + return null; + } catch (error) { + // Return error message + return error.message || 'Save failed'; + } + }; +}; +``` + +### 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 + +## 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') { + // 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); + } + } + + return Array.from(uniqueCategories).sort(); + } + 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. **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({ ...}); + ``` + +2. **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; + ``` + +3. **Type safety**: The table instance is properly typed + ```tsx + // TypeScript knows the exact row type + const row: EditableInventoryItemData = table.getRowData(0); + ``` + +4. **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: + +#### 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. **Unstable Column References**: Always memoize column definitions inside custom hooks +2. **New Function References**: Use `useCallback` for handler functions +3. **Object Recreation**: Memoize complex objects passed as props +4. **Hook Violations**: Never call hooks inside `useMemo`, `useCallback`, or other hooks + +## 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; + + // 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 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'; + +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(); + + // 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} + enableRowSelection={true} + rowSelection={rowSelection} + onRowSelectionChange={handleRowSelectionChange} + onView={handleView} + onDelete={handleDelete} + /> + ); +}; +``` + +### Step 6: Performance Checklist + +Before deploying your table, verify these performance requirements: + +- [ ] **Column definitions are memoized** inside the custom hook +- [ ] **Handler functions use `useCallback`** with proper 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 + +### Common Issues and Solutions + +| Issue | Symptom | Solution | +|-------|---------|----------| +| Status indicators not showing | Cells reset state during saves | Memoize column definitions in custom hook | +| Performance issues | Excessive re-renders | Use `useCallback` for all handler functions | +| 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:** +- 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** to ensure stable references +- **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..e52cf72 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/columnHelpers.tsx @@ -0,0 +1,58 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import type { EditableTableCellMeta } from './types/cells'; +import type { EditableColumnType } from './types/columns'; + +// Utility to generate column sizing based on field types +export function getDefaultColumnSizing(type: string): number { + const sizeMap: Record = { + avatar: 80, + boolean: 80, + number: 80, + date: 120, + phone: 140, + image: 80, + }; + + return sizeMap[type] || 180; +} + +export const canSortColumn = (type: EditableColumnType) => ['text', 'number'].includes(type); + +// 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..d1efdc7 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx @@ -0,0 +1,104 @@ +import { Table, clx } from '@medusajs/ui'; +import { type ReactNode, useMemo } from 'react'; +import { useEditableCellActions } from '../hooks/useEditableCellActions'; +import { useEditableTable } from '../hooks/useEditableTable'; +import type { EditableCellActionFn, 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: (key: string) => EditableCellActionFn, string | null> | undefined; + getSaveHandler: (key: string) => EditableCellActionFn, string | null> | undefined; + getOptionsHandler: ( + key: string, + ) => EditableCellActionFn, { label: string; value: unknown }[]> | undefined; + // Additional actions to render in table controls + additionalActions?: ReactNode; +} + +// Main EditableTable component +export function EditableTable>({ + tableId, + showControls = true, + showPagination = true, + showInfo = true, + className, + loading = false, + data, + getValidateHandler, + getSaveHandler, + getOptionsHandler, + additionalActions, + ...inputConfig +}: EditableTableProps) { + const config = useMemo( + () => ({ + ...inputConfig, + data, + }), + [inputConfig, data], + ); + + const getCellActionsFn = useEditableCellActions({ getValidateHandler, getSaveHandler, getOptionsHandler }); + + const { table } = useEditableTable( + { + ...config, + data, + getCellActions: getCellActionsFn, + }, + tableId, + ); + + // 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..303293a --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx @@ -0,0 +1,200 @@ +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 { getColumnHeaderClassName } from '../columnHelpers'; +import type { EditableTableCellMeta } from '../types/cells'; +import { TooltipColumnHeader } from './TooltipColumnHeader'; + +interface EditableTableContentProps> { + table: TanStackTable; + className?: string; + getTooltipContent?: (columnKey: string, columnName: string) => string | React.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 ( + { + if (!header.column.getCanSort()) return; + header.column.getToggleSortingHandler(); + }} + > + {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..b35b99f --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx @@ -0,0 +1,200 @@ +import { DescendingSorting, SidebarRight, 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; + showColumnVisibility?: boolean; + showColumnPinning?: boolean; + showColumnFilters?: boolean; + showSorting?: 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, filters, column visibility + * Includes global search functionality with debouncing and URL persistence + */ +export function EditableTableControls>({ + table, + columnDefs, + showGlobalFilter = false, + showColumnVisibility = false, + showColumnPinning = false, + showColumnFilters = false, + showSorting = 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 + 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 + if ( + !showGlobalFilter && + !showColumnVisibility && + !showColumnPinning && + !showColumnFilters && + !showSorting && + !additionalActions + ) { + return null; + } + + return ( +
+ {/* Top Row: Add Filter + Search + Controls */} +
+ {/* Left Side: Add Filter + Search */} +
+ {/* Add Filter Dropdown */} + {showColumnFilters && ( + + )} + + {/* Global Search Filter */} + {showGlobalFilter && ( +
+ +
+ )} +
+ + {/* Right Side: Sort + Column Controls + Additional Actions */} +
+ {/* Sorting Button */} + {showSorting && ( + + )} + + {/* Column Visibility Toggle */} + {showColumnVisibility && ( + + )} + + {/* Column Pinning Controls */} + {showColumnPinning &&
{/* Column pinning implementation - future enhancement */}
} + + {/* Additional Actions from parent component */} + {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..44e820f --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx @@ -0,0 +1,47 @@ +import { Badge, 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 ( +
+
+
+ + Items (0) + +
+
+ + +
+
+
+ + {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..ef84408 --- /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..03777f4 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx @@ -0,0 +1,35 @@ +import { InformationCircleSolid } from '@medusajs/icons'; +import { Tooltip } from '@medusajs/ui'; + +interface TooltipColumnHeaderProps { + children: React.ReactNode; + columnKey: string; + columnName: string; + getTooltipContent?: (columnKey: string, columnName: string) => string | React.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..c0d22a9 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx @@ -0,0 +1,61 @@ +import { ArrowPath, Check, ExclamationCircle, LockClosedSolid, PencilSquare } from '@medusajs/icons'; +import { Tooltip, clx } from '@medusajs/ui'; +import type { CellStatus } from '../../types/cells'; + +export const CellStatusIndicator = ({ + status, + error, + className, +}: { + status: CellStatus; + error: string | null; + className?: string; +}) => { + let icon: React.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..854a1d2 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx @@ -0,0 +1,70 @@ +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 = ({ meta, value }: CellContentProps) => { + if (!value?.status) { + return ; + } + + const colorMap: { [key: string]: ComponentProps['color'] } = { + active: 'green', + inactive: 'red', + }; + + const color = value.status === 'active' ? 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..fcf09f5 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { Input, clx } from '@medusajs/ui'; +import { + type ChangeEvent, + type FocusEvent, + type KeyboardEvent, + 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 | React.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 ? () => {} : 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; + } + }; + + 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..22b34a3 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx @@ -0,0 +1,99 @@ +import { clx } from '@medusajs/ui'; +import { 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 React.ChangeEvent); + }; + + const getTooltipContent = useCallback( + (suggestion: string): React.ReactNode | null => { + const option = filteredOptions.find((opt) => opt.label === suggestion); + if (!option || !option.usedBy?.length) { + return null; + } + + return ( +
+ {`${option.usedBy.length} item${option.usedBy.length > 1 ? 's' : ''} using this value:`} +
    + {option.usedBy.map((usedBy) => ( +
  • {usedBy.name}
  • + ))} +
+
+ ); + }, + [filteredOptions], + ); + + return ( +
+ 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..4752025 --- /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'; + +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 | React.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..cc70df8 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts @@ -0,0 +1,231 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { 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 + 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((e) => { + cellState.setCanRetrySave(true); + + return 'An error occurred. Please try again.'; + }); + + cellState.setError(error); + cellState.setIsSaving(false); + }, + [actions, cellState], + ); + + const handleInputChange = useCallback( + async (e: React.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: React.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: React.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; + } + }, + [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..dadfc30 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -0,0 +1,129 @@ +import { Input, clx } from '@medusajs/ui'; +import { 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'; + +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 /^\d+(\.\d+)?$/.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: React.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((e) => { + cellState.setCanRetrySave(true); + + return 'An error occurred. Please try again.'; + }); + + cellState.setError(error); + cellState.setIsSaving(false); + }, + [actions, cellState, meta.key], + ); + + const debouncedSave = useDebouncedCallback(_save, SAVE_DELAY_MS); + + const onChangeHandler = useCallback( + async (e: React.ChangeEvent) => { + if (!hasValueChanged(e.target.value)) { + cellState.setIsSaving(false); + return; + } + + cellState.setError(null); + cellState.setCanRetrySave(false); + cellState.setIsEditing(true); + + await debouncedSave(e); + }, + [debouncedSave, cellState], + ); + const onBlurHandler = useCallback( + async (e: React.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], + ); + + const isSavePending = debouncedSave.isPending(); + + 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..8e11614 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import type { EditableCellActionFn, EditableCellActions, GetCellActionsFn } from '../types/cells'; + +// biome-ignore lint/suspicious/noExplicitAny: It can be any type +type EditableCellActionsFn = (key: string) => EditableCellActionFn, T> | undefined; + +export const useEditableCellActions = ({ + getValidateHandler, + getSaveHandler, + getOptionsHandler, +}: { + getValidateHandler: EditableCellActionsFn; + getSaveHandler: EditableCellActionsFn; + getOptionsHandler: EditableCellActionsFn<{ label: string; value: unknown }[]>; +}): GetCellActionsFn => { + return useCallback( + ({ meta, data, table }): EditableCellActions => { + const validateHandler = getValidateHandler?.(meta.key) || (() => null); + const saverHandler = + getSaveHandler?.(meta.key) || + (async () => { + // Consistent error surface; do not throw in UI flow + return 'No save handler available for this field'; + }); + 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..8b13a40 --- /dev/null +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts @@ -0,0 +1,197 @@ +import type { Row } 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, tableId?: string) { + 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, + // 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, + }, + + // State update handlers + onGlobalFilterChange: updateTableState.setGlobalFilter, + onColumnFiltersChange: updateTableState.setColumnFilters, + onSortingChange: updateTableState.setSorting, + onPaginationChange: updateTableState.setPagination, + + // Manual pagination for server-side pagination (if needed) + manualPagination: false, + manualSorting: false, + manualFiltering: false, + }), + [ + data, + columns, + enableColumnFilters, + enableGlobalFilter, + enableSorting, + enablePagination, + tableState, + updateTableState, + globalFilterFn, + customColumnFilterFn, + ], + ); + + // 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..4b51c32 --- /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 = []; + if (checkboxColumn) allColumns.push(checkboxColumn); + allColumns.push(...editableColumns); + if (actionsColumn) allColumns.push(actionsColumn); + + return allColumns as ColumnDef[]; + }, [columnDefs, getCellActions, enableRowSelection, rowSelection, onRowSelectionChange]); + + 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)} /> + 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..60a31a3 --- /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) 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..f4fc95c --- /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, + EditableCellActionFn, + EditableCellActionsMap, + CellState, + CellStatus, + CellContentProps, + EditableTableState, +} from './types/cells'; + +export type { + EditableColumnType, + EditableColumnDefinition, +} from './types/columns'; + +// Utilities +export { + getDefaultColumnSizing, + canSortColumn, + 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..1624133 --- /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 } 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; + enableColumnPinning?: boolean; + enableColumnVisibility?: 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 | React.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 }[] }[]>; + // formatValue: (value: unknown) => string; // TODO: enable this +}; + +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 EditableCellActionFn, TReturn = unknown> = (args: { + meta: EditableTableCellMeta; + data: TData; + value: unknown; + table: EditableTableInstance>; +}) => Promise; + +export type EditableCellActionsMap = Partial<{ + // biome-ignore lint/suspicious/noExplicitAny: It can be any type + [key: string]: EditableCellActionFn, any>; +}>; 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..ff2a558 --- /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..81b6279 --- /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..a930a8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1675,7 +1675,11 @@ __metadata: 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.10.8" "@types/glob": "npm:^8.1.0" "@types/react": "npm:^19.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.21.0" @@ -1683,17 +1687,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.21.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 +1757,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 +4946,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 +5490,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 +5520,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.10.8": + 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 +5551,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 +10016,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 +10455,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 +12667,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" From dbb01c7a5e4af929e83c495637acd1b45da28c3a Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 13:20:48 -0600 Subject: [PATCH 02/38] chore: Update EditableTable documentation - Added detailed documentation on the new autocomplete feature, including usage examples and options handling. --- .../medusa-forms/src/editable-table/README.md | 190 +++++++++++++++++- 1 file changed, 184 insertions(+), 6 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/README.md b/packages/medusa-forms/src/editable-table/README.md index 1f674e3..cbf5e2c 100644 --- a/packages/medusa-forms/src/editable-table/README.md +++ b/packages/medusa-forms/src/editable-table/README.md @@ -318,6 +318,19 @@ const getSaveHandler = (key: string) => { }; }; +// 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([]); @@ -329,6 +342,7 @@ export const MyEditableTable = () => { editableColumns={columns} getValidateHandler={getValidateHandler} getSaveHandler={getSaveHandler} + getOptionsHandler={getOptionsHandler} loading={loading} showControls={true} showPagination={true} @@ -382,15 +396,50 @@ export const MyEditableTable = () => { } ``` -### Select Column (Future Enhancement) +### 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: 'electronics', label: 'Electronics' }, - { value: 'clothing', label: 'Clothing' }, + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, ], } ``` @@ -576,6 +625,94 @@ const getSaveHandler = (key: string) => { - **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. @@ -639,6 +776,8 @@ const getValidateHandler = (key: string) => { 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(); @@ -650,7 +789,11 @@ const getOptionsHandler = (key: string) => { } } - return Array.from(uniqueCategories).sort(); + // 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 []; }; @@ -918,6 +1061,7 @@ interface EditableTableProps> { 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 @@ -1451,7 +1595,38 @@ export const useMyTableSaveHandlers = () => { }; ``` -### Step 5: Create the Main Table Component +### 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 @@ -1460,6 +1635,7 @@ 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 @@ -1477,6 +1653,7 @@ export const MyTable = () => { // Handlers (stable references) const getValidateHandler = useMyTableValidateHandlers(); const getSaveHandler = useMyTableSaveHandlers(); + const getOptionsHandler = useMyTableOptionsHandlers(); // Action handlers const handleView = useCallback((item: MyTableData) => { @@ -1500,6 +1677,7 @@ export const MyTable = () => { loading={isLoading} getValidateHandler={getValidateHandler} getSaveHandler={getSaveHandler} + getOptionsHandler={getOptionsHandler} enableRowSelection={true} rowSelection={rowSelection} onRowSelectionChange={handleRowSelectionChange} @@ -1510,7 +1688,7 @@ export const MyTable = () => { }; ``` -### Step 6: Performance Checklist +### Step 7: Performance Checklist Before deploying your table, verify these performance requirements: From ae0e0d882f98e8861a7d50ca40aa315c480b8656 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 16:18:10 -0600 Subject: [PATCH 03/38] feat: saver Handler to include validation before saving - Improved error handling by providing specific feedback when no save handler is available for a field. - Enhanced code readability and maintainability in the useEditableCellActions hook. --- .../hooks/useEditableCellActions.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts index 8e11614..3dcf2cf 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts @@ -16,12 +16,21 @@ export const useEditableCellActions = ({ return useCallback( ({ meta, data, table }): EditableCellActions => { const validateHandler = getValidateHandler?.(meta.key) || (() => null); - const saverHandler = - getSaveHandler?.(meta.key) || - (async () => { + const saverHandler: EditableCellActionFn, string | null> = 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 this field'; - }); + return `No save handler available for ${meta.name}`; + } + + return saveHandler(args); + }; const optionsHandler = getOptionsHandler?.(meta.key) || (async (): Promise<{ label: string; value: unknown }[]> => []); From 314114ff11991c1f3de20f7b3c7fbaac77e184d5 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 16:36:39 -0600 Subject: [PATCH 04/38] fix: Update dependencies in useEditableTableColumns hook - Added onView and onDelete to the dependency array of the useEditableTableColumns hook to ensure proper reactivity and functionality. --- .../src/editable-table/hooks/useEditableTableColumns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index 4b51c32..d3a2028 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -40,7 +40,7 @@ export const useEditableTableColumns = >({ if (actionsColumn) allColumns.push(actionsColumn); return allColumns as ColumnDef[]; - }, [columnDefs, getCellActions, enableRowSelection, rowSelection, onRowSelectionChange]); + }, [columnDefs, getCellActions, enableRowSelection, rowSelection, onRowSelectionChange, onView, onDelete]); return columns; }; From 4e828e7157e83aa9e35ec254253bd9aa303e010a Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 16:37:12 -0600 Subject: [PATCH 05/38] docs: Enhance EditableTable README with critical best practices - Added important notes on automatic validation and the necessity of not calling validation manually in save handlers. - Updated best practices section to emphasize the use of the table instance instead of data state in handler functions to prevent re-renders. - Included detailed examples to illustrate correct and incorrect usage patterns for handlers. - Expanded on the importance of type safety and memoization in handler functions for improved performance and stability. --- .../medusa-forms/src/editable-table/README.md | 149 +++++++++++++++--- 1 file changed, 131 insertions(+), 18 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/README.md b/packages/medusa-forms/src/editable-table/README.md index cbf5e2c..822f11d 100644 --- a/packages/medusa-forms/src/editable-table/README.md +++ b/packages/medusa-forms/src/editable-table/README.md @@ -592,15 +592,16 @@ const getValidateHandler = (key: string) => { ### 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, meta }) => { + return async ({ value, data }) => { try { - // Validate before saving - const validationError = await validateField(key, value); - if (validationError) return validationError; + // ✅ Validation is handled automatically by EditableTable + // ❌ DO NOT call validateField() here - it's already done - // Check if value actually changed + // Check if value actually changed (optional optimization) if (value === data[key]) return null; // Perform the save operation @@ -609,13 +610,20 @@ const getSaveHandler = (key: string) => { // Return null for success return null; } catch (error) { - // Return error message + // 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 @@ -865,7 +873,28 @@ const getValidateHandler = (key: string) => { ### Best Practices -1. **Use for read-only operations**: The table instance is for reading state, not mutating +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); @@ -874,7 +903,7 @@ const getValidateHandler = (key: string) => { // table.setRowSelection({ ...}); ``` -2. **Consider performance**: Accessing all rows can be expensive for large datasets +3. **Consider performance**: Accessing all rows can be expensive for large datasets ```tsx // ✅ Good - Use filtered rows when possible const visibleRows = table.getFilteredRowModel().rows; @@ -883,13 +912,13 @@ const getValidateHandler = (key: string) => { const allRows = table.getCoreRowModel().rows; ``` -3. **Type safety**: The table instance is properly typed +4. **Type safety**: The table instance is properly typed ```tsx // TypeScript knows the exact row type const row: EditableInventoryItemData = table.getRowData(0); ``` -4. **Access is always available**: The table parameter is always provided to handlers +5. **Access is always available**: The table parameter is always provided to handlers ```tsx // ✅ No need for optional chaining const totalRows = table.getRowCount(); @@ -978,6 +1007,78 @@ const columns = [ 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 @@ -1045,10 +1146,13 @@ const columns = useInventoryItemColumnsDefinition(stockLocations); ### Common Pitfalls to Avoid -1. **Unstable Column References**: Always memoize column definitions inside custom hooks -2. **New Function References**: Use `useCallback` for handler functions -3. **Object Recreation**: Memoize complex objects passed as props -4. **Hook Violations**: Never call hooks inside `useMemo`, `useCallback`, or other hooks +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 @@ -1692,19 +1796,25 @@ export const MyTable = () => { 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 proper dependencies +- [ ] **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 | Memoize column definitions in custom hook | -| Performance issues | Excessive re-renders | Use `useCallback` for all handler functions | +| 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 | @@ -1798,13 +1908,16 @@ The EditableTable component represents a sophisticated solution for inline data 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** to ensure stable references +- **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 From 2d60ee0561bc2ebdb7fd9ea3844ce33bd2eb69dc Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 18:58:15 -0600 Subject: [PATCH 06/38] refactor: Rename EditableCellActionFn to EditableCellActionHandler and introduce CellActionsHandlerGetter - Updated type definitions for improved clarity and consistency in editable table actions. - Refactored EditableTable and useEditableCellActions to utilize new type names, enhancing type safety. - Adjusted prop types in EditableTable to align with the new handler structure, ensuring better maintainability. --- .../components/EditableTable.tsx | 32 +++++++------------ .../components/EditableTableControls.tsx | 2 ++ .../hooks/useEditableCellActions.ts | 18 ++++++----- .../medusa-forms/src/editable-table/index.ts | 4 +-- .../src/editable-table/types/cells.ts | 9 ++++-- 5 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx index d1efdc7..cb50913 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx @@ -1,8 +1,8 @@ import { Table, clx } from '@medusajs/ui'; -import { type ReactNode, useMemo } from 'react'; +import type { ReactNode } from 'react'; import { useEditableCellActions } from '../hooks/useEditableCellActions'; import { useEditableTable } from '../hooks/useEditableTable'; -import type { EditableCellActionFn, EditableTableConfig } from '../types/cells'; +import type { CellActionsHandlerGetter, EditableTableConfig } from '../types/cells'; import { EditableTableContent } from './EditableTableContent'; import { EditableTableControls } from './EditableTableControls'; import { TableSkeleton } from './TableSkeleton'; @@ -14,11 +14,9 @@ interface EditableTableProps> extends Omit EditableCellActionFn, string | null> | undefined; - getSaveHandler: (key: string) => EditableCellActionFn, string | null> | undefined; - getOptionsHandler: ( - key: string, - ) => EditableCellActionFn, { label: string; value: unknown }[]> | undefined; + getValidateHandler: CellActionsHandlerGetter; + getSaveHandler: CellActionsHandlerGetter; + getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]>; // Additional actions to render in table controls additionalActions?: ReactNode; } @@ -38,19 +36,11 @@ export function EditableTable>({ additionalActions, ...inputConfig }: EditableTableProps) { - const config = useMemo( - () => ({ - ...inputConfig, - data, - }), - [inputConfig, data], - ); - const getCellActionsFn = useEditableCellActions({ getValidateHandler, getSaveHandler, getOptionsHandler }); const { table } = useEditableTable( { - ...config, + ...inputConfig, data, getCellActions: getCellActionsFn, }, @@ -70,11 +60,11 @@ export function EditableTable>({ diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx index b35b99f..3bc4d9e 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx @@ -77,6 +77,8 @@ export function EditableTableControls>({ }, [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 || ''; diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts index 3dcf2cf..032cf98 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableCellActions.ts @@ -1,22 +1,24 @@ import { useCallback } from 'react'; -import type { EditableCellActionFn, EditableCellActions, GetCellActionsFn } from '../types/cells'; - -// biome-ignore lint/suspicious/noExplicitAny: It can be any type -type EditableCellActionsFn = (key: string) => EditableCellActionFn, T> | undefined; +import type { + CellActionsHandlerGetter, + EditableCellActionHandler, + EditableCellActions, + GetCellActionsFn, +} from '../types/cells'; export const useEditableCellActions = ({ getValidateHandler, getSaveHandler, getOptionsHandler, }: { - getValidateHandler: EditableCellActionsFn; - getSaveHandler: EditableCellActionsFn; - getOptionsHandler: EditableCellActionsFn<{ label: string; value: unknown }[]>; + 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: EditableCellActionFn, string | null> = async (args) => { + const saverHandler: EditableCellActionHandler = async (args) => { const saveHandler = getSaveHandler?.(meta.key); const validationError = validateHandler ? await validateHandler(args) : null; diff --git a/packages/medusa-forms/src/editable-table/index.ts b/packages/medusa-forms/src/editable-table/index.ts index f4fc95c..aa9c24d 100644 --- a/packages/medusa-forms/src/editable-table/index.ts +++ b/packages/medusa-forms/src/editable-table/index.ts @@ -27,7 +27,8 @@ export type { EditableTableConfig, EditableTableInstance, EditableCellActions, - EditableCellActionFn, + EditableCellActionHandler, + CellActionsHandlerGetter, EditableCellActionsMap, CellState, CellStatus, @@ -48,4 +49,3 @@ export { 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 index 1624133..1bba06c 100644 --- a/packages/medusa-forms/src/editable-table/types/cells.ts +++ b/packages/medusa-forms/src/editable-table/types/cells.ts @@ -143,14 +143,17 @@ export type CellState = { export type CellStatus = 'editing' | 'saving' | 'saved' | 'error' | 'disabled' | 'retry' | 'idle'; -export type EditableCellActionFn, TReturn = unknown> = (args: { +export type EditableCellActionHandler = (args: { meta: EditableTableCellMeta; - data: TData; + data: Record; value: unknown; table: EditableTableInstance>; }) => Promise; +// 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]: EditableCellActionFn, any>; + [key: string]: EditableCellActionHandler; }>; From ff56d3305141c7898c9ffd2dca938c26f15355a3 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 18:59:29 -0600 Subject: [PATCH 07/38] feat: introduce stories demonstrating inline validation using Zod schemas and async operations for validation and saving. - Added dynamic column generation and filtering capabilities, allowing for context-aware options based on existing table data. - Implemented loading and empty states to improve user experience during data fetching and when no data is available. - Enhanced documentation for new features, including detailed examples and use cases for validation patterns and dynamic options. This update significantly improves the functionality and usability of the EditableTable component. --- .../medusa-forms/EditableTable.stories.tsx | 1342 ++++++++++++----- 1 file changed, 1000 insertions(+), 342 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 85553a8..c667297 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -1,10 +1,11 @@ import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; -import type { EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; +import type { CellActionsHandlerGetter, EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; import { 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 { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { z } from 'zod'; const meta = { title: 'Medusa Forms/Editable Table', @@ -18,11 +19,11 @@ A powerful, feature-rich table component with inline editing capabilities for ta ## Features - **Inline Editing**: Edit data directly in table cells -- **Real-time Validation**: Immediate feedback on field changes +- **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, and more +- **Multiple Cell Types**: Text, number, autocomplete, badge - **Performance Optimized**: Handles large datasets efficiently `, }, @@ -58,209 +59,426 @@ A powerful, feature-rich table component with inline editing capabilities for ta export default meta; -// Regex patterns defined at top level for performance -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const PHONE_REGEX = /^\d{3}-\d{4}$/; - -// Mock data types -interface Product extends Record { - id: string; - name: string; - sku: string; - price: number; - stock: number; - category: string; - status: 'active' | 'inactive' | 'draft'; -} - -interface InventoryItem extends Record { - id: string; - location: string; - item_name: string; - quantity: number; - min_quantity: number; - supplier: string; -} - -// Mock data generators -const mockProducts: Product[] = Array.from({ length: 50 }, (_, i) => ({ - id: `prod-${i + 1}`, - name: `Product ${i + 1}`, - sku: `SKU-${String(i + 1).padStart(4, '0')}`, - price: Math.floor(Math.random() * 500) + 10, - stock: Math.floor(Math.random() * 200), - category: ['Electronics', 'Clothing', 'Home & Garden', 'Sports'][Math.floor(Math.random() * 4)] || 'Electronics', - status: (['active', 'inactive', 'draft'] as const)[Math.floor(Math.random() * 3)] || 'active', -})); - -const mockInventory: InventoryItem[] = Array.from({ length: 30 }, (_, i) => ({ - id: `inv-${i + 1}`, - location: `Warehouse ${String.fromCharCode(65 + (i % 5))}`, - item_name: `Item ${i + 1}`, - quantity: Math.floor(Math.random() * 500), - min_quantity: Math.floor(Math.random() * 50), - supplier: ['Supplier A', 'Supplier B', 'Supplier C', 'Supplier D'][Math.floor(Math.random() * 4)] || 'Supplier A', -})); - -// Product columns -const productColumns: EditableTableColumnDefinition[] = [ - { - name: 'Product Name', - key: 'name', - type: 'text', - required: true, - enableSorting: true, - enableFiltering: true, - }, - { - name: 'SKU', - key: 'sku', - type: 'text', - required: true, - enableSorting: true, - enableFiltering: true, - }, - { - name: 'Price', - key: 'price', - type: 'number', - required: true, - enableSorting: true, - cellProps: { min: 0, step: 0.01 }, - }, - { - name: 'Stock', - key: 'stock', - type: 'number', - required: true, - enableSorting: true, - cellProps: { min: 0 }, - }, - { - name: 'Category', - key: 'category', - type: 'autocomplete', - enableFiltering: true, - }, - { - name: 'Status', - key: 'status', - type: 'badge', - enableFiltering: true, - calculateValue: (key, data) => data[key], - }, -]; - -// Inventory columns -const inventoryColumns: EditableTableColumnDefinition[] = [ - { - name: 'Location', - key: 'location', - type: 'autocomplete', - required: true, - enableFiltering: true, - }, - { - name: 'Item Name', - key: 'item_name', - type: 'text', - required: true, - enableSorting: true, - enableFiltering: true, +// 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 async ({ value }: { value: unknown }) => { + if (_key === 'name' && (!value || String(value).length < 2)) { + return 'Name must be at least 2 characters'; + } + if ((_key === 'price' || _key === 'stock') && (value === null || Number(value) < 0)) { + return 'Must be a positive number'; + } + return 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 ( + + ); }, - { - name: 'Quantity', - key: 'quantity', - type: 'number', - required: true, - enableSorting: true, - cellProps: { min: 0 }, + parameters: { + docs: { + description: { + story: ` +The simplest EditableTable implementation with basic inline validation. + +**Key Features:** +- Inline validation functions +- Simple length and numeric checks +- Direct state updates +- No external dependencies + +**Use this pattern when:** +- You have simple validation rules +- No need for complex schemas +- Quick prototyping + `, + }, + }, }, - { - name: 'Min Quantity', - key: 'min_quantity', - type: 'number', - required: true, - enableSorting: true, - cellProps: { min: 0 }, +}; + +// ============================================================================ +// 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 async ({ value }: { value: unknown }) => { + const schema = schemas[_key as keyof typeof schemas]; + alert(`validate ${_key} ${value}`); + console.log('🚀 ~ ZodValidationExample ~ schema:', schema); + if (!schema) return null; + + const result = schema.safeParse(value); + console.log('🚀 ~ ZodValidationExample ~ result:', result); + if (!result.success) { + return result.error.errors[0]?.message || 'Invalid value'; + } + return 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 ( + + ); }, - { - name: 'Supplier', - key: 'supplier', - type: 'autocomplete', - required: true, - enableFiltering: true, + parameters: { + docs: { + description: { + story: ` +Schema-based validation using Zod for robust type-safe validation. + +**Features Demonstrated:** +- Zod schema validation for each field +- Complex validation rules (email format, regex patterns) +- Range validation (min/max) +- Detailed error messages + +**Use this pattern when:** +- You need robust validation +- Type safety is important +- Complex validation rules +- Reusable validation logic + `, + }, + }, }, -]; +}; + +// ============================================================================ +// Story 3: Async Operations Example +// ============================================================================ -// Basic Product Table -export const BasicProductTable = { - name: 'Basic Product Table', +export const AsyncOperationsExample = { + name: '3. Async Validation & Save', render: () => { - const [data, setData] = useState(mockProducts); - - const validateProductField = (key: string, value: unknown) => { - const valueStr = String(value); - const valueNum = Number(value); - - if (key === 'name' && (!value || valueStr.length < 3)) { - return 'Product name must be at least 3 characters'; - } - if (key === 'sku' && (!value || valueStr.length < 4)) { - return 'SKU must be at least 4 characters'; - } - if ((key === 'price' || key === 'stock') && (value === null || value === undefined || valueNum < 0)) { - return 'Value must be greater than or equal to 0'; - } - return null; - }; + interface Product extends Record { + id: string; + sku: string; + name: string; + category: string; + } - const getValidateHandler = (key: string) => { - return async ({ value }: { value: unknown }) => validateProductField(key, value); - }; + 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 getSaveHandler = (key: string) => { - return async ({ value, data }: { value: unknown; data: Record }) => { + 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, 500)); + await new Promise((resolve) => setTimeout(resolve, 800)); - // Update data - setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + // Simulate occasional API errors + if (Math.random() > 0.9) { + return 'Network error - please retry'; + } - return null; // Success + setData((prev) => prev.map((item) => (item.id === data.id ? ({ ...item, [key]: value } as Product) : item))); + return null; }; - }; + }, []); - const getOptionsHandler = (key: string) => { - return async ({ value }: { value: unknown }) => { - await new Promise((resolve) => setTimeout(resolve, 200)); + // 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) +- **Save**: Simulates API call with error handling +- **Options**: Simulates fetching categories from server + +**Features:** +- Debounced validation (300ms delay) +- Error simulation for retry testing +- Autocomplete with async data fetching +- Loading indicators during operations - const searchTerm = String(value || '').toLowerCase(); +**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 `$${total.toFixed(2)}`; + }, + }, + { + name: 'Status', + key: 'status', + type: 'badge', + calculateValue: (_key, data) => data.status, + }, + ], + [], + ); - if (key === 'category') { - const categories = ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books', 'Toys']; - return categories - .filter((cat) => cat.toLowerCase().includes(searchTerm)) - .map((cat) => ({ label: cat, value: cat })); + const getValidateHandler = useCallback((_key: string) => { + return async ({ value }: { value: unknown }) => { + await Promise.resolve(); // Ensure async + 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; + }; + }, []); - return []; + 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 ( ); }, @@ -268,113 +486,458 @@ export const BasicProductTable = { docs: { description: { story: ` -A basic product table demonstrating core EditableTable functionality: +Badge columns with calculated values based on other fields. -**Features Demonstrated:** -- Inline text and number editing -- Real-time validation (min length, non-negative numbers) -- Auto-save with debouncing -- Global search across all columns -- Column sorting -- Column filtering -- Pagination -- Autocomplete for category selection -- Visual status indicators for cell states - -**Interactions:** -- Click any cell to edit inline -- Changes are validated and auto-saved after a brief delay -- Use the search bar to filter products globally -- Click column headers to sort -- Use the filter dropdowns to filter by specific columns -- Navigate pages using pagination controls +**Calculated Fields:** +- **Total**: Automatically calculated from quantity × price +- **Status**: Read-only display of order status + +**Key Concepts:** +- \`calculateValue\` function computes display value +- Badge columns are read-only +- Values update automatically when dependencies change +- Perfect for derived data + +**Use this pattern when:** +- Display computed values +- Show status indicators +- Present read-only derived data `, }, }, }, }; -// Inventory Management Table -export const InventoryManagementTable = { - name: 'Inventory Management', +// ============================================================================ +// Story 5: Cross-Field Validation (Table Instance) +// ============================================================================ + +export const CrossFieldValidationExample = { + name: '5. Cross-Field Validation', render: () => { - const [data, setData] = useState(mockInventory); - - const validateInventoryField = (key: string, value: unknown, data: Record) => { - const valueNum = Number(value); - const valueStr = String(value); - - if (!value || valueStr.trim() === '') { - return 'This field is required'; - } - if ((key === 'quantity' || key === 'min_quantity') && valueNum < 0) { - return 'Quantity cannot be negative'; - } - const minQty = data.min_quantity as number; - if (key === 'quantity' && minQty && valueNum < minQty) { - return `Quantity cannot be less than minimum (${minQty})`; - } - return null; - }; + interface InventoryItem extends Record { + id: string; + sku: string; + name: string; + min_stock: number; + current_stock: number; + } - const getValidateHandler = (key: string) => { - return async ({ value, data }: { value: unknown; data: Record }) => - validateInventoryField(key, value, data); - }; + 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 'Low Stock'; + if (current < min * 1.5) return 'Warning'; + return 'Good'; + }, + }, + ], + [], + ); + + // Cross-field validation using table instance + const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { + return async ({ value, data, table }) => { + await Promise.resolve(); // Ensure async + if (!value || String(value).trim() === '') { + return 'Required field'; + } - const getSaveHandler = (key: string) => { + // 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, 600)); + 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 + +**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 + +**Use this pattern when:** +- Validate uniqueness constraints +- Check relationships between fields +- 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 getOptionsHandler = (key: string) => { + const columns = useStockColumnsDefinition(); + + const getValidateHandler = useCallback((_key: string) => { return async ({ value }: { value: unknown }) => { - await new Promise((resolve) => setTimeout(resolve, 150)); - const searchTerm = String(value || '').toLowerCase(); - - if (key === 'location') { - const locations = ['Warehouse A', 'Warehouse B', 'Warehouse C', 'Warehouse D', 'Warehouse E']; - return locations - .filter((loc) => loc.toLowerCase().includes(searchTerm)) - .map((loc) => ({ label: loc, value: loc })); + await Promise.resolve(); // Ensure async + 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; + }; + }, []); - if (key === 'supplier') { - const suppliers = [ - 'Supplier A', - 'Supplier B', - 'Supplier C', - 'Supplier D', - 'Global Suppliers Inc', - 'Direct Wholesale', - ]; - return suppliers - .filter((sup) => sup.toLowerCase().includes(searchTerm)) - .map((sup) => ({ label: sup, value: sup })); + 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 total column sums all location stocks +- Memoized with \`useMemo\` for performance + +**Key Concepts:** +- Base columns + dynamically generated columns +- Column keys generated programmatically +- Calculated column aggregates dynamic fields +- Type-safe with generics + +**Use this pattern when:** +- Columns depend on runtime data +- Number of columns varies (e.g., locations, time periods) +- Calculated columns aggregate dynamic fields + +**Performance Note:** +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 async ({ value }: { value: unknown }) => { + await Promise.resolve(); // Ensure async + if (_key.startsWith('region_') && Number(value) < 0) { + return 'Stock cannot be negative'; } + return null; + }; + }, []); - return []; + 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 ( ); }, @@ -382,82 +945,135 @@ export const InventoryManagementTable = { docs: { description: { story: ` -An inventory management table with complex validation rules: - -**Advanced Features:** -- **Cross-field validation**: Quantity must be >= min_quantity -- **Autocomplete fields**: Location and supplier with async search -- **Conditional validation**: Different rules for different field types -- **Required field validation**: All fields must have values - -**Business Rules:** -- Quantities cannot be negative -- Current quantity must meet or exceed minimum quantity threshold -- Locations and suppliers are selected from autocomplete dropdowns -- All fields are required +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 + +**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 +- Clean, readable URLs `, }, }, }, }; -// Simple Text Table -export const SimpleTextTable = { - name: 'Simple Text Table', +// ============================================================================ +// Story 8: Table Instance - Dynamic Options +// ============================================================================ + +export const TableInstanceOptionsExample = { + name: '8. Table Instance in Options', render: () => { - interface SimpleData extends Record { + interface TeamMember extends Record { id: string; name: string; - email: string; - phone: string; + role: string; + department: string; + manager: string; } - const simpleData: SimpleData[] = [ - { id: '1', name: 'John Doe', email: 'john@example.com', phone: '555-0001' }, - { id: '2', name: 'Jane Smith', email: 'jane@example.com', phone: '555-0002' }, - { id: '3', name: 'Bob Johnson', email: 'bob@example.com', phone: '555-0003' }, - { id: '4', name: 'Alice Brown', email: 'alice@example.com', phone: '555-0004' }, - { id: '5', name: 'Charlie Davis', email: 'charlie@example.com', phone: '555-0005' }, - ]; + 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 [data, setData] = useState(simpleData); + 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 columns: EditableTableColumnDefinition[] = [ - { name: 'Name', key: 'name', type: 'text', required: true, enableSorting: true }, - { name: 'Email', key: 'email', type: 'text', required: true, enableSorting: true }, - { name: 'Phone', key: 'phone', type: 'text', required: true }, - ]; + const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { + return async ({ value, data }) => { + await Promise.resolve(); // Ensure async + if ((_key === 'name' || _key === 'role' || _key === 'department') && !value) { + return 'Required field'; + } - const validateContactField = (key: string, value: unknown) => { - const valueStr = String(value); - - if (!value || valueStr.trim() === '') { - return 'This field is required'; - } - if (key === 'email' && !EMAIL_REGEX.test(valueStr)) { - return 'Invalid email format'; - } - if (key === 'phone' && !PHONE_REGEX.test(valueStr)) { - return 'Phone must be in format: XXX-XXXX'; - } - return null; - }; + // Can't be your own manager + if (_key === 'manager' && value === data.name) { + return 'Cannot be your own manager'; + } - const getValidateHandler = (key: string) => { - return async ({ value }: { value: unknown }) => validateContactField(key, value); - }; + return null; + }; + }, []); - const getSaveHandler = (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 SimpleData) : item))); + 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; }; - }; + }, []); - const getOptionsHandler = () => { - return async () => []; - }; + // Use table instance to provide context-aware options + const getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]> = useCallback( + (_key: string) => { + return async ({ value, data, table }) => { + await Promise.resolve(); // Ensure async + 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 ( @@ -478,35 +1093,60 @@ export const SimpleTextTable = { docs: { description: { story: ` -A simple contact table demonstrating format validation: +Using table instance in getOptionsHandler to provide context-aware autocomplete options. -**Validation Rules:** -- **Email**: Must match standard email format -- **Phone**: Must match XXX-XXXX format -- **All fields**: Required - -**Simplified Configuration:** -- No pagination (small dataset) -- Text-only fields -- Format-specific validation -- Real-time feedback on validation errors +**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 +- Prevents invalid selections (e.g., self as manager) + +**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 `, }, }, }, }; -// Loading State +// ============================================================================ +// Story 9: Loading State +// ============================================================================ + export const LoadingState = { - name: 'Loading State', + name: '9. Loading State', render: () => { - const columns: EditableTableColumnDefinition[] = [ - { name: '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' }, - ]; + 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 ( { - const columns: EditableTableColumnDefinition[] = [ - { name: 'Name', key: 'name', type: 'text' }, - { name: 'SKU', key: 'sku', type: 'text' }, - { name: 'Price', key: 'price', type: 'number' }, - { name: 'Stock', key: 'stock', type: 'number' }, - ]; + 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 ( Date: Tue, 28 Oct 2025 19:05:08 -0600 Subject: [PATCH 08/38] lint: fix biome errors - Removed unused props from BadgeCell for cleaner code. - Updated type imports in CellStatusIndicator for consistency. - Enhanced type handling in AutocompleteCell for better clarity and safety. - Adjusted tooltip content logic in AutocompleteCell to improve functionality. - Cleaned up AutocompleteSuggestion component by removing unnecessary whitespace. These changes improve code readability and maintainability across the editable table components. --- .../components/cells/CellStatusIndicator.tsx | 3 ++- .../src/editable-table/components/cells/cells.tsx | 2 +- .../editables/AutocompleteCell/AutocompleteCell.tsx | 10 +++++----- .../AutocompleteCell/AutocompleteSuggestion.tsx | 1 - 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx index c0d22a9..a774d47 100644 --- a/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx +++ b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx @@ -1,5 +1,6 @@ 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 = ({ @@ -11,7 +12,7 @@ export const CellStatusIndicator = ({ error: string | null; className?: string; }) => { - let icon: React.ReactNode | null = null; + let icon: ReactNode | null = null; let tooltip: string | undefined; switch (status) { diff --git a/packages/medusa-forms/src/editable-table/components/cells/cells.tsx b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx index 854a1d2..8d83357 100644 --- a/packages/medusa-forms/src/editable-table/components/cells/cells.tsx +++ b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx @@ -23,7 +23,7 @@ export type BadgeCellValue = { }; // Badge cell component for arrays or multiple values -const BadgeCell = ({ meta, value }: CellContentProps) => { +const BadgeCell = ({ value }: CellContentProps) => { if (!value?.status) { return ; } 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 index 22b34a3..b1c99d8 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteCell.tsx @@ -1,5 +1,5 @@ import { clx } from '@medusajs/ui'; -import { useCallback } from 'react'; +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'; @@ -45,13 +45,13 @@ export const AutocompleteCell = ({ meta, value: defaultValue, actions, cellProps // Handle change for controlled input const handleChange = (value: string) => { - handleInputChange({ target: { value } } as React.ChangeEvent); + handleInputChange({ target: { value } } as ChangeEvent); }; const getTooltipContent = useCallback( - (suggestion: string): React.ReactNode | null => { + (suggestion: string): ReactNode | null => { const option = filteredOptions.find((opt) => opt.label === suggestion); - if (!option || !option.usedBy?.length) { + if (option?.usedBy?.length === undefined) { return null; } @@ -80,7 +80,7 @@ export const AutocompleteCell = ({ meta, value: defaultValue, actions, cellProps onBlur={handleInputBlur} onFocus={handleInputFocus} onKeyDown={handleKeyDown} - inputRef={inputRef} + inputRef={inputRef as RefObject} preFiltered={true} open={isDropdownOpen && suggestions.length > 0} getTooltipContent={getTooltipContent} 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 index 4752025..9856b0d 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx @@ -72,4 +72,3 @@ export const AutocompleteSuggestion = ({ button ); }; - From 434d84e12fc2f9d385cfa334495cddacf41d4454 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:05:50 -0600 Subject: [PATCH 09/38] refactor: Improve status handling in CellStatusIndicator component - Enhanced the switch case structure for status handling by adding block scopes for better readability and maintainability. - Updated tooltip messages for clarity and consistency across different status cases. These changes streamline the code and improve the user experience by providing clearer status indications. --- .../components/cells/CellStatusIndicator.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx index a774d47..a47aa74 100644 --- a/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx +++ b/packages/medusa-forms/src/editable-table/components/cells/CellStatusIndicator.tsx @@ -16,32 +16,38 @@ export const CellStatusIndicator = ({ let tooltip: string | undefined; switch (status) { - case 'editing': + case 'editing': { icon = ; tooltip = 'Editing...'; break; - case 'saving': + } + case 'saving': { icon = (
); tooltip = 'Saving...'; break; - case 'saved': + } + case 'saved': { icon = ; tooltip = 'Saved successfully'; break; - case 'error': + } + case 'error': { icon = ; tooltip = error || 'An error occurred'; break; - case 'disabled': + } + case 'disabled': { icon = ; tooltip = 'Field is disabled'; break; - case 'retry': + } + case 'retry': { icon = ; tooltip = error ? `Retry... ${error}` : 'Retry...'; break; + } default: icon = null; } From d24b45196bcd61b1c8005bb04a2bc06adbe4213c Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:07:45 -0600 Subject: [PATCH 10/38] biome: Simplify control visibility logic in EditableTableControls component - Replaced multiple conditional checks with a single array-based check to determine if any controls are enabled. - Improved code readability and maintainability by streamlining the early return logic. These changes enhance the clarity of the control visibility handling in the EditableTableControls component. --- .../components/EditableTableControls.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx index 3bc4d9e..dec175a 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTableControls.tsx @@ -96,16 +96,16 @@ export function EditableTableControls>({ }; // Early return if no controls are enabled - if ( - !showGlobalFilter && - !showColumnVisibility && - !showColumnPinning && - !showColumnFilters && - !showSorting && - !additionalActions - ) { - return null; - } + const hasControls = [ + showGlobalFilter, + showColumnVisibility, + showColumnPinning, + showColumnFilters, + showSorting, + additionalActions, + ].some(Boolean); + + if (!hasControls) return null; return (
From 129c1924adbc6f50996089b217625aff65191fa8 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:16:03 -0600 Subject: [PATCH 11/38] biome: Update type handling and improve key generation in editable table components - Changed type imports from ReactNode to improve consistency across components. - Enhanced key generation for mapped elements in TableSkeleton and AutocompleteSuggestion to ensure uniqueness. - Updated prop types in EditableTableContent and TooltipColumnHeader for better clarity. These changes enhance code readability and maintainability across the editable table components. --- .../editable-table/components/EditableTableContent.tsx | 8 +++----- .../src/editable-table/components/TableSkeleton.tsx | 6 +++--- .../src/editable-table/components/TooltipColumnHeader.tsx | 5 +++-- .../editables/AutocompleteCell/AutocompleteSuggestion.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx index 303293a..a731aa1 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx @@ -2,14 +2,14 @@ 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 type { EditableTableCellMeta } from '../types/cells'; import { TooltipColumnHeader } from './TooltipColumnHeader'; interface EditableTableContentProps> { table: TanStackTable; className?: string; - getTooltipContent?: (columnKey: string, columnName: string) => string | React.ReactNode | null; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; } export function EditableTableContent>({ @@ -129,7 +129,7 @@ export function EditableTableContent>({ - {table.getRowModel().rows?.length ? ( + {table.getRowModel().rows?.length > 0 ? ( table.getRowModel().rows.map((row, rowIndex) => ( >({ className="txt-compact-small border-none" > {row.getVisibleCells().map((cell, cellIndex) => { - const columnMeta = cell.column.columnDef.meta as EditableTableCellMeta; - // Calculate left offset for pinned columns let leftOffset = 0; if (cell.column.getIsPinned() === 'left') { diff --git a/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx b/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx index ef84408..81a227f 100644 --- a/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx +++ b/packages/medusa-forms/src/editable-table/components/TableSkeleton.tsx @@ -9,7 +9,7 @@ interface TableSkeletonProps { const SkeletonRow = ({ columns }: { columns: number }) => ( {Array.from({ length: columns }).map((_, index) => ( - +
@@ -22,7 +22,7 @@ const SkeletonHeader = ({ columns }: { columns: number }) => ( {Array.from({ length: columns }).map((_, index) => ( - +
))} @@ -40,7 +40,7 @@ export const TableSkeleton = ({ columns = 6, rows = 8, className }: TableSkeleto {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 index 03777f4..a9ba024 100644 --- a/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx +++ b/packages/medusa-forms/src/editable-table/components/TooltipColumnHeader.tsx @@ -1,11 +1,12 @@ import { InformationCircleSolid } from '@medusajs/icons'; import { Tooltip } from '@medusajs/ui'; +import type { ReactNode } from 'react'; interface TooltipColumnHeaderProps { - children: React.ReactNode; + children: ReactNode; columnKey: string; columnName: string; - getTooltipContent?: (columnKey: string, columnName: string) => string | React.ReactNode | null; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; } export const TooltipColumnHeader = ({ 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 index 9856b0d..693cb09 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/AutocompleteSuggestion.tsx @@ -1,6 +1,7 @@ '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; @@ -10,11 +11,11 @@ const highlightMatch = (text: string, query: string) => { <> {parts.map((part, index) => part.toLowerCase() === query.toLowerCase() ? ( - + {part} ) : ( - {part} + {part} ), )} @@ -26,7 +27,7 @@ interface AutocompleteSuggestionProps { index: number; highlightedIndex: number; inputValue: string; - getTooltipContent?: (suggestion: string) => string | React.ReactNode; + getTooltipContent?: (suggestion: string) => string | ReactNode; onSelect: (value: string) => void; onMouseEnter: (index: number) => void; } From ae3dbb3f84774ced233a4b772b66d9f0a329270c Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:16:30 -0600 Subject: [PATCH 12/38] biome: Enhance type handling and improve event management in AutocompleteCell hook - Updated type imports for ChangeEvent, FocusEvent, and KeyboardEvent to improve clarity and consistency. - Simplified error handling in the save function by removing unnecessary parameters. - Improved event handling logic for input changes, blur events, and keydown events to enhance maintainability. These changes streamline the AutocompleteCell hook, improving code readability and functionality. --- .../editables/AutocompleteCell/hooks.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) 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 index cc70df8..126ea07 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/hooks.ts @@ -1,5 +1,14 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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'; @@ -73,6 +82,7 @@ export const useAutocompleteCell = ( }, []); // Reset highlighted index when filtered options change + // biome-ignore lint/correctness/useExhaustiveDependencies: only run when filtered options change useEffect(() => { setHighlightedIndex(-1); }, [filteredOptions]); @@ -89,7 +99,7 @@ export const useAutocompleteCell = ( cellState.setIsEditing(false); cellState.setIsSaving(true); - const error = await actions.save(value).catch((e) => { + const error = await actions.save(value).catch(() => { cellState.setCanRetrySave(true); return 'An error occurred. Please try again.'; @@ -102,7 +112,7 @@ export const useAutocompleteCell = ( ); const handleInputChange = useCallback( - async (e: React.ChangeEvent) => { + (e: ChangeEvent) => { const newValue = e.target.value; setInputValue(newValue); @@ -120,7 +130,7 @@ export const useAutocompleteCell = ( ); const handleInputBlur = useCallback( - async (e: React.FocusEvent) => { + async (e: FocusEvent) => { // Don't trigger blur save if clicking on dropdown if (dropdownRef.current?.contains(e.relatedTarget as Node)) { return; @@ -172,7 +182,7 @@ export const useAutocompleteCell = ( ); const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { + (e: KeyboardEvent) => { if (!isDropdownOpen || filteredOptions.length === 0) { if (e.key === 'ArrowDown') { setIsDropdownOpen(true); @@ -181,15 +191,17 @@ export const useAutocompleteCell = ( } switch (e.key) { - case 'ArrowDown': + case 'ArrowDown': { e.preventDefault(); setHighlightedIndex((prev) => (prev < filteredOptions.length - 1 ? prev + 1 : prev)); break; - case 'ArrowUp': + } + case 'ArrowUp': { e.preventDefault(); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1)); break; - case 'Enter': + } + case 'Enter': { e.preventDefault(); if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { handleOptionSelect(filteredOptions[highlightedIndex]); @@ -198,11 +210,16 @@ export const useAutocompleteCell = ( setIsDropdownOpen(false); } break; - case 'Escape': + } + case 'Escape': { e.preventDefault(); setIsDropdownOpen(false); setHighlightedIndex(-1); break; + } + default: { + break; + } } }, [isDropdownOpen, filteredOptions, highlightedIndex, handleOptionSelect], From 8945da7a2f4697aaf13f9b337ff5b2a753f7ee02 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:29:22 -0600 Subject: [PATCH 13/38] refactor: Simplify validation handler in ZodValidationExample story - Removed unnecessary async keyword from the validation handler for improved clarity. - Eliminated console log statements to streamline the code and enhance readability. These changes enhance the maintainability of the ZodValidationExample story by simplifying the validation logic. --- apps/docs/src/medusa-forms/EditableTable.stories.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index c667297..1a6d66a 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -200,14 +200,12 @@ export const ZodValidationExample = { const getValidateHandler = useCallback( (_key: string) => { - return async ({ value }: { value: unknown }) => { + return ({ value }: { value: unknown }) => { const schema = schemas[_key as keyof typeof schemas]; alert(`validate ${_key} ${value}`); - console.log('🚀 ~ ZodValidationExample ~ schema:', schema); if (!schema) return null; const result = schema.safeParse(value); - console.log('🚀 ~ ZodValidationExample ~ result:', result); if (!result.success) { return result.error.errors[0]?.message || 'Invalid value'; } From 988e14f56a1c1ec79e9fd7d6a70bc3ad5568e094 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:29:35 -0600 Subject: [PATCH 14/38] biome: Enhance input validation and type handling in InputCell component - Introduced a constant for the valid number regex to improve readability and maintainability. - Updated type imports for ChangeEvent to ensure consistency across event handlers. - Simplified the save function's dependency array for better clarity. These changes improve the overall structure and functionality of the InputCell component, enhancing its maintainability. --- .../components/editables/InputCell.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx index dadfc30..b928088 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -1,11 +1,13 @@ import { Input, clx } from '@medusajs/ui'; -import { useCallback } from 'react'; +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; @@ -14,7 +16,7 @@ function isValidNumberInput(value: string): boolean { // \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 /^\d+(\.\d+)?$/.test(value.trim()); + return VALID_NUMBER_REGEX.test(value.trim()); } export const InputCell = ({ @@ -32,7 +34,7 @@ export const InputCell = ({ ); const _save = useCallback( - async (e: React.ChangeEvent) => { + async (e: ChangeEvent) => { cellState.setIsEditing(false); if (meta.type === 'number' && !isValidNumberInput(e.target.value)) { @@ -52,13 +54,13 @@ export const InputCell = ({ cellState.setError(error); cellState.setIsSaving(false); }, - [actions, cellState, meta.key], + [actions, cellState, meta.type], ); const debouncedSave = useDebouncedCallback(_save, SAVE_DELAY_MS); const onChangeHandler = useCallback( - async (e: React.ChangeEvent) => { + async (e: ChangeEvent) => { if (!hasValueChanged(e.target.value)) { cellState.setIsSaving(false); return; @@ -70,10 +72,10 @@ export const InputCell = ({ await debouncedSave(e); }, - [debouncedSave, cellState], + [debouncedSave, cellState, hasValueChanged], ); const onBlurHandler = useCallback( - async (e: React.ChangeEvent) => { + async (e: ChangeEvent) => { if (!hasValueChanged(e.target.value)) { cellState.setIsSaving(false); return; @@ -86,11 +88,9 @@ export const InputCell = ({ debouncedSave.cancel(); await _save(e); }, - [debouncedSave, _save, cellState], + [debouncedSave, _save, cellState, hasValueChanged], ); - const isSavePending = debouncedSave.isPending(); - const cellStatus = getStatusIndicator({ isEditing: cellState.isEditing, isSaving: cellState.isSaving, From 2bb7715b58fb19285625d5326f5a760ae1539212 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:30:41 -0600 Subject: [PATCH 15/38] biome: Update type imports and enhance tooltip content handling in editable table types - Added ReactNode to type imports for improved flexibility in tooltip content. - Updated the getTooltipContent type definition for consistency with ReactNode. - Removed commented-out code in EditableCellActions for cleaner code. These changes enhance type handling and improve the clarity of tooltip content management in the editable table types. --- packages/medusa-forms/src/editable-table/types/cells.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/types/cells.ts b/packages/medusa-forms/src/editable-table/types/cells.ts index 1bba06c..4f5f271 100644 --- a/packages/medusa-forms/src/editable-table/types/cells.ts +++ b/packages/medusa-forms/src/editable-table/types/cells.ts @@ -1,5 +1,5 @@ import type { SortingState, Table, VisibilityState } from '@tanstack/react-table'; -import type { FunctionComponent } from 'react'; +import type { FunctionComponent, ReactNode } from 'react'; import type { EditableColumnType } from './columns'; // Editable Table field definition @@ -107,7 +107,7 @@ export type EditableTableConfig> = { onView?: (item: T) => void; onDelete?: (item: T) => void; getCellActions: GetCellActionsFn; - getTooltipContent?: (columnKey: string, columnName: string) => string | React.ReactNode | null; + getTooltipContent?: (columnKey: string, columnName: string) => string | ReactNode | null; }; // Cell component map type @@ -124,7 +124,6 @@ export type EditableCellActions = { getOptions: ( updatedValue: unknown, ) => Promise<{ label: string; value: unknown; usedBy?: { id: string; name: string }[] }[]>; - // formatValue: (value: unknown) => string; // TODO: enable this }; export type GetCellActionsFn = >(args: { From e732baabe831ff8b06f15d171518ce5214896362 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:30:53 -0600 Subject: [PATCH 16/38] biome: Remove optional tableId parameter from useEditableTable hook - Simplified the useEditableTable hook by removing the optional tableId parameter, streamlining its usage. - This change enhances the clarity and focus of the hook's functionality, ensuring it aligns with the current requirements of the editable table. --- .../medusa-forms/src/editable-table/hooks/useEditableTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts index 8b13a40..168f5d6 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts @@ -15,7 +15,7 @@ import { useEditableTableColumns } from './useEditableTableColumns'; import { useEditableTableUrlState } from './useEditableTableUrlState'; // Main hook for EditableTable functionality -export function useEditableTable>(config: EditableTableConfig, tableId?: string) { +export function useEditableTable>(config: EditableTableConfig) { const { data, editableColumns, From 07fb62889b19f0898175cfed623d7a150c7499d6 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:34:47 -0600 Subject: [PATCH 17/38] biome: Refactor validation and error handling in EditableTable components - Simplified the validation handler in the SimpleValidationExample story by removing the async keyword for improved clarity. - Enhanced error handling in the InputCell component by removing unnecessary parameters from the catch block. - Updated type definitions in the Autocomplete component to use ReactNode for tooltip content, improving flexibility. - Streamlined event handling logic in the Autocomplete component for better maintainability. These changes enhance the overall readability and functionality of the EditableTable components. --- .../medusa-forms/EditableTable.stories.tsx | 2 +- .../AutocompleteCell/Autocomplete.tsx | 20 +++++++++++++------ .../components/editables/InputCell.tsx | 2 +- .../hooks/useEditableTableColumns.tsx | 2 +- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 1a6d66a..5cc1696 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -93,7 +93,7 @@ export const SimpleValidationExample = { // Simple inline validation const getValidateHandler = useCallback((_key: string) => { - return async ({ value }: { value: unknown }) => { + return ({ value }: { value: unknown }) => { if (_key === 'name' && (!value || String(value).length < 2)) { return 'Name must be at least 2 characters'; } 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 index fcf09f5..a4112ea 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/AutocompleteCell/Autocomplete.tsx @@ -5,6 +5,7 @@ import { type ChangeEvent, type FocusEvent, type KeyboardEvent, + type ReactNode, type RefObject, useEffect, useMemo, @@ -31,7 +32,7 @@ interface AutocompleteProps { onFocus?: (e: FocusEvent) => void; onKeyDown?: (e: KeyboardEvent) => void; /** Tooltip content for each suggestion - can be string or React component */ - getTooltipContent?: (suggestion: string) => string | React.ReactNode; + getTooltipContent?: (suggestion: string) => string | ReactNode; } export function Autocomplete({ @@ -62,7 +63,7 @@ export function Autocomplete({ const inputRef = externalInputRef || internalInputRef; const isOpenControlled = controlledOpen !== undefined; const isOpen = isOpenControlled ? controlledOpen : internalIsOpen; - const setIsOpen = isOpenControlled ? () => {} : setInternalIsOpen; + const setIsOpen = isOpenControlled ? () => undefined : setInternalIsOpen; const filteredSuggestions = useMemo(() => { if (preFiltered) return suggestions; @@ -109,24 +110,31 @@ export function Autocomplete({ if (!isOpen || filteredSuggestions.length === 0) return; switch (e.key) { - case 'ArrowDown': + case 'ArrowDown': { e.preventDefault(); setHighlightedIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev)); break; - case 'ArrowUp': + } + case 'ArrowUp': { e.preventDefault(); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev)); break; - case 'Enter': + } + case 'Enter': { e.preventDefault(); if (filteredSuggestions[highlightedIndex]) { handleSelect(filteredSuggestions[highlightedIndex]); } break; - case 'Escape': + } + case 'Escape': { e.preventDefault(); setIsOpen(false); break; + } + default: { + break; + } } }; diff --git a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx index b928088..584fd81 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -45,7 +45,7 @@ export const InputCell = ({ cellState.setIsSaving(true); - const error = await actions.save(e.target.value).catch((e) => { + const error = await actions.save(e.target.value).catch(() => { cellState.setCanRetrySave(true); return 'An error occurred. Please try again.'; diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index d3a2028..371d157 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -34,7 +34,7 @@ export const useEditableTableColumns = >({ const actionsColumn = _createActionsColumn(onView, onDelete); const checkboxColumn = enableRowSelection ? _createCheckboxColumn(rowSelection, onRowSelectionChange) : null; - const allColumns = []; + const allColumns: ColumnDef[] = []; if (checkboxColumn) allColumns.push(checkboxColumn); allColumns.push(...editableColumns); if (actionsColumn) allColumns.push(actionsColumn); From 1257db9704ef1c364ec15656add6c841554b30c2 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:36:16 -0600 Subject: [PATCH 18/38] biome: Refactor column handling and error checks in editable table hooks - Renamed parameters in the checkbox column creation function for clarity. - Simplified conditional checks in the actions column to enhance readability. - Updated sorting serialization to explicitly check for an empty array. - Improved filter deserialization logic by refining the condition for valid values. - Standardized error variable naming in search utility functions for consistency. These changes enhance the clarity and maintainability of the editable table hooks and utilities. --- .../src/editable-table/hooks/useEditableTableColumns.tsx | 6 +++--- .../src/editable-table/hooks/useEditableTableUrlState.ts | 2 +- .../src/editable-table/utils/columnFilterStateUtils.ts | 2 +- .../medusa-forms/src/editable-table/utils/searchUtils.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index 371d157..e7f903f 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -98,8 +98,8 @@ function _createEditableTableColumn>( // Create checkbox column for row selection function _createCheckboxColumn>( - rowSelection?: Record, - onRowSelectionChange?: (rowSelection: Record) => void, + _rowSelection?: Record, + _onRowSelectionChange?: (rowSelection: Record) => void, ): ColumnDef { return { id: 'select', @@ -132,7 +132,7 @@ function _createActionsColumn>( onView?: (item: T) => void, onDelete?: (item: T) => void, ): ColumnDef | null { - if (!onView && !onDelete) return null; + if (!(onView || onDelete)) return null; return { id: 'actions', diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts index 60a31a3..bfa1bb2 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableUrlState.ts @@ -42,7 +42,7 @@ function createParameterKeys(tableId = '') { // Serialize sorting to compact format function serializeSorting(sorting: SortingState): string { - if (!sorting.length) return ''; + if (sorting.length === 0) return ''; const sort = sorting[0]; // Only single column sorting return sort.desc ? `-${sort.id}` : sort.id; diff --git a/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts b/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts index ff2a558..b67c8e6 100644 --- a/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts +++ b/packages/medusa-forms/src/editable-table/utils/columnFilterStateUtils.ts @@ -115,7 +115,7 @@ export function deserializeColumnFilters( // Get filters from urlParams (nuqs state only) for (const [key, value] of Object.entries(urlParams)) { - if (!value || !Array.isArray(value) || value.length === 0) continue; + if (!(value && Array.isArray(value)) || value.length === 0) continue; // Check if this is a cf_* parameter if (key.startsWith('cf_')) { diff --git a/packages/medusa-forms/src/editable-table/utils/searchUtils.ts b/packages/medusa-forms/src/editable-table/utils/searchUtils.ts index 81b6279..b5e9d04 100644 --- a/packages/medusa-forms/src/editable-table/utils/searchUtils.ts +++ b/packages/medusa-forms/src/editable-table/utils/searchUtils.ts @@ -38,7 +38,7 @@ export function createGlobalFilterFn>( searchableValue = JSON.stringify(calculatedValue); } } - } catch (error) { + } catch (_error) { // If calculation fails, skip this column continue; } @@ -113,7 +113,7 @@ export function extractSearchableValues>( const rawValue = row[columnKey]; searchableValues[columnKey] = getSearchableText(rawValue); } - } catch (error) { + } catch (_error) { searchableValues[columnKey] = ''; } } From 2a5c6dc5d6a454c91b1bf09d8f5da9493258c886 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:41:11 -0600 Subject: [PATCH 19/38] feat: Enhance status handling in BadgeCell component - Added a new 'warning' status to the color mapping for improved status representation. - Updated the color assignment logic to use a fallback for undefined statuses, enhancing robustness. These changes improve the clarity and functionality of the BadgeCell component in the editable table. --- .../medusa-forms/src/editable-table/components/cells/cells.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/medusa-forms/src/editable-table/components/cells/cells.tsx b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx index 8d83357..059cb3c 100644 --- a/packages/medusa-forms/src/editable-table/components/cells/cells.tsx +++ b/packages/medusa-forms/src/editable-table/components/cells/cells.tsx @@ -31,9 +31,10 @@ const BadgeCell = ({ value }: CellContentProps) => { const colorMap: { [key: string]: ComponentProps['color'] } = { active: 'green', inactive: 'red', + warning: 'orange', }; - const color = value.status === 'active' ? colorMap[value.status] : colorMap.inactive; + const color = colorMap[value.status] ?? colorMap.inactive; return (
From 108be299f9e80cdf789d7d098d66dd68f3291360 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:42:13 -0600 Subject: [PATCH 20/38] feat: status and value calculations in EditableTable stories - Updated the calculation logic for total values to return an object with status and title for better clarity. - Enhanced the status calculation in the BadgeCell to reflect 'inactive' or 'active' based on delivery status. - Improved stock status handling to return structured objects indicating status and title for low stock, warning, and good conditions. These changes enhance the clarity and functionality of the EditableTable stories, providing a more structured approach to status representation. --- .../src/medusa-forms/EditableTable.stories.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 5cc1696..15f341d 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -202,7 +202,6 @@ export const ZodValidationExample = { (_key: string) => { return ({ value }: { value: unknown }) => { const schema = schemas[_key as keyof typeof schemas]; - alert(`validate ${_key} ${value}`); if (!schema) return null; const result = schema.safeParse(value); @@ -429,14 +428,20 @@ export const CalculatedValuesExample = { const quantity = Number(data.quantity) || 0; const price = Number(data.price) || 0; const total = quantity * price; - return `$${total.toFixed(2)}`; + return { + status: 'active', + title: `$${total.toFixed(2)}`, + }; }, }, { name: 'Status', key: 'status', type: 'badge', - calculateValue: (_key, data) => data.status, + calculateValue: (_key, data) => ({ + status: data.status === 'delivered' ? 'inactive' : 'active', + title: String(data.status), + }), }, ], [], @@ -540,9 +545,9 @@ export const CrossFieldValidationExample = { calculateValue: (_key, data) => { const current = Number(data.current_stock) || 0; const min = Number(data.min_stock) || 0; - if (current < min) return 'Low Stock'; - if (current < min * 1.5) return 'Warning'; - return 'Good'; + if (current < min) return { status: 'inactive', title: 'Low Stock' }; + if (current < min * 1.5) return { status: 'warning', title: 'Warning' }; + return { status: 'active', title: 'Good' }; }, }, ], From 25fba8611d625cb417727ec79340e69a77b081ca Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:48:20 -0600 Subject: [PATCH 21/38] feat: enhance story descriptions in EditableTable - Refactored validation functions in SimpleValidationExample and ZodValidationExample to return promises for consistency in async handling. - Improved story descriptions to clarify validation features, including synchronous checks and simulated save delays. - Enhanced the presentation of calculated values and status indicators in the CalculatedValuesExample and Badge columns. These changes improve the clarity and functionality of the EditableTable stories, ensuring a more robust validation experience. --- .../medusa-forms/EditableTable.stories.tsx | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 15f341d..d27b9ba 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -95,12 +95,12 @@ export const SimpleValidationExample = { 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'; + return Promise.resolve('Name must be at least 2 characters'); } if ((_key === 'price' || _key === 'stock') && (value === null || Number(value) < 0)) { - return 'Must be a positive number'; + return Promise.resolve('Must be a positive number'); } - return null; + return Promise.resolve(null); }; }, []); @@ -140,14 +140,14 @@ export const SimpleValidationExample = { The simplest EditableTable implementation with basic inline validation. **Key Features:** -- Inline validation functions -- Simple length and numeric checks -- Direct state updates -- No external dependencies +- 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 +- No need for complex schemas or async validation - Quick prototyping `, }, @@ -202,13 +202,13 @@ export const ZodValidationExample = { (_key: string) => { return ({ value }: { value: unknown }) => { const schema = schemas[_key as keyof typeof schemas]; - if (!schema) return null; + if (!schema) return Promise.resolve(null); const result = schema.safeParse(value); if (!result.success) { - return result.error.errors[0]?.message || 'Invalid value'; + return Promise.resolve(result.error.errors[0]?.message || 'Invalid value'); } - return null; + return Promise.resolve(null); }; }, [schemas], @@ -248,16 +248,17 @@ export const ZodValidationExample = { Schema-based validation using Zod for robust type-safe validation. **Features Demonstrated:** -- Zod schema validation for each field -- Complex validation rules (email format, regex patterns) -- Range validation (min/max) -- Detailed error messages +- 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 validation +- You need robust, synchronous validation - Type safety is important -- Complex validation rules -- Reusable validation logic +- Complex validation rules (regex, formats, ranges) +- Reusable validation logic across your app `, }, }, @@ -374,13 +375,13 @@ export const AsyncOperationsExample = { Demonstrates async operations for validation, saving, and fetching options. **Async Patterns:** -- **Validation**: Simulates API check (SKU uniqueness) -- **Save**: Simulates API call with error handling -- **Options**: Simulates fetching categories from server +- **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:** -- Debounced validation (300ms delay) -- Error simulation for retry testing +- Async validation with loading indicators +- Error simulation for retry testing (10% failure rate) - Autocomplete with async data fetching - Loading indicators during operations @@ -492,18 +493,19 @@ export const CalculatedValuesExample = { Badge columns with calculated values based on other fields. **Calculated Fields:** -- **Total**: Automatically calculated from quantity × price -- **Status**: Read-only display of order status +- **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\` function computes display value -- Badge columns are read-only +- \`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 +- Perfect for derived data and status indicators +- 300ms simulated save delay **Use this pattern when:** -- Display computed values -- Show status indicators +- Display computed values (totals, aggregates) +- Show status indicators with color coding - Present read-only derived data `, }, @@ -629,15 +631,21 @@ Cross-field validation using the table instance to access other rows and fields. - **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 +- Check relationships between fields in the same row - Enforce business rules across rows `, }, @@ -762,22 +770,23 @@ 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 total column sums all location stocks +- Calculated badge column sums all location stocks and displays total - Memoized with \`useMemo\` for performance **Key Concepts:** -- Base columns + dynamically generated columns -- Column keys generated programmatically -- Calculated column aggregates dynamic fields -- Type-safe with generics +- 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 -**Performance Note:** -Always memoize dynamic columns to prevent re-renders. +**Implementation Details:** +- 400ms simulated save delay +- Always memoize dynamic columns to prevent re-renders `, }, }, @@ -961,6 +970,7 @@ Dynamic column filters for grouping similar columns under a single URL parameter - \`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 @@ -970,8 +980,8 @@ Dynamic column filters for grouping similar columns under a single URL parameter **Benefits:** - Single multi-parser handles all matching columns -- Better performance than individual parsers -- Clean, readable URLs +- Better performance than individual parsers per column +- Clean, readable URLs with grouped filters `, }, }, @@ -1111,8 +1121,9 @@ Using table instance in getOptionsHandler to provide context-aware autocomplete **Benefits:** - Options automatically update as data changes - No need for separate state management -- Context-aware suggestions -- Prevents invalid selections (e.g., self as manager) +- 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 From 9534ba222c4f728ffd49ae62d5735ee848175e10 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:53:45 -0600 Subject: [PATCH 22/38] fix: remove unnecessary async handling from validation functions in multiple examples for improved clarity and consistency. - This change enhances the readability of the validation logic across the EditableTable stories, ensuring a more straightforward implementation. --- .../docs/src/medusa-forms/EditableTable.stories.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index d27b9ba..127ec63 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -449,8 +449,7 @@ export const CalculatedValuesExample = { ); const getValidateHandler = useCallback((_key: string) => { - return async ({ value }: { value: unknown }) => { - await Promise.resolve(); // Ensure async + return ({ value }: { value: unknown }) => { if ((_key === 'quantity' || _key === 'price') && Number(value) <= 0) { return 'Must be greater than 0'; } @@ -559,7 +558,6 @@ export const CrossFieldValidationExample = { // Cross-field validation using table instance const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { return async ({ value, data, table }) => { - await Promise.resolve(); // Ensure async if (!value || String(value).trim() === '') { return 'Required field'; } @@ -721,8 +719,7 @@ export const DynamicColumnsExample = { const columns = useStockColumnsDefinition(); const getValidateHandler = useCallback((_key: string) => { - return async ({ value }: { value: unknown }) => { - await Promise.resolve(); // Ensure async + return ({ value }: { value: unknown }) => { if (_key.startsWith('location_') && Number(value) < 0) { return 'Stock cannot be negative'; } @@ -914,8 +911,7 @@ export const DynamicColumnFiltersExample = { ); const getValidateHandler = useCallback((_key: string) => { - return async ({ value }: { value: unknown }) => { - await Promise.resolve(); // Ensure async + return ({ value }: { value: unknown }) => { if (_key.startsWith('region_') && Number(value) < 0) { return 'Stock cannot be negative'; } @@ -1023,7 +1019,7 @@ export const TableInstanceOptionsExample = { const getValidateHandler: CellActionsHandlerGetter = useCallback((_key: string) => { return async ({ value, data }) => { - await Promise.resolve(); // Ensure async + await Promise.resolve(); if ((_key === 'name' || _key === 'role' || _key === 'department') && !value) { return 'Required field'; } @@ -1049,7 +1045,6 @@ export const TableInstanceOptionsExample = { const getOptionsHandler: CellActionsHandlerGetter<{ label: string; value: unknown }[]> = useCallback( (_key: string) => { return async ({ value, data, table }) => { - await Promise.resolve(); // Ensure async const searchTerm = String(value || '').toLowerCase(); if (_key === 'role') { From 29bff9655bc28790589256a465da1c1ebd438d0a Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 19:59:58 -0600 Subject: [PATCH 23/38] fix: update EditableCellActionHandler type to allow synchronous return values - Modified the EditableCellActionHandler type definition to permit returning either a Promise or a synchronous value. This change enhances flexibility in handling cell actions within the editable table, improving the overall functionality. --- packages/medusa-forms/src/editable-table/types/cells.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa-forms/src/editable-table/types/cells.ts b/packages/medusa-forms/src/editable-table/types/cells.ts index 4f5f271..5d7742a 100644 --- a/packages/medusa-forms/src/editable-table/types/cells.ts +++ b/packages/medusa-forms/src/editable-table/types/cells.ts @@ -147,7 +147,7 @@ export type EditableCellActionHandler = (args: { data: Record; value: unknown; table: EditableTableInstance>; -}) => Promise; +}) => Promise | TReturn; // biome-ignore lint/suspicious/noExplicitAny: It can be any type export type CellActionsHandlerGetter = (key: string) => EditableCellActionHandler | undefined; From 0a0dff64bc4faa728c966ee81ef8523ea2afb513 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 20:00:07 -0600 Subject: [PATCH 24/38] refactor: remove unused tableId parameter from EditableTable component - Eliminated the tableId parameter from the EditableTable component to streamline its implementation. This change enhances the clarity and focus of the component's functionality, aligning it with current usage requirements. --- .../editable-table/components/EditableTable.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx index cb50913..b7ac539 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx @@ -23,7 +23,6 @@ interface EditableTableProps> extends Omit>({ - tableId, showControls = true, showPagination = true, showInfo = true, @@ -38,14 +37,11 @@ export function EditableTable>({ }: EditableTableProps) { const getCellActionsFn = useEditableCellActions({ getValidateHandler, getSaveHandler, getOptionsHandler }); - const { table } = useEditableTable( - { - ...inputConfig, - data, - getCellActions: getCellActionsFn, - }, - tableId, - ); + const { table } = useEditableTable({ + ...inputConfig, + data, + getCellActions: getCellActionsFn, + }); // Show skeleton if loading if (loading) { From 917809068ff159148aea88998742a926c3fdb64c Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 20:38:03 -0600 Subject: [PATCH 25/38] feat: simplify table controls - Removed unused props related to column visibility and pinning from EditableTable, EditableTableControls, and EditableTableContent components to enhance clarity and focus. - Simplified the onClick event handling in EditableTableContent for better readability. - Updated the EditableTableControls component to reflect the changes in available controls, ensuring a cleaner implementation. --- .../components/EditableTable.tsx | 3 -- .../components/EditableTableContent.tsx | 5 +- .../components/EditableTableControls.tsx | 48 ++++--------------- .../src/editable-table/types/cells.ts | 2 - 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx index b7ac539..61389f4 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTable.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTable.tsx @@ -57,10 +57,7 @@ export function EditableTable>({ table={table} columnDefs={inputConfig.editableColumns} showGlobalFilter={inputConfig.enableGlobalFilter} - showColumnVisibility={inputConfig.enableColumnVisibility} - showColumnPinning={inputConfig.enableColumnPinning} showColumnFilters={inputConfig.enableColumnFilters} - showSorting={inputConfig.enableSorting} searchDebounceMs={1000} additionalActions={additionalActions} /> diff --git a/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx index a731aa1..e56f934 100644 --- a/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx +++ b/packages/medusa-forms/src/editable-table/components/EditableTableContent.tsx @@ -67,10 +67,7 @@ export function EditableTableContent>({ zIndex: 20 - headerIndex, // Higher z-index for columns to the left }), }} - onClick={() => { - if (!header.column.getCanSort()) return; - header.column.getToggleSortingHandler(); - }} + onClick={header.column.getToggleSortingHandler()} > {header.isPlaceholder ? null : (
> { table: TanStackTable; columnDefs: EditableTableColumnDefinition[]; showGlobalFilter?: boolean; - showColumnVisibility?: boolean; - showColumnPinning?: boolean; showColumnFilters?: boolean; - showSorting?: boolean; className?: string; searchDebounceMs?: number; // Configurable debounce time // Additional action buttons to render on the right side @@ -23,17 +20,14 @@ interface EditableTableControlsProps> { } /** - * EditableTableControls - Component for table controls like search, filters, column visibility + * 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, - showColumnVisibility = false, - showColumnPinning = false, showColumnFilters = false, - showSorting = false, className, searchDebounceMs = 1000, // Default 1 second additionalActions, @@ -96,20 +90,13 @@ export function EditableTableControls>({ }; // Early return if no controls are enabled - const hasControls = [ - showGlobalFilter, - showColumnVisibility, - showColumnPinning, - showColumnFilters, - showSorting, - additionalActions, - ].some(Boolean); + const hasControls = [showGlobalFilter, showColumnFilters, additionalActions].some(Boolean); if (!hasControls) return null; return (
- {/* Top Row: Add Filter + Search + Controls */} + {/* Top Row: Add Filter + Search + Additional Actions */}
{/* Left Side: Add Filter + Search */}
@@ -138,29 +125,10 @@ export function EditableTableControls>({ )}
- {/* Right Side: Sort + Column Controls + Additional Actions */} -
- {/* Sorting Button */} - {showSorting && ( - - )} - - {/* Column Visibility Toggle */} - {showColumnVisibility && ( - - )} - - {/* Column Pinning Controls */} - {showColumnPinning &&
{/* Column pinning implementation - future enhancement */}
} - - {/* Additional Actions from parent component */} - {additionalActions} -
+ {/* Right Side: Additional Actions */} + {additionalActions && ( +
{additionalActions}
+ )}
{/* Active Filters Row */} diff --git a/packages/medusa-forms/src/editable-table/types/cells.ts b/packages/medusa-forms/src/editable-table/types/cells.ts index 5d7742a..273cc2f 100644 --- a/packages/medusa-forms/src/editable-table/types/cells.ts +++ b/packages/medusa-forms/src/editable-table/types/cells.ts @@ -96,8 +96,6 @@ export type EditableTableConfig> = { enableColumnFilters?: boolean; enableSorting?: boolean; enablePagination?: boolean; - enableColumnPinning?: boolean; - enableColumnVisibility?: boolean; /** Dynamic column filter patterns (e.g., ['location_levels.*']) for grouping related columns */ dynamicColumnFilters?: string[]; enableRowSelection?: boolean; From 7bec07ce48fe7d4c1273eeb4c864c3f9da28317f Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 20:45:20 -0600 Subject: [PATCH 26/38] feat: enhance EditableTable with column sorting and filtering - Added column sorting and filtering capabilities to the EditableTable component, allowing users to sort data by clicking on column headers and filter data using visual chips. - Updated documentation and stories to reflect these new features, improving clarity on usage and functionality. - Enhanced the README files to include sorting and filtering in the key features of the EditableTable. --- .../medusa-forms/EditableTable.stories.tsx | 20 ++++++--- packages/medusa-forms/README.md | 2 +- .../medusa-forms/src/editable-table/README.md | 42 +++++++++++-------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 127ec63..8e06642 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -22,7 +22,8 @@ A powerful, feature-rich table component with inline editing capabilities for ta - **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 +- **Column Sorting**: Click column headers to sort data (ascending → descending → unsorted) +- **Column Filtering**: Filter data by column values with visual chips - **Multiple Cell Types**: Text, number, autocomplete, badge - **Performance Optimized**: Handles large datasets efficiently `, @@ -84,9 +85,9 @@ export const SimpleValidationExample = { 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 } }, + { 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 }, ], [], ); @@ -137,14 +138,20 @@ export const SimpleValidationExample = { docs: { description: { story: ` -The simplest EditableTable implementation with basic inline validation. +The simplest EditableTable implementation with basic inline validation and column sorting. **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 +- **Column sorting** - Click column headers to sort (ascending → descending → unsorted) - No external dependencies (no Zod, no async validation) +**Sorting:** +- All columns have \`enableSorting: true\` +- Click any column header to toggle sort direction +- Visual indicators show current sort state + **Use this pattern when:** - You have simple validation rules - No need for complex schemas or async validation @@ -953,7 +960,7 @@ export const DynamicColumnFiltersExample = { docs: { description: { story: ` -Dynamic column filters for grouping similar columns under a single URL parameter. +Dynamic column filters for grouping similar columns under a single URL parameter, with column sorting. **Filter Categories:** - Out of Stock (0 units) @@ -965,6 +972,7 @@ Dynamic column filters for grouping similar columns under a single URL parameter - \`calculateFilterValue\` converts numeric values to filterable categories - \`dynamicColumnFilters\` groups region columns: ['region_*'] - Clean URL format: \`?cf_region=region_east:Low,region_west:High\` +- **Column sorting** on SKU and Product columns - click headers to sort - Works with async-loaded columns - 300ms simulated save delay diff --git a/packages/medusa-forms/README.md b/packages/medusa-forms/README.md index e9d937b..5988ec3 100644 --- a/packages/medusa-forms/README.md +++ b/packages/medusa-forms/README.md @@ -13,7 +13,7 @@ npm install @lambdacurry/medusa-forms ## Components - **Controlled Form Components** - React Hook Form integrated inputs, selects, checkboxes, etc. -- **EditableTable** - Inline-editing table with validation, auto-save, and URL state persistence +- **EditableTable** - Inline-editing table with validation, auto-save, sorting, filtering, and URL state persistence - **UI Components** - Low-level form components ## Documentation diff --git a/packages/medusa-forms/src/editable-table/README.md b/packages/medusa-forms/src/editable-table/README.md index 822f11d..3dabf66 100644 --- a/packages/medusa-forms/src/editable-table/README.md +++ b/packages/medusa-forms/src/editable-table/README.md @@ -2,7 +2,7 @@ ## 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. +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, column sorting, filtering, and URL state persistence to create an efficient data management experience. ## Installation & Peer Dependencies @@ -207,7 +207,7 @@ You only need to configure these providers explicitly in isolated environments l - **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 +- **Column Management**: Sorting and filtering - **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 @@ -347,7 +347,8 @@ export const MyEditableTable = () => { showControls={true} showPagination={true} enableGlobalFilter={true} - enableSorting={true} + enableSorting={true} // Enable sorting (click headers) + enableColumnFilters={true} // Enable column filters enablePagination={true} tableId="my-table" // Optional: for URL state persistence /> @@ -365,6 +366,7 @@ export const MyEditableTable = () => { type: 'text', placeholder: 'Enter title', required: true, + enableSorting: true, // Click header to sort } ``` @@ -380,6 +382,7 @@ export const MyEditableTable = () => { max: 999999, step: 1, }, + enableSorting: true, // Click header to sort } ``` @@ -1180,10 +1183,8 @@ interface EditableTableProps> { // Table Features enableGlobalFilter?: boolean; // Enable global search enableColumnFilters?: boolean; // Enable column-specific filters - enableSorting?: boolean; // Enable column sorting + enableSorting?: boolean; // Enable column sorting (click headers to sort) enablePagination?: boolean; // Enable pagination - enableColumnPinning?: boolean; // Enable column pinning - enableColumnVisibility?: boolean; // Enable column show/hide enableRowSelection?: boolean; // Enable row selection // Event Handlers @@ -1217,10 +1218,8 @@ interface EditableTableColumnDefinition { maxWidth?: number; // Maximum column width // Features - enableSorting?: boolean; // Enable sorting for this column + enableSorting?: boolean; // Enable sorting for this column (click header to sort) 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 @@ -1542,16 +1541,23 @@ describe('Cell Save', () => { // Basic column filtering is available + ``` + +4. **Column Sorting** (✅ Implemented) + ```tsx + // Click column headers to sort (ascending → descending → unsorted) + ``` -4. **Column Tooltips** (✅ Implemented) +5. **Column Tooltips** (✅ Implemented) ```tsx // Tooltip support for column headers is now available { }} /> ``` - -5. **Pagination** (✅ Implemented) + +6. **Pagination** (✅ Implemented) ```tsx // Full pagination system with customizable page sizes Date: Tue, 28 Oct 2025 20:49:50 -0600 Subject: [PATCH 27/38] feat: remove unused getDefaultColumnSizing function from columnHelpers - Eliminated the getDefaultColumnSizing function from columnHelpers as it was not utilized in the EditableTable implementation. - Updated imports in related files to reflect this change, streamlining the codebase and enhancing clarity. --- .../src/editable-table/columnHelpers.tsx | 14 -------------- .../hooks/useEditableTableColumns.tsx | 7 +++---- packages/medusa-forms/src/editable-table/index.ts | 1 - 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/columnHelpers.tsx b/packages/medusa-forms/src/editable-table/columnHelpers.tsx index e52cf72..b340e8d 100644 --- a/packages/medusa-forms/src/editable-table/columnHelpers.tsx +++ b/packages/medusa-forms/src/editable-table/columnHelpers.tsx @@ -2,20 +2,6 @@ import type { ColumnDef } from '@tanstack/react-table'; import type { EditableTableCellMeta } from './types/cells'; import type { EditableColumnType } from './types/columns'; -// Utility to generate column sizing based on field types -export function getDefaultColumnSizing(type: string): number { - const sizeMap: Record = { - avatar: 80, - boolean: 80, - number: 80, - date: 120, - phone: 140, - image: 80, - }; - - return sizeMap[type] || 180; -} - export const canSortColumn = (type: EditableColumnType) => ['text', 'number'].includes(type); // Get filter function based on field type diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index e7f903f..3dd42f6 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -2,7 +2,7 @@ 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 { canSortColumn, getSortingFunction } from '../columnHelpers'; import { CellContent } from '../components/cells/cells'; import type { EditableTableCellMeta, @@ -50,9 +50,8 @@ 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 minSize = columnDef.minWidth; + const maxSize = Math.max(columnDef.maxWidth || 0, minSize || 0); const fieldKey = columnDef.getFieldKey?.(columnDef.key) || columnDef.key; const columnMeta: EditableTableCellMeta = { name: columnDef.name, diff --git a/packages/medusa-forms/src/editable-table/index.ts b/packages/medusa-forms/src/editable-table/index.ts index aa9c24d..a4eb635 100644 --- a/packages/medusa-forms/src/editable-table/index.ts +++ b/packages/medusa-forms/src/editable-table/index.ts @@ -43,7 +43,6 @@ export type { // Utilities export { - getDefaultColumnSizing, canSortColumn, getFilterFunction, getSortingFunction, From 6a50e50a378d0035b99b70eea6f27a4185bacc27 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 28 Oct 2025 21:08:43 -0600 Subject: [PATCH 28/38] feat: refactor InputCell component for improved styling and functionality - Replaced the Input component with a native input element to streamline rendering. - Introduced a new base style for the input using the clx utility, enhancing the visual consistency and user experience. - Updated the className to incorporate the new styles, ensuring a cohesive design across the component. --- .../components/editables/InputCell.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx index 584fd81..ed343ca 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -1,4 +1,4 @@ -import { Input, clx } from '@medusajs/ui'; +import { clx } from '@medusajs/ui'; import { type ChangeEvent, useCallback } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { useCellState } from '../../hooks/useCellState'; @@ -6,6 +6,14 @@ import type { CellContentProps } from '../../types/cells'; import { SAVE_DELAY_MS, getCellStatusClassName, getStatusIndicator } from '../../utils/cell-status'; import { CellStatusIndicator } from '../cells/CellStatusIndicator'; +const medusaInputBaseStyles = clx( + 'caret-ui-fg-base bg-ui-bg-field hover:bg-ui-bg-field-hover shadow-borders-base placeholder-ui-fg-muted text-ui-fg-base transition-fg relative w-full appearance-none rounded-md outline-none', + 'focus-visible:shadow-borders-interactive-with-active', + 'disabled:text-ui-fg-disabled disabled:!bg-ui-bg-disabled disabled:placeholder-ui-fg-disabled disabled:cursor-not-allowed', + 'aria-[invalid=true]:!shadow-borders-error invalid:!shadow-borders-error', + 'txt-compact-small !h-7 px-2 py-1', +); + const VALID_NUMBER_REGEX = /^\d+(\.\d+)?$/; function isValidNumberInput(value: string): boolean { @@ -106,7 +114,7 @@ export const InputCell = ({ return (
{showLeftIndicator && } - Date: Tue, 4 Nov 2025 18:31:34 -0600 Subject: [PATCH 29/38] chore: update package versions and dependencies in yarn.lock and package.json - Bumped version of @lambdacurry/medusa-forms to 0.3.0-alpha.1. - Updated dependencies for @tanstack/react-table and @tanstack/react-virtual to specific versions for compatibility. - Adjusted peer dependencies in yarn.lock to reflect the new versioning and ensure proper resolution. --- packages/medusa-forms/package.json | 6 +++--- yarn.lock | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/medusa-forms/package.json b/packages/medusa-forms/package.json index 2f1d74b..e9a7580 100644 --- a/packages/medusa-forms/package.json +++ b/packages/medusa-forms/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/medusa-forms", - "version": "0.2.9", + "version": "0.3.0-alpha.1", "description": "Comprehensive form and data management component library for Medusa Admin with controlled form components and EditableTable", "keywords": [ "medusa", @@ -85,7 +85,7 @@ "@medusajs/icons": "^2.0.0", "@medusajs/ui": "^4.0.0", "@tanstack/react-query": "^5.0.0", - "@tanstack/react-table": "^8.21.0", + "@tanstack/react-table": "^8.20.0", "lucide-react": "^0.263.0", "nuqs": "^2.6.0", "react": "^18.3.0 || ^19.0.0", @@ -100,7 +100,7 @@ "@medusajs/ui": "^4.0.0", "@tanstack/react-query": "^5.62.15", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.10.8", + "@tanstack/react-virtual": "^3.8.3", "@types/glob": "^8.1.0", "@types/react": "^19.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", diff --git a/yarn.lock b/yarn.lock index a930a8f..8f30596 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1670,7 +1670,20 @@ __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: @@ -1679,7 +1692,7 @@ __metadata: "@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.10.8" + "@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" @@ -1701,7 +1714,7 @@ __metadata: "@medusajs/icons": ^2.0.0 "@medusajs/ui": ^4.0.0 "@tanstack/react-query": ^5.0.0 - "@tanstack/react-table": ^8.21.0 + "@tanstack/react-table": ^8.20.0 lucide-react: ^0.263.0 nuqs: ^2.6.0 react: ^18.3.0 || ^19.0.0 @@ -5532,7 +5545,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-virtual@npm:^3.10.8": +"@tanstack/react-virtual@npm:^3.8.3": version: 3.13.12 resolution: "@tanstack/react-virtual@npm:3.13.12" dependencies: From e0420f989f735cb9ad56bf75016bba05bb221925 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:08:43 -0600 Subject: [PATCH 30/38] feat: add row selection functionality to useEditableTable hook - Enhanced the useEditableTable hook to support row selection by introducing a stable row ID mechanism and handling row selection state updates. - Updated the useEditableTableColumns to improve the delete action's structure for better readability. - These changes improve the user experience by allowing users to select and manage rows effectively within the editable table. --- .../editable-table/hooks/useEditableTable.ts | 31 ++++++++++++++++++- .../hooks/useEditableTableColumns.tsx | 4 +-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts index 168f5d6..ef752d3 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTable.ts @@ -1,4 +1,4 @@ -import type { Row } from '@tanstack/react-table'; +import type { Row, RowSelectionState } from '@tanstack/react-table'; import { getCoreRowModel, getFilteredRowModel, @@ -66,6 +66,14 @@ export function useEditableTable>(config: Edit () => ({ 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(), @@ -93,6 +101,9 @@ export function useEditableTable>(config: Edit columnFilters: tableState.columnFilters, sorting: tableState.sorting, pagination: tableState.pagination, + ...(config.enableRowSelection && config.rowSelection !== undefined + ? { rowSelection: config.rowSelection } + : {}), }, // State update handlers @@ -100,6 +111,21 @@ export function useEditableTable>(config: Edit 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, @@ -117,6 +143,9 @@ export function useEditableTable>(config: Edit updateTableState, globalFilterFn, customColumnFilterFn, + config.enableRowSelection, + config.rowSelection, + config.onRowSelectionChange, ], ); diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index 3dd42f6..dae47a3 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -161,8 +161,8 @@ function _createActionsColumn>( )} {onDelete && ( - - onDelete(item)} /> + onDelete(item)} className="gap-x-2"> + Delete )} From 9e2a9ead0a23a844974343c719be589e4538e41b Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:10:26 -0600 Subject: [PATCH 31/38] feat: add row selection and actions column examples to EditableTable stories - Introduced new stories for row selection and actions column, showcasing the ability to select rows, perform bulk actions, and manage individual row actions like view and delete. - Enhanced the EditableTable component with features for managing row selection state and implementing bulk delete functionality. - Updated documentation to reflect these new capabilities, improving the overall user experience and clarity of the EditableTable's functionality. --- .../medusa-forms/EditableTable.stories.tsx | 269 +++++++++++++++++- 1 file changed, 254 insertions(+), 15 deletions(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 8e06642..552712f 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -1,6 +1,6 @@ import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; import type { CellActionsHandlerGetter, EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; -import { Toaster, TooltipProvider } from '@medusajs/ui'; +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'; @@ -22,8 +22,7 @@ A powerful, feature-rich table component with inline editing capabilities for ta - **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 Sorting**: Click column headers to sort data (ascending → descending → unsorted) -- **Column Filtering**: Filter data by column values with visual chips +- **Column Management**: Sorting, filtering, pinning, and resizing - **Multiple Cell Types**: Text, number, autocomplete, badge - **Performance Optimized**: Handles large datasets efficiently `, @@ -85,9 +84,9 @@ export const SimpleValidationExample = { 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 }, + { 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 } }, ], [], ); @@ -138,20 +137,14 @@ export const SimpleValidationExample = { docs: { description: { story: ` -The simplest EditableTable implementation with basic inline validation and column sorting. +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 -- **Column sorting** - Click column headers to sort (ascending → descending → unsorted) - No external dependencies (no Zod, no async validation) -**Sorting:** -- All columns have \`enableSorting: true\` -- Click any column header to toggle sort direction -- Visual indicators show current sort state - **Use this pattern when:** - You have simple validation rules - No need for complex schemas or async validation @@ -960,7 +953,7 @@ export const DynamicColumnFiltersExample = { docs: { description: { story: ` -Dynamic column filters for grouping similar columns under a single URL parameter, with column sorting. +Dynamic column filters for grouping similar columns under a single URL parameter. **Filter Categories:** - Out of Stock (0 units) @@ -972,7 +965,6 @@ Dynamic column filters for grouping similar columns under a single URL parameter - \`calculateFilterValue\` converts numeric values to filterable categories - \`dynamicColumnFilters\` groups region columns: ['region_*'] - Clean URL format: \`?cf_region=region_east:Low,region_west:High\` -- **Column sorting** on SKU and Product columns - click headers to sort - Works with async-loaded columns - 300ms simulated save delay @@ -1252,3 +1244,250 @@ Empty state displayed when no data is available. }, }, }; + +// ============================================================================ +// 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. + `, + }, + }, + }, +}; From 66fcd93632b6790272b68fc37264fe88fba5e504 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:23:59 -0600 Subject: [PATCH 32/38] feat: add default column sizing utility for EditableTable - Introduced the getDefaultColumnSizing function to determine default widths for columns based on their type, enhancing the layout consistency of the EditableTable. - Updated the useEditableTableColumns hook to utilize the new sizing function, improving the management of column dimensions. - Exported the new utility function from columnHelpers for broader accessibility within the EditableTable component. --- .../src/editable-table/columnHelpers.tsx | 15 +++++++++++++++ .../hooks/useEditableTableColumns.tsx | 7 ++++--- packages/medusa-forms/src/editable-table/index.ts | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/columnHelpers.tsx b/packages/medusa-forms/src/editable-table/columnHelpers.tsx index b340e8d..ba0ca47 100644 --- a/packages/medusa-forms/src/editable-table/columnHelpers.tsx +++ b/packages/medusa-forms/src/editable-table/columnHelpers.tsx @@ -4,6 +4,21 @@ 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) { diff --git a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx index dae47a3..be3ccc6 100644 --- a/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx +++ b/packages/medusa-forms/src/editable-table/hooks/useEditableTableColumns.tsx @@ -2,7 +2,7 @@ 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, getSortingFunction } from '../columnHelpers'; +import { canSortColumn, getDefaultColumnSizing, getSortingFunction } from '../columnHelpers'; import { CellContent } from '../components/cells/cells'; import type { EditableTableCellMeta, @@ -50,8 +50,9 @@ function _createEditableTableColumn>( columnDef: EditableTableColumnDefinition, getCellActions: GetCellActionsFn, ): ColumnDef { - const minSize = columnDef.minWidth; - const maxSize = Math.max(columnDef.maxWidth || 0, minSize || 0); + 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, diff --git a/packages/medusa-forms/src/editable-table/index.ts b/packages/medusa-forms/src/editable-table/index.ts index a4eb635..e002a9b 100644 --- a/packages/medusa-forms/src/editable-table/index.ts +++ b/packages/medusa-forms/src/editable-table/index.ts @@ -44,6 +44,7 @@ export type { // Utilities export { canSortColumn, + getDefaultColumnSizing, getFilterFunction, getSortingFunction, getColumnHeaderClassName, From 9a9b997d4cf7400a884eac9d24a573b8d97faee2 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:26:02 -0600 Subject: [PATCH 33/38] feat: add custom column sizing example to EditableTable stories - Introduced a new story demonstrating custom column sizes for the EditableTable, allowing for tailored widths for various data types. - Implemented a flexible layout with minWidth and maxWidth properties for each column, enhancing readability and layout control. - Updated documentation to include details on column sizing features and use cases, improving clarity for users. --- .../medusa-forms/EditableTable.stories.tsx | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 552712f..64a4be2 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -1491,3 +1491,178 @@ Actions column provides a dropdown menu (⋯) with row-specific actions like vie }, }, }; + +// ============================================================================ +// 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 + `, + }, + }, + }, +}; From 280fa8182a051a6212f279f45c316da85667a735 Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:41:27 -0600 Subject: [PATCH 34/38] feat: integrate Input component into InputCell for enhanced styling and functionality - Replaced the native input element with the Input component from @medusajs/ui, improving rendering and consistency. - Removed the previous base styles and adjusted the className to utilize the Input component's built-in styles, enhancing the overall design. - Updated the InputCell component to support a more cohesive user experience with better styling options. --- .../components/editables/InputCell.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx index ed343ca..81db3e3 100644 --- a/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx +++ b/packages/medusa-forms/src/editable-table/components/editables/InputCell.tsx @@ -1,4 +1,4 @@ -import { clx } from '@medusajs/ui'; +import { Input, clx } from '@medusajs/ui'; import { type ChangeEvent, useCallback } from 'react'; import { useDebouncedCallback } from 'use-debounce'; import { useCellState } from '../../hooks/useCellState'; @@ -6,14 +6,6 @@ import type { CellContentProps } from '../../types/cells'; import { SAVE_DELAY_MS, getCellStatusClassName, getStatusIndicator } from '../../utils/cell-status'; import { CellStatusIndicator } from '../cells/CellStatusIndicator'; -const medusaInputBaseStyles = clx( - 'caret-ui-fg-base bg-ui-bg-field hover:bg-ui-bg-field-hover shadow-borders-base placeholder-ui-fg-muted text-ui-fg-base transition-fg relative w-full appearance-none rounded-md outline-none', - 'focus-visible:shadow-borders-interactive-with-active', - 'disabled:text-ui-fg-disabled disabled:!bg-ui-bg-disabled disabled:placeholder-ui-fg-disabled disabled:cursor-not-allowed', - 'aria-[invalid=true]:!shadow-borders-error invalid:!shadow-borders-error', - 'txt-compact-small !h-7 px-2 py-1', -); - const VALID_NUMBER_REGEX = /^\d+(\.\d+)?$/; function isValidNumberInput(value: string): boolean { @@ -114,18 +106,15 @@ export const InputCell = ({ return (
{showLeftIndicator && } - Date: Tue, 4 Nov 2025 19:49:10 -0600 Subject: [PATCH 35/38] fix: clean up ErrorState component by removing unused Badge element - Removed the Badge element from the ErrorState component to streamline the layout and improve clarity. - Adjusted the structure of the component to focus on essential elements, enhancing the overall user experience. --- .../src/editable-table/components/LoadingStates.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx b/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx index 44e820f..9c0eebe 100644 --- a/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx +++ b/packages/medusa-forms/src/editable-table/components/LoadingStates.tsx @@ -1,4 +1,4 @@ -import { Badge, Button, Container, Text } from '@medusajs/ui'; +import { Button, Container, Text } from '@medusajs/ui'; import { AlertCircle, RefreshCw } from 'lucide-react'; interface ErrorStateProps { @@ -11,14 +11,6 @@ interface ErrorStateProps { export const ErrorState = ({ title, message, onRetry, showRetry = true }: ErrorStateProps) => { return (
-
-
- - Items (0) - -
-
-
From f76333f89ed3338eade2276ba54b21b9327364fe Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:50:49 -0600 Subject: [PATCH 36/38] feat: add ErrorState example to EditableTable stories - Introduced a new story demonstrating the ErrorState component within the EditableTable, showcasing how to handle data loading errors effectively. - Implemented a retry mechanism and customizable error messages to enhance user experience during error scenarios. - Updated documentation to include details on the ErrorState component's props and best practices for error handling, improving clarity for users. --- .../medusa-forms/EditableTable.stories.tsx | 141 +++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/apps/docs/src/medusa-forms/EditableTable.stories.tsx b/apps/docs/src/medusa-forms/EditableTable.stories.tsx index 64a4be2..dec1722 100644 --- a/apps/docs/src/medusa-forms/EditableTable.stories.tsx +++ b/apps/docs/src/medusa-forms/EditableTable.stories.tsx @@ -1,4 +1,4 @@ -import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; +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'; @@ -1666,3 +1666,142 @@ Custom column sizing allows you to control the width of table columns for optima }, }, }; + +// ============================================================================ +// 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 + `, + }, + }, + }, +}; From d26c18e876418aa05605c5ed9509bd6e193a1dad Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 19:56:03 -0600 Subject: [PATCH 37/38] chore: bump version of @lambdacurry/medusa-forms to 0.3.0-alpha.6 - Updated the package version in package.json to reflect the latest alpha release. - Ensured compatibility with recent changes and improvements in the library. --- packages/medusa-forms/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa-forms/package.json b/packages/medusa-forms/package.json index e9a7580..90189ae 100644 --- a/packages/medusa-forms/package.json +++ b/packages/medusa-forms/package.json @@ -1,6 +1,6 @@ { "name": "@lambdacurry/medusa-forms", - "version": "0.3.0-alpha.1", + "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", From b414a595bf0880f485e502d36ed2b12d839eb4fe Mon Sep 17 00:00:00 2001 From: Antony Duran Date: Tue, 4 Nov 2025 20:10:22 -0600 Subject: [PATCH 38/38] docs: update README for EditableTable component - Revised the overview to clarify features, removing redundant mentions of column sorting and filtering. - Updated the setup instructions to replace Storybook setup with NuqsAdapter setup for URL state management. - Enhanced the documentation on Medusa Admin setup, emphasizing the need for the NuqsAdapter and providing code examples. - Improved clarity on key features, including column management capabilities. - Removed outdated sections related to Storybook decorators and unnecessary provider details. --- .../medusa-forms/src/editable-table/README.md | 218 ++++-------------- 1 file changed, 50 insertions(+), 168 deletions(-) diff --git a/packages/medusa-forms/src/editable-table/README.md b/packages/medusa-forms/src/editable-table/README.md index 3dabf66..b8a50a7 100644 --- a/packages/medusa-forms/src/editable-table/README.md +++ b/packages/medusa-forms/src/editable-table/README.md @@ -2,7 +2,7 @@ ## 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, column sorting, filtering, and URL state persistence to create an efficient data management experience. +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 @@ -41,165 +41,53 @@ For virtual scrolling support with large datasets: yarn add @tanstack/react-virtual@^3.10.0 ``` -## Storybook Setup Requirements +## NuqsAdapter Setup -> **Prerequisites:** Make sure you have installed all [peer dependencies](#installation--peer-dependencies) before setting up Storybook stories. +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`. -The `EditableTable` component requires several context providers to function correctly in Storybook (or any standalone environment). Below are the required providers and the errors you'll encounter if they're missing: +> **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). -### Required Providers +### Medusa Admin Setup -#### 1. NuqsAdapter (URL State Management) -**Package:** `nuqs` (peer dependency) -**Error if missing:** -``` -[nuqs] nuqs requires an adapter to work with your framework. -``` - -**Why needed:** The EditableTable uses `nuqs` for URL state persistence (search, filters, pagination, sorting). In Storybook, which doesn't have a framework router, you need the React adapter. - -**Setup:** -```typescript -import { NuqsAdapter } from 'nuqs/adapters/react'; -``` +In Medusa Admin, add the `NuqsAdapter` at the page level when creating custom pages: -#### 2. QueryClientProvider (React Query) -**Package:** `@tanstack/react-query` (peer dependency) -**Error if missing:** -``` -No QueryClient set, use QueryClientProvider to set one -``` - -**Why needed:** The EditableTable's autocomplete cells and async operations depend on React Query for data fetching and caching. +```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'; -**Setup:** -```typescript -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +const MyCustomPage = () => { + return ( + + + + ); +}; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 1000 * 60 * 5, // 5 minutes - }, - }, +export const config = defineRouteConfig({ + label: 'My Custom Page', + icon: Buildings, }); -``` - -#### 3. TooltipProvider (Medusa UI) -**Package:** `@medusajs/ui` (peer dependency) -**Error if missing:** -``` -`Tooltip` must be used within `TooltipProvider` -``` - -**Why needed:** The EditableTable uses tooltips for column headers and cell status indicators. Medusa UI's Tooltip component requires a provider. - -**Setup:** -```typescript -import { TooltipProvider } from '@medusajs/ui'; -``` - -#### 4. Toaster (Medusa UI - Optional but Recommended) -**Package:** `@medusajs/ui` (peer dependency) -**Why needed:** For displaying toast notifications for validation errors, save confirmations, etc. -**Setup:** -```typescript -import { Toaster } from '@medusajs/ui'; +export default MyCustomPage; ``` -### Complete Storybook Decorator Example - -Here's the complete decorator setup for EditableTable stories with all required imports: - -```typescript -// Component imports -import { EditableTable } from '@lambdacurry/medusa-forms/editable-table'; -import type { EditableTableColumnDefinition } from '@lambdacurry/medusa-forms/editable-table'; - -// Provider imports -import { Toaster, TooltipProvider } from '@medusajs/ui'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { NuqsAdapter } from 'nuqs/adapters/react'; - -// Storybook imports -import type { Meta } from '@storybook/react-vite'; - -// React imports -import { useState } from 'react'; - -const meta = { - title: 'Your Stories/Editable Table', - component: EditableTable, - decorators: [ - (Story) => { - // Initialize React Query client - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - staleTime: 1000 * 60 * 5, // 5 minutes - }, - }, - }); - - return ( - - - -
- -
- -
-
-
- ); - }, - ], -} satisfies Meta; - -export default meta; -``` - -**Key Imports Explained:** -- `EditableTable` - Main component from medusa-forms package -- `Toaster`, `TooltipProvider` - From `@medusajs/ui` peer dependency -- `QueryClient`, `QueryClientProvider` - From `@tanstack/react-query` peer dependency -- `NuqsAdapter` - From `nuqs` peer dependency (React adapter for standalone usage) - -### Provider Hierarchy - -The providers must be nested in this specific order (outermost to innermost): +**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:** ``` -NuqsAdapter ← URL state management - └─ QueryClientProvider ← React Query context - └─ TooltipProvider ← Medusa UI tooltips - └─ Your Content - └─ Toaster ← Toast notifications (sibling to content) +[nuqs] nuqs requires an adapter to work with your framework. ``` -### Troubleshooting - -| Error Message | Missing Provider | Package | Solution | -|---------------|------------------|---------|----------| -| `nuqs requires an adapter` | `NuqsAdapter` | `nuqs` | Wrap in `` from `nuqs/adapters/react` | -| `No QueryClient set` | `QueryClientProvider` | `@tanstack/react-query` | Wrap in `` | -| `Tooltip must be used within TooltipProvider` | `TooltipProvider` | `@medusajs/ui` | Wrap in `` from `@medusajs/ui` | -| Toast notifications not appearing | `Toaster` | `@medusajs/ui` | Add `` component from `@medusajs/ui` | -| Icons not rendering | `@medusajs/icons` | `@medusajs/icons` | Ensure peer dependency is installed | -| Table functionality broken | `@tanstack/react-table` | `@tanstack/react-table` | Ensure peer dependency is installed | - -### Production Usage - -In a production Medusa Admin application, these providers are typically already set up at the app level: -- Next.js or React Router provides the routing context for `nuqs` -- React Query is configured globally -- Medusa UI providers are included in the app shell - -You only need to configure these providers explicitly in isolated environments like Storybook or standalone demos. +For other frameworks or standalone usage, refer to the [official nuqs adapters documentation](https://nuqs.dev/docs/adapters). ## Key Features @@ -207,7 +95,7 @@ You only need to configure these providers explicitly in isolated environments l - **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 and filtering +- **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 @@ -347,8 +235,7 @@ export const MyEditableTable = () => { showControls={true} showPagination={true} enableGlobalFilter={true} - enableSorting={true} // Enable sorting (click headers) - enableColumnFilters={true} // Enable column filters + enableSorting={true} enablePagination={true} tableId="my-table" // Optional: for URL state persistence /> @@ -366,7 +253,6 @@ export const MyEditableTable = () => { type: 'text', placeholder: 'Enter title', required: true, - enableSorting: true, // Click header to sort } ``` @@ -382,7 +268,6 @@ export const MyEditableTable = () => { max: 999999, step: 1, }, - enableSorting: true, // Click header to sort } ``` @@ -1183,8 +1068,10 @@ interface EditableTableProps> { // Table Features enableGlobalFilter?: boolean; // Enable global search enableColumnFilters?: boolean; // Enable column-specific filters - enableSorting?: boolean; // Enable column sorting (click headers to sort) + 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 @@ -1218,8 +1105,10 @@ interface EditableTableColumnDefinition { maxWidth?: number; // Maximum column width // Features - enableSorting?: boolean; // Enable sorting for this column (click header to sort) + 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 @@ -1541,23 +1430,16 @@ describe('Cell Save', () => { // Basic column filtering is available - ``` - -4. **Column Sorting** (✅ Implemented) - ```tsx - // Click column headers to sort (ascending → descending → unsorted) - ``` -5. **Column Tooltips** (✅ Implemented) +4. **Column Tooltips** (✅ Implemented) ```tsx // Tooltip support for column headers is now available { }} /> ``` - -6. **Pagination** (✅ Implemented) + +5. **Pagination** (✅ Implemented) ```tsx // Full pagination system with customizable page sizes