Clean up old validationstep, clean up various type errors
This commit is contained in:
@@ -37,18 +37,22 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
type Props<T extends string = string> = {
|
type Product = {
|
||||||
data: any[];
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
supplier_no?: string;
|
||||||
|
upc?: string;
|
||||||
|
sku?: string;
|
||||||
|
model?: string;
|
||||||
|
product_images?: string | string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
data: Product[];
|
||||||
file: File;
|
file: File;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onSubmit: (data: any[], file: File) => void | Promise<any>;
|
onSubmit: (data: Product[], file: File) => void | Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProductImage = {
|
type ProductImage = {
|
||||||
@@ -56,6 +60,12 @@ type ProductImage = {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
pid?: string | number;
|
||||||
|
iid?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
type?: number;
|
||||||
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnassignedImage = {
|
type UnassignedImage = {
|
||||||
@@ -68,13 +78,13 @@ type ProductImageSortable = ProductImage & {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageUploadStep = <T extends string>({
|
export const ImageUploadStep = ({
|
||||||
data,
|
data,
|
||||||
file,
|
file,
|
||||||
onBack,
|
onBack,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: Props<T>) => {
|
}: Props) => {
|
||||||
const { translations } = useRsi<T>();
|
useRsi();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
|
const fileInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>({});
|
||||||
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
const [unassignedImages, setUnassignedImages] = useState<UnassignedImage[]>([]);
|
||||||
@@ -95,7 +105,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Convert existing product_images to ProductImageSortable objects
|
// Convert existing product_images to ProductImageSortable objects
|
||||||
const initialImages: ProductImageSortable[] = [];
|
const initialImages: ProductImageSortable[] = [];
|
||||||
|
|
||||||
data.forEach((product, productIndex) => {
|
data.forEach((product: Product, productIndex: number) => {
|
||||||
if (product.product_images) {
|
if (product.product_images) {
|
||||||
let imageUrls: string[] = [];
|
let imageUrls: string[] = [];
|
||||||
|
|
||||||
@@ -119,7 +129,11 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: url.trim(),
|
imageUrl: url.trim(),
|
||||||
loading: false,
|
loading: false,
|
||||||
fileName: `Image ${i + 1}`
|
fileName: `Image ${i + 1}`,
|
||||||
|
pid: product.id || '',
|
||||||
|
iid: '',
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -232,7 +246,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Add product IDs to the valid droppable elements
|
// Add product IDs to the valid droppable elements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Add data-droppable attributes to make product containers easier to identify
|
// Add data-droppable attributes to make product containers easier to identify
|
||||||
data.forEach((_, index) => {
|
data.forEach((_: Product, index: number) => {
|
||||||
const container = document.getElementById(`product-${index}`);
|
const container = document.getElementById(`product-${index}`);
|
||||||
if (container) {
|
if (container) {
|
||||||
container.setAttribute('data-droppable', 'true');
|
container.setAttribute('data-droppable', 'true');
|
||||||
@@ -255,7 +269,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Effect to register browser-level drag events on product containers
|
// Effect to register browser-level drag events on product containers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// For each product container
|
// For each product container
|
||||||
data.forEach((_, index) => {
|
data.forEach((_: Product, index: number) => {
|
||||||
const container = document.getElementById(`product-${index}`);
|
const container = document.getElementById(`product-${index}`);
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -343,88 +357,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to add an image URL to a product
|
// Function to add an image URL to a product
|
||||||
const addImageToProduct = (productIndex: number, imageUrl: string, imageData?: any) => {
|
|
||||||
// Create a copy of the data
|
|
||||||
const newData = [...data];
|
|
||||||
|
|
||||||
// Get the current product
|
|
||||||
const product = newData[productIndex];
|
|
||||||
|
|
||||||
// Initialize product_images array if it doesn't exist
|
|
||||||
if (!product.product_images) {
|
|
||||||
product.product_images = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different formats of product_images
|
|
||||||
let images: any[] = [];
|
|
||||||
|
|
||||||
if (typeof product.product_images === 'string') {
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
images = JSON.parse(product.product_images);
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, split by comma if it's a string
|
|
||||||
images = product.product_images.split(',').filter(Boolean).map(url => ({
|
|
||||||
imageUrl: url.trim(),
|
|
||||||
pid: product.id || 0,
|
|
||||||
iid: 0,
|
|
||||||
type: 0,
|
|
||||||
order: 255,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
hidden: 0
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(product.product_images)) {
|
|
||||||
// Use the array directly
|
|
||||||
images = product.product_images;
|
|
||||||
} else if (product.product_images) {
|
|
||||||
// Handle case where it might be a single value
|
|
||||||
images = [product.product_images];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the image URL already exists
|
|
||||||
const exists = images.some(img => {
|
|
||||||
const imgUrl = typeof img === 'string' ? img : img.imageUrl;
|
|
||||||
return imgUrl === imageUrl;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only add if the URL doesn't already exist
|
|
||||||
if (!exists) {
|
|
||||||
// Create a new image object with schema fields
|
|
||||||
const newImage = imageData || {
|
|
||||||
imageUrl,
|
|
||||||
pid: product.id || 0,
|
|
||||||
iid: Math.floor(Math.random() * 10000), // Generate a temporary iid
|
|
||||||
type: 0,
|
|
||||||
order: images.length,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
hidden: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// If imageData is a string, convert it to an object
|
|
||||||
if (typeof newImage === 'string') {
|
|
||||||
newImage = {
|
|
||||||
imageUrl: newImage,
|
|
||||||
pid: product.id || 0,
|
|
||||||
iid: Math.floor(Math.random() * 10000),
|
|
||||||
type: 0,
|
|
||||||
order: images.length,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
hidden: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
images.push(newImage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the product_images field
|
|
||||||
product.product_images = images;
|
|
||||||
|
|
||||||
return newData;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update handleDragEnd to work with the updated product data structure
|
// Update handleDragEnd to work with the updated product data structure
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
@@ -524,13 +456,8 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update both products' image data fields
|
// Update both products' image data fields
|
||||||
let updatedData = [...data]; // Start with a fresh copy
|
|
||||||
|
|
||||||
// First remove from source
|
|
||||||
updatedData = removeImageFromProduct(sourceProductIndex, activeImage.imageUrl);
|
|
||||||
|
|
||||||
// Then add to target
|
|
||||||
updatedData = addImageToProduct(targetProductIndex, activeImage.imageUrl);
|
|
||||||
|
|
||||||
// Show notification
|
// Show notification
|
||||||
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
|
toast.success(`Image moved from ${data[sourceProductIndex].name || `Product #${sourceProductIndex + 1}`} to ${data[targetProductIndex].name || `Product #${targetProductIndex + 1}`}`);
|
||||||
@@ -602,44 +529,41 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
setActiveImage(null);
|
setActiveImage(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to handle image upload - update product data
|
// Function to handle image upload
|
||||||
const handleImageUpload = async (files: FileList | File[], productIndex: number) => {
|
const handleImageUpload = async (files: FileList | File[], productIndex: number): Promise<void> => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
const fileArray = Array.from(files);
|
||||||
const file = files[i];
|
|
||||||
|
|
||||||
// Add placeholder for this image
|
for (let i = 0; i < fileArray.length; i++) {
|
||||||
const newImage: ProductImageSortable = {
|
const file = fileArray[i];
|
||||||
id: `image-${productIndex}-${Date.now()}-${i}`, // Generate a unique ID
|
|
||||||
|
// Create initial image data
|
||||||
|
let imageData: ProductImageSortable = {
|
||||||
|
id: `image-${productIndex}-${Date.now()}-${i}`,
|
||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
// Add schema fields
|
pid: String(data[productIndex].id || ''),
|
||||||
pid: data[productIndex].id || 0,
|
iid: '',
|
||||||
iid: 0, // Will be assigned by server
|
|
||||||
type: 0,
|
type: 0,
|
||||||
order: productImages.filter(img => img.productIndex === productIndex).length + i,
|
order: productImages.filter(img => img.productIndex === productIndex).length + i
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
hidden: 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setProductImages(prev => [...prev, newImage]);
|
// Add to state
|
||||||
|
setProductImages(prev => [...prev, imageData]);
|
||||||
// Create form data for upload
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('productIndex', productIndex.toString());
|
|
||||||
formData.append('upc', data[productIndex].upc || '');
|
|
||||||
formData.append('supplier_no', data[productIndex].supplier_no || '');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload the image
|
// Create form data for upload
|
||||||
const response = await fetch(`${config.apiUrl}/import/upload-image`, {
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('productIndex', String(productIndex));
|
||||||
|
|
||||||
|
// Upload the file
|
||||||
|
const response = await fetch(`${config.apiUrl}/upload-image`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -648,38 +572,32 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// Update the image URL in our state
|
// Update the image data with server response
|
||||||
setProductImages(prev =>
|
if (result) {
|
||||||
prev.map(img =>
|
imageData = {
|
||||||
(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
...imageData,
|
||||||
? {
|
|
||||||
...img,
|
|
||||||
imageUrl: result.imageUrl,
|
imageUrl: result.imageUrl,
|
||||||
loading: false,
|
loading: false,
|
||||||
// Update schema fields if returned from server
|
iid: result.iid || imageData.iid,
|
||||||
iid: result.iid || img.iid,
|
width: result.width || imageData.width,
|
||||||
width: result.width || img.width,
|
height: result.height || imageData.height
|
||||||
height: result.height || img.height
|
};
|
||||||
}
|
|
||||||
: img
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
// Update the product images state
|
||||||
addImageToProduct(productIndex, result.imageUrl, result);
|
|
||||||
|
|
||||||
toast.success(`Image uploaded for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
|
|
||||||
// Remove the failed image from our state
|
|
||||||
setProductImages(prev =>
|
setProductImages(prev =>
|
||||||
prev.filter(img =>
|
prev.map(img =>
|
||||||
!(img.loading && img.productIndex === productIndex && img.fileName === file.name)
|
img.id === imageData.id ? imageData : img
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image:', error);
|
||||||
|
toast.error('Failed to upload image');
|
||||||
|
|
||||||
toast.error(`Failed to upload image: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
// Remove the placeholder image on error
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.filter(img => img.id !== imageData.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -717,7 +635,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
// Function to find product index by identifier
|
// Function to find product index by identifier
|
||||||
const findProductByIdentifier = (identifier: string): number => {
|
const findProductByIdentifier = (identifier: string): number => {
|
||||||
// Try to match against supplier_no, upc, SKU, or name
|
// Try to match against supplier_no, upc, SKU, or name
|
||||||
return data.findIndex(product => {
|
return data.findIndex((product: Product) => {
|
||||||
// Skip if product is missing all identifiers
|
// Skip if product is missing all identifiers
|
||||||
if (!product.supplier_no && !product.upc && !product.sku && !product.name) {
|
if (!product.supplier_no && !product.upc && !product.sku && !product.name) {
|
||||||
return false;
|
return false;
|
||||||
@@ -756,7 +674,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function to handle bulk image upload
|
// Function to handle bulk image upload
|
||||||
const handleBulkUpload = async (files: File[]) => {
|
const handleBulkUpload = async (files: File[]): Promise<void> => {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|
||||||
setProcessingBulk(true);
|
setProcessingBulk(true);
|
||||||
@@ -765,26 +683,80 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Extract identifiers from filename
|
// Extract identifiers from filename
|
||||||
const identifiers = extractIdentifiers(file.name);
|
const identifiers = extractIdentifiers(file.name);
|
||||||
let assigned = false;
|
|
||||||
|
|
||||||
// Try to match each identifier
|
// Try to find matching product
|
||||||
|
let productIndex = -1;
|
||||||
for (const identifier of identifiers) {
|
for (const identifier of identifiers) {
|
||||||
const productIndex = findProductByIdentifier(identifier);
|
productIndex = findProductByIdentifier(identifier);
|
||||||
|
if (productIndex !== -1) break;
|
||||||
if (productIndex !== -1) {
|
|
||||||
// Found a match, upload to this product
|
|
||||||
await handleImageUpload([file], productIndex);
|
|
||||||
assigned = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no match was found, add to unassigned
|
if (productIndex === -1) {
|
||||||
if (!assigned) {
|
// If no match found, add to unassigned images
|
||||||
unassigned.push({
|
const previewUrl = createPreviewUrl(file);
|
||||||
file,
|
setUnassignedImages(prev => [...prev, { file, previewUrl }]);
|
||||||
previewUrl: createPreviewUrl(file)
|
} else {
|
||||||
|
// If match found, add to product images
|
||||||
|
let imageData: ProductImageSortable = {
|
||||||
|
id: `image-${productIndex}-${Date.now()}`,
|
||||||
|
productIndex,
|
||||||
|
imageUrl: '',
|
||||||
|
loading: true,
|
||||||
|
fileName: file.name,
|
||||||
|
pid: String(data[productIndex].id || ''),
|
||||||
|
iid: '',
|
||||||
|
type: 0,
|
||||||
|
order: productImages.filter(img => img.productIndex === productIndex).length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to state
|
||||||
|
setProductImages(prev => [...prev, imageData]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create form data for upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('productIndex', String(productIndex));
|
||||||
|
|
||||||
|
// Upload the file
|
||||||
|
const response = await fetch(`${config.apiUrl}/upload-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to upload image');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Update the image data with server response
|
||||||
|
if (result) {
|
||||||
|
imageData = {
|
||||||
|
...imageData,
|
||||||
|
imageUrl: result.imageUrl,
|
||||||
|
loading: false,
|
||||||
|
iid: result.iid || imageData.iid,
|
||||||
|
width: result.width || imageData.width,
|
||||||
|
height: result.height || imageData.height
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the product images state
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.id === imageData.id ? imageData : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading image:', error);
|
||||||
|
toast.error('Failed to upload image');
|
||||||
|
|
||||||
|
// Remove the placeholder image on error
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.filter(img => img.id !== imageData.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,18 +910,13 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
}
|
}
|
||||||
}, [data, file, onSubmit, productImages]);
|
}, [data, file, onSubmit, productImages]);
|
||||||
|
|
||||||
// Function to ensure URLs are properly formatted with absolute paths
|
// Function to get full image URL
|
||||||
const getFullImageUrl = (url: string): string => {
|
const getFullImageUrl = (urlInput: string | undefined | null): string => {
|
||||||
// If the URL is already absolute (starts with http:// or https://) return it as is
|
if (!urlInput) return '';
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (urlInput.startsWith('http://') || urlInput.startsWith('https://')) {
|
||||||
return url;
|
return urlInput;
|
||||||
}
|
}
|
||||||
|
return `${config.apiUrl}/images/${urlInput}`;
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
|
||||||
const baseUrl = 'https://inventory.acot.site';
|
|
||||||
// Make sure url starts with / for path
|
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
|
||||||
return `${baseUrl}${path}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generic dropzone component
|
// Generic dropzone component
|
||||||
@@ -1057,7 +1024,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
<SelectValue placeholder="Assign to..." />
|
<SelectValue placeholder="Assign to..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{data.map((product: any, productIndex: number) => (
|
{data.map((product: Product, productIndex: number) => (
|
||||||
<SelectItem key={productIndex} value={productIndex.toString()}>
|
<SelectItem key={productIndex} value={productIndex.toString()}>
|
||||||
{product.name || `Product #${productIndex + 1}`}
|
{product.name || `Product #${productIndex + 1}`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1342,112 +1309,76 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add a URL input component that doesn't expand/collapse
|
// Add a URL input component that doesn't expand/collapse
|
||||||
const ImageUrlInput = ({ productIndex }: { productIndex: number }) => {
|
|
||||||
// Use a stable format that won't get affected by DndContext events
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="flex items-center gap-1"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (urlInputs[productIndex]) {
|
|
||||||
handleAddImageFromUrl(productIndex, urlInputs[productIndex]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Image URL"
|
|
||||||
value={urlInputs[productIndex] || ''}
|
|
||||||
onChange={(e) => updateUrlInput(productIndex, e.target.value)}
|
|
||||||
className="text-xs h-8 max-w-[180px]"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 whitespace-nowrap"
|
|
||||||
disabled={processingUrls[productIndex] || !urlInputs[productIndex]}
|
|
||||||
>
|
|
||||||
{processingUrls[productIndex] ?
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> :
|
|
||||||
<Link2 className="h-3.5 w-3.5 mr-1" />}
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle adding an image from a URL - simplified to skip server
|
// Function to handle adding image from URL
|
||||||
const handleAddImageFromUrl = async (productIndex: number, url: string) => {
|
const handleAddImageFromUrl = async (productIndex: number, urlInput: string): Promise<void> => {
|
||||||
if (!url || !url.trim()) {
|
if (!urlInput) return;
|
||||||
toast.error("Please enter a valid URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Set processing state
|
// Set processing state
|
||||||
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
|
setProcessingUrls(prev => ({ ...prev, [productIndex]: true }));
|
||||||
|
|
||||||
// Validate URL format
|
// Create initial image data
|
||||||
let validatedUrl = url.trim();
|
let newImage: ProductImageSortable = {
|
||||||
|
id: `image-${productIndex}-${Date.now()}`,
|
||||||
// Add protocol if missing
|
|
||||||
if (!validatedUrl.startsWith('http://') && !validatedUrl.startsWith('https://')) {
|
|
||||||
validatedUrl = `https://${validatedUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic URL validation
|
|
||||||
try {
|
|
||||||
new URL(validatedUrl);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Invalid URL format. Please enter a valid URL");
|
|
||||||
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a unique ID for this image
|
|
||||||
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
||||||
|
|
||||||
// Get the next order value for this product
|
|
||||||
const nextOrder = productImages
|
|
||||||
.filter(img => img.productIndex === productIndex)
|
|
||||||
.length;
|
|
||||||
|
|
||||||
// Create the new image object with the URL
|
|
||||||
const newImage: ProductImageSortable = {
|
|
||||||
id: imageId,
|
|
||||||
productIndex,
|
productIndex,
|
||||||
imageUrl: validatedUrl,
|
imageUrl: urlInput,
|
||||||
loading: false, // We're not loading from server, so it's ready immediately
|
loading: true,
|
||||||
fileName: "From URL",
|
fileName: urlInput.split('/').pop() || 'url-image',
|
||||||
// Add schema fields
|
pid: String(data[productIndex].id || ''),
|
||||||
pid: data[productIndex].id || 0,
|
iid: '',
|
||||||
iid: Math.floor(Math.random() * 10000), // Generate a temporary iid
|
|
||||||
type: 0,
|
type: 0,
|
||||||
order: nextOrder,
|
order: productImages.filter(img => img.productIndex === productIndex).length
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
hidden: 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the image directly to the product images list
|
// Add to state
|
||||||
setProductImages(prev => [...prev, newImage]);
|
setProductImages(prev => [...prev, newImage]);
|
||||||
|
|
||||||
// Update the product data with the new image URL
|
try {
|
||||||
addImageToProduct(productIndex, validatedUrl, newImage);
|
// Validate URL format
|
||||||
|
const validUrl = getFullImageUrl(urlInput);
|
||||||
|
if (!validUrl) {
|
||||||
|
throw new Error('Invalid URL format');
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the URL input field on success
|
// Update image data with validated URL
|
||||||
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));
|
newImage = {
|
||||||
|
...newImage,
|
||||||
|
imageUrl: validUrl,
|
||||||
|
loading: false
|
||||||
|
};
|
||||||
|
|
||||||
toast.success(`Image URL added to ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
// Update product images state
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.map(img =>
|
||||||
|
img.id === newImage.id ? newImage : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear URL input
|
||||||
|
updateUrlInput(productIndex, '');
|
||||||
|
|
||||||
|
toast.success(`Image added from URL for ${data[productIndex].name || `Product #${productIndex + 1}`}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add image from URL error:', error);
|
console.error('Error adding image from URL:', error);
|
||||||
toast.error(`Failed to add image URL: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error('Failed to add image from URL');
|
||||||
|
|
||||||
|
// Remove the placeholder image on error
|
||||||
|
setProductImages(prev =>
|
||||||
|
prev.filter(img => img.id !== newImage.id)
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Clear processing state
|
||||||
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
setProcessingUrls(prev => ({ ...prev, [productIndex]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to update URL input
|
||||||
|
const updateUrlInput = (productIndex: number, value: string): void => {
|
||||||
|
setUrlInputs((prev: { [key: number]: string }) => ({ ...prev, [productIndex]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
// Function to copy text to clipboard
|
// Function to copy text to clipboard
|
||||||
const copyToClipboard = (text: string, key: string) => {
|
const copyToClipboard = (text: string, key: string): void => {
|
||||||
if (!text || text === 'N/A') return;
|
if (!text || text === 'N/A') return;
|
||||||
|
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
@@ -1500,11 +1431,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the URL input value
|
|
||||||
const updateUrlInput = (productIndex: number, value: string) => {
|
|
||||||
setUrlInputs(prev => ({ ...prev, [productIndex]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)] overflow-hidden">
|
||||||
{/* Header - fixed at top */}
|
{/* Header - fixed at top */}
|
||||||
@@ -1555,7 +1481,7 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{data.map((product: any, index: number) => (
|
{data.map((product: Product, index: number) => (
|
||||||
<Card
|
<Card
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1585,7 +1511,6 @@ export const ImageUploadStep = <T extends string>({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (urlInputs[index]) {
|
if (urlInputs[index]) {
|
||||||
handleAddImageFromUrl(index, urlInputs[index]);
|
handleAddImageFromUrl(index, urlInputs[index]);
|
||||||
updateUrlInput(index, '');
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -535,7 +535,6 @@ const SubLineSelector = React.memo(({
|
|||||||
// Add this new component before the MatchColumnsStep component
|
// Add this new component before the MatchColumnsStep component
|
||||||
const FieldSelector = React.memo(({
|
const FieldSelector = React.memo(({
|
||||||
column,
|
column,
|
||||||
isUnmapped = false,
|
|
||||||
fieldCategories,
|
fieldCategories,
|
||||||
allFields,
|
allFields,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -794,18 +793,13 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
|||||||
const fieldOptions = fieldOptionsData || { suppliers: [], companies: [] };
|
const fieldOptions = fieldOptionsData || { suppliers: [], companies: [] };
|
||||||
|
|
||||||
// Create a stable identity for these queries to avoid re-renders
|
// Create a stable identity for these queries to avoid re-renders
|
||||||
const stableFieldOptions = useMemo(() => fieldOptionsData || { suppliers: [], companies: [] }, [fieldOptionsData]);
|
|
||||||
const stableProductLines = useMemo(() => productLines || [], [productLines]);
|
const stableProductLines = useMemo(() => productLines || [], [productLines]);
|
||||||
const stableSublines = useMemo(() => sublines || [], [sublines]);
|
const stableSublines = useMemo(() => sublines || [], [sublines]);
|
||||||
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
|
const stableMappedProductLines = useMemo(() => mappedProductLines || [], [mappedProductLines]);
|
||||||
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
|
const stableMappedSublines = useMemo(() => mappedSublines || [], [mappedSublines]);
|
||||||
|
|
||||||
// Type guard for suppliers and companies
|
// Type guard for suppliers and companies
|
||||||
const hasSuppliers = (options: any): options is { suppliers: any[] } =>
|
|
||||||
options && Array.isArray(options.suppliers);
|
|
||||||
|
|
||||||
const hasCompanies = (options: any): options is { companies: any[] } =>
|
|
||||||
options && Array.isArray(options.companies);
|
|
||||||
|
|
||||||
// Check if a field is covered by global selections
|
// Check if a field is covered by global selections
|
||||||
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
||||||
@@ -976,20 +970,6 @@ export const MatchColumnsStep = React.memo(<T extends string>({
|
|||||||
}, [availableFields]);
|
}, [availableFields]);
|
||||||
|
|
||||||
// Group all fields by category (for editing mapped columns)
|
// Group all fields by category (for editing mapped columns)
|
||||||
const allFieldCategories = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{ name: "Basic Info", fields: getFieldsByKeyPrefix('basic', allFields) },
|
|
||||||
{ name: "Product", fields: getFieldsByKeyPrefix('product', allFields) },
|
|
||||||
{ name: "Inventory", fields: getFieldsByKeyPrefix('inventory', allFields) },
|
|
||||||
{ name: "Pricing", fields: getFieldsByKeyPrefix('pricing', allFields) },
|
|
||||||
{ name: "Other", fields: allFields.filter(f =>
|
|
||||||
!f.key.startsWith('basic') &&
|
|
||||||
!f.key.startsWith('product') &&
|
|
||||||
!f.key.startsWith('inventory') &&
|
|
||||||
!f.key.startsWith('pricing')
|
|
||||||
) }
|
|
||||||
].filter(category => category.fields.length > 0);
|
|
||||||
}, [allFields, getFieldsByKeyPrefix]);
|
|
||||||
|
|
||||||
// Group available fields by category (for unmapped columns)
|
// Group available fields by category (for unmapped columns)
|
||||||
const availableFieldCategories = useMemo(() => {
|
const availableFieldCategories = useMemo(() => {
|
||||||
|
|||||||
@@ -15,30 +15,30 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
|
|||||||
(key) => key.toLowerCase() === curr?.toLowerCase(),
|
(key) => key.toLowerCase() === curr?.toLowerCase(),
|
||||||
)!
|
)!
|
||||||
const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey]
|
const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey]
|
||||||
acc[column.value] = booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)
|
acc[column.value] = (booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)) as Data<T>[T]
|
||||||
} else {
|
} else {
|
||||||
acc[column.value] = normalizeCheckboxValue(curr)
|
acc[column.value] = normalizeCheckboxValue(curr) as Data<T>[T]
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
case ColumnType.matched: {
|
case ColumnType.matched: {
|
||||||
acc[column.value] = curr === "" ? undefined : curr
|
acc[column.value] = (curr === "" ? undefined : curr) as Data<T>[T]
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
case ColumnType.matchedMultiInput: {
|
case ColumnType.matchedMultiInput: {
|
||||||
const field = fields.find((field) => field.key === column.value)!
|
const field = fields.find((field) => field.key === column.value)!
|
||||||
if (curr) {
|
if (curr) {
|
||||||
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
|
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
|
||||||
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean)
|
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean) as Data<T>[T]
|
||||||
} else {
|
} else {
|
||||||
acc[column.value] = undefined
|
acc[column.value] = undefined as Data<T>[T]
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
case ColumnType.matchedSelect:
|
case ColumnType.matchedSelect:
|
||||||
case ColumnType.matchedSelectOptions: {
|
case ColumnType.matchedSelectOptions: {
|
||||||
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
|
const matchedOption = column.matchedOptions.find(({ entry }) => entry === curr)
|
||||||
acc[column.value] = matchedOption?.value || undefined
|
acc[column.value] = (matchedOption?.value || undefined) as Data<T>[T]
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
case ColumnType.matchedMultiSelect: {
|
case ColumnType.matchedMultiSelect: {
|
||||||
@@ -50,9 +50,9 @@ export const normalizeTableData = <T extends string>(columns: Columns<T>, data:
|
|||||||
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
|
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
|
||||||
return matchedOption?.value
|
return matchedOption?.value
|
||||||
}).filter(Boolean) as string[]
|
}).filter(Boolean) as string[]
|
||||||
acc[column.value] = values.length ? values : undefined
|
acc[column.value] = (values.length ? values : undefined) as Data<T>[T]
|
||||||
} else {
|
} else {
|
||||||
acc[column.value] = undefined
|
acc[column.value] = undefined as Data<T>[T]
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid"
|
import { Column, FormatterProps, useRowSelection } from "react-data-grid"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import type { RawData } from "../../../types"
|
import type { RawData } from "../../../types"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const SELECT_COLUMN_KEY = "select-row"
|
const SELECT_COLUMN_KEY = "select-row"
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ export const generateSelectionColumns = (data: RawData[]) => {
|
|||||||
key: index.toString(),
|
key: index.toString(),
|
||||||
name: `Column ${index + 1}`,
|
name: `Column ${index + 1}`,
|
||||||
width: 150,
|
width: 150,
|
||||||
formatter: ({ row }) => (
|
formatter: ({ row }: { row: RawData }) => (
|
||||||
<div className="p-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
<div className="p-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{row[index]}
|
{row[index]}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
|||||||
import { mapWorkbook } from "../utils/mapWorkbook"
|
import { mapWorkbook } from "../utils/mapWorkbook"
|
||||||
import { ValidationStepNew } from "./ValidationStepNew"
|
import { ValidationStepNew } from "./ValidationStepNew"
|
||||||
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
||||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
|
||||||
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
|
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
|
||||||
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
||||||
import { useRsi } from "../hooks/useRsi"
|
import { useRsi } from "../hooks/useRsi"
|
||||||
import type { RawData } from "../types"
|
import type { RawData, Data } from "../types"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { addErrorsAndRunHooks } from "./ValidationStepNew/utils/dataMutations"
|
||||||
|
|
||||||
export enum StepType {
|
export enum StepType {
|
||||||
upload = "upload",
|
upload = "upload",
|
||||||
@@ -185,7 +185,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
|
|
||||||
// Apply global selections to each row of data if they exist
|
// Apply global selections to each row of data if they exist
|
||||||
const dataWithGlobalSelections = globalSelections
|
const dataWithGlobalSelections = globalSelections
|
||||||
? dataWithMeta.map(row => {
|
? dataWithMeta.map((row: Data<string> & { __errors?: any; __index?: string }) => {
|
||||||
const newRow = { ...row };
|
const newRow = { ...row };
|
||||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||||
if (globalSelections.company) newRow.company = globalSelections.company;
|
if (globalSelections.company) newRow.company = globalSelections.company;
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { Fields } from "../../../types"
|
|
||||||
import { useMemo } from "react"
|
|
||||||
import { Table } from "../../../components/Table"
|
|
||||||
import { generateColumns } from "./columns"
|
|
||||||
import { generateExampleRow } from "../utils/generateExampleRow"
|
|
||||||
|
|
||||||
interface Props<T extends string> {
|
|
||||||
fields: Fields<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ExampleTable = <T extends string>({ fields }: Props<T>) => {
|
|
||||||
const data = useMemo(() => generateExampleRow(fields), [fields])
|
|
||||||
const columns = useMemo(() => generateColumns(fields), [fields])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full">
|
|
||||||
<Table
|
|
||||||
rows={data}
|
|
||||||
columns={columns}
|
|
||||||
className="rdg-example h-full"
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export const FadingOverlay = () => (
|
|
||||||
<div
|
|
||||||
className="absolute inset-x-0 bottom-0 h-12 pointer-events-none bg-gradient-to-t from-background to-transparent"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { Field, Fields } from "../../../types"
|
|
||||||
|
|
||||||
const titleMap: Record<Field<string>["fieldType"]["type"], string> = {
|
|
||||||
checkbox: "Boolean",
|
|
||||||
select: "Options",
|
|
||||||
input: "Text",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const generateExampleRow = <T extends string>(fields: Fields<T>) => [
|
|
||||||
fields.reduce((acc, field) => {
|
|
||||||
acc[field.key as T] = field.example || titleMap[field.fieldType.type]
|
|
||||||
return acc
|
|
||||||
}, {} as Record<T, string>),
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export const getDropZoneBorder = (color: string) => {
|
|
||||||
return {
|
|
||||||
bgGradient: `repeating-linear(0deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(90deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(180deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(270deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px)`,
|
|
||||||
backgroundSize: "2px 100%, 100% 2px, 2px 100% , 100% 2px",
|
|
||||||
backgroundPosition: "0 0, 0 0, 100% 0, 0 100%",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,7 +50,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [] = useState<string | null>(null);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList
|
CommandList
|
||||||
} from '@/components/ui/command'
|
} from '@/components/ui/command'
|
||||||
import { ChevronsUpDown, Check, X } from 'lucide-react'
|
import { ChevronsUpDown, Check } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Template } from '../hooks/useValidationState'
|
import { Template } from '../hooks/useValidationState'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import React, { useEffect, useCallback } from 'react';
|
|
||||||
import { useValidationState } from '../hooks/useValidationState';
|
|
||||||
import ValidationTable from './ValidationTable';
|
|
||||||
import { Fields, Field } from '../../../types';
|
|
||||||
import { RowData } from '../hooks/useValidationState';
|
|
||||||
|
|
||||||
interface ValidationStepProps<T extends string> {
|
|
||||||
data: RowData<T>[];
|
|
||||||
fields: Fields<T>;
|
|
||||||
onContinue?: (data: RowData<T>[]) => void;
|
|
||||||
onBack?: () => void;
|
|
||||||
initialValidationErrors?: Map<number, Record<string, any>>;
|
|
||||||
initialValidationState?: Map<number, 'pending' | 'validating' | 'validated' | 'error'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ValidationStep = <T extends string>({
|
|
||||||
data,
|
|
||||||
fields,
|
|
||||||
onContinue,
|
|
||||||
onBack,
|
|
||||||
initialValidationErrors,
|
|
||||||
initialValidationState,
|
|
||||||
}: ValidationStepProps<T>) => {
|
|
||||||
const {
|
|
||||||
data: rowData,
|
|
||||||
validationErrors,
|
|
||||||
rowValidationStatus,
|
|
||||||
validateRow,
|
|
||||||
hasErrors,
|
|
||||||
fields: fieldsWithOptions,
|
|
||||||
rowSelection,
|
|
||||||
setRowSelection,
|
|
||||||
updateRow,
|
|
||||||
isValidatingUpc,
|
|
||||||
validatingUpcRows,
|
|
||||||
filters,
|
|
||||||
templates,
|
|
||||||
applyTemplate,
|
|
||||||
getTemplateDisplayText,
|
|
||||||
} = useValidationState({
|
|
||||||
initialData: data,
|
|
||||||
fields,
|
|
||||||
onNext: onContinue,
|
|
||||||
onBack,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<ValidationTable
|
|
||||||
data={rowData}
|
|
||||||
fields={fieldsWithOptions}
|
|
||||||
rowSelection={rowSelection}
|
|
||||||
setRowSelection={setRowSelection}
|
|
||||||
updateRow={updateRow}
|
|
||||||
validationErrors={validationErrors}
|
|
||||||
isValidatingUpc={isValidatingUpc}
|
|
||||||
validatingUpcRows={validatingUpcRows}
|
|
||||||
filters={filters}
|
|
||||||
templates={templates}
|
|
||||||
applyTemplate={applyTemplate}
|
|
||||||
getTemplateDisplayText={getTemplateDisplayText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface InputCellProps<T extends string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InputCell = <T extends string>({
|
const InputCell = <T extends string>({
|
||||||
field,
|
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onStartEdit,
|
onStartEdit,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useCallback, useMemo } from 'react'
|
||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||||
@@ -15,12 +14,6 @@ interface FieldOption {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define extended field type that includes multi-select
|
|
||||||
interface MultiSelectFieldType {
|
|
||||||
type: 'multi-select';
|
|
||||||
options?: readonly FieldOption[];
|
|
||||||
separator?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MultiInputCellProps<T extends string> {
|
interface MultiInputCellProps<T extends string> {
|
||||||
field: Field<T>
|
field: Field<T>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Field } from '../../../../types'
|
import { Field } from '../../../../types'
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getApiUrl, RowData } from './useValidationState';
|
import { getApiUrl, RowData } from './useValidationState';
|
||||||
import { Fields } from '../../../types';
|
import { Fields, InfoWithSource, ErrorSources } from '../../../types';
|
||||||
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations';
|
import { Meta } from '../types';
|
||||||
|
import { addErrorsAndRunHooks } from '../utils/dataMutations';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
|
|
||||||
// Define interfaces for AI validation
|
// Define interfaces for AI validation
|
||||||
@@ -81,6 +82,51 @@ export const useAiValidation = <T extends string>(
|
|||||||
// Track reverted changes
|
// Track reverted changes
|
||||||
const [revertedChanges, setRevertedChanges] = useState<Set<string>>(new Set());
|
const [revertedChanges, setRevertedChanges] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Create an adapter for the rowHook to match the expected RowHook<T> type
|
||||||
|
const adaptedRowHook = rowHook ? async (
|
||||||
|
row: RowData<T> ): Promise<Meta> => {
|
||||||
|
// Call the original hook
|
||||||
|
const result = await rowHook(row);
|
||||||
|
// Extract Meta-specific properties
|
||||||
|
const { __index, __errors } = result;
|
||||||
|
// Return a Meta object with properly typed errors
|
||||||
|
return {
|
||||||
|
__index: __index || row.__index || '',
|
||||||
|
__errors: __errors ?
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(__errors).map(([key, value]) => {
|
||||||
|
const errorArray = Array.isArray(value) ? value : [value];
|
||||||
|
return [key, {
|
||||||
|
message: errorArray[0].message,
|
||||||
|
level: errorArray[0].level,
|
||||||
|
source: ErrorSources.Row
|
||||||
|
} as InfoWithSource]
|
||||||
|
})
|
||||||
|
) : null
|
||||||
|
};
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
// Create an adapter for the tableHook to match the expected TableHook<T> type
|
||||||
|
const adaptedTableHook = tableHook ? async (rows: RowData<T>[]): Promise<Meta[]> => {
|
||||||
|
// Call the original hook
|
||||||
|
const results = await tableHook(rows);
|
||||||
|
// Extract Meta-specific properties from each result
|
||||||
|
return results.map((result, index) => ({
|
||||||
|
__index: result.__index || rows[index].__index || '',
|
||||||
|
__errors: result.__errors ?
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(result.__errors).map(([key, value]) => {
|
||||||
|
const errorArray = Array.isArray(value) ? value : [value];
|
||||||
|
return [key, {
|
||||||
|
message: errorArray[0].message,
|
||||||
|
level: errorArray[0].level,
|
||||||
|
source: ErrorSources.Table
|
||||||
|
} as InfoWithSource]
|
||||||
|
})
|
||||||
|
) : null
|
||||||
|
}));
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
// Get field display value
|
// Get field display value
|
||||||
const getFieldDisplayValue = useCallback((fieldKey: string, value: any): string => {
|
const getFieldDisplayValue = useCallback((fieldKey: string, value: any): string => {
|
||||||
const field = fields.find(f => f.key === fieldKey);
|
const field = fields.find(f => f.key === fieldKey);
|
||||||
@@ -543,8 +589,8 @@ export const useAiValidation = <T extends string>(
|
|||||||
const validatedData = await addErrorsAndRunHooks<T>(
|
const validatedData = await addErrorsAndRunHooks<T>(
|
||||||
processedData,
|
processedData,
|
||||||
fields,
|
fields,
|
||||||
rowHook,
|
adaptedRowHook,
|
||||||
tableHook
|
adaptedTableHook
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the component state with the validated data
|
// Update the component state with the validated data
|
||||||
@@ -620,7 +666,7 @@ export const useAiValidation = <T extends string>(
|
|||||||
elapsedSeconds: prev.elapsedSeconds
|
elapsedSeconds: prev.elapsedSeconds
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, rowHook, tableHook]);
|
}, [isAiValidating, data, aiValidationProgress.estimatedSeconds, aiValidationProgress.promptLength, fields, adaptedRowHook, adaptedTableHook]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAiValidating,
|
isAiValidating,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Data } from '../../../types'
|
|
||||||
import { RowSelectionState } from '@tanstack/react-table'
|
import { RowSelectionState } from '@tanstack/react-table'
|
||||||
import { Template, RowData, getApiUrl } from './useValidationState'
|
import { Template, RowData, getApiUrl } from './useValidationState'
|
||||||
|
|
||||||
@@ -100,7 +99,11 @@ export const useTemplates = <T extends string>(
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
company: template.company,
|
company: template.company,
|
||||||
product_type: type,
|
product_type: type,
|
||||||
...template
|
...Object.fromEntries(
|
||||||
|
Object.entries(template).filter(([key]) =>
|
||||||
|
!['company', 'product_type'].includes(key)
|
||||||
|
)
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { Data, Field, Fields, RowHook, TableHook } from '../../../types'
|
import type { Field, Fields, RowHook, TableHook } from '../../../types'
|
||||||
import type { Meta } from '../../ValidationStep/types'
|
import type { Meta } from '../types'
|
||||||
import { ErrorSources } from '../../../types'
|
import { ErrorSources } from '../../../types'
|
||||||
import { addErrorsAndRunHooks } from '../../ValidationStep/utils/dataMutations'
|
|
||||||
import { RowData } from './useValidationState'
|
import { RowData } from './useValidationState'
|
||||||
|
|
||||||
interface ValidationError {
|
interface ValidationError {
|
||||||
@@ -10,6 +9,12 @@ interface ValidationError {
|
|||||||
level: 'info' | 'warning' | 'error'
|
level: 'info' | 'warning' | 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InfoWithSource {
|
||||||
|
message: string
|
||||||
|
level: 'info' | 'warning' | 'error'
|
||||||
|
source: ErrorSources
|
||||||
|
}
|
||||||
|
|
||||||
export const useValidation = <T extends string>(
|
export const useValidation = <T extends string>(
|
||||||
fields: Fields<T>,
|
fields: Fields<T>,
|
||||||
rowHook?: RowHook<T>,
|
rowHook?: RowHook<T>,
|
||||||
@@ -62,31 +67,31 @@ export const useValidation = <T extends string>(
|
|||||||
|
|
||||||
// Validate a single row
|
// Validate a single row
|
||||||
const validateRow = useCallback(async (
|
const validateRow = useCallback(async (
|
||||||
row: Data<T>,
|
row: RowData<T>,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
allRows: Data<T>[]
|
allRows: RowData<T>[]
|
||||||
): Promise<Meta> => {
|
): Promise<Meta> => {
|
||||||
// Run field-level validations
|
// Run field-level validations
|
||||||
const fieldErrors: Record<string, ValidationError[]> = {}
|
const fieldErrors: Record<string, ValidationError[]> = {}
|
||||||
|
|
||||||
fields.forEach(field => {
|
fields.forEach(field => {
|
||||||
const value = row[field.key]
|
const value = row[String(field.key) as keyof typeof row]
|
||||||
const errors = validateField(value, field)
|
const errors = validateField(value, field as Field<T>)
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
fieldErrors[field.key] = errors
|
fieldErrors[String(field.key)] = errors
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Special validation for supplier and company fields
|
// Special validation for supplier and company fields
|
||||||
if (fields.some(field => field.key === 'supplier' as any) && !row.supplier) {
|
if (fields.some(field => String(field.key) === 'supplier') && !row.supplier) {
|
||||||
fieldErrors['supplier'] = [{
|
fieldErrors['supplier'] = [{
|
||||||
message: 'Supplier is required',
|
message: 'Supplier is required',
|
||||||
level: 'error'
|
level: 'error'
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.some(field => field.key === 'company' as any) && !row.company) {
|
if (fields.some(field => String(field.key) === 'company') && !row.company) {
|
||||||
fieldErrors['company'] = [{
|
fieldErrors['company'] = [{
|
||||||
message: 'Company is required',
|
message: 'Company is required',
|
||||||
level: 'error'
|
level: 'error'
|
||||||
@@ -94,7 +99,10 @@ export const useValidation = <T extends string>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run row hook if provided
|
// Run row hook if provided
|
||||||
let rowHookResult: Meta = { __errors: {} }
|
let rowHookResult: Meta = {
|
||||||
|
__index: row.__index || String(rowIndex),
|
||||||
|
__errors: {}
|
||||||
|
}
|
||||||
if (rowHook) {
|
if (rowHook) {
|
||||||
try {
|
try {
|
||||||
rowHookResult = await rowHook(row, rowIndex, allRows)
|
rowHookResult = await rowHook(row, rowIndex, allRows)
|
||||||
@@ -104,52 +112,82 @@ export const useValidation = <T extends string>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge field errors and row hook errors
|
// Merge field errors and row hook errors
|
||||||
const mergedErrors: Record<string, ValidationError[]> = { ...fieldErrors }
|
const mergedErrors: Record<string, InfoWithSource> = {}
|
||||||
|
|
||||||
|
// Convert field errors to InfoWithSource
|
||||||
|
Object.entries(fieldErrors).forEach(([key, errors]) => {
|
||||||
|
if (errors.length > 0) {
|
||||||
|
mergedErrors[key] = {
|
||||||
|
message: errors[0].message,
|
||||||
|
level: errors[0].level,
|
||||||
|
source: ErrorSources.Row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Merge row hook errors
|
||||||
if (rowHookResult.__errors) {
|
if (rowHookResult.__errors) {
|
||||||
Object.entries(rowHookResult.__errors).forEach(([key, errors]) => {
|
Object.entries(rowHookResult.__errors).forEach(([key, error]) => {
|
||||||
const errorArray = Array.isArray(errors) ? errors : [errors]
|
if (error) {
|
||||||
mergedErrors[key] = [
|
mergedErrors[key] = error
|
||||||
...(mergedErrors[key] || []),
|
}
|
||||||
...errorArray
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
__index: row.__index || String(rowIndex),
|
||||||
__errors: mergedErrors
|
__errors: mergedErrors
|
||||||
}
|
}
|
||||||
}, [fields, validateField, rowHook])
|
}, [fields, validateField, rowHook])
|
||||||
|
|
||||||
// Validate all data at the table level
|
// Validate all data at the table level
|
||||||
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
|
const validateTable = useCallback(async (data: RowData<T>[]): Promise<Meta[]> => {
|
||||||
if (!tableHook) return data.map(() => ({ __errors: {} }))
|
if (!tableHook) {
|
||||||
|
return data.map((row, index) => ({
|
||||||
|
__index: row.__index || String(index),
|
||||||
|
__errors: {}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tableResults = await tableHook(data)
|
const tableResults = await tableHook(data)
|
||||||
|
|
||||||
// Process table validation results
|
// Process table validation results
|
||||||
return tableResults.map(result => {
|
return tableResults.map((result, index) => {
|
||||||
// Ensure errors are properly formatted
|
// Ensure errors are properly formatted
|
||||||
const formattedErrors: Record<string, ValidationError[]> = {}
|
const formattedErrors: Record<string, InfoWithSource> = {}
|
||||||
|
|
||||||
if (result.__errors) {
|
if (result.__errors) {
|
||||||
Object.entries(result.__errors).forEach(([key, errors]) => {
|
Object.entries(result.__errors).forEach(([key, error]) => {
|
||||||
formattedErrors[key] = Array.isArray(errors) ? errors : [errors]
|
if (error) {
|
||||||
|
formattedErrors[key] = {
|
||||||
|
...error,
|
||||||
|
source: ErrorSources.Table
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { __errors: formattedErrors }
|
return {
|
||||||
|
__index: result.__index || data[index].__index || String(index),
|
||||||
|
__errors: formattedErrors
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in table hook:', error)
|
console.error('Error in table hook:', error)
|
||||||
return data.map(() => ({ __errors: {} }))
|
return data.map((row, index) => ({
|
||||||
|
__index: row.__index || String(index),
|
||||||
|
__errors: {}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}, [tableHook])
|
}, [tableHook])
|
||||||
|
|
||||||
// Validate unique fields across the table
|
// Validate unique fields across the table
|
||||||
const validateUnique = useCallback((data: RowData<T>[]) => {
|
const validateUnique = useCallback((data: RowData<T>[]) => {
|
||||||
const uniqueErrors: Meta[] = data.map(() => ({ __errors: {} }))
|
const uniqueErrors: Meta[] = data.map((row, index) => ({
|
||||||
|
__index: row.__index || String(index),
|
||||||
|
__errors: {}
|
||||||
|
}))
|
||||||
|
|
||||||
// Find fields with unique validation
|
// Find fields with unique validation
|
||||||
const uniqueFields = fields.filter(field =>
|
const uniqueFields = fields.filter(field =>
|
||||||
@@ -173,7 +211,7 @@ export const useValidation = <T extends string>(
|
|||||||
|
|
||||||
// Build value map
|
// Build value map
|
||||||
data.forEach((row, rowIndex) => {
|
data.forEach((row, rowIndex) => {
|
||||||
const value = String(row[key] || '')
|
const value = String(row[String(key) as keyof typeof row] || '')
|
||||||
|
|
||||||
// Skip empty values if allowed
|
// Skip empty values if allowed
|
||||||
if (allowEmpty && (value === '' || value === undefined || value === null)) {
|
if (allowEmpty && (value === '' || value === undefined || value === null)) {
|
||||||
@@ -188,20 +226,17 @@ export const useValidation = <T extends string>(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Add errors for duplicate values
|
// Add errors for duplicate values
|
||||||
valueMap.forEach((rowIndexes, value) => {
|
valueMap.forEach((rowIndexes) => {
|
||||||
if (rowIndexes.length > 1) {
|
if (rowIndexes.length > 1) {
|
||||||
// Add error to all duplicate rows
|
// Add error to all duplicate rows
|
||||||
rowIndexes.forEach(rowIndex => {
|
rowIndexes.forEach(rowIndex => {
|
||||||
const rowErrors = uniqueErrors[rowIndex].__errors || {}
|
const rowErrors = uniqueErrors[rowIndex].__errors || {}
|
||||||
|
|
||||||
rowErrors[String(key)] = [
|
rowErrors[String(key)] = {
|
||||||
...(rowErrors[String(key)] || []),
|
|
||||||
{
|
|
||||||
message: errorMessage,
|
message: errorMessage,
|
||||||
level,
|
level,
|
||||||
source: ErrorSources.Table
|
source: ErrorSources.Table
|
||||||
}
|
}
|
||||||
]
|
|
||||||
|
|
||||||
uniqueErrors[rowIndex].__errors = rowErrors
|
uniqueErrors[rowIndex].__errors = rowErrors
|
||||||
})
|
})
|
||||||
@@ -248,10 +283,10 @@ export const useValidation = <T extends string>(
|
|||||||
}, [validateRow, validateUnique, validateTable])
|
}, [validateRow, validateUnique, validateTable])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
validateData,
|
||||||
validateField,
|
validateField,
|
||||||
validateRow,
|
validateRow,
|
||||||
validateTable,
|
validateTable,
|
||||||
validateUnique,
|
validateUnique
|
||||||
validateData
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import React from 'react'
|
|
||||||
import ValidationContainer from './components/ValidationContainer'
|
import ValidationContainer from './components/ValidationContainer'
|
||||||
import { Props } from './hooks/useValidationState'
|
import { Props } from './hooks/useValidationState'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { InfoWithSource } from "../../types"
|
||||||
|
|
||||||
|
export type Meta = { __index: string; __errors?: Error | null }
|
||||||
|
export type Error = { [key: string]: InfoWithSource }
|
||||||
|
export type Errors = { [id: string]: Error }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { InfoWithSource, ErrorLevel } from "../../types"
|
import { InfoWithSource, ErrorLevel } from "../../../types"
|
||||||
|
|
||||||
// Define our own Error type that's compatible with the original
|
// Define our own Error type that's compatible with the original
|
||||||
export interface ErrorType {
|
export interface ErrorType {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
|
||||||
|
import type { Meta, Error, Errors } from "../types"
|
||||||
|
import { v4 } from "uuid"
|
||||||
|
import { ErrorSources } from "../../../types"
|
||||||
|
|
||||||
|
|
||||||
|
type DataWithMeta<T extends string> = Data<T> & Meta & {
|
||||||
|
__index?: string;
|
||||||
|
__errors?: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addErrorsAndRunHooks = async <T extends string>(
|
||||||
|
data: (Data<T> & Partial<Meta>)[],
|
||||||
|
fields: Fields<T>,
|
||||||
|
rowHook?: RowHook<T>,
|
||||||
|
tableHook?: TableHook<T>,
|
||||||
|
changedRowIndexes?: number[],
|
||||||
|
): Promise<DataWithMeta<T>[]> => {
|
||||||
|
const errors: Errors = {}
|
||||||
|
|
||||||
|
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info) => {
|
||||||
|
errors[rowIndex] = {
|
||||||
|
...errors[rowIndex],
|
||||||
|
[fieldKey]: { ...error, source },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedData = [...data] as DataWithMeta<T>[]
|
||||||
|
|
||||||
|
if (tableHook) {
|
||||||
|
const tableResults = await tableHook(processedData)
|
||||||
|
processedData = tableResults.map((result, index) => ({
|
||||||
|
...processedData[index],
|
||||||
|
...result
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowHook) {
|
||||||
|
if (changedRowIndexes) {
|
||||||
|
for (const index of changedRowIndexes) {
|
||||||
|
const rowResult = await rowHook(processedData[index], index, processedData)
|
||||||
|
processedData[index] = {
|
||||||
|
...processedData[index],
|
||||||
|
...rowResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const rowResults = await Promise.all(
|
||||||
|
processedData.map(async (value, index) => {
|
||||||
|
const result = await rowHook(value, index, processedData)
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
...result
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
processedData = rowResults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const fieldKey = field.key as string
|
||||||
|
field.validations?.forEach((validation) => {
|
||||||
|
switch (validation.rule) {
|
||||||
|
case "unique": {
|
||||||
|
const values = processedData.map((entry) => {
|
||||||
|
const value = entry[fieldKey as keyof typeof entry]
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
|
||||||
|
const taken = new Set() // Set of items used at least once
|
||||||
|
const duplicates = new Set() // Set of items used multiple times
|
||||||
|
|
||||||
|
values.forEach((value) => {
|
||||||
|
if (validation.allowEmpty && !value) {
|
||||||
|
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taken.has(value)) {
|
||||||
|
duplicates.add(value)
|
||||||
|
} else {
|
||||||
|
taken.add(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (duplicates.has(value)) {
|
||||||
|
addError(ErrorSources.Table, index, fieldKey, {
|
||||||
|
level: validation.level || "error",
|
||||||
|
message: validation.errorMessage || "Field must be unique",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "required": {
|
||||||
|
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
|
||||||
|
dataToValidate.forEach((entry, index) => {
|
||||||
|
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
||||||
|
const value = entry[fieldKey as keyof typeof entry]
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
addError(ErrorSources.Row, realIndex, fieldKey, {
|
||||||
|
level: validation.level || "error",
|
||||||
|
message: validation.errorMessage || "Field is required",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "regex": {
|
||||||
|
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
|
||||||
|
const regex = new RegExp(validation.value, validation.flags)
|
||||||
|
dataToValidate.forEach((entry, index) => {
|
||||||
|
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
|
||||||
|
const value = entry[fieldKey as keyof typeof entry]
|
||||||
|
const stringValue = value?.toString() ?? ""
|
||||||
|
if (!stringValue.match(regex)) {
|
||||||
|
addError(ErrorSources.Row, realIndex, fieldKey, {
|
||||||
|
level: validation.level || "error",
|
||||||
|
message:
|
||||||
|
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return processedData.map((value, index) => {
|
||||||
|
// This is required only for table. Mutates to prevent needless rerenders
|
||||||
|
const result: DataWithMeta<T> = { ...value }
|
||||||
|
if (!result.__index) {
|
||||||
|
result.__index = v4()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are validating all indexes, or we did full validation on this row - apply all errors
|
||||||
|
if (!changedRowIndexes || changedRowIndexes.includes(index)) {
|
||||||
|
if (errors[index]) {
|
||||||
|
return { ...result, __errors: errors[index] }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors[index] && result.__errors) {
|
||||||
|
return { ...result, __errors: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if we have not validated this row, keep it's row errors but apply global error changes
|
||||||
|
else {
|
||||||
|
// at this point errors[index] contains only table source errors, previous row and table errors are in value.__errors
|
||||||
|
const hasRowErrors =
|
||||||
|
result.__errors && Object.values(result.__errors).some((error) => error.source === ErrorSources.Row)
|
||||||
|
|
||||||
|
if (!hasRowErrors) {
|
||||||
|
if (errors[index]) {
|
||||||
|
return { ...result, __errors: errors[index] }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorsWithoutTableError = Object.entries(result.__errors!).reduce((acc, [key, value]) => {
|
||||||
|
if (value.source === ErrorSources.Row) {
|
||||||
|
acc[key] = value
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as Error)
|
||||||
|
|
||||||
|
const newErrors = { ...errorsWithoutTableError, ...errors[index] }
|
||||||
|
|
||||||
|
return { ...result, __errors: newErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { InfoWithSource } from '../../../types'
|
import { ErrorType } from '../types/index'
|
||||||
import { ErrorType, ErrorTypes } from '../types/index'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts an InfoWithSource or similar error object to our Error type
|
* Converts an InfoWithSource or similar error object to our Error type
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Field, Data, ErrorSources } from '../../../types'
|
import { Field, Data, ErrorSources } from '../../../types'
|
||||||
import { ErrorType, ErrorTypes } from '../types/index'
|
import { ErrorType } from '../types/index'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a price value to a consistent format
|
* Formats a price value to a consistent format
|
||||||
@@ -29,7 +29,10 @@ export const formatPrice = (value: string | number): string => {
|
|||||||
* @returns True if the field is a price field
|
* @returns True if the field is a price field
|
||||||
*/
|
*/
|
||||||
export const isPriceField = (field: Field<any>): boolean => {
|
export const isPriceField = (field: Field<any>): boolean => {
|
||||||
return !!field.fieldType.price
|
const fieldType = field.fieldType;
|
||||||
|
return (fieldType.type === 'input' || fieldType.type === 'multi-input') &&
|
||||||
|
'price' in fieldType &&
|
||||||
|
!!fieldType.price;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Meta } from "./steps/ValidationStep/types"
|
import type { Meta } from "./steps/ValidationStepNew/types"
|
||||||
import type { DeepReadonly } from "ts-essentials"
|
import type { DeepReadonly } from "ts-essentials"
|
||||||
import type { TranslationsRSIProps } from "./translationsRSIProps"
|
import type { TranslationsRSIProps } from "./translationsRSIProps"
|
||||||
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
|
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
|
||||||
@@ -53,9 +53,9 @@ export type RsiProps<T extends string> = {
|
|||||||
|
|
||||||
export type RawData = (string | undefined)[]
|
export type RawData = (string | undefined)[]
|
||||||
|
|
||||||
export type Data<T extends string> = {
|
export type DataValue = string | boolean | string[] | undefined
|
||||||
[key in T]?: string | boolean | undefined
|
|
||||||
} & {
|
export type Data<T extends string> = Partial<Record<T, DataValue>> & {
|
||||||
supplier?: string
|
supplier?: string
|
||||||
company?: string
|
company?: string
|
||||||
line?: string
|
line?: string
|
||||||
|
|||||||
100
inventory/src/types/react-data-grid.d.ts
vendored
Normal file
100
inventory/src/types/react-data-grid.d.ts
vendored
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
declare module 'react-data-grid' {
|
||||||
|
export interface FormatterProps<TRow = any, TSummaryRow = unknown> {
|
||||||
|
row: TRow;
|
||||||
|
column: Column<TRow, TSummaryRow>;
|
||||||
|
isCellSelected: boolean;
|
||||||
|
onRowChange: (row: TRow) => void;
|
||||||
|
rowIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Column<TRow, TSummaryRow = unknown> {
|
||||||
|
/** The name of the column. By default it will be displayed in the header cell */
|
||||||
|
name: string | JSX.Element;
|
||||||
|
/** A unique key to distinguish each column */
|
||||||
|
key: string;
|
||||||
|
/** Column width. If not specified, it will be determined automatically based on grid width and specified widths of other columns */
|
||||||
|
width?: number | string;
|
||||||
|
/** Minimum column width in px. */
|
||||||
|
minWidth?: number;
|
||||||
|
/** Maximum column width in px. */
|
||||||
|
maxWidth?: number;
|
||||||
|
cellClass?: string | ((row: TRow) => string);
|
||||||
|
headerCellClass?: string;
|
||||||
|
summaryCellClass?: string | ((row: TSummaryRow) => string);
|
||||||
|
/** Formatter to be used to render the cell content */
|
||||||
|
formatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
|
||||||
|
/** Formatter to be used to render the summary cell content */
|
||||||
|
summaryFormatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
|
||||||
|
/** Formatter to be used to render the group cell content */
|
||||||
|
groupFormatter?: React.ComponentType<FormatterProps<TRow, TSummaryRow>>;
|
||||||
|
/** Enables cell editing. If set and no editor property specified, then a textinput will be used as the cell editor */
|
||||||
|
editable?: boolean | ((row: TRow) => boolean);
|
||||||
|
/** Enable filtering for this column */
|
||||||
|
filterable?: boolean;
|
||||||
|
/** Enable sorting for this column */
|
||||||
|
sortable?: boolean;
|
||||||
|
/** Sets the column sort order to be descending instead of ascending the first time the column is sorted */
|
||||||
|
sortDescendingFirst?: boolean;
|
||||||
|
/** Editor to be rendered when cell of column is being edited. If set, then the column is automatically set to be editable */
|
||||||
|
editor?: React.ComponentType<any>;
|
||||||
|
/** Header renderer for each header cell */
|
||||||
|
headerRenderer?: React.ComponentType<any>;
|
||||||
|
/** Component to be used to filter the data of the column */
|
||||||
|
filterRenderer?: React.ComponentType<any>;
|
||||||
|
/** Whether the column can be resized */
|
||||||
|
resizable?: boolean;
|
||||||
|
/** Whether the column is frozen (fixed position) */
|
||||||
|
frozen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectRowEvent<TRow> {
|
||||||
|
row: TRow;
|
||||||
|
checked: boolean;
|
||||||
|
isShiftClick: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowSelectionHook {
|
||||||
|
(): [boolean, (selectRowEvent: SelectRowEvent<any>) => void];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRowSelection: RowSelectionHook;
|
||||||
|
|
||||||
|
export interface DataGridProps<TRow, TSummaryRow = unknown> {
|
||||||
|
/** Grid columns. */
|
||||||
|
columns: readonly Column<TRow, TSummaryRow>[];
|
||||||
|
/** An array of objects representing each row */
|
||||||
|
rows: readonly TRow[];
|
||||||
|
/** Rows to be pinned at the top of the rows view for summary */
|
||||||
|
summaryRows?: readonly TSummaryRow[];
|
||||||
|
/** Used to uniquely identify each row */
|
||||||
|
rowKeyGetter?: (row: TRow) => React.Key;
|
||||||
|
onRowsChange?: (rows: TRow[], data: any) => void;
|
||||||
|
/** Function called whenever row data is updated */
|
||||||
|
onRowChange?: (row: TRow, data: any) => void;
|
||||||
|
/** Called when the grid is scrolled */
|
||||||
|
onScroll?: (event: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
/** Number of rows to render at a time */
|
||||||
|
rowHeight?: number;
|
||||||
|
/** Header row height */
|
||||||
|
headerRowHeight?: number;
|
||||||
|
/** Summary row height */
|
||||||
|
summaryRowHeight?: number;
|
||||||
|
/** Set of selected row keys */
|
||||||
|
selectedRows?: ReadonlySet<React.Key>;
|
||||||
|
/** Function called whenever row selection is changed */
|
||||||
|
onSelectedRowsChange?: (selectedRows: Set<React.Key>) => void;
|
||||||
|
/** Used to specify the height of the grid */
|
||||||
|
height?: number;
|
||||||
|
/** Used to specify the width of the grid */
|
||||||
|
width?: number;
|
||||||
|
/** Toggles whether cells should be autofocused when navigated to via keyboard */
|
||||||
|
enableCellAutoFocus?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
/** Direction of the grid */
|
||||||
|
direction?: 'ltr' | 'rtl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataGrid: React.FC<DataGridProps<any, any>>;
|
||||||
|
export default DataGrid;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user