Rebuild validationstep to make it actually manageable

This commit is contained in:
2025-03-03 14:25:26 -05:00
parent 56c3f0534d
commit e21da8330e
28 changed files with 5482 additions and 1117 deletions

View File

@@ -0,0 +1,396 @@
# ValidationStep Component Refactoring Plan
## Overview
This document outlines a comprehensive plan to refactor the current ValidationStep component (4000+ lines) into a more maintainable, modular structure. The new implementation will be developed alongside the existing component without modifying the original code. Once completed, the previous step in the workflow will offer the option to continue to either the original ValidationStep or the new implementation.
## Table of Contents
1. [Current Component Analysis](#current-component-analysis)
2. [New Architecture Design](#new-architecture-design)
3. [Component Structure](#component-structure)
4. [State Management](#state-management)
5. [Key Features Implementation](#key-features-implementation)
6. [Integration Plan](#integration-plan)
7. [Testing Strategy](#testing-strategy)
8. [Project Timeline](#project-timeline)
9. [Design Principles](#design-principles)
10. [Appendix: Function Reference](#appendix-function-reference)
## Current Component Analysis
The current ValidationStep component has several issues:
- **Size**: At over 4000 lines, it's difficult to maintain and understand
- **Multiple responsibilities**: Handles validation, UI rendering, template management, and more
- **Special cases**: Contains numerous special case handlers and exceptions
- **Complex state management**: State is distributed across multiple useState calls
- **Tightly coupled concerns**: UI, validation logic, and business rules are intertwined
### Key Features to Preserve
1. **Data Validation**
- Field-level validation (required, regex, unique)
- Row-level validation (supplier, company fields)
- UPC validation with API integration
- AI-assisted validation
2. **Template Management**
- Saving, loading, and applying templates
- Template-based validation
3. **UI Components**
- Editable table with specialized cell renderers
- Error display and management
- Filtering and sorting capabilities
- Status indicators and progress tracking
4. **Special Field Handling**
- Input fields with price formatting
- Multi-input fields with separator configuration
- Select fields with dropdown options
- Checkbox fields with boolean value mapping
- UPC fields with specialized validation
5. **User Interaction Flows**
- Tab and keyboard navigation
- Bulk operations (select all, apply template)
- Row validation on value change
- Error reporting and display
## New Architecture Design
The new architecture will follow these principles:
1. **Separation of Concerns**
- UI rendering separate from business logic
- Validation logic isolated from state management
- Clear interfaces between components
2. **Composable Components**
- Small, focused components with single responsibilities
- Reusable pattern for different field types
3. **Centralized State Management**
- Custom hooks for state management
- Clear data flow patterns
- Reduced prop drilling
4. **Consistent Error Handling**
- Standardized error structure
- Predictable error propagation
- User-friendly error display
5. **Performance Optimization**
- Virtualized table rendering
- Memoization of expensive computations
- Deferred validation for better user experience
## Component Structure
The new ValidationStepNew folder has the following structure:
```
ValidationStepNew/
├── index.tsx # Main entry point that composes all pieces
├── components/ # UI Components
│ ├── ValidationContainer.tsx # Main wrapper component
│ ├── ValidationTable.tsx # Table implementation
│ ├── ValidationCell.tsx # Cell component
│ ├── ValidationSidebar.tsx # Sidebar with controls
│ ├── ValidationToolbar.tsx # Top toolbar (removed as unnecessary)
│ ├── TemplateManager.tsx # Template management
│ ├── FilterPanel.tsx # Filtering interface (integrated into Container)
│ └── cells/ # Specialized cell renderers
│ ├── InputCell.tsx
│ ├── SelectCell.tsx
│ ├── MultiInputCell.tsx
│ └── CheckboxCell.tsx
├── hooks/ # Custom hooks
│ ├── useValidationState.tsx # Main state management
│ ├── useTemplates.tsx # Template-related logic (integrated into ValidationState)
│ ├── useFilters.tsx # Filtering logic (integrated into ValidationState)
│ └── useUpcValidation.tsx # UPC-specific validation
└── utils/ # Utility functions
├── validationUtils.ts # Validation helper functions
├── formatters.ts # Value formatting utilities
└── constants.ts # Constant values and configuration
```
### Component Responsibilities
#### ValidationContainer
- Main container component
- Coordinates between subcomponents
- Manages global state
- Handles navigation events (next, back)
- Contains filter controls
#### ValidationTable
- Displays the data in tabular form
- Manages selection state
- Handles keyboard navigation
- Integrates with TanStack Table
- Displays properly styled rows and cells
#### ValidationCell
- Factory component that renders appropriate cell type
- Manages cell-level state
- Handles validation errors display
- Manages edit mode
- Shows consistent error indicators
#### TemplateManager
- Handles template selection UI
- Provides template save/load functionality
- Manages template application to rows
#### Cell Components
- **InputCell**: Handles text input with multiline and price support
- **MultiInputCell**: Handles multiple values with separator configuration
- **SelectCell**: Command/popover component for single selection
- **CheckboxCell**: Boolean value selection with mapping support
## State Management
### Core State Interface
```typescript
interface ValidationState<T extends string> {
// Core data
data: RowData<T>[];
filteredData: RowData<T>[];
// Validation state
isValidating: boolean;
validationErrors: Map<number, Record<string, Error[]>>;
rowValidationStatus: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
// Selection state
rowSelection: RowSelectionState;
// Template state
templates: Template[];
selectedTemplateId: string | null;
// Filter state
filters: FilterState;
// Methods
updateRow: (rowIndex: number, key: T, value: any) => void;
validateRow: (rowIndex: number) => Promise<void>;
validateUpc: (rowIndex: number, upcValue: string) => Promise<void>;
applyTemplate: (templateId: string, rowIndexes: number[]) => void;
saveTemplate: (name: string, type: string) => void;
setFilters: (newFilters: Partial<FilterState>) => void;
// Additional methods...
}
```
### useValidationState Hook
The main state management hook handles:
- Data manipulation (update, sort, filter)
- Selection management
- Validation coordination
- Integration with validation utilities
- Template management
- Filtering and sorting
## Key Features Implementation
### 1. Field Type Handling
Implemented a strategy pattern for different field types:
```typescript
// In ValidationCell
const renderCellContent = () => {
const fieldType = field.fieldType.type
switch (fieldType) {
case 'input':
return <InputCell<T> field={field} value={value} onChange={onChange} ... />
case 'multi-input':
return <MultiInputCell<T> field={field} value={value} onChange={onChange} ... />
case 'select':
return <SelectCell<T> field={field} value={value} onChange={onChange} ... />
// etc.
}
}
```
### 2. Validation Logic
Validation is broken down into clear steps:
1. **Field Validation**: Apply field-level validations (required, regex, etc.)
2. **Row Validation**: Apply row-level validations and rowHook
3. **Table Validation**: Apply table-level validations (unique) and tableHook
Validation now happens automatically without explicit buttons, with immediate feedback on field blur.
### 3. UI Components
UI components follow these principles:
1. **Consistent Styling**: All components use shadcn UI for consistent look and feel
2. **Visual Feedback**: Errors are clearly indicated with icons and border styling
3. **Intuitive Editing**: Fields show outlines even when not in focus, and edit on click
4. **Proper Command Pattern**: Select and multi-select fields use command/popover pattern for better UX
5. **Focus Management**: Fields close when clicking away and perform validation on blur
## Design Principles
Based on user preferences and best practices, the following design principles guide this refactoring:
1. **Automatic Validation**
- Validation should happen automatically without explicit buttons
- All validation should run on initial data load
- Fields should validate on blur (when user clicks away)
2. **Modern UI Patterns**
- Command/popover components for all selects and multi-selects
- Consistent field outlines and borders even when not in focus
- Badge patterns for multi-select items
- Clear visual indicators for errors
3. **Reduced Complexity**
- Remove unnecessary UI elements like "validate all" buttons
- Eliminate redundant state and toast notifications
- Simplify component hierarchy where possible
- Find root causes rather than adding special cases
4. **Consistent Component Behavior**
- Fields should close when clicking away
- All inputs should follow the same editing pattern
- Error handling should be consistent across all field types
- Multi-select fields should allow selecting multiple items with clear visual feedback
## Integration Plan
### 1. Creating the New Component Structure
Folder structure has been created without modifying the existing code:
```bash
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/{components,hooks,utils}
mkdir -p inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/cells
```
### 2. Implementing Basic Components
Core components have been implemented:
1. Created index.tsx as the main entry point
2. Implemented ValidationContainer with basic state management
3. Created ValidationTable for data display
4. Implemented basic cell rendering with specialized cell types
### 3. Implementing State Management
State management has been implemented:
1. Created useValidationState hook
2. Implemented data transformation utilities
3. Added validation logic
### 4. Integrating with Previous Step
The previous step component allows choosing between validation implementations, enabling gradual testing and adoption.
## Testing Strategy
1. **Unit Tests**
- Test individual utility functions
- Test hooks in isolation
- Test individual UI components
2. **Integration Tests**
- Test component interactions
- Test state management flow
- Test validation logic integration
3. **Comparison Tests**
- Compare output of new component with original
- Verify that all functionality works the same
4. **Performance Tests**
- Measure render times
- Evaluate memory usage
- Compare against original component
## Project Timeline
1. **Phase 1: Initial Structure (Completed)**
- Set up folder structure
- Implement basic components
- Create core state management
2. **Phase 2: Core Functionality (In Progress)**
- Implement validation logic (completed)
- Create cell renderers (completed)
- Add template management (in progress)
3. **Phase 3: Special Features (Upcoming)**
- Implement UPC validation
- Add AI validation
- Handle special cases
4. **Phase 4: UI Refinement (Ongoing)**
- Improve error display (completed)
- Enhance user interactions (completed)
- Optimize performance (in progress)
5. **Phase 5: Testing and Integration (Upcoming)**
- Write tests
- Fix bugs
- Integrate with previous step
## Appendix: Function Reference
This section documents the core functions from the original ValidationStep that need to be preserved in the new implementation.
### Validation Functions
1. **validateRegex** - Validates values against regex patterns
2. **getValidationError** - Determines field-level validation errors
3. **validateAndCommit** - Validates and commits new values
4. **validateData** - Validates all data rows
5. **validateUpcAndGenerateItemNumbers** - Validates UPC codes and generates item numbers
### Formatting Functions
1. **formatPrice** - Formats price values
2. **getDisplayValue** - Gets formatted display value based on field type
3. **isMultiInputType** - Checks if field is multi-input type
4. **getMultiInputSeparator** - Gets separator for multi-input fields
5. **isPriceField** - Checks if field should be formatted as price
### Template Functions
1. **loadTemplates** - Loads templates from storage
2. **saveTemplate** - Saves a new template
3. **applyTemplate** - Applies a template to selected rows
4. **getTemplateDisplayText** - Gets display text for a template
### AI Validation Functions
1. **handleAiValidation** - Triggers AI validation
2. **showCurrentPrompt** - Shows current AI prompt
3. **getFieldDisplayValue** - Gets display value for a field
4. **highlightDifferences** - Highlights differences between original and corrected values
5. **getFieldDisplayValueWithHighlight** - Gets display value with highlighted differences
6. **revertAiChange** - Reverts an AI-suggested change
7. **isChangeReverted** - Checks if an AI change has been reverted
### Event Handlers
1. **handleUpcValueUpdate** - Handles UPC value updates
2. **handleBlur** - Handles input blur events
3. **handleWheel** - Handles wheel events for navigation
4. **copyValueDown** - Copies a value to cells below
5. **handleSkuGeneration** - Generates SKUs
By following this refactoring plan, we continue to transform the monolithic ValidationStep component into a modular, maintainable set of components while preserving all existing functionality and aligning with user preferences for design and behavior.

View File

@@ -0,0 +1,137 @@
# ValidationStepNew Implementation Status
## Overview
This document outlines the current status of the ValidationStepNew implementation, a refactored version of the original ValidationStep component. The goal is to create a more maintainable, modular component that preserves all functionality of the original while eliminating technical debt and implementing modern UI patterns.
## Design Principles
Based on the user's preferences, we're following these core design principles:
1. **Automatic Validation**
- ✅ Validation runs automatically on data load
- ✅ No explicit "validate all" button needed
- ✅ Fields validate on blur when user clicks away
- ✅ Immediate visual feedback for validation errors
2. **Modern UI Patterns**
- ✅ Command/popover components for selects and multi-selects
- ✅ Consistent field outlines and borders even when not in focus
- ✅ Badge pattern for multi-select field items
- ✅ Visual indicators for errors with appropriate styling
3. **Reduced Complexity**
- ✅ Removed unnecessary UI elements like "validate all" button
- ✅ Eliminated redundant toast notifications
- ✅ Simplified component hierarchy
- ✅ Fixed root causes rather than adding special cases
4. **Consistent Behavior**
- ✅ Fields close when clicking away
- ✅ All inputs follow the same editing pattern
- ✅ Error handling is consistent across field types
- ✅ Multi-select fields allow selecting multiple items
## Completed Components
### Core Structure
- ✅ Main component structure
- ✅ Directory organization
- ✅ TypeScript interfaces
- ✅ Props definition and passing
### State Management
-`useValidationState` hook for centralized state
- ✅ Data validation logic
- ✅ Integration with rowHook and tableHook
- ✅ Error tracking and management
- ✅ Row selection
- ✅ Automatic validation on data load
### UI Components
- ✅ ValidationContainer with appropriate layout
- ✅ ValidationTable with shadcn UI components
- ✅ ValidationCell factory component
- ✅ Row select/deselect functionality
- ✅ Error display and indicators
- ✅ Selection action bar
### Cell Components
- ✅ InputCell with price and multiline support
- ✅ MultiInputCell with separator configuration
- ✅ SelectCell using command/popover pattern
- ✅ CheckboxCell with boolean mapping
- ✅ Consistent styling across all field types
- ✅ Proper edit/view state management
- ✅ Outlined borders in both edit and view modes
### Utility Functions
- ✅ Value formatting for display
- ✅ Field type detection
- ✅ Error creation and management
- ✅ Price formatting
### UI Improvements
- ✅ Consistent borders and field outlines
- ✅ Fields that properly close when clicking away
- ✅ Multi-select with badge UI pattern
- ✅ Command pattern for searchable select menus
- ✅ Better visual error indication
## Pending Tasks
### Enhanced Validation
- ⏳ AI validation system
- ⏳ Custom validation hooks
- ⏳ Enhanced UPC validation with API integration
- ⏳ Validation visualizations
### Advanced UI Features
- ⏳ Table virtualization for performance
- ⏳ Drag-and-drop reordering
- ⏳ Bulk operations (copy down, fill all, etc.)
- ⏳ Keyboard navigation improvements
- ⏳ Template dialogs and management UI
### Special Features
- ⏳ Image preview integration
- ⏳ SKU generation system
- ⏳ Item number generation
- ⏳ Dependent dropdown values
### Testing
- ⏳ Unit tests for utility functions
- ⏳ Component tests
- ⏳ Integration tests
- ⏳ Performance benchmarks
## Known Issues
1. TypeScript error for `validationDisabled` property in ValidationCell.tsx
2. Some type casting is needed due to complex generic types
3. Need to address edge cases for multi-select fields validation
4. Proper error handling for API calls needs implementation
## Next Steps
1. Fix TypeScript errors in ValidationCell and related components
2. Complete template management functionality
3. Implement UPC validation with API integration
4. Make multi-select field validation more robust
5. Add comprehensive tests
## Performance Improvements
We've already implemented several performance optimizations:
1. ✅ More efficient state updates by removing unnecessary re-renders
2. ✅ Better error handling to prevent cascading validations
3. ✅ Improved component isolation to prevent unnecessary re-renders
4. ✅ Automatic validation that doesn't block the UI
Additional planned improvements:
1. Virtualized table rendering for large datasets
2. Memoization of expensive calculations
3. Optimized state updates to minimize re-renders
4. Batched API calls for validation

View File

@@ -34,6 +34,8 @@ import {
CommandList, CommandList,
} from "@/components/ui/command" } from "@/components/ui/command"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
export type MatchColumnsProps<T extends string> = { export type MatchColumnsProps<T extends string> = {
data: RawData[] data: RawData[]
@@ -48,6 +50,7 @@ export type GlobalSelections = {
company?: string company?: string
line?: string line?: string
subline?: string subline?: string
useNewValidation?: boolean
} }
export enum ColumnType { export enum ColumnType {
@@ -1698,11 +1701,25 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<div className="border-t bg-muted px-6 py-4"> <div className="border-t bg-muted px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}> {onBack && (
{translations.matchColumnsStep.backButtonTitle} <Button variant="outline" onClick={onBack}>
</Button> {translations.matchColumnsStep.backButtonTitle}
)} </Button>
)}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2 ml-4">
<Switch
id="use-new-validation"
checked={!!globalSelections.useNewValidation}
onCheckedChange={(checked) =>
setGlobalSelections(prev => ({ ...prev, useNewValidation: checked }))
}
/>
<Label htmlFor="use-new-validation">Use new validation component</Label>
</div>
<Button <Button
className="ml-auto" className="ml-auto"
disabled={isLoading} disabled={isLoading}
@@ -1710,6 +1727,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
> >
{translations.matchColumnsStep.nextButtonTitle} {translations.matchColumnsStep.nextButtonTitle}
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep" import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook" import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep/ValidationStep" import { ValidationStep } from "./ValidationStep/ValidationStep"
import { ValidationStepNew } from "./ValidationStepNew"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep" import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations" import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep" import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
@@ -172,10 +173,23 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
try { try {
const data = await matchColumnsStepHook(values, rawData, columns) const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook) const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map(row => {
const newRow = { ...row };
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections.company) newRow.company = globalSelections.company;
if (globalSelections.line) newRow.line = globalSelections.line;
if (globalSelections.subline) newRow.subline = globalSelections.subline;
return newRow;
})
: dataWithMeta;
setPersistedGlobalSelections(globalSelections) setPersistedGlobalSelections(globalSelections)
onNext({ onNext({
type: StepType.validateData, type: StepType.validateData,
data: dataWithMeta, data: dataWithGlobalSelections,
globalSelections, globalSelections,
}) })
} catch (e) { } catch (e) {
@@ -186,6 +200,35 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
/> />
) )
case StepType.validateData: case StepType.validateData:
// Check if new validation component should be used
if (state.globalSelections?.useNewValidation) {
// Use the new ValidationStepNew component
return (
<ValidationStepNew
initialData={state.data}
file={uploadedFile!}
onBack={() => {
if (onBack) {
// When going back, preserve the global selections
setPersistedGlobalSelections(state.globalSelections)
onBack()
}
}}
onNext={(validatedData) => {
// Go to image upload step with the validated data
onNext({
type: StepType.imageUpload,
data: validatedData,
file: uploadedFile!,
globalSelections: state.globalSelections
});
}}
isFromScratch={state.isFromScratch}
/>
);
}
// Otherwise, use the original ValidationStep component
return ( return (
<ValidationStep <ValidationStep
initialData={state.data} initialData={state.data}

View File

@@ -0,0 +1,58 @@
# ValidationStepNew Component
This is a refactored version of the original ValidationStep component with improved architecture, performance, and maintainability.
## Overview
The ValidationStepNew component is designed to replace the original ValidationStep component in the React Spreadsheet Import library. It provides the same functionality but with a more modular and maintainable codebase.
## Features
- Field-level validation (required, regex, unique)
- Row-level validation (supplier, company fields)
- UPC validation with API integration
- Template management (saving, loading, applying)
- Filtering and sorting capabilities
- Error display and management
- Special field handling (input, multi-input, select, checkbox)
## Usage
To use the new ValidationStepNew component, select the "Use new validation component" checkbox in the MatchColumnsStep. This will route you to the new implementation instead of the original one.
## Architecture
The component is structured as follows:
- **ValidationContainer**: Main container component that coordinates all subcomponents
- **ValidationTable**: Handles data display, filtering, and column configuration
- **ValidationCell**: Cell-level component with specialized rendering based on field type
- **ValidationToolbar**: Top toolbar with actions and statistics
- **ValidationSidebar**: Contains filters, actions, and other UI controls
## State Management
State is managed through custom hooks:
- **useValidationState**: Main state management hook
- **useValidation**: Validation logic
- **useTemplates**: Template management
- **useFilters**: Filtering logic
- **useUpcValidation**: UPC validation
## Development
This component is still under development. The goal is to eventually replace the original ValidationStep component completely once all functionality is implemented and tested.
## Known Issues
- Some TypeScript errors in the UploadFlow component when integrating with the new component
- Not all features from the original component are fully implemented yet
## Roadmap
1. Complete implementation of all features from the original component
2. Add comprehensive tests
3. Improve performance with virtualization for large datasets
4. Add more customization options
5. Replace the original component completely

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface SaveTemplateDialogProps {
isOpen: boolean
onClose: () => void
onSave: (company: string, productType: string) => void
}
const SaveTemplateDialog: React.FC<SaveTemplateDialogProps> = ({
isOpen,
onClose,
onSave,
}) => {
const [company, setCompany] = useState("")
const [productType, setProductType] = useState("")
return (
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open) {
onClose()
setCompany("")
setProductType("")
}
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Template</DialogTitle>
<DialogDescription>
Enter the company and product type for this template.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="company" className="text-sm font-medium">
Company
</label>
<Input
id="company"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Enter company name"
/>
</div>
<div className="space-y-2">
<label htmlFor="productType" className="text-sm font-medium">
Product Type
</label>
<Input
id="productType"
value={productType}
onChange={(e) => setProductType(e.target.value)}
placeholder="Enter product type"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
onSave(company, productType)
onClose()
setCompany("")
setProductType("")
}}
disabled={!company || !productType}
>
Save Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default SaveTemplateDialog

View File

@@ -0,0 +1,331 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Template } from '../hooks/useValidationState'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { Check, ChevronsUpDown } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface SearchableTemplateSelectProps {
templates: Template[];
value: string;
onValueChange: (value: string) => void;
getTemplateDisplayText: (templateId: string | null) => string;
placeholder?: string;
className?: string;
triggerClassName?: string;
defaultBrand?: string;
}
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
templates,
value,
onValueChange,
getTemplateDisplayText,
placeholder = "Select template",
className,
triggerClassName,
defaultBrand,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
if (defaultBrand) {
setSelectedBrand(defaultBrand);
}
}, [defaultBrand]);
// Handle wheel events for scrolling
const handleWheel = (e: React.WheelEvent) => {
const scrollArea = e.currentTarget;
scrollArea.scrollTop += e.deltaY;
};
// Extract unique brands from templates
const brands = useMemo(() => {
try {
if (!templates || templates.length === 0) {
console.log('No templates available for brand extraction');
return [];
}
console.log('Extracting brands from templates:', templates);
const brandSet = new Set<string>();
const brandNames: {id: string, name: string}[] = [];
templates.forEach(template => {
if (!template || !template.company) return;
const companyId = template.company;
if (!brandSet.has(companyId)) {
brandSet.add(companyId);
// Try to get the company name from the template display text
try {
// Extract company name from the template display text
const displayText = getTemplateDisplayText(template.id.toString());
const companyName = displayText.split(' - ')[0];
brandNames.push({ id: companyId, name: companyName || companyId });
} catch (err) {
console.error("Error extracting company name:", err);
brandNames.push({ id: companyId, name: companyId });
}
}
});
console.log('Extracted brands:', brandNames);
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
console.error("Error extracting brands:", err);
return [];
}
}, [templates, getTemplateDisplayText]);
// Group templates by company for better organization
const groupedTemplates = useMemo(() => {
try {
if (!templates || templates.length === 0) return {};
const groups: Record<string, Template[]> = {};
templates.forEach(template => {
if (!template) return;
const companyId = template.company;
if (!groups[companyId]) {
groups[companyId] = [];
}
groups[companyId].push(template);
});
return groups;
} catch (err) {
console.error("Error grouping templates:", err);
return {};
}
}, [templates]);
// Filter templates based on selected brand and search term
const filteredTemplates = useMemo(() => {
try {
if (!templates || templates.length === 0) return [];
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
brandFiltered = templates.filter(t => t && t.company === selectedBrand);
}
// Then filter by search term if provided
if (!searchTerm.trim()) return brandFiltered;
const lowerSearchTerm = searchTerm.toLowerCase();
return brandFiltered.filter(template => {
if (!template) return false;
try {
const displayText = getTemplateDisplayText(template.id.toString());
const productType = template.product_type?.toLowerCase() || '';
// Search in both the display text and product type
return displayText.toLowerCase().includes(lowerSearchTerm) ||
productType.includes(lowerSearchTerm);
} catch (error) {
console.error("Error filtering template:", error, template);
return false;
}
});
} catch (err) {
console.error("Error in filteredTemplates:", err);
setError("Error filtering templates");
return [];
}
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
// Handle errors gracefully
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
return getTemplateDisplayText(value);
} catch (err) {
console.error("Error getting template display text:", err);
setError("Error displaying template");
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value]);
// Handle errors in the component
if (error) {
return (
<Button
variant="outline"
className={cn("w-full justify-between text-destructive", triggerClassName)}
onClick={() => setError(null)}
>
Error: {error}
</Button>
);
}
// Safe render function for CommandItem
const renderCommandItem = useCallback((template: Template) => {
try {
// Get the display text for the template
const displayText = getTemplateDisplayText(template.id.toString());
return (
<CommandItem
key={template.id}
value={template.id.toString()}
onSelect={(currentValue) => {
try {
onValueChange(currentValue);
setOpen(false);
setSearchTerm("");
// Don't reset the brand filter when selecting a template
// This allows users to keep filtering by brand
} catch (err) {
console.error("Error in onSelect:", err);
setError("Error selecting template");
}
}}
className="flex items-center justify-between"
>
<span>{displayText}</span>
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
</CommandItem>
);
} catch (err) {
console.error("Error rendering CommandItem:", err);
return null;
}
}, [onValueChange, value, getTemplateDisplayText]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", triggerClassName)}
>
{getDisplayText()}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-[300px] p-0", className)}>
<Command>
<div className="flex flex-col p-2 gap-2">
{/* Brand filter dropdown */}
{brands.length > 0 && (
<div className="flex items-center gap-2">
<Select
value={selectedBrand || "all"}
onValueChange={(value) => {
setSelectedBrand(value === "all" ? null : value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Brands" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Brands</SelectItem>
{brands && brands.length > 0 && brands.map(brand => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CommandSeparator />
{/* Search input */}
<div className="flex items-center gap-2">
<CommandInput
placeholder="Search by product type..."
value={searchTerm}
onValueChange={(value) => {
try {
setSearchTerm(value);
} catch (err) {
console.error("Error in onValueChange:", err);
setError("Error searching templates");
}
}}
className="h-8 flex-1"
/>
</div>
</div>
{/* Results */}
<CommandEmpty>
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No templates found.</p>
</div>
</CommandEmpty>
<CommandList>
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
// When no search term is applied, show templates grouped by company
// If a brand is selected, only show that brand's templates
selectedBrand ? (
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
</CommandGroup>
) : (
// Show all brands and their templates
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
// Get company name from the brands array
const brand = brands.find(b => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
// When search term is applied, show filtered results
<CommandGroup>
{filteredTemplates.map(template => template ? renderCommandItem(template) : null)}
</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default SearchableTemplateSelect;

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@/components/ui/popover'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command'
import { ChevronsUpDown, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Template } from '../hooks/useValidationState'
import { toast } from 'sonner'
interface TemplateManagerProps {
templates: Template[]
selectedTemplateId: string | null
onSelectTemplate: (templateId: string | null) => void
onSaveTemplate: (name: string, type: string) => void
onApplyTemplate: (templateId: string) => void
showDialog: boolean
onCloseDialog: () => void
selectedCount: number
}
const TemplateManager: React.FC<TemplateManagerProps> = ({
templates,
selectedTemplateId,
onSelectTemplate,
onSaveTemplate,
onApplyTemplate,
showDialog,
onCloseDialog,
selectedCount,
}) => {
const [templateName, setTemplateName] = useState('')
const [templateType, setTemplateType] = useState('')
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
// Filter templates based on search
const filteredTemplates = searchQuery
? templates.filter(template =>
template.product_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.company.toLowerCase().includes(searchQuery.toLowerCase())
)
: templates
const handleSaveTemplate = () => {
if (!templateName.trim()) {
toast.error('Please enter a template name')
return
}
if (!templateType.trim()) {
toast.error('Please select a template type')
return
}
onSaveTemplate(templateName, templateType)
setTemplateName('')
setTemplateType('')
}
// Get display text for template
const getTemplateDisplayText = (template: Template) => {
return `${template.product_type} - ${template.company}`
}
// Find the currently selected template
const selectedTemplate = templates.find(t => t.id.toString() === selectedTemplateId)
return (
<>
<div className="space-y-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Template</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between w-full"
>
{selectedTemplate
? getTemplateDisplayText(selectedTemplate)
: "Select a template"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[350px]">
<Command>
<CommandInput
placeholder="Search templates..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>No templates found.</CommandEmpty>
<CommandGroup>
{filteredTemplates.map((template) => (
<CommandItem
key={template.id}
value={template.id.toString()}
onSelect={(value) => {
onSelectTemplate(value)
setOpen(false)
setSearchQuery('')
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTemplateId === template.id.toString()
? "opacity-100"
: "opacity-0"
)}
/>
{getTemplateDisplayText(template)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex space-x-2">
<Button
variant="outline"
onClick={() => selectedTemplateId && onApplyTemplate(selectedTemplateId)}
disabled={!selectedTemplateId || selectedCount === 0}
className="flex-1"
>
Apply Template
</Button>
<Button
variant="outline"
onClick={onCloseDialog}
disabled={selectedCount === 0}
className="flex-1"
>
Save as Template
</Button>
</div>
</div>
{/* Template Save Dialog */}
<Dialog open={showDialog} onOpenChange={onCloseDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Template</DialogTitle>
<DialogDescription>
Create a template from the selected row.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Company</label>
<Input
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="Enter company name"
/>
</div>
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">Product Type</label>
<Input
value={templateType}
onChange={(e) => setTemplateType(e.target.value)}
placeholder="Enter product type"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCloseDialog}>
Cancel
</Button>
<Button
onClick={handleSaveTemplate}
disabled={!templateName.trim() || !templateType.trim()}
>
Save Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
export default TemplateManager

View File

@@ -0,0 +1,175 @@
import React, { useState, useCallback } from 'react'
import { Field } from '../../../types'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
import InputCell from './cells/InputCell'
import MultiInputCell from './cells/MultiInputCell'
import SelectCell from './cells/SelectCell'
import CheckboxCell from './cells/CheckboxCell'
// Define an error object type
type ErrorObject = {
message: string;
level: string;
source?: string;
}
/**
* ValidationIcon - Renders an appropriate icon based on error level
*/
const ValidationIcon = ({ error }: { error: ErrorObject }) => {
const iconClasses = "h-4 w-4"
switch(error.level) {
case 'error':
return <AlertCircle className={cn(iconClasses, "text-destructive")} />
case 'warning':
return <AlertTriangle className={cn(iconClasses, "text-amber-500")} />
case 'info':
return <Info className={cn(iconClasses, "text-blue-500")} />
default:
return <AlertCircle className={iconClasses} />
}
}
export interface ValidationCellProps<T extends string> {
rowIndex: number
field: Field<T>
value: any
onChange: (value: any) => void
errors: ErrorObject[]
isValidatingUpc?: boolean
}
const ValidationCell = <T extends string>({
rowIndex,
field,
value,
onChange,
errors,
isValidatingUpc = false
}: ValidationCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false)
// Get the most severe error
const currentError = errors.length > 0 ? errors[0] : null
// Determine if field is disabled
const isFieldDisabled = field.disabled || false
// Check if this is a UPC field for validation
const isUpcField = field.key === 'upc' ||
(field.fieldType as any)?.upcField ||
field.key.toString().toLowerCase().includes('upc')
// Render cell contents based on field type
const renderCellContent = () => {
// If we're validating UPC, show a spinner
if (isValidatingUpc) {
return (
<div className="flex items-center justify-center w-full min-h-[32px]">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)
}
const fieldType = field.fieldType.type
// Handle different field types
switch (fieldType) {
case 'input':
return (
<InputCell<T>
field={field}
value={value}
onChange={onChange}
onStartEdit={() => setIsEditing(true)}
onEndEdit={() => setIsEditing(false)}
hasErrors={errors.length > 0}
isMultiline={(field.fieldType as any).multiline}
isPrice={(field.fieldType as any).price}
/>
)
case 'multi-input':
return (
<MultiInputCell<T>
field={field}
value={value}
onChange={onChange}
onStartEdit={() => setIsEditing(true)}
onEndEdit={() => setIsEditing(false)}
hasErrors={errors.length > 0}
separator={(field.fieldType as any).separator || ','}
isMultiline={(field.fieldType as any).multiline}
isPrice={(field.fieldType as any).price}
/>
)
case 'select':
return (
<SelectCell<T>
field={field}
value={value}
onChange={onChange}
onStartEdit={() => setIsEditing(true)}
onEndEdit={() => setIsEditing(false)}
hasErrors={errors.length > 0}
options={field.fieldType.type === 'select' ? field.fieldType.options : undefined}
/>
)
case 'multi-select':
return (
<MultiInputCell<T>
field={field}
value={value}
onChange={onChange}
onStartEdit={() => setIsEditing(true)}
onEndEdit={() => setIsEditing(false)}
hasErrors={errors.length > 0}
separator={(field.fieldType as any).separator || ','}
options={field.fieldType.type === 'multi-select' ? field.fieldType.options : undefined}
/>
)
case 'checkbox':
return (
<CheckboxCell<T>
field={field}
value={value}
onChange={onChange}
hasErrors={errors.length > 0}
booleanMatches={(field.fieldType as any).booleanMatches}
/>
)
default:
return (
<div className="p-2">
{String(value || '')}
</div>
)
}
}
// Main cell rendering
return (
<div className={cn(
"relative w-full",
isFieldDisabled ? "opacity-70 pointer-events-none" : ""
)}>
{renderCellContent()}
{/* Show error icon if there are errors and we're not editing */}
{currentError && !isEditing && (
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<ValidationIcon error={currentError} />
</div>
)}
</div>
)
}
export default ValidationCell

View File

@@ -0,0 +1,255 @@
import React, { useState } from 'react'
import { useValidationState, Props } from '../hooks/useValidationState'
import ValidationTable from './ValidationTable'
import { Button } from '@/components/ui/button'
import { Loader2, X, Plus, Edit3 } from 'lucide-react'
import { toast } from 'sonner'
import { Switch } from '@/components/ui/switch'
import { useRsi } from '../../../hooks/useRsi'
import { ProductSearchDialog } from '@/components/products/ProductSearchDialog'
import SearchableTemplateSelect from './SearchableTemplateSelect'
/**
* ValidationContainer component - the main wrapper for the validation step
*
* This component is responsible for:
* - Managing global state using hooks
* - Coordinating between subcomponents
* - Handling navigation events (next, back)
*/
const ValidationContainer = <T extends string>({
initialData,
file,
onBack,
onNext,
isFromScratch
}: Props<T>) => {
// Use our main validation state hook
const validationState = useValidationState<T>({
initialData,
file,
onBack,
onNext,
isFromScratch
})
const {
data,
filteredData,
isValidating,
validationErrors,
rowSelection,
setRowSelection,
updateRow,
hasErrors,
templates,
selectedTemplateId,
applyTemplate,
applyTemplateToSelected,
getTemplateDisplayText,
filters,
updateFilters,
setTemplateState,
templateState,
saveTemplate,
loadTemplates,
setData
} = validationState
const { translations } = useRsi<T>()
// State for product search dialog
const [isProductSearchDialogOpen, setIsProductSearchDialogOpen] = useState(false)
const handleNext = () => {
// Call the onNext callback with the validated data
onNext?.(data)
}
// Delete selected rows
const deleteSelectedRows = () => {
const selectedRowIndexes = Object.keys(rowSelection).map(Number);
const newData = data.filter((_, index) => !selectedRowIndexes.includes(index));
setData(newData);
setRowSelection({});
toast.success(
selectedRowIndexes.length === 1
? "Row deleted"
: `${selectedRowIndexes.length} rows deleted`
);
}
return (
<div className="flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
<div className="flex-1 overflow-hidden">
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col">
{/* Header section */}
<div className="px-8 pt-6">
<div className="mb-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between">
<h2 className="text-2xl font-semibold text-foreground">
{translations.validationStep.title || "Validate Data"}
</h2>
<div className="flex items-center gap-4">
{isFromScratch && (
<Button
variant="outline"
onClick={() => {
// Create a new empty row
const newRow = {} as any;
// Add __index property with a unique string id
newRow.__index = `row-${Date.now()}`;
// Add the new row and validate
const newData = [...data, newRow];
setData(newData);
}}
className="flex items-center gap-1"
>
<Plus className="h-4 w-4" />
Add Row
</Button>
)}
<Button
variant="outline"
onClick={() => setIsProductSearchDialogOpen(true)}
className="flex items-center gap-1"
>
<Edit3 className="h-4 w-4" />
Create New Template
</Button>
<div className="flex items-center gap-2">
<Switch
checked={filters.showErrorsOnly}
onCheckedChange={(checked) => updateFilters({ showErrorsOnly: checked })}
id="filter-errors"
/>
<label htmlFor="filter-errors" className="text-sm text-muted-foreground">
{translations.validationStep.filterSwitchTitle || "Show only rows with errors"}
</label>
</div>
</div>
</div>
</div>
</div>
{/* Main table section */}
<div className="px-8 pb-6 flex-1 min-h-0">
<div className="rounded-md border h-full flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
<ValidationTable<T>
data={filteredData}
fields={validationState.fields}
updateRow={updateRow}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
validationErrors={validationErrors}
isValidatingUpc={validationState.isValidatingUpc}
validatingUpcRows={validationState.validatingUpcRows}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
/>
</div>
</div>
</div>
</div>
</div>
{/* Selection Action Bar - only shown when items are selected */}
{Object.keys(rowSelection).length > 0 && (
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-5 duration-300">
<div className="bg-card shadow-xl rounded-lg border border-muted px-4 py-3 flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="mr-2 bg-muted items-center flex text-primary pl-2 pr-7 h-[32px] flex-shrink-0 rounded-md text-xs font-medium border border-primary">
{Object.keys(rowSelection).length} selected
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setRowSelection({})}
className="h-8 w-8 -ml-[48px] hover:bg-transparent hover:text-muted-foreground"
title="Clear selection"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center">
{templates && templates.length > 0 ? (
<SearchableTemplateSelect
templates={templates}
value={selectedTemplateId || ""}
onValueChange={(value) => {
if (value) {
applyTemplateToSelected(value);
}
}}
getTemplateDisplayText={getTemplateDisplayText}
placeholder="Apply template to selected"
triggerClassName="w-[220px]"
/>
) : (
<Button variant="outline" className="w-full justify-between" disabled>
Loading templates...
</Button>
)}
</div>
{Object.keys(rowSelection).length === 1 && (
<Button
variant="outline"
size="sm"
onClick={() => setIsProductSearchDialogOpen(true)}
>
Save as Template
</Button>
)}
<Button
variant={isFromScratch ? "destructive" : "outline"}
size="sm"
onClick={deleteSelectedRows}
>
{isFromScratch ? "Delete Row" : translations.validationStep.discardButtonTitle || "Discard Row"}
</Button>
</div>
</div>
)}
</div>
</div>
{/* Footer with navigation buttons */}
<div className="border-t bg-muted px-8 py-4">
<div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.validationStep.backButtonTitle || "Back"}
</Button>
)}
<div className="flex items-center gap-2">
<Button
disabled={hasErrors}
onClick={handleNext}
>
{translations.validationStep.nextButtonTitle || "Next"}
</Button>
</div>
</div>
</div>
{/* Product Search Dialog */}
<ProductSearchDialog
isOpen={isProductSearchDialogOpen}
onClose={() => setIsProductSearchDialogOpen(false)}
onTemplateCreated={loadTemplates}
/>
</div>
)
}
export default ValidationContainer

View File

@@ -0,0 +1,251 @@
import React, { useMemo } from 'react'
import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
RowSelectionState
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/useValidationState'
import { Checkbox } from '@/components/ui/checkbox'
import ValidationCell from './ValidationCell'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useRsi } from '../../../hooks/useRsi'
import { Button } from '@/components/ui/button'
import SearchableTemplateSelect from './SearchableTemplateSelect'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
message: string;
level: string;
source?: string;
}
interface ValidationTableProps<T extends string> {
data: RowData<T>[]
fields: Fields<T>
rowSelection: RowSelectionState
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
filters?: { showErrorsOnly?: boolean }
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
[key: string]: any
}
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
isValidatingUpc,
validatingUpcRows,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
...props
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>()
const columnHelper = createColumnHelper<RowData<T>>()
// Build table columns
const columns = useMemo(() => {
const selectionColumn = columnHelper.display({
id: 'selection',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
aria-label="Select all rows"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
size: 40,
})
// Add template column
const templateColumn = columnHelper.display({
id: 'template',
header: "Template",
cell: ({ row }) => {
try {
// Only render the component if templates are available
if (!templates || templates.length === 0) {
return (
<Button variant="outline" className="w-full justify-between" disabled>
Loading templates...
</Button>
);
}
return (
<SearchableTemplateSelect
templates={templates}
value={row.original.__template || ""}
onValueChange={(value: string) => {
try {
// Apply template to this row
applyTemplate(value, [row.index]);
} catch (error) {
console.error("Error applying template in cell:", error);
}
}}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={row.original.company as string || ""}
/>
);
} catch (error) {
console.error("Error rendering template cell:", error);
return (
<Button variant="outline" className="w-full text-destructive">
Error loading templates
</Button>
);
}
},
size: 200,
});
// Create columns for each field
const fieldColumns = fields.map(field => {
return columnHelper.accessor(
(row: RowData<T>) => row[field.key as keyof typeof row] as any,
{
id: String(field.key),
header: field.label,
cell: ({ row, column, getValue }) => {
const rowIndex = row.index
const key = column.id as T
const value = getValue()
const errors = validationErrors.get(rowIndex)?.[key] || []
// Create a properly typed field object
const typedField: Field<T> = {
label: field.label,
key: field.key as T,
alternateMatches: field.alternateMatches as string[] | undefined,
validations: field.validations as any[] | undefined,
fieldType: field.fieldType,
example: field.example,
width: field.width,
disabled: field.disabled,
onChange: field.onChange
}
return (
<ValidationCell<T>
rowIndex={rowIndex}
field={typedField}
value={value}
onChange={(newValue) => updateRow(rowIndex, key, newValue)}
errors={errors}
isValidatingUpc={isValidatingUpc(rowIndex)}
/>
)
},
size: (field as any).width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
200
),
}
)
})
return [selectionColumn, templateColumn, ...fieldColumns]
}, [columnHelper, fields, updateRow, validationErrors, isValidatingUpc, templates, applyTemplate, getTemplateDisplayText])
// Initialize table
const table = useReactTable({
data,
columns,
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
})
return (
<Table>
<TableHeader className="sticky top-0 bg-muted z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{
width: header.getSize(),
minWidth: header.getSize(),
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={validatingUpcRows.includes(row.index) ? "opacity-70" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="p-2 align-top"
style={{
width: cell.column.getSize(),
minWidth: cell.column.getSize(),
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{filters?.showErrorsOnly
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors found."
: translations.validationStep.noRowsMessage || "No rows found."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
export default ValidationTable

View File

@@ -0,0 +1,99 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
interface CheckboxCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
hasErrors?: boolean
booleanMatches?: Record<string, boolean>
}
const CheckboxCell = <T extends string>({
field,
value,
onChange,
hasErrors,
booleanMatches = {}
}: CheckboxCellProps<T>) => {
const [checked, setChecked] = useState(false)
// Initialize checkbox state
useEffect(() => {
if (value === undefined || value === null) {
setChecked(false)
return
}
if (typeof value === 'boolean') {
setChecked(value)
return
}
// Handle string values using booleanMatches
if (typeof value === 'string') {
// First try the field's booleanMatches
const fieldBooleanMatches = field.fieldType.type === 'checkbox'
? field.fieldType.booleanMatches || {}
: {}
// Merge with the provided booleanMatches, with the provided ones taking precedence
const allMatches = { ...fieldBooleanMatches, ...booleanMatches }
// Try to find the value in the matches
const matchEntry = Object.entries(allMatches).find(([k]) =>
k.toLowerCase() === value.toLowerCase())
if (matchEntry) {
setChecked(matchEntry[1])
return
}
// If no match found, use common true/false strings
const trueStrings = ['yes', 'true', '1', 'y']
const falseStrings = ['no', 'false', '0', 'n']
if (trueStrings.includes(value.toLowerCase())) {
setChecked(true)
return
}
if (falseStrings.includes(value.toLowerCase())) {
setChecked(false)
return
}
}
// For any other values, try to convert to boolean
setChecked(!!value)
}, [value, field.fieldType, booleanMatches])
// Handle checkbox change
const handleChange = useCallback((checked: boolean) => {
setChecked(checked)
onChange(checked)
}, [onChange])
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
return (
<div className={cn(
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
outlineClass,
hasErrors ? "bg-red-50 border-destructive" : "border-input"
)}>
<Checkbox
checked={checked}
onCheckedChange={handleChange}
className={cn(
hasErrors ? "border-destructive" : ""
)}
/>
</div>
)
}
export default CheckboxCell

View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
interface InputCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
isMultiline?: boolean
isPrice?: boolean
}
const InputCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
isMultiline = false,
isPrice = false
}: InputCellProps<T>) => {
const [inputValue, setInputValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
// Initialize input value
useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(String(value))
} else {
setInputValue('')
}
}, [value])
// Handle focus event
const handleFocus = useCallback(() => {
setIsEditing(true)
onStartEdit?.()
}, [onStartEdit])
// Handle blur event
const handleBlur = useCallback(() => {
setIsEditing(false)
// Format the value for storage (remove formatting like $ for price)
let processedValue = inputValue
if (isPrice) {
// Remove any non-numeric characters except decimal point
processedValue = inputValue.replace(/[^\d.]/g, '')
// Parse as float and format to 2 decimal places to ensure valid number
const numValue = parseFloat(processedValue)
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2)
}
}
onChange(processedValue)
onEndEdit?.()
}, [inputValue, onChange, onEndEdit, isPrice])
// Format price value for display
const getDisplayValue = useCallback(() => {
if (!isPrice || !inputValue) return inputValue
// Extract numeric part
const numericValue = inputValue.replace(/[^\d.]/g, '')
// Parse as float and format with dollar sign
const numValue = parseFloat(numericValue)
if (isNaN(numValue)) return inputValue
return `$${numValue.toFixed(2)}`
}, [inputValue, isPrice])
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
return (
<div className="w-full">
{isMultiline ? (
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={field.description}
className={cn(
"min-h-[80px] resize-none",
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
isEditing ? (
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={field.description}
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
>
{isPrice ? getDisplayValue() : (inputValue || <span className="text-muted-foreground">{field.description}</span>)}
</div>
)
)}
</div>
)
}
export default InputCell

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
interface MultiInputCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
separator?: string
isMultiline?: boolean
isPrice?: boolean
options?: readonly { label: string; value: string }[]
}
const MultiInputCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
separator = ',',
isMultiline = false,
isPrice = false,
options: providedOptions
}: MultiInputCellProps<T>) => {
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const [selectedValues, setSelectedValues] = useState<string[]>([])
const [isEditing, setIsEditing] = useState(false)
// Check if field is a multi-select field
const isMultiSelect = field.fieldType.type === 'multi-select'
const fieldOptions = isMultiSelect && field.fieldType.options
? field.fieldType.options
: undefined
// Convert options to a regular array to avoid issues with readonly arrays
const options = providedOptions
? Array.from(providedOptions)
: (fieldOptions ? Array.from(fieldOptions) : [])
// Initialize input value and selected values
useEffect(() => {
if (value !== undefined && value !== null) {
setInputValue(String(value))
// Split the value based on separator for display
const valueArray = String(value).split(separator).map(v => v.trim()).filter(Boolean)
setSelectedValues(valueArray)
} else {
setInputValue('')
setSelectedValues([])
}
}, [value, separator])
// Handle focus
const handleFocus = useCallback(() => {
setIsEditing(true)
onStartEdit?.()
}, [onStartEdit])
// Handle blur
const handleBlur = useCallback(() => {
setIsEditing(false)
// Format all values and join with separator
let processedValues = selectedValues
if (isPrice) {
// Format all values as prices
processedValues = selectedValues.map(val => {
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? numValue.toFixed(2) : val
})
}
// Join all values with the separator
const joinedValue = processedValues.join(separator + ' ')
onChange(joinedValue)
onEndEdit?.()
}, [selectedValues, onChange, onEndEdit, isPrice, separator])
// Toggle a value selection
const toggleValue = useCallback((value: string) => {
setSelectedValues(current => {
const isSelected = current.includes(value)
if (isSelected) {
return current.filter(v => v !== value)
} else {
return [...current, value]
}
})
}, [])
// Remove a selected value
const removeValue = useCallback((value: string) => {
setSelectedValues(current => current.filter(v => v !== value))
}, [])
// Format display values for price
const getDisplayValues = useCallback(() => {
if (!isPrice) return selectedValues
return selectedValues.map(val => {
const numValue = parseFloat(val.replace(/[^\d.]/g, ''))
return !isNaN(numValue) ? `$${numValue.toFixed(2)}` : val
})
}, [selectedValues, isPrice])
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
// If we have a multi-select field with options, use command UI
if (isMultiSelect && options.length > 0) {
// Ensure all options have the expected shape
const safeOptions = options.map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
// Make sure selectedValues are all strings
const safeSelectedValues = selectedValues.map(String);
return (
<div className="w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal min-h-10",
outlineClass,
"text-left",
hasErrors ? "border-destructive" : ""
)}
onClick={() => {
setOpen(!open)
handleFocus()
}}
>
<div className="flex flex-wrap gap-1 max-w-[90%]">
{safeSelectedValues.length > 0 ? (
safeSelectedValues.map(value => {
const option = safeOptions.find(opt => opt.value === value);
return (
<Badge
key={value}
variant="secondary"
className="mr-1 mb-1"
>
{option ? option.label : value}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onMouseDown={e => {
e.preventDefault()
e.stopPropagation()
removeValue(value)
}}
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})
) : (
<span className="text-muted-foreground">{field.description || "Select options..."}</span>
)}
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
toggleValue(option.value);
}}
>
<div className="flex items-center">
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
safeSelectedValues.includes(option.value)
? "bg-primary border-primary"
: "opacity-50"
)}>
{safeSelectedValues.includes(option.value) && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</div>
{option.label}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}
// For standard multi-input without options, use text input
return (
<div className="w-full">
{isMultiline ? (
<Textarea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
className={cn(
"min-h-[80px] resize-none",
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
isEditing ? (
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={`Enter values separated by ${separator}`}
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : ""
)}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center overflow-hidden",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
>
{inputValue ?
<div className="truncate">
{isPrice ?
getDisplayValues().join(separator + ' ') :
inputValue
}
</div> :
<span className="text-muted-foreground">{`Enter values separated by ${separator}`}</span>
}
</div>
)
)}
</div>
)
}
export default MultiInputCell

View File

@@ -0,0 +1,124 @@
import React, { useState } from 'react'
import { Field } from '../../../../types'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { cn } from '@/lib/utils'
export type SelectOption = {
label: string;
value: string;
};
interface SelectCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
options?: readonly SelectOption[]
}
const SelectCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
options
}: SelectCellProps<T>) => {
const [open, setOpen] = useState(false)
// Ensure we always have an array of options with the correct shape
const fieldType = field.fieldType;
const fieldOptions = fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
fieldType.options ?
fieldType.options :
[];
// Always ensure selectOptions is a valid array with at least a default option
const selectOptions = (options || fieldOptions || []).map(option => ({
label: option.label || String(option.value),
value: String(option.value)
}));
if (selectOptions.length === 0) {
// Add a default empty option if we have none
selectOptions.push({ label: 'No options available', value: '' });
}
// Get current display value
const displayValue = value ?
selectOptions.find(option => String(option.value) === String(value))?.label || String(value) :
field.description || 'Select...';
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setOpen(false);
if (onEndEdit) onEndEdit();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!value && "text-muted-foreground",
hasErrors ? "border-destructive" : ""
)}
onClick={() => {
setOpen(!open)
if (onStartEdit) onStartEdit()
}}
>
{displayValue}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{selectOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
>
{option.label}
{String(option.value) === String(value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export default SelectCell

View File

@@ -0,0 +1,115 @@
import { useState, useCallback, useMemo } from 'react'
import type { Fields } from '../../../types'
import { RowData } from './useValidationState'
export interface FilterState {
searchText: string
showErrorsOnly: boolean
filterField: string | null
filterValue: string | null
}
export const useFilters = <T extends string>(
data: RowData<T>[],
fields: Fields<T>
) => {
// Filter state
const [filters, setFilters] = useState<FilterState>({
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null
})
// Get available filter fields
const filterFields = useMemo(() => {
return fields.map(field => ({
key: field.key,
label: field.label
}))
}, [fields])
// Get available filter values for the selected field
const filterValues = useMemo(() => {
if (!filters.filterField) return []
// Get unique values for the selected field
const uniqueValues = new Set<string>()
data.forEach(row => {
const value = row[filters.filterField as keyof typeof row]
if (value !== undefined && value !== null) {
uniqueValues.add(String(value))
}
})
return Array.from(uniqueValues).map(value => ({
value,
label: value
}))
}, [data, filters.filterField])
// Update filters
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
setFilters(prev => ({
...prev,
...newFilters
}))
}, [])
// Apply filters to data
const applyFilters = useCallback((dataToFilter: RowData<T>[]) => {
return dataToFilter.filter(row => {
// Filter by search text
if (filters.searchText) {
const lowerSearchText = filters.searchText.toLowerCase()
const matchesSearch = Object.entries(row).some(([key, value]) => {
// Skip metadata fields
if (key.startsWith('__')) return false
// Check if the value contains the search text
return value !== undefined &&
value !== null &&
String(value).toLowerCase().includes(lowerSearchText)
})
if (!matchesSearch) return false
}
// Filter by errors
if (filters.showErrorsOnly) {
const hasErrors = row.__errors && Object.keys(row.__errors).length > 0
if (!hasErrors) return false
}
// Filter by field value
if (filters.filterField && filters.filterValue) {
const fieldValue = row[filters.filterField as keyof typeof row]
return fieldValue !== undefined &&
fieldValue !== null &&
String(fieldValue) === filters.filterValue
}
return true
})
}, [filters])
// Reset all filters
const resetFilters = useCallback(() => {
setFilters({
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null
})
}, [])
return {
filters,
filterFields,
filterValues,
updateFilters,
applyFilters,
resetFilters
}
}

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, useCallback } from 'react'
import { Data } from '../../../types'
import { RowSelectionState } from '@tanstack/react-table'
import { Template, RowData, getApiUrl } from './useValidationState'
interface TemplateState {
selectedTemplateId: string | null
showSaveTemplateDialog: boolean
newTemplateName: string
newTemplateType: string
}
export const useTemplates = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
toast: any,
rowSelection: RowSelectionState
) => {
const [templates, setTemplates] = useState<Template[]>([])
const [templateState, setTemplateState] = useState<TemplateState>({
selectedTemplateId: null,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
})
// Load templates from API
const loadTemplates = useCallback(async () => {
try {
console.log('Fetching templates...');
const response = await fetch(`${getApiUrl()}/templates`)
console.log('Templates response status:', response.status);
if (!response.ok) throw new Error('Failed to fetch templates')
const templateData = await response.json()
console.log('Templates fetched successfully:', templateData);
// Validate template data
const validTemplates = templateData.filter((t: any) =>
t && typeof t === 'object' && t.id && t.company && t.product_type
);
if (validTemplates.length !== templateData.length) {
console.warn('Some templates were filtered out due to invalid data', {
original: templateData.length,
valid: validTemplates.length
});
}
setTemplates(validTemplates)
} catch (error) {
console.error('Error loading templates:', error)
toast({
title: 'Error',
description: 'Failed to load templates',
})
}
}, [toast])
// Save a new template based on selected rows
const saveTemplate = useCallback(async (name: string, type: string) => {
try {
// Get selected rows
const selectedRows = Object.keys(rowSelection)
.map(index => data[parseInt(index)])
.filter(Boolean)
if (selectedRows.length === 0) {
toast({
title: 'Error',
description: 'Please select at least one row to create a template',
})
return
}
// Create template based on selected rows
const template: Template = {
id: Date.now(), // Temporary ID, will be replaced by server
company: selectedRows[0].company as string || '',
product_type: type,
...selectedRows[0], // Copy all fields from the first selected row
}
// Remove metadata fields
delete (template as any).__errors
delete (template as any).__meta
delete (template as any).__template
delete (template as any).__original
delete (template as any).__corrected
delete (template as any).__changes
delete (template as any).__index
// Send to API
const response = await fetch(`${getApiUrl()}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
company: template.company,
product_type: type,
...template
}),
})
if (!response.ok) {
throw new Error('Failed to save template')
}
// Reload templates to get the server-generated ID
await loadTemplates()
toast({
title: 'Success',
description: `Template "${name}" saved successfully`,
})
// Reset dialog state
setTemplateState(prev => ({
...prev,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
}))
} catch (error) {
console.error('Error saving template:', error)
toast({
title: 'Error',
description: 'Failed to save template',
})
}
}, [data, rowSelection, toast, loadTemplates])
// Apply a template to selected rows
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
const template = templates.find(t => t.id.toString() === templateId)
if (!template) {
toast({
title: 'Error',
description: 'Template not found',
})
return
}
setData(prevData => {
const newData = [...prevData]
rowIndexes.forEach(index => {
if (index >= 0 && index < newData.length) {
// Create a new row with template values
const updatedRow = { ...newData[index] }
// Apply template fields (excluding metadata and ID fields)
Object.entries(template).forEach(([key, value]) => {
if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) {
// Handle numeric values that might be stored as strings
if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) {
// If it's a price field, add the dollar sign
if (['msrp', 'cost_each'].includes(key)) {
updatedRow[key as keyof typeof updatedRow] = `$${value}` as any;
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
}
// Special handling for array fields like categories and ship_restrictions
else if (key === 'categories' || key === 'ship_restrictions') {
if (Array.isArray(value)) {
updatedRow[key as keyof typeof updatedRow] = value as any;
} else if (typeof value === 'string') {
try {
// Try to parse as JSON if it's a JSON string
if (value.startsWith('[') && value.endsWith(']')) {
const parsed = JSON.parse(value);
updatedRow[key as keyof typeof updatedRow] = parsed as any;
}
// Otherwise, it might be a PostgreSQL array format like {val1,val2}
else if (value.startsWith('{') && value.endsWith('}')) {
const parsed = value.slice(1, -1).split(',');
updatedRow[key as keyof typeof updatedRow] = parsed as any;
}
// If it's a single value, wrap it in an array
else {
updatedRow[key as keyof typeof updatedRow] = [value] as any;
}
} catch (error) {
console.error(`Error parsing ${key}:`, error);
// If parsing fails, use as-is
updatedRow[key as keyof typeof updatedRow] = value as any;
}
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
} else {
updatedRow[key as keyof typeof updatedRow] = value as any;
}
}
})
// Mark the row as using this template
updatedRow.__template = templateId
// Update the row in the data array
newData[index] = updatedRow
}
})
return newData
})
toast({
title: 'Success',
description: `Template applied to ${rowIndexes.length} row(s)`,
})
}, [templates, setData, toast])
// Get display text for a template
const getTemplateDisplayText = useCallback((templateId: string | null) => {
if (!templateId) return 'Select a template'
const template = templates.find(t => t.id.toString() === templateId)
return template
? `${template.company} - ${template.product_type}`
: 'Unknown template'
}, [templates])
// Load templates on component mount and set up refresh event listener
useEffect(() => {
loadTemplates()
// Add event listener for template refresh
const handleRefreshTemplates = () => {
loadTemplates()
}
window.addEventListener('refresh-templates', handleRefreshTemplates)
// Clean up event listener
return () => {
window.removeEventListener('refresh-templates', handleRefreshTemplates)
}
}, [loadTemplates])
return {
templates,
selectedTemplateId: templateState.selectedTemplateId,
showSaveTemplateDialog: templateState.showSaveTemplateDialog,
newTemplateName: templateState.newTemplateName,
newTemplateType: templateState.newTemplateType,
setTemplateState,
loadTemplates,
saveTemplate,
applyTemplate,
getTemplateDisplayText,
// Helper method to apply to selected rows
applyTemplateToSelected: (templateId: string) => {
const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i))
applyTemplate(templateId, selectedIndexes)
}
}
}

View File

@@ -0,0 +1,68 @@
import { useState, useCallback } from 'react'
interface UpcValidationResult {
error?: boolean
message?: string
data?: Record<string, any>
}
export const useUpcValidation = () => {
const [validatingUpcRows, setValidatingUpcRows] = useState<number[]>([])
// Mock API call for UPC validation
// In a real implementation, you would call an actual API
const mockUpcValidationApi = async (upcValue: string): Promise<UpcValidationResult> => {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 500))
// Validate UPC format
if (!/^\d{12,14}$/.test(upcValue)) {
return {
error: true,
message: 'Invalid UPC format. UPC should be 12-14 digits.'
}
}
// Mock successful validation
// In a real implementation, this would return data from the API
return {
error: false,
data: {
item_number: `ITEM-${upcValue.substring(0, 6)}`,
// Add any other fields that would be returned by the UPC validation
}
}
}
// Validate a UPC value
const validateUpc = useCallback(async (upcValue: string, rowIndex: number): Promise<UpcValidationResult> => {
// Add row to validating state
setValidatingUpcRows(prev => [...prev, rowIndex])
try {
// Call the UPC validation API (mock for now)
const result = await mockUpcValidationApi(upcValue)
return result
} catch (error) {
console.error('Error validating UPC:', error)
return {
error: true,
message: 'Failed to validate UPC'
}
} finally {
// Remove row from validating state
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex))
}
}, [])
// Check if a row is currently being validated
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
return validatingUpcRows.includes(rowIndex)
}, [validatingUpcRows])
return {
validateUpc,
isValidatingUpc,
validatingUpcRows
}
}

View File

@@ -0,0 +1,257 @@
import { useCallback } from 'react'
import type { Data, Field, Fields, RowHook, TableHook } from '../../../types'
import type { Meta } from '../../ValidationStep/types'
import { ErrorSources } from '../../../types'
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations'
import { RowData } from './useValidationState'
interface ValidationError {
message: string
level: 'info' | 'warning' | 'error'
}
export const useValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>
) => {
// Validate a single field
const validateField = useCallback((
value: any,
field: Field<T>
): ValidationError[] => {
const errors: ValidationError[] = []
if (!field.validations) return errors
field.validations.forEach(validation => {
switch (validation.rule) {
case 'required':
if (value === undefined || value === null || value === '') {
errors.push({
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error'
})
}
break
case 'unique':
// Unique validation happens at table level, not here
break
case 'regex':
if (value !== undefined && value !== null && value !== '') {
try {
const regex = new RegExp(validation.value, validation.flags)
if (!regex.test(String(value))) {
errors.push({
message: validation.errorMessage,
level: validation.level || 'error'
})
}
} catch (error) {
console.error('Invalid regex in validation:', error)
}
}
break
}
})
return errors
}, [])
// Validate a single row
const validateRow = useCallback(async (
row: Data<T>,
rowIndex: number,
allRows: Data<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {}
fields.forEach(field => {
const value = row[field.key]
const errors = validateField(value, field)
if (errors.length > 0) {
fieldErrors[field.key] = errors
}
})
// Special validation for supplier and company fields
if (fields.some(field => field.key === 'supplier' as any) && !row.supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error'
}]
}
if (fields.some(field => field.key === 'company' as any) && !row.company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error'
}]
}
// Run row hook if provided
let rowHookResult: Meta = { __errors: {} }
if (rowHook) {
try {
rowHookResult = await rowHook(row, rowIndex, allRows)
} catch (error) {
console.error('Error in row hook:', error)
}
}
// Merge field errors and row hook errors
const mergedErrors: Record<string, ValidationError[]> = { ...fieldErrors }
if (rowHookResult.__errors) {
Object.entries(rowHookResult.__errors).forEach(([key, errors]) => {
const errorArray = Array.isArray(errors) ? errors : [errors]
mergedErrors[key] = [
...(mergedErrors[key] || []),
...errorArray
]
})
}
return {
__errors: mergedErrors
}
}, [fields, validateField, rowHook])
// Validate all data at the table level
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
if (!tableHook) return data.map(() => ({ __errors: {} }))
try {
const tableResults = await tableHook(data)
// Process table validation results
return tableResults.map(result => {
// Ensure errors are properly formatted
const formattedErrors: Record<string, ValidationError[]> = {}
if (result.__errors) {
Object.entries(result.__errors).forEach(([key, errors]) => {
formattedErrors[key] = Array.isArray(errors) ? errors : [errors]
})
}
return { __errors: formattedErrors }
})
} catch (error) {
console.error('Error in table hook:', error)
return data.map(() => ({ __errors: {} }))
}
}, [tableHook])
// Validate unique fields across the table
const validateUnique = useCallback((data: RowData<T>[]) => {
const uniqueErrors: Meta[] = data.map(() => ({ __errors: {} }))
// Find fields with unique validation
const uniqueFields = fields.filter(field =>
field.validations?.some(v => v.rule === 'unique')
)
if (uniqueFields.length === 0) {
return uniqueErrors
}
// Check each unique field
uniqueFields.forEach(field => {
const { key } = field
const validation = field.validations?.find(v => v.rule === 'unique')
const allowEmpty = validation?.allowEmpty ?? false
const errorMessage = validation?.errorMessage || `${field.label} must be unique`
const level = validation?.level || 'error'
// Track values for uniqueness check
const valueMap = new Map<string, number[]>()
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[key] || '')
// Skip empty values if allowed
if (allowEmpty && (value === '' || value === undefined || value === null)) {
return
}
if (!valueMap.has(value)) {
valueMap.set(value, [rowIndex])
} else {
valueMap.get(value)?.push(rowIndex)
}
})
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
const rowErrors = uniqueErrors[rowIndex].__errors || {}
rowErrors[String(key)] = [
...(rowErrors[String(key)] || []),
{
message: errorMessage,
level,
source: ErrorSources.Table
}
]
uniqueErrors[rowIndex].__errors = rowErrors
})
}
})
})
return uniqueErrors
}, [fields])
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[]) => {
// Step 1: Run field and row validation
const rowValidations = await Promise.all(
data.map((row, index) => validateRow(row, index, data))
)
// Step 2: Run unique validations
const uniqueValidations = validateUnique(data)
// Step 3: Run table hook
const tableValidations = await validateTable(data)
// Merge all validation results
return data.map((row, index) => {
const rowValidation = rowValidations[index]
const uniqueValidation = uniqueValidations[index]
const tableValidation = tableValidations[index]
// Start with the original data
const newRow = { ...row }
// Combine all errors
const combinedErrors = {
...(rowValidation.__errors || {}),
...(uniqueValidation.__errors || {}),
...(tableValidation.__errors || {})
}
newRow.__errors = combinedErrors
return newRow
})
}, [validateRow, validateUnique, validateTable])
return {
validateField,
validateRow,
validateTable,
validateUnique,
validateData
}
}

View File

@@ -0,0 +1,787 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRsi } from '../../../hooks/useRsi'
import type { Data, Field, Fields, ErrorSources } from '../../../types'
import { RowSelectionState } from '@tanstack/react-table'
import { toast } from 'sonner'
// Define ErrorType directly in file to avoid import issues
type ErrorType = {
message: string;
level: string;
source?: string;
}
// Define Meta type directly to avoid import issues
type Meta = {
__index?: string;
__errors?: Record<string, any>;
}
// Define the Props interface for ValidationStepNew
export interface Props<T extends string> {
initialData: RowData<T>[]
file: File
onBack?: () => void
onNext?: (data: RowData<T>[]) => void
isFromScratch?: boolean
}
// Extended Data type with meta information
export type RowData<T extends string> = Data<T> & {
__index?: string;
__errors?: Record<string, ErrorType[] | ErrorType>;
__template?: string;
__original?: Record<string, any>;
__corrected?: Record<string, any>;
__changes?: Record<string, boolean>;
}
// Template interface
export interface Template {
id: number;
company: string;
product_type: string;
[key: string]: string | number | boolean | undefined;
}
// Props for the useValidationState hook
export interface ValidationStateProps<T extends string> extends Props<T> {}
// Interface for validation results
export interface ValidationResult {
error?: boolean;
message?: string;
data?: Record<string, any>;
}
// Filter state interface
export interface FilterState {
searchText: string;
showErrorsOnly: boolean;
filterField: string | null;
filterValue: string | null;
}
// Add config at the top of the file
// Import the config or access it through window
declare global {
interface Window {
config?: {
apiUrl: string;
};
}
}
// Use a helper to get API URL consistently
export const getApiUrl = () => window.config?.apiUrl || '/api';
// Main validation state hook
export const useValidationState = <T extends string>({
initialData,
file,
onBack,
onNext,
isFromScratch
}: ValidationStateProps<T>) => {
const { fields, rowHook, tableHook } = useRsi<T>();
// Core data state
const [data, setData] = useState<RowData<T>[]>(initialData)
// Row selection state
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
// Validation state
const [isValidating, setIsValidating] = useState(false)
const [validationErrors, setValidationErrors] = useState<Map<number, Record<string, ErrorType[]>>>(new Map())
const [rowValidationStatus, setRowValidationStatus] = useState<Map<number, 'pending' | 'validating' | 'validated' | 'error'>>(new Map())
// Template state
const [templates, setTemplates] = useState<Template[]>([])
const [templateState, setTemplateState] = useState({
selectedTemplateId: null as string | null,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
})
// Field options state for company names
const [fieldOptions, setFieldOptions] = useState<any>(null)
// Filter state
const [filters, setFilters] = useState<FilterState>({
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null
})
// UPC validation state
const [validatingUpcRows, setValidatingUpcRows] = useState<number[]>([])
// Compute filtered data based on current filters
const filteredData = useMemo(() => {
return data.filter(row => {
// Filter by search text
if (filters.searchText) {
const lowerSearchText = filters.searchText.toLowerCase()
const matchesSearch = Object.entries(row).some(([key, value]) => {
// Skip metadata fields
if (key.startsWith('__')) return false
// Check if the value contains the search text
return value !== undefined &&
value !== null &&
String(value).toLowerCase().includes(lowerSearchText)
})
if (!matchesSearch) return false
}
// Filter by errors
if (filters.showErrorsOnly) {
const hasErrors = row.__errors && Object.keys(row.__errors).length > 0
if (!hasErrors) return false
}
// Filter by field value
if (filters.filterField && filters.filterValue) {
const fieldValue = row[filters.filterField as keyof typeof row]
return fieldValue !== undefined &&
fieldValue !== null &&
String(fieldValue) === filters.filterValue
}
return true
})
}, [data, filters])
// Get filter fields
const filterFields = useMemo(() => {
return fields.map(field => ({
key: String(field.key),
label: field.label
}))
}, [fields])
// Get filter values for the selected field
const filterValues = useMemo(() => {
if (!filters.filterField) return []
// Get unique values for the selected field
const uniqueValues = new Set<string>()
data.forEach(row => {
const value = row[filters.filterField as keyof typeof row]
if (value !== undefined && value !== null) {
uniqueValues.add(String(value))
}
})
return Array.from(uniqueValues).map(value => ({
value,
label: value
}))
}, [data, filters.filterField])
// Update filters
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
setFilters(prev => ({
...prev,
...newFilters
}))
}, [])
// Reset filters
const resetFilters = useCallback(() => {
setFilters({
searchText: '',
showErrorsOnly: false,
filterField: null,
filterValue: null
})
}, [])
// Validate a field
const validateField = useCallback((value: any, field: Field<T>): ErrorType[] => {
const errors: ErrorType[] = []
if (!field.validations) return errors
// Type casting to handle readonly fields
const validations = field.validations as Array<{ rule: string; value?: string; flags?: string; errorMessage?: string; level?: string; }>
for (const validation of validations) {
if (validation.rule === 'required') {
if (value === undefined || value === null || value === '') {
errors.push({
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
source: 'row'
});
}
}
else if (validation.rule === 'regex') {
if (value !== undefined && value !== null && value !== '') {
try {
const regex = new RegExp(validation.value || '', validation.flags);
if (!regex.test(String(value))) {
errors.push({
message: validation.errorMessage || 'Invalid format',
level: validation.level || 'error',
source: 'row'
});
}
} catch (error) {
console.error('Invalid regex in validation:', error);
}
}
}
// Add other validation types as needed
}
return errors;
}, []);
// Update a single row's data
const updateRow = useCallback((rowIndex: number, key: T, value: any) => {
setData(prevData => {
const newData = [...prevData]
const row = { ...newData[rowIndex] }
row[key] = value
// Mark row as needing validation
setRowValidationStatus(prev => {
const updated = new Map(prev)
updated.set(rowIndex, 'pending')
return updated
})
newData[rowIndex] = row as RowData<T>
return newData
})
}, [])
// Validate a single row - optimized version
const validateRow = useCallback(async (rowIndex: number) => {
if (!data[rowIndex]) return;
// Update row status to validating
setRowValidationStatus(prev => {
const updated = new Map(prev);
updated.set(rowIndex, 'validating');
return updated;
});
try {
// Collect field errors
const fieldErrors: Record<string, ErrorType[]> = {};
// Basic field validations
for (let j = 0; j < fields.length; j++) {
const field = fields[j];
const key = String(field.key);
const value = data[rowIndex][key as keyof typeof data[typeof rowIndex]];
// Skip validation for disabled fields
if (field.disabled) {
continue;
}
// Use type assertion to address readonly fields issue
const errors = validateField(value, field as unknown as Field<T>);
if (errors.length > 0) {
fieldErrors[key] = errors;
}
}
// Special validation for supplier and company
if (!data[rowIndex].supplier) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
source: 'row'
}];
}
if (!data[rowIndex].company) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
source: 'row'
}];
}
// Batch update all state at once to minimize re-renders
const newStatus = new Map(rowValidationStatus);
newStatus.set(rowIndex, Object.keys(fieldErrors).length > 0 ? 'error' : 'validated');
const newErrors = new Map(validationErrors);
newErrors.set(rowIndex, fieldErrors);
// Use functional updates to ensure we have the latest state
setData(prevData => {
const newData = [...prevData];
newData[rowIndex] = {
...newData[rowIndex],
__errors: fieldErrors
};
return newData;
});
setValidationErrors(newErrors);
setRowValidationStatus(newStatus);
} catch (error) {
console.error('Error validating row:', error);
// Update row status to error
setRowValidationStatus(prev => {
const updated = new Map(prev);
updated.set(rowIndex, 'error');
return updated;
});
}
}, [data, fields, validateField, rowValidationStatus, validationErrors]);
// Validate UPC for a row
const validateUpc = useCallback(async (rowIndex: number, upcValue: string) => {
if (!data[rowIndex]) return
// Add row to validating state
setValidatingUpcRows(prev => [...prev, rowIndex])
try {
// Mock UPC validation (replace with actual API call in production)
await new Promise(resolve => setTimeout(resolve, 500))
// Validate UPC format
if (!/^\d{12,14}$/.test(upcValue)) {
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
return
}
// Mock successful validation result
const result: ValidationResult = {
data: {
item_number: `ITEM-${upcValue.substring(0, 6)}`,
}
}
// Update row with UPC validation results
if (result && !result.error) {
setData(prevData => {
const newData = [...prevData]
newData[rowIndex] = {
...newData[rowIndex],
...result.data
}
return newData
})
// Validate the row after UPC update
validateRow(rowIndex)
}
} catch (error) {
console.error('Error validating UPC:', error)
toast.error('Failed to validate UPC')
} finally {
// Remove row from validating state
setValidatingUpcRows(prev => prev.filter(idx => idx !== rowIndex))
}
}, [data, validateRow])
// Validate all rows - optimized version without batching
const validateAllRows = useCallback(async () => {
if (isValidating) return; // Prevent multiple validation calls
setIsValidating(true);
try {
// Process all rows in a single pass
const updatedData = [...data];
const updatedErrors = new Map(validationErrors);
const updatedStatus = new Map(rowValidationStatus);
// Mark all rows as validating first (single state update)
data.forEach((_, index) => {
updatedStatus.set(index, 'validating');
});
setRowValidationStatus(updatedStatus);
// Process each row
for (let i = 0; i < data.length; i++) {
const rowErrors: Record<string, ErrorType[]> = {};
// Basic field validations - use type assertion to fix TypeScript error
for (let j = 0; j < fields.length; j++) {
const field = fields[j];
// Type assertion to handle readonly fields
const fieldKey = String(field.key);
const value = data[i][fieldKey as keyof typeof data[typeof i]];
// Skip validation for disabled fields
if (field.disabled) {
continue;
}
// Use type assertion to address readonly fields issue
const errors = validateField(value, field as unknown as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
}
// Special validation for supplier and company
if (!data[i].supplier) {
rowErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
source: 'row'
}];
}
if (!data[i].company) {
rowErrors['company'] = [{
message: 'Company is required',
level: 'error',
source: 'row'
}];
}
// Store errors for this row
updatedErrors.set(i, rowErrors);
updatedStatus.set(i, Object.keys(rowErrors).length > 0 ? 'error' : 'validated');
// Update row with errors
updatedData[i] = {
...updatedData[i],
__errors: rowErrors
};
}
// Batch state updates at the end to avoid multiple re-renders
setValidationErrors(updatedErrors);
setRowValidationStatus(updatedStatus);
setData(updatedData);
toast.success('Validation complete');
} catch (error) {
console.error('Error validating all rows:', error);
toast.error('Error during validation');
} finally {
setIsValidating(false);
}
}, [data, fields, isValidating, validateField, validationErrors, rowValidationStatus]);
// Load templates
const loadTemplates = useCallback(async () => {
try {
console.log('Fetching templates...');
// Fetch templates from the API
const response = await fetch(`${getApiUrl()}/templates`)
console.log('Templates response status:', response.status);
if (!response.ok) throw new Error('Failed to fetch templates')
const templateData = await response.json()
console.log('Templates fetched successfully:', templateData);
// Validate template data
const validTemplates = templateData.filter((t: any) =>
t && typeof t === 'object' && t.id && t.company && t.product_type
);
if (validTemplates.length !== templateData.length) {
console.warn('Some templates were filtered out due to invalid data', {
original: templateData.length,
valid: validTemplates.length
});
}
setTemplates(validTemplates)
// Fetch field options if not already loaded
if (!fieldOptions) {
try {
const optionsResponse = await fetch(`${getApiUrl()}/import/field-options`);
if (optionsResponse.ok) {
const optionsData = await optionsResponse.json();
console.log('Field options fetched successfully:', optionsData);
setFieldOptions(optionsData);
}
} catch (error) {
console.error('Error fetching field options:', error);
}
}
} catch (error) {
console.error('Error fetching templates:', error)
toast.error('Failed to load templates')
}
}, [])
// Save a new template
const saveTemplate = useCallback(async (name: string, type: string) => {
try {
// Get selected rows
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
const selectedRow = data[selectedRowIndex];
if (!selectedRow) {
toast.error('Please select a row to create a template')
return
}
// Extract data for template, removing metadata fields
const { __index, __errors, __template, __original, __corrected, __changes, ...templateData } = selectedRow as any;
// Clean numeric values (remove $ from price fields)
const cleanedData: Record<string, any> = {};
// Process each key-value pair
Object.entries(templateData).forEach(([key, value]) => {
// Handle numeric values with dollar signs
if (typeof value === 'string' && value.includes('$')) {
cleanedData[key] = value.replace(/[$,\s]/g, '').trim();
}
// Handle array values (like categories or ship_restrictions)
else if (Array.isArray(value)) {
cleanedData[key] = value;
}
// Handle other values
else {
cleanedData[key] = value;
}
});
// Send the template to the API
const response = await fetch(`${getApiUrl()}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...cleanedData,
company: name,
product_type: type,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || errorData.details || "Failed to save template");
}
// Get the new template from the response
const newTemplate = await response.json();
// Update the templates list with the new template
setTemplates(prev => [...prev, newTemplate]);
// Update the row to show it's using this template
setData(prev => {
const newData = [...prev];
if (newData[selectedRowIndex]) {
newData[selectedRowIndex] = {
...newData[selectedRowIndex],
__template: newTemplate.id.toString()
};
}
return newData;
});
toast.success(`Template "${name}" saved successfully`)
// Reset dialog state
setTemplateState(prev => ({
...prev,
showSaveTemplateDialog: false,
newTemplateName: '',
newTemplateType: '',
}))
} catch (error) {
console.error('Error saving template:', error)
toast.error(error instanceof Error ? error.message : 'Failed to save template')
}
}, [data, rowSelection, setData]);
// Apply a template to selected rows
const applyTemplate = useCallback((templateId: string, rowIndexes: number[]) => {
const template = templates.find(t => t.id.toString() === templateId)
if (!template) {
toast.error('Template not found')
return
}
setData(prevData => {
const newData = [...prevData]
rowIndexes.forEach(index => {
if (index >= 0 && index < newData.length) {
// Create a new row with template values
const updatedRow = { ...newData[index] }
// Apply template fields (excluding metadata and ID fields)
Object.entries(template).forEach(([key, value]) => {
if (!['id', '__errors', '__meta', '__template', '__original', '__corrected', '__changes'].includes(key)) {
(updatedRow as any)[key] = value
}
})
// Mark the row as using this template
updatedRow.__template = templateId
// Update the row in the data array
newData[index] = updatedRow
}
})
return newData
})
toast.success(`Template applied to ${rowIndexes.length} row(s)`)
// Validate updated rows
rowIndexes.forEach(index => {
validateRow(index)
})
}, [templates, validateRow])
// Apply a template to selected rows
const applyTemplateToSelected = useCallback((templateId: string) => {
if (!templateId) return;
// Update the selected template ID
setTemplateState(prev => ({
...prev,
selectedTemplateId: templateId
}));
// Get selected row indexes
const selectedIndexes = Object.keys(rowSelection).map(i => parseInt(i));
// Apply template to selected rows
applyTemplate(templateId, selectedIndexes);
}, [rowSelection, applyTemplate]);
// Helper function to get company name from ID
const getCompanyName = useCallback((companyId: string) => {
if (!fieldOptions || !fieldOptions.companies) return companyId;
try {
const company = fieldOptions.companies.find((c: { value: string; label: string }) => c.value === companyId);
return company ? company.label : companyId;
} catch (error) {
console.error("Error getting company name:", error);
return companyId;
}
}, [fieldOptions]);
// Get display text for a template
const getTemplateDisplayText = useCallback((templateId: string | null) => {
if (!templateId) return 'Select a template'
const template = templates.find(t => t.id.toString() === templateId)
if (!template) return 'Unknown template'
try {
const companyId = template.company || "";
const productType = template.product_type || "Unknown Type";
const companyName = getCompanyName(companyId);
return `${companyName} - ${productType}`;
} catch (error) {
console.error("Error formatting template display text:", error, template);
return "Error displaying template";
}
}, [templates, getCompanyName])
// Check if there are any errors
const hasErrors = useMemo(() => {
for (const [_, status] of rowValidationStatus.entries()) {
if (status === 'error') return true
}
return false
}, [rowValidationStatus])
// Check if a row is currently being validated for UPC
const isValidatingUpc = useCallback((rowIndex: number): boolean => {
return validatingUpcRows.includes(rowIndex)
}, [validatingUpcRows])
// Initialize data with better performance
useEffect(() => {
// Only run this once on mount
const initializeData = () => {
// Initialize row validation status map in a single operation
const initialStatus = new Map();
data.forEach((_, index) => {
initialStatus.set(index, 'pending');
});
setRowValidationStatus(initialStatus);
// Validate all rows automatically
data.forEach((_, index) => {
validateRow(index);
});
};
// Initialize data and load templates
initializeData();
loadTemplates();
}, [loadTemplates]); // Add loadTemplates to dependencies
// Add a refreshTemplates function
const refreshTemplates = useCallback(() => {
loadTemplates();
}, [loadTemplates]);
return {
// Data
data,
setData,
filteredData,
// Validation
isValidating,
validationErrors,
rowValidationStatus,
validateRow,
validateUpc,
hasErrors,
// Row selection
rowSelection,
setRowSelection,
// Row manipulation
updateRow,
// Templates
templates,
selectedTemplateId: templateState.selectedTemplateId,
showSaveTemplateDialog: templateState.showSaveTemplateDialog,
newTemplateName: templateState.newTemplateName,
newTemplateType: templateState.newTemplateType,
setTemplateState,
templateState,
loadTemplates,
saveTemplate,
applyTemplate,
applyTemplateToSelected,
getTemplateDisplayText,
refreshTemplates,
// Filters
filters,
filterFields,
filterValues,
updateFilters,
resetFilters,
// UPC validation
validatingUpcRows,
isValidatingUpc,
// Fields reference
fields
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react'
import ValidationContainer from './components/ValidationContainer'
import { Props } from './hooks/useValidationState'
/**
* ValidationStepNew component - modern implementation of the validation step
*
* This component is a refactored version of the original ValidationStep component
* with improved architecture, performance and maintainability.
*/
export const ValidationStepNew = <T extends string>({
initialData,
file,
onBack,
onNext,
isFromScratch
}: Props<T>) => {
return (
<ValidationContainer<T>
initialData={initialData}
file={file}
onBack={onBack}
onNext={onNext}
isFromScratch={isFromScratch}
/>
)
}
export default ValidationStepNew

View File

@@ -0,0 +1,24 @@
import { InfoWithSource, ErrorLevel } from "../../types"
// Define our own Error type that's compatible with the original
export interface ErrorType {
message: string;
level: ErrorLevel;
source?: string;
}
// Export a namespace to make it accessible at runtime
export const ErrorTypes = {
createError: (message: string, level: ErrorLevel = 'error', source: string = 'row'): ErrorType => {
return { message, level, source };
}
};
// Type for a collection of errors
export interface Errors { [id: string]: ErrorType[] }
// Make our Meta type match the original for compatibility
export interface Meta {
__index?: string;
__errors?: Record<string, ErrorType[] | ErrorType | InfoWithSource | InfoWithSource[] | null>;
}

View File

@@ -0,0 +1,42 @@
import { InfoWithSource } from '../../../types'
import { ErrorType, ErrorTypes } from '../types/index'
/**
* Converts an InfoWithSource or similar error object to our Error type
* @param error The error object to convert
* @returns Our standardized Error object
*/
export const convertToError = (error: any): ErrorType => {
return {
message: typeof error.message === 'string' ? error.message : String(error.message || ''),
level: error.level || 'error',
source: error.source || 'row'
}
}
/**
* Safely convert an error or array of errors to our Error[] format
* @param errors The error or array of errors to convert
* @returns Array of our Error objects
*/
export const convertToErrorArray = (errors: any): ErrorType[] => {
if (Array.isArray(errors)) {
return errors.map(convertToError)
}
return [convertToError(errors)]
}
/**
* Converts a record of errors to our standardized format
* @param errorRecord Record with string keys and error values
* @returns Standardized error record
*/
export const convertErrorRecord = (errorRecord: Record<string, any>): Record<string, ErrorType[]> => {
const result: Record<string, ErrorType[]> = {}
Object.entries(errorRecord).forEach(([key, errors]) => {
result[key] = convertToErrorArray(errors)
})
return result
}

View File

@@ -0,0 +1,74 @@
import { toast } from 'sonner'
/**
* Placeholder for validating UPC codes
* @param upcValue UPC value to validate
* @returns Validation result
*/
export const validateUpc = async (upcValue: string): Promise<any> => {
// Basic validation - UPC should be 12-14 digits
if (!/^\d{12,14}$/.test(upcValue)) {
toast.error('Invalid UPC format. UPC should be 12-14 digits.')
return {
error: true,
message: 'Invalid UPC format'
}
}
// In a real implementation, call an API to validate the UPC
// For now, just return a successful result
return {
error: false,
data: {
// Mock data that would be returned from the API
item_number: `ITEM-${upcValue.substring(0, 6)}`,
sku: `SKU-${upcValue.substring(0, 4)}`,
description: `Sample Product ${upcValue.substring(0, 4)}`
}
}
}
/**
* Generates an item number for a UPC
* @param upcValue UPC value
* @returns Generated item number
*/
export const generateItemNumber = (upcValue: string): string => {
// Simple item number generation logic
return `ITEM-${upcValue.substring(0, 6)}`
}
/**
* Placeholder for handling UPC validation process
* @param upcValue UPC value to validate
* @param rowIndex Row index being validated
* @param updateRow Function to update row data
*/
export const handleUpcValidation = async (
upcValue: string,
rowIndex: number,
updateRow: (rowIndex: number, key: string, value: any) => void
): Promise<void> => {
try {
// Validate the UPC
const result = await validateUpc(upcValue)
if (result.error) {
toast.error(result.message || 'UPC validation failed')
return
}
// Update row with the validation result data
if (result.data) {
// Update each field returned from the API
Object.entries(result.data).forEach(([key, value]) => {
updateRow(rowIndex, key, value)
})
toast.success('UPC validated successfully')
}
} catch (error) {
console.error('Error validating UPC:', error)
toast.error('Failed to validate UPC')
}
}

View File

@@ -0,0 +1,44 @@
/**
* Helper functions for validation that ensure proper error objects
*/
// Create a standard error object
export const createError = (message, level = 'error', source = 'row') => {
return { message, level, source };
};
// Convert any error to standard format
export const convertError = (error) => {
if (!error) return createError('Unknown error');
if (typeof error === 'string') {
return createError(error);
}
return {
message: error.message || 'Unknown error',
level: error.level || 'error',
source: error.source || 'row'
};
};
// Convert array of errors or single error to array
export const convertToErrorArray = (errors) => {
if (Array.isArray(errors)) {
return errors.map(convertError);
}
return [convertError(errors)];
};
// Convert a record of errors to standard format
export const convertErrorRecord = (errorRecord) => {
const result = {};
if (!errorRecord) return result;
Object.entries(errorRecord).forEach(([key, errors]) => {
result[key] = convertToErrorArray(errors);
});
return result;
};

View File

@@ -0,0 +1,176 @@
import { Field, Data, ErrorSources } from '../../../types'
import { ErrorType, ErrorTypes } from '../types/index'
/**
* Formats a price value to a consistent format
* @param value The price value to format
* @returns The formatted price string
*/
export const formatPrice = (value: string | number): string => {
if (!value) return ''
// Convert to string and clean
const numericValue = String(value).replace(/[^\d.]/g, '')
// Parse the number
const number = parseFloat(numericValue)
if (isNaN(number)) return ''
// Format as currency
return number.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
/**
* Checks if a field is a price field
* @param field The field to check
* @returns True if the field is a price field
*/
export const isPriceField = (field: Field<any>): boolean => {
return !!field.fieldType.price
}
/**
* Determines if a field is a multi-input type
* @param fieldType The field type to check
* @returns True if the field is a multi-input type
*/
export const isMultiInputType = (fieldType: any): boolean => {
return fieldType.type === 'multi-input'
}
/**
* Gets the separator for multi-input fields
* @param fieldType The field type
* @returns The separator string
*/
export const getMultiInputSeparator = (fieldType: any): string => {
if (isMultiInputType(fieldType) && fieldType.separator) {
return fieldType.separator
}
return ','
}
/**
* Performs regex validation on a value
* @param value The value to validate
* @param regex The regex pattern
* @param flags Regex flags
* @returns True if validation passes
*/
export const validateRegex = (value: any, regex: string, flags?: string): boolean => {
if (value === undefined || value === null || value === '') return true
try {
const regexObj = new RegExp(regex, flags)
return regexObj.test(String(value))
} catch (error) {
console.error('Invalid regex in validation:', error)
return false
}
}
/**
* Creates a validation error object
* @param message Error message
* @param level Error level
* @param source Error source
* @returns Error object
*/
export const createError = (
message: string,
level: 'info' | 'warning' | 'error' = 'error',
source: ErrorSources = ErrorSources.Row
): ErrorType => {
return {
message,
level,
source
} as ErrorType
}
/**
* Formats a display value based on field type
* @param value The value to format
* @param field The field definition
* @returns Formatted display value
*/
export const getDisplayValue = (value: any, field: Field<any>): string => {
if (value === undefined || value === null) return ''
// Handle price fields
if (isPriceField(field)) {
return formatPrice(value)
}
// Handle multi-input fields
if (isMultiInputType(field.fieldType)) {
if (Array.isArray(value)) {
return value.join(`${getMultiInputSeparator(field.fieldType)} `)
}
}
// Handle boolean values
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No'
}
return String(value)
}
/**
* Validates supplier and company fields
* @param row The data row
* @returns Object with errors for invalid fields
*/
export const validateSpecialFields = <T extends string>(row: Data<T>): Record<string, ErrorType[]> => {
const errors: Record<string, ErrorType[]> = {}
// Validate supplier field
if (!row.supplier) {
errors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
source: ErrorSources.Row
} as ErrorType]
}
// Validate company field
if (!row.company) {
errors['company'] = [{
message: 'Company is required',
level: 'error',
source: ErrorSources.Row
} as ErrorType]
}
return errors
}
/**
* Merges multiple error objects
* @param errors Array of error objects to merge
* @returns Merged error object
*/
export const mergeErrors = (...errors: Record<string, ErrorType[]>[]): Record<string, ErrorType[]> => {
const merged: Record<string, ErrorType[]> = {}
errors.forEach(errorObj => {
if (!errorObj) return
Object.entries(errorObj).forEach(([key, errs]) => {
if (!merged[key]) {
merged[key] = []
}
merged[key] = [
...merged[key],
...(Array.isArray(errs) ? errs : [errs as ErrorType])
]
})
})
return merged
}

View File

@@ -69,6 +69,17 @@ const BASE_IMPORT_FIELDS = [
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
], ],
}, },
{
label: "Item Number",
key: "item_number",
description: "Internal item reference number",
fieldType: { type: "input" },
width: 130,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
],
},
{ {
label: "Supplier #", label: "Supplier #",
key: "supplier_no", key: "supplier_no",
@@ -106,17 +117,6 @@ const BASE_IMPORT_FIELDS = [
{ rule: "unique", errorMessage: "Must be unique", level: "error" }, { rule: "unique", errorMessage: "Must be unique", level: "error" },
], ],
}, },
{
label: "Item Number",
key: "item_number",
description: "Internal item reference number",
fieldType: { type: "input" },
width: 130,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
],
},
{ {
label: "MSRP", label: "MSRP",
key: "msrp", key: "msrp",