13 Commits

68 changed files with 32329 additions and 775 deletions

View File

@@ -0,0 +1,270 @@
const express = require('express');
const router = express.Router();
const { Client } = require('ssh2');
const mysql = require('mysql2/promise');
// Helper function to setup SSH tunnel
async function setupSshTunnel() {
const sshConfig = {
host: process.env.PROD_SSH_HOST,
port: process.env.PROD_SSH_PORT || 22,
username: process.env.PROD_SSH_USER,
privateKey: process.env.PROD_SSH_KEY_PATH
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
: undefined,
compress: true
};
const dbConfig = {
host: process.env.PROD_DB_HOST || 'localhost',
user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z'
};
return new Promise((resolve, reject) => {
const ssh = new Client();
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
reject(err);
});
ssh.on('ready', () => {
ssh.forwardOut(
'127.0.0.1',
0,
dbConfig.host,
dbConfig.port,
(err, stream) => {
if (err) reject(err);
resolve({ ssh, stream, dbConfig });
}
);
}).connect(sshConfig);
});
}
// Get all options for import fields
router.get('/field-options', async (req, res) => {
let ssh;
let connection;
try {
// Setup SSH tunnel and get database connection
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
// Create MySQL connection over SSH tunnel
connection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
// Fetch companies (type 1)
const [companies] = await connection.query(`
SELECT cat_id, name
FROM product_categories
WHERE type = 1
ORDER BY name
`);
// Fetch artists (type 40)
const [artists] = await connection.query(`
SELECT cat_id, name
FROM product_categories
WHERE type = 40
ORDER BY name
`);
// Fetch sizes (type 50)
const [sizes] = await connection.query(`
SELECT cat_id, name
FROM product_categories
WHERE type = 50
ORDER BY name
`);
// Fetch themes with subthemes
const [themes] = await connection.query(`
SELECT t.cat_id, t.name AS display_name, t.type, t.name AS sort_theme,
'' AS sort_subtheme, 1 AS level_order
FROM product_categories t
WHERE t.type = 20
UNION ALL
SELECT ts.cat_id, CONCAT(t.name,' - ',ts.name) AS display_name, ts.type,
t.name AS sort_theme, ts.name AS sort_subtheme, 2 AS level_order
FROM product_categories ts
JOIN product_categories t ON ts.master_cat_id = t.cat_id
WHERE ts.type = 21 AND t.type = 20
ORDER BY sort_theme, sort_subtheme
`);
// Fetch categories with all levels
const [categories] = await connection.query(`
SELECT s.cat_id, s.name AS display_name, s.type, s.name AS sort_section,
'' AS sort_category, '' AS sort_subcategory, '' AS sort_subsubcategory,
1 AS level_order
FROM product_categories s
WHERE s.type = 10
UNION ALL
SELECT c.cat_id, CONCAT(s.name,' - ',c.name) AS display_name, c.type,
s.name AS sort_section, c.name AS sort_category, '' AS sort_subcategory,
'' AS sort_subsubcategory, 2 AS level_order
FROM product_categories c
JOIN product_categories s ON c.master_cat_id = s.cat_id
WHERE c.type = 11 AND s.type = 10
UNION ALL
SELECT sc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name) AS display_name,
sc.type, s.name AS sort_section, c.name AS sort_category,
sc.name AS sort_subcategory, '' AS sort_subsubcategory, 3 AS level_order
FROM product_categories sc
JOIN product_categories c ON sc.master_cat_id = c.cat_id
JOIN product_categories s ON c.master_cat_id = s.cat_id
WHERE sc.type = 12 AND c.type = 11 AND s.type = 10
UNION ALL
SELECT ssc.cat_id, CONCAT(s.name,' - ',c.name,' - ',sc.name,' - ',ssc.name) AS display_name,
ssc.type, s.name AS sort_section, c.name AS sort_category,
sc.name AS sort_subcategory, ssc.name AS sort_subsubcategory, 4 AS level_order
FROM product_categories ssc
JOIN product_categories sc ON ssc.master_cat_id = sc.cat_id
JOIN product_categories c ON sc.master_cat_id = c.cat_id
JOIN product_categories s ON c.master_cat_id = s.cat_id
WHERE ssc.type = 13 AND sc.type = 12 AND c.type = 11 AND s.type = 10
ORDER BY sort_section, sort_category, sort_subcategory, sort_subsubcategory
`);
// Fetch colors
const [colors] = await connection.query(`
SELECT color, name, hex_color
FROM product_color_list
ORDER BY \`order\`
`);
// Fetch suppliers
const [suppliers] = await connection.query(`
SELECT supplierid as value, companyname as label
FROM suppliers
WHERE companyname <> ''
ORDER BY companyname
`);
// Fetch tax categories
const [taxCategories] = await connection.query(`
SELECT tax_code_id as value, name as label
FROM product_tax_codes
ORDER BY tax_code_id = 0 DESC, name
`);
res.json({
companies: companies.map(c => ({ label: c.name, value: c.cat_id.toString() })),
artists: artists.map(a => ({ label: a.name, value: a.cat_id.toString() })),
sizes: sizes.map(s => ({ label: s.name, value: s.cat_id.toString() })),
themes: themes.map(t => ({
label: t.display_name,
value: t.cat_id.toString(),
type: t.type,
level: t.level_order
})),
categories: categories.map(c => ({
label: c.display_name,
value: c.cat_id.toString(),
type: c.type,
level: c.level_order
})),
colors: colors.map(c => ({
label: c.name,
value: c.color,
hexColor: c.hex_color
})),
suppliers: suppliers,
taxCategories: taxCategories,
shippingRestrictions: [
{ label: "None", value: "0" },
{ label: "US Only", value: "1" },
{ label: "Limited Quantity", value: "2" },
{ label: "US/CA Only", value: "3" },
{ label: "No FedEx 2 Day", value: "4" },
{ label: "North America Only", value: "5" }
]
});
} catch (error) {
console.error('Error fetching import field options:', error);
res.status(500).json({ error: 'Failed to fetch import field options' });
} finally {
if (connection) await connection.end();
if (ssh) ssh.end();
}
});
// Get product lines for a specific company
router.get('/product-lines/:companyId', async (req, res) => {
let ssh;
let connection;
try {
// Setup SSH tunnel and get database connection
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
// Create MySQL connection over SSH tunnel
connection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
const [lines] = await connection.query(`
SELECT cat_id as value, name as label
FROM product_categories
WHERE type = 2
AND master_cat_id = ?
ORDER BY name
`, [req.params.companyId]);
res.json(lines.map(l => ({ label: l.label, value: l.value.toString() })));
} catch (error) {
console.error('Error fetching product lines:', error);
res.status(500).json({ error: 'Failed to fetch product lines' });
} finally {
if (connection) await connection.end();
if (ssh) ssh.end();
}
});
// Get sublines for a specific product line
router.get('/sublines/:lineId', async (req, res) => {
let ssh;
let connection;
try {
// Setup SSH tunnel and get database connection
const tunnel = await setupSshTunnel();
ssh = tunnel.ssh;
// Create MySQL connection over SSH tunnel
connection = await mysql.createConnection({
...tunnel.dbConfig,
stream: tunnel.stream
});
const [sublines] = await connection.query(`
SELECT cat_id as value, name as label
FROM product_categories
WHERE type = 3
AND master_cat_id = ?
ORDER BY name
`, [req.params.lineId]);
res.json(sublines.map(s => ({ label: s.label, value: s.value.toString() })));
} catch (error) {
console.error('Error fetching sublines:', error);
res.status(500).json({ error: 'Failed to fetch sublines' });
} finally {
if (connection) await connection.end();
if (ssh) ssh.end();
}
});
module.exports = router;

View File

@@ -17,6 +17,7 @@ const metricsRouter = require('./routes/metrics');
const vendorsRouter = require('./routes/vendors');
const categoriesRouter = require('./routes/categories');
const testConnectionRouter = require('./routes/test-connection');
const importRouter = require('./routes/import');
// Get the absolute path to the .env file
const envPath = path.resolve(process.cwd(), '.env');
@@ -65,58 +66,68 @@ app.use(corsMiddleware);
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Initialize database pool
const pool = initPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
// Initialize database pool and start server
async function startServer() {
try {
// Initialize database pool
const pool = await initPool({
waitForConnections: true,
connectionLimit: process.env.NODE_ENV === 'production' ? 20 : 10,
queueLimit: 0,
enableKeepAlive: true,
keepAliveInitialDelay: 0
});
// Make pool available to routes
app.locals.pool = pool;
// Make pool available to routes
app.locals.pool = pool;
// Routes
app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/csv', csvRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/purchase-orders', purchaseOrdersRouter);
app.use('/api/config', configRouter);
app.use('/api/metrics', metricsRouter);
app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api', testConnectionRouter);
// Set up routes after pool is initialized
app.use('/api/products', productsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/orders', ordersRouter);
app.use('/api/csv', csvRouter);
app.use('/api/analytics', analyticsRouter);
app.use('/api/purchase-orders', purchaseOrdersRouter);
app.use('/api/config', configRouter);
app.use('/api/metrics', metricsRouter);
app.use('/api/vendors', vendorsRouter);
app.use('/api/categories', categoriesRouter);
app.use('/api/import', importRouter);
app.use('/api', testConnectionRouter);
// Basic health check route
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV
});
});
// Basic health check route
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV
});
});
// CORS error handler - must be before other error handlers
app.use(corsErrorHandler);
// CORS error handler - must be before other error handlers
app.use(corsErrorHandler);
// Error handling middleware - MUST be after routes and CORS error handler
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] Error:`, err);
// Error handling middleware - MUST be after routes and CORS error handler
app.use((err, req, res, next) => {
console.error(`[${new Date().toISOString()}] Error:`, err);
// Send detailed error in development, generic in production
const error = process.env.NODE_ENV === 'production'
? 'An internal server error occurred'
: err.message || err;
// Send detailed error in development, generic in production
const error = process.env.NODE_ENV === 'production'
? 'An internal server error occurred'
: err.message || err;
res.status(err.status || 500).json({ error });
});
res.status(err.status || 500).json({ error });
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Handle uncaught exceptions
process.on('uncaughtException', (err) => {
@@ -128,17 +139,6 @@ process.on('unhandledRejection', (reason, promise) => {
console.error(`[${new Date().toISOString()}] Unhandled Rejection at:`, promise, 'reason:', reason);
});
// Test database connection
pool.getConnection()
.then(connection => {
console.log('[Database] Connected successfully');
connection.release();
})
.catch(err => {
console.error('[Database] Error connecting:', err);
process.exit(1);
});
// Initialize client sets for SSE
const importClients = new Set();
const updateClients = new Set();
@@ -189,62 +189,5 @@ const setupSSE = (req, res) => {
}
};
// Update the status endpoint to include reset-metrics
app.get('/csv/status', (req, res) => {
res.json({
active: !!currentOperation,
type: currentOperation?.type || null,
progress: currentOperation ? {
status: currentOperation.status,
operation: currentOperation.operation,
current: currentOperation.current,
total: currentOperation.total,
percentage: currentOperation.percentage
} : null
});
});
// Update progress endpoint mapping
app.get('/csv/:type/progress', (req, res) => {
const { type } = req.params;
if (!['import', 'update', 'reset', 'reset-metrics'].includes(type)) {
res.status(400).json({ error: 'Invalid operation type' });
return;
}
setupSSE(req, res);
});
// Update the cancel endpoint to handle reset-metrics
app.post('/csv/cancel', (req, res) => {
const { operation } = req.query;
if (!currentOperation) {
res.status(400).json({ error: 'No operation in progress' });
return;
}
if (operation && operation.toLowerCase() !== currentOperation.type) {
res.status(400).json({ error: 'Operation type mismatch' });
return;
}
try {
// Handle cancellation based on operation type
if (currentOperation.type === 'reset-metrics') {
// Reset metrics doesn't need special cleanup
currentOperation = null;
res.json({ message: 'Reset metrics cancelled' });
} else {
// ... existing cancellation logic for other operations ...
}
} catch (error) {
console.error('Error during cancellation:', error);
res.status(500).json({ error: 'Failed to cancel operation' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});
// Start the server
startServer();

View File

@@ -1,10 +1,66 @@
const mysql = require('mysql2/promise');
const { Client } = require('ssh2');
let pool;
function initPool(config) {
pool = mysql.createPool(config);
return pool;
async function setupSshTunnel() {
const sshConfig = {
host: process.env.PROD_SSH_HOST,
port: process.env.PROD_SSH_PORT || 22,
username: process.env.PROD_SSH_USER,
privateKey: process.env.PROD_SSH_KEY_PATH
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
: undefined,
compress: true
};
return new Promise((resolve, reject) => {
const ssh = new Client();
ssh.on('error', (err) => {
console.error('SSH connection error:', err);
reject(err);
});
ssh.on('ready', () => {
ssh.forwardOut(
'127.0.0.1',
0,
process.env.PROD_DB_HOST || 'localhost',
process.env.PROD_DB_PORT || 3306,
(err, stream) => {
if (err) reject(err);
resolve({ ssh, stream });
}
);
}).connect(sshConfig);
});
}
async function initPool(config) {
try {
const tunnel = await setupSshTunnel();
pool = mysql.createPool({
...config,
stream: tunnel.stream,
host: process.env.PROD_DB_HOST || 'localhost',
user: process.env.PROD_DB_USER,
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306
});
// Test the connection
const connection = await pool.getConnection();
console.log('[Database] Connected successfully through SSH tunnel');
connection.release();
return pool;
} catch (error) {
console.error('[Database] Error initializing pool:', error);
throw error;
}
}
async function getConnection() {

File diff suppressed because it is too large Load Diff

View File

@@ -10,58 +10,90 @@
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/button": "^2.1.0",
"@chakra-ui/checkbox": "^2.3.2",
"@chakra-ui/form-control": "^2.2.0",
"@chakra-ui/hooks": "^2.4.3",
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/input": "^2.1.2",
"@chakra-ui/layout": "^2.3.1",
"@chakra-ui/modal": "^2.3.1",
"@chakra-ui/popper": "^3.1.0",
"@chakra-ui/react": "^2.8.1",
"@chakra-ui/select": "^2.1.2",
"@chakra-ui/system": "^2.6.2",
"@chakra-ui/theme": "^3.4.7",
"@chakra-ui/theme-tools": "^2.2.7",
"@chakra-ui/utils": "^2.2.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.1",
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query": "^5.66.7",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2",
"@types/js-levenshtein": "^1.1.3",
"@types/uuid": "^10.0.0",
"chakra-react-select": "^4.7.5",
"chakra-ui-steps": "^2.0.4",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^12.4.4",
"js-levenshtein": "^1.1.6",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"motion": "^11.18.0",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tanstack": "^1.0.0",
"vaul": "^1.1.2"
"uuid": "^11.0.5",
"vaul": "^1.1.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14",
"@types/lodash": "^4.17.15",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View File

@@ -15,6 +15,8 @@ import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/import/Import';
import { ChakraProvider } from '@chakra-ui/react';
const queryClient = new QueryClient();
@@ -50,26 +52,29 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
<Route element={
<RequireAuth>
<MainLayout />
</RequireAuth>
}>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
<Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
<ChakraProvider>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
<Route element={
<RequireAuth>
<MainLayout />
</RequireAuth>
}>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} />
<Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
<Route path="/purchase-orders" element={<PurchaseOrders />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/forecasting" element={<Forecasting />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ChakraProvider>
</QueryClientProvider>
);
}

View File

@@ -8,6 +8,7 @@ import {
LogOut,
Users,
Tags,
FileSpreadsheet,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -35,6 +36,11 @@ const items = [
icon: Package,
url: "/products",
},
{
title: "Import",
icon: FileSpreadsheet,
url: "/import",
},
{
title: "Forecasting",
icon: IconCrystalBall,

View File

@@ -27,7 +27,7 @@ import {
import { Loader2, X, RefreshCw, AlertTriangle, RefreshCcw, Hourglass } from "lucide-react";
import config from "../../config";
import { toast } from "sonner";
import { Table, TableBody, TableCell, TableRow, TableHeader, TableHead } from "@/components/ui/table";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
interface ImportProgress {
status: "running" | "error" | "complete" | "cancelled";

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<div
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
@@ -73,4 +76,16 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("overflow-auto", className)}
{...props}
/>
))
ScrollArea.displayName = "ScrollArea"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, ScrollArea }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface CodeProps extends React.HTMLAttributes<HTMLPreElement> {}
const Code = React.forwardRef<HTMLPreElement, CodeProps>(
({ className, ...props }, ref) => {
return (
<pre
ref={ref}
className={cn(
"rounded-lg bg-muted px-4 py-4 font-mono text-sm",
className
)}
{...props}
/>
)
}
)
Code.displayName = "Code"
export { Code }

View File

@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 UGNIS,
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,341 @@
<h1 align="center">RSI react-spreadsheet-import ⚡️</h1>
<div align="center">
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/UgnisSoftware/react-spreadsheet-import/test.yaml)
![GitHub](https://img.shields.io/github/license/UgnisSoftware/react-spreadsheet-import) [![npm](https://img.shields.io/npm/v/react-spreadsheet-import)](https://www.npmjs.com/package/react-spreadsheet-import)
</div>
<br />
A component used for importing XLS / XLSX / CSV documents built with [**Chakra UI**](https://chakra-ui.com). Import flow combines:
- 📥 Uploader
- ⚙️ Parser
- 📊 File preview
- 🧪 UI for column mapping
- ✏ UI for validating and editing data
✨ [**Demo**](https://ugnissoftware.github.io/react-spreadsheet-import/iframe.html?id=react-spreadsheet-import--basic&args=&viewMode=story) ✨
<br />
## Features
- Custom styles - edit Chakra UI theme to match your project's styles 🎨
- Custom validation rules - make sure valid data is being imported, easily spot and correct errors
- Hooks - alter raw data after upload or make adjustments on data changes
- Auto-mapping columns - automatically map most likely value to your template values, e.g. `name` -> `firstName`
<br />
![rsi-preview](https://user-images.githubusercontent.com/45755753/159503528-90aacb69-128f-4ece-b45b-ab97d403a9d3.gif)
## Figma
We provide full figma designs. You can copy the designs
[here](https://www.figma.com/community/file/1080776795891439629)
## Getting started
```sh
npm i react-spreadsheet-import
```
Using the component: (it's up to you when the flow is open and what you do on submit with the imported data)
```tsx
import { ReactSpreadsheetImport } from "react-spreadsheet-import";
<ReactSpreadsheetImport isOpen={isOpen} onClose={onClose} onSubmit={onSubmit} fields={fields} />
```
## Required Props
```tsx
// Determines if modal is visible.
isOpen: Boolean
// Called when flow is closed without reaching submit.
onClose: () => void
// Called after user completes the flow. Provides data array, where data keys matches your field keys.
onSubmit: (data, file) => void | Promise<any>
```
### Fields
Fields describe what data you are trying to collect.
```tsx
const fields = [
{
// Visible in table header and when matching columns.
label: "Name",
// This is the key used for this field when we call onSubmit.
key: "name",
// Allows for better automatic column matching. Optional.
alternateMatches: ["first name", "first"],
// Used when editing and validating information.
fieldType: {
// There are 3 types - "input" / "checkbox" / "select".
type: "input",
},
// Used in the first step to provide an example of what data is expected in this field. Optional.
example: "Stephanie",
// Can have multiple validations that are visible in Validation Step table.
validations: [
{
// Can be "required" / "unique" / "regex"
rule: "required",
errorMessage: "Name is required",
// There can be "info" / "warning" / "error" levels. Optional. Default "error".
level: "error",
},
],
},
] as const
```
## Optional Props
### Hooks
You can transform and validate data with custom hooks. There are hooks after each step:
- **uploadStepHook** - runs only once after uploading the file.
- **selectHeaderStepHook** - runs only once after selecting the header row in spreadsheet.
- **matchColumnsStepHook** - runs only once after column matching. Operations on data that are expensive should be done here.
The last step - validation step has 2 unique hooks that run only in that step with different performance tradeoffs:
- **tableHook** - runs at the start and on any change. Runs on all rows. Very expensive, but can change rows that depend on other rows.
- **rowHook** - runs at the start and on any row change. Runs only on the rows changed. Fastest, most validations and transformations should be done here.
Example:
```tsx
<ReactSpreadsheetImport
rowHook={(data, addError) => {
// Validation
if (data.name === "John") {
addError("name", { message: "No Johns allowed", level: "info" })
}
// Transformation
return { ...data, name: "Not John" }
// Sorry John
}}
/>
```
### Initial state
In rare case when you need to skip the beginning of the flow, you can start the flow from any of the steps.
- **initialStepState** - initial state of component that will be rendered on load.
```tsx
initialStepState?: StepState
type StepState =
| {
type: StepType.upload
}
| {
type: StepType.selectSheet
workbook: XLSX.WorkBook
}
| {
type: StepType.selectHeader
data: RawData[]
}
| {
type: StepType.matchColumns
data: RawData[]
headerValues: RawData
}
| {
type: StepType.validateData
data: any[]
}
type RawData = Array<string | undefined>
// XLSX.workbook type is native to SheetJS and can be viewed here: https://github.com/SheetJS/sheetjs/blob/83ddb4c1203f6bac052d8c1608b32fead02ea32f/types/index.d.ts#L269
```
Example:
```tsx
import { ReactSpreadsheetImport, StepType } from "react-spreadsheet-import";
<ReactSpreadsheetImport
initialStepState={{
type: StepType.matchColumns,
data: [
["Josh", "2"],
["Charlie", "3"],
["Lena", "50"],
],
headerValues: ["name", "age"],
}}
/>
```
### Dates and time
Excel stores dates and times as numbers - offsets from an epoch. When reading xlsx files SheetJS provides date formatting helpers.
**Default date import format** is `yyyy-mm-dd`. Date parsing with SheetJS sometimes yields unexpected results, therefore thorough date validations are recommended.
- **dateFormat** - sets SheetJS `dateNF` option. Can be used to format dates when importing sheet data.
- **parseRaw** - sets SheetJS `raw` option. If `true`, date formatting will be applied to XLSX date fields only. Default is `true`
Common date-time formats can be viewed [here](https://docs.sheetjs.com/docs/csf/features/dates/#date-and-time-number-formats).
### Other optional props
```tsx
// Allows submitting with errors. Default: true
allowInvalidSubmit?: boolean
// Translations for each text. See customisation bellow
translations?: object
// Theme configuration passed to underlying Chakra-UI. See customisation bellow
customTheme?: object
// Specifies maximum number of rows for a single import
maxRecords?: number
// Maximum upload filesize (in bytes)
maxFileSize?: number
// Automatically map imported headers to specified fields if possible. Default: true
autoMapHeaders?: boolean
// When field type is "select", automatically match values if possible. Default: false
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching. Default: 2
autoMapDistance?: number
// Enable navigation in stepper component and show back button. Default: false
isNavigationEnabled?: boolean
```
## Customisation
### Customising styles (colors, fonts)
You can see default theme we use [here](https://github.com/UgnisSoftware/react-spreadsheet-import/blob/master/src/theme.ts). Your override should match this object's structure.
There are 3 ways you can style the component:
1.) Change theme colors globally
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
colors: {
background: 'white',
...
rsi: {
// your brand colors should go here
50: '...'
...
500: 'teal',
...
900: "...",
},
},
}}
/>
```
<img width="1189" alt="Screenshot 2022-04-13 at 10 24 34" src="https://user-images.githubusercontent.com/5903616/163123718-15c05ad8-243b-4a81-8141-c47216047468.png">
2.) Change all components of the same type, like all Buttons, at the same time
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
components: {
Button: {
baseStyle: {
borderRadius: "none",
},
defaultProps: {
colorScheme: "yellow",
},
},
},
}}
/>
```
<img width="1191" alt="Screenshot 2022-04-13 at 11 04 30" src="https://user-images.githubusercontent.com/5903616/163130213-82f955b4-5081-49e0-8f43-8857d480dacd.png">
3.) Change components specifically in each Step.
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
components: {
UploadStep: {
baseStyle: {
dropzoneButton: {
bg: "red",
},
},
},
},
}}
/>
```
<img width="1182" alt="Screenshot 2022-04-13 at 10 21 58" src="https://user-images.githubusercontent.com/5903616/163123694-5b79179e-037e-4f9d-b1a9-6078f758bb7e.png">
Underneath we use Chakra-UI, you can send in a custom theme for us to apply. Read more about themes [here](https://chakra-ui.com/docs/styled-system/theming/theme)
### Changing text (translations)
You can change any text in the flow:
```tsx
<ReactSpreadsheetImport
translations={{
uploadStep: {
title: "Upload Employees",
},
}}
/>
```
You can see all the translation keys [here](https://github.com/UgnisSoftware/react-spreadsheet-import/blob/master/src/translationsRSIProps.ts)
## VS other libraries
Flatfile vs react-spreadsheet-import and Dromo vs react-spreadsheet-import:
| | RSI | Flatfile | Dromo |
| ------------------------------ | -------------- | ----------- | ----------- |
| Licence | MIT | Proprietary | Proprietary |
| Price | Free | Paid | Paid |
| Support | Github Issues | Enterprise | Enterprise |
| Self-host | Yes | Paid | Paid |
| Hosted solution | In development | Yes | Yes |
| On-prem deployment | N/A | Yes | Yes |
| Hooks | Yes | Yes | Yes |
| Automatic header matching | Yes | Yes | Yes |
| Data validation | Yes | Yes | Yes |
| Custom styling | Yes | Yes | Yes |
| Translations | Yes | Yes | Yes |
| Trademarked words `Data Hooks` | No | Yes | No |
React-spreadsheet-import can be used as a free and open-source alternative to Flatfile and Dromo.
## Contributing
Feel free to open issues if you have any questions or notice bugs. If you want different component behaviour, consider forking the project.
## Credits
Created by Ugnis. [Julita Kriauciunaite](https://github.com/JulitorK) and [Karolis Masiulis](https://github.com/masiulis). You can contact us at `info@ugnis.com`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
{
"name": "react-spreadsheet-import",
"version": "4.7.1",
"description": "React spreadsheet import for xlsx and csv files with column matching and validation",
"main": "./dist-commonjs/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"files": [
"dist-commonjs",
"dist",
"types"
],
"scripts": {
"start": "storybook dev -p 6006",
"test:unit": "jest",
"test:e2e": "npx playwright test",
"test:chromatic": "npx chromatic ",
"ts": "tsc",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"prebuild": "npm run clean",
"build": "rollup -c rollup.config.ts",
"build-storybook": "storybook build -o docs-build",
"release:patch": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version patch && git add -A && git push && git push --tags && npm publish",
"release:minor": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version minor && git add -A && git push && git push --tags && npm publish",
"release:major": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version major && git add -A && git push && git push --tags && npm publish",
"clean": "rimraf dist dist-commonjs types"
},
"repository": {
"type": "git",
"url": "git+https://github.com/UgnisSoftware/react-spreadsheet-import.git"
},
"keywords": [
"React",
"spreadsheet",
"import",
"upload",
"csv",
"xlsx",
"validate",
"automatic",
"match"
],
"author": {
"name": "Ugnis"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/UgnisSoftware/react-spreadsheet-import/issues"
},
"homepage": "https://github.com/UgnisSoftware/react-spreadsheet-import#readme",
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@chakra-ui/react": "^2.8.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"chakra-react-select": "^4.7.5",
"chakra-ui-steps": "2.0.4",
"framer-motion": "^10.16.4",
"js-levenshtein": "1.1.6",
"lodash": "4.17.21",
"react-data-grid": "7.0.0-beta.13",
"react-dropzone": "14.2.3",
"react-icons": "4.11.0",
"uuid": "^9.0.1",
"xlsx-ugnis": "0.20.3"
},
"devDependencies": {
"@babel/core": "7.23.2",
"@babel/preset-env": "7.23.2",
"@babel/preset-react": "7.22.15",
"@babel/preset-typescript": "7.23.2",
"@emotion/jest": "11.11.0",
"@jest/types": "27.5.1",
"@playwright/test": "^1.39.0",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/blocks": "7.5.1",
"@storybook/cli": "7.5.1",
"@storybook/react": "7.5.1",
"@storybook/react-webpack5": "7.5.1",
"@storybook/testing-library": "^0.0.14-next.2",
"@testing-library/dom": "9.3.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/jest": "27.4.1",
"@types/js-levenshtein": "1.1.1",
"@types/node": "^20.8.7",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@types/styled-system": "5.1.16",
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.59.7",
"@typescript-eslint/parser": "5.59.7",
"babel-loader": "9.1.3",
"chromatic": "^7.4.0",
"eslint": "8.41.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"jest": "27.5.1",
"jest-watch-typeahead": "1.0.0",
"lint-staged": "13.2.2",
"prettier": "2.8.8",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select-event": "5.5.1",
"rollup": "2.70.1",
"rollup-plugin-typescript2": "0.31.2",
"storybook": "7.5.1",
"ts-essentials": "9.3.2",
"ts-jest": "27.1.4",
"ttypescript": "1.5.15",
"typescript": "4.9.5",
"typescript-transform-paths": "3.4.6"
},
"overrides": {
"semver": "^7.5.3"
},
"lint-staged": {
"*.{ts,tsx}": "eslint",
"*.{js,ts,tsx,md,html,css,json}": "prettier --write"
},
"prettier": {
"tabWidth": 2,
"trailingComma": "all",
"semi": false,
"printWidth": 120
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"mjs"
],
"transform": {
"^.+\\.(ts|tsx)?$": "ts-jest/dist",
"^.+\\.mjs$": "ts-jest/dist"
},
"moduleNameMapper": {
"~/(.*)": "<rootDir>/src/$1"
},
"modulePathIgnorePatterns": [
"<rootDir>/e2e/"
],
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
],
"setupFiles": [
"./src/tests/setup.ts"
],
"globals": {
"ts-jest": {
"tsconfig": "tsconfig.json"
}
},
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},
"readme": "ERROR: No README data found!"
}

View File

@@ -0,0 +1,41 @@
import merge from "lodash/merge"
import { Steps } from "./steps/Steps"
import { rtlThemeSupport, themeOverrides } from "./theme"
import { Providers } from "./components/Providers"
import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps"
export const defaultTheme = themeOverrides
export const defaultRSIProps: Partial<RsiProps<any>> = {
autoMapHeaders: true,
autoMapSelectValues: false,
allowInvalidSubmit: true,
autoMapDistance: 2,
isNavigationEnabled: false,
translations: translations,
uploadStepHook: async (value) => value,
selectHeaderStepHook: async (headerValues, data) => ({ headerValues, data }),
matchColumnsStepHook: async (table) => table,
dateFormat: "yyyy-mm-dd", // ISO 8601,
parseRaw: true,
} as const
export const ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: RsiProps<T>) => {
const props = merge({}, defaultRSIProps, propsWithoutDefaults)
const mergedTranslations =
props.translations !== translations ? merge(translations, props.translations) : translations
const mergedThemes = props.rtl
? merge(defaultTheme, rtlThemeSupport, props.customTheme)
: merge(defaultTheme, props.customTheme)
return (
<Providers theme={mergedThemes} rsiValues={{ ...props, translations: mergedTranslations }}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
</ModalWrapper>
</Providers>
)
}

View File

@@ -0,0 +1,88 @@
import type React from "react"
import {
Dialog,
DialogContent,
DialogOverlay,
DialogPortal,
DialogClose,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
import { useRsi } from "../hooks/useRsi"
import { useState } from "react"
type Props = {
children: React.ReactNode
isOpen: boolean
onClose: () => void
}
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl, translations } = useRsi()
const [showCloseAlert, setShowCloseAlert] = useState(false)
return (
<>
<Dialog open={isOpen} onOpenChange={() => setShowCloseAlert(true)} modal>
<DialogPortal>
<DialogOverlay className="bg-background/80 backdrop-blur-sm" />
<DialogContent
onEscapeKeyDown={(e) => {
e.preventDefault()
setShowCloseAlert(true)
}}
onPointerDownOutside={(e) => e.preventDefault()}
className="fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] h-[calc(100%-2rem)] max-w-[100vw] max-h-[100vh] flex flex-col overflow-hidden rounded-lg border bg-background p-0 shadow-lg sm:w-[calc(100%-3rem)] sm:h-[calc(100%-3rem)] md:w-[calc(100%-4rem)] md:h-[calc(100%-4rem)]"
>
<AlertDialog>
<AlertDialogTrigger asChild>
<DialogClose className="absolute right-4 top-4" onClick={(e) => {
e.preventDefault()
setShowCloseAlert(true)
}} />
</AlertDialogTrigger>
</AlertDialog>
<div dir={rtl ? "rtl" : "ltr"} className="flex-1 overflow-auto">
{children}
</div>
</DialogContent>
</DialogPortal>
</Dialog>
<AlertDialog open={showCloseAlert} onOpenChange={setShowCloseAlert}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>
{translations.alerts.confirmClose.headerTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{translations.alerts.confirmClose.bodyText}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowCloseAlert(false)}>
{translations.alerts.confirmClose.cancelButtonTitle}
</AlertDialogCancel>
<AlertDialogAction onClick={onClose}>
{translations.alerts.confirmClose.exitButtonTitle}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,33 @@
import { ChakraProvider, extendTheme } from "@chakra-ui/react"
import { createContext } from "react"
import type { RsiProps } from "../types"
import type { CustomTheme } from "../theme"
export const RsiContext = createContext({} as any)
type ProvidersProps<T extends string> = {
children: React.ReactNode
theme: CustomTheme
rsiValues: RsiProps<T>
}
export const rootId = "chakra-modal-rsi"
export const Providers = <T extends string>({ children, theme, rsiValues }: ProvidersProps<T>) => {
const mergedTheme = extendTheme(theme)
if (!rsiValues.fields) {
throw new Error("Fields must be provided to react-spreadsheet-import")
}
return (
<RsiContext.Provider value={rsiValues}>
<ChakraProvider>
{/* cssVarsRoot used to override RSI defaultTheme but not the rest of chakra defaultTheme */}
<ChakraProvider cssVarsRoot={`#${rootId}`} theme={mergedTheme}>
{children}
</ChakraProvider>
</ChakraProvider>
</RsiContext.Provider>
)
}

View File

@@ -0,0 +1,23 @@
import type { DataGridProps, Column } from "react-data-grid"
import DataGrid from "react-data-grid"
import { useRsi } from "../hooks/useRsi"
export type { Column }
export type Props<TRow> = DataGridProps<TRow> & {
rowHeight?: number
hiddenHeader?: boolean
className?: string
style?: React.CSSProperties
}
export const Table = <TRow,>({ className, ...props }: Props<TRow>) => {
const { rtl } = useRsi()
return (
<DataGrid
className={"rdg-light " + (className || "")}
direction={rtl ? "rtl" : "ltr"}
{...props}
/>
)
}

View File

@@ -0,0 +1,9 @@
import { useContext } from "react"
import { RsiContext } from "../components/Providers"
import type { RsiProps } from "../types"
import type { MarkRequired } from "ts-essentials"
import type { defaultRSIProps } from "../ReactSpreadsheetImport"
import type { Translations } from "../translationsRSIProps"
export const useRsi = <T extends string>() =>
useContext<MarkRequired<RsiProps<T>, keyof typeof defaultRSIProps> & { translations: Translations }>(RsiContext)

View File

@@ -0,0 +1,2 @@
export { StepType } from "./steps/UploadFlow"
export { ReactSpreadsheetImport } from "./ReactSpreadsheetImport"

View File

@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { UserTableColumn } from "./components/UserTableColumn"
import { useRsi } from "../../hooks/useRsi"
import { TemplateColumn } from "./components/TemplateColumn"
import { ColumnGrid } from "./components/ColumnGrid"
import { setColumn } from "./utils/setColumn"
import { setIgnoreColumn } from "./utils/setIgnoreColumn"
import { setSubColumn } from "./utils/setSubColumn"
import { normalizeTableData } from "./utils/normalizeTableData"
import type { Field, RawData } from "../../types"
import { getMatchedColumns } from "./utils/getMatchedColumns"
import { findUnmatchedRequiredFields } from "./utils/findUnmatchedRequiredFields"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
headerValues: RawData
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void
onBack?: () => void
}
export enum ColumnType {
empty,
ignored,
matched,
matchedCheckbox,
matchedSelect,
matchedSelectOptions,
matchedMultiInput,
matchedMultiSelect,
}
export type MatchedOptions<T> = {
entry: string
value: T
}
type EmptyColumn = { type: ColumnType.empty; index: number; header: string }
type IgnoredColumn = { type: ColumnType.ignored; index: number; header: string }
type MatchedColumn<T> = { type: ColumnType.matched; index: number; header: string; value: T }
type MatchedSwitchColumn<T> = { type: ColumnType.matchedCheckbox; index: number; header: string; value: T }
export type MatchedSelectColumn<T> = {
type: ColumnType.matchedSelect
index: number
header: string
value: T
matchedOptions: Partial<MatchedOptions<T>>[]
}
export type MatchedSelectOptionsColumn<T> = {
type: ColumnType.matchedSelectOptions
index: number
header: string
value: T
matchedOptions: MatchedOptions<T>[]
}
export type MatchedMultiInputColumn<T> = {
type: ColumnType.matchedMultiInput
index: number
header: string
value: T
}
export type MatchedMultiSelectColumn<T> = {
type: ColumnType.matchedMultiSelect
index: number
header: string
value: T
matchedOptions: MatchedOptions<T>[]
}
export type Column<T extends string> =
| EmptyColumn
| IgnoredColumn
| MatchedColumn<T>
| MatchedSwitchColumn<T>
| MatchedSelectColumn<T>
| MatchedSelectOptionsColumn<T>
| MatchedMultiInputColumn<T>
| MatchedMultiSelectColumn<T>
export type Columns<T extends string> = Column<T>[]
export const MatchColumnsStep = <T extends string>({
data,
headerValues,
onContinue,
onBack,
}: MatchColumnsProps<T>) => {
const dataExample = data.slice(0, 2)
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations, allowInvalidSubmit } = useRsi<T>()
const [isLoading, setIsLoading] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })),
)
const [showUnmatchedFieldsAlert, setShowUnmatchedFieldsAlert] = useState(false)
const onChange = useCallback(
(value: T, columnIndex: number) => {
const field = fields.find((field: Field<T>) => field.key === value)
if (!field) return
const existingFieldIndex = columns.findIndex((column) => "value" in column && column.value === field.key)
setColumns(
columns.map<Column<T>>((column, index) => {
if (columnIndex === index) {
// Set the new column value
return setColumn(column, field, data, autoMapSelectValues)
} else if (index === existingFieldIndex) {
// Clear the old column that had this field
toast.warning(translations.matchColumnsStep.duplicateColumnWarningTitle, {
description: translations.matchColumnsStep.duplicateColumnWarningDescription,
})
return setColumn(column)
} else {
// Leave other columns unchanged
return column
}
}),
)
},
[
autoMapSelectValues,
columns,
data,
fields,
translations.matchColumnsStep.duplicateColumnWarningDescription,
translations.matchColumnsStep.duplicateColumnWarningTitle,
],
)
const onIgnore = useCallback(
(columnIndex: number) => {
setColumns(columns.map((column, index) => (columnIndex === index ? setIgnoreColumn<T>(column) : column)))
},
[columns, setColumns],
)
const onRevertIgnore = useCallback(
(columnIndex: number) => {
setColumns(columns.map((column, index) => (columnIndex === index ? setColumn(column) : column)))
},
[columns, setColumns],
)
const onSubChange = useCallback(
(value: string, columnIndex: number, entry: string) => {
setColumns(
columns.map((column, index) =>
columnIndex === index && "matchedOptions" in column ? setSubColumn(column, entry, value) : column,
),
)
},
[columns, setColumns],
)
const unmatchedRequiredFields = useMemo(() => findUnmatchedRequiredFields(fields, columns), [fields, columns])
const handleOnContinue = useCallback(async () => {
if (unmatchedRequiredFields.length > 0) {
setShowUnmatchedFieldsAlert(true)
} else {
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
setIsLoading(false)
}
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields])
const handleAlertOnContinue = useCallback(async () => {
setShowUnmatchedFieldsAlert(false)
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
setIsLoading(false)
}, [onContinue, columns, data, fields])
useEffect(
() => {
if (autoMapHeaders) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance, autoMapSelectValues))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
return (
<>
<AlertDialog open={showUnmatchedFieldsAlert} onOpenChange={setShowUnmatchedFieldsAlert}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>
{translations.alerts.unmatchedRequiredFields.headerTitle}
</AlertDialogTitle>
<div className="space-y-3">
<AlertDialogDescription>
{translations.alerts.unmatchedRequiredFields.bodyText}
</AlertDialogDescription>
<p className="text-sm text-muted-foreground">
{translations.alerts.unmatchedRequiredFields.listTitle}{" "}
<span className="font-bold">
{unmatchedRequiredFields.join(", ")}
</span>
</p>
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{translations.alerts.unmatchedRequiredFields.cancelButtonTitle}
</AlertDialogCancel>
{allowInvalidSubmit && (
<AlertDialogAction onClick={handleAlertOnContinue}>
{translations.alerts.unmatchedRequiredFields.continueButtonTitle}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
<ColumnGrid
columns={columns}
onContinue={handleOnContinue}
onBack={onBack}
isLoading={isLoading}
userColumn={(column) => (
<UserTableColumn
column={column}
onIgnore={onIgnore}
onRevertIgnore={onRevertIgnore}
entries={dataExample.map((row) => row[column.index])}
/>
)}
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
/>
</>
)
}

View File

@@ -0,0 +1,112 @@
import type React from "react"
import type { Column, Columns } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import { useRsi } from "../../../hooks/useRsi"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
type ColumnGridProps<T extends string> = {
columns: Columns<T>
userColumn: (column: Column<T>) => React.ReactNode
templateColumn: (column: Column<T>) => React.ReactNode
onContinue: (val: Record<string, string>[]) => void
onBack?: () => void
isLoading: boolean
}
export const ColumnGrid = <T extends string>({
columns,
userColumn,
templateColumn,
onContinue,
onBack,
isLoading,
}: ColumnGridProps<T>) => {
const { translations } = useRsi()
const normalColumnWidth = 250
const ignoredColumnWidth = 48 // 12 units = 3rem = 48px
const gap = 16
const totalWidth = columns.reduce((acc, col) =>
acc + (col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth) + gap,
-gap // Subtract one gap since we need gaps between columns only
)
return (
<div className="flex h-[calc(100vh-10rem)] flex-col">
<div className="flex-1 overflow-hidden">
<div className="px-8 py-6">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-foreground">
{translations.matchColumnsStep.title}
</h2>
</div>
<ScrollArea className="relative">
<div className="space-y-8" style={{ width: totalWidth }}>
{/* Your table section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.userTableTitle}
</h3>
<div className="relative">
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{userColumn(column)}
</div>
))}
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/50 to-background" />
</div>
</div>
{/* Will become section */}
<div>
<h3 className="mb-4 text-lg font-medium text-foreground">
{translations.matchColumnsStep.templateTitle}
</h3>
<div
className="grid auto-cols-fr gap-4"
style={{
gridTemplateColumns: columns.map(col =>
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
).join(" "),
}}
>
{columns.map((column, index) => (
<div key={column.header + index}>
{templateColumn(column)}
</div>
))}
</div>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
<div className="border-t bg-muted px-8 py-4 -mb-1">
<div className="flex items-center justify-between">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.matchColumnsStep.backButtonTitle}
</Button>
)}
<Button
className="ml-auto"
disabled={isLoading}
onClick={() => onContinue([])}
>
{translations.matchColumnsStep.nextButtonTitle}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { chakra, useStyleConfig, Flex } from "@chakra-ui/react"
import { dataAttr } from "@chakra-ui/utils"
import { motion } from "framer-motion"
import { CgCheck } from "react-icons/cg"
const MotionFlex = motion(Flex)
const animationConfig = {
transition: {
duration: 0.1,
},
exit: { scale: 0.5, opacity: 0 },
initial: { scale: 0.5, opacity: 0 },
animate: { scale: 1, opacity: 1 },
}
type MatchIconProps = {
isChecked: boolean
}
export const MatchIcon = (props: MatchIconProps) => {
const style = useStyleConfig("MatchIcon", props)
return (
<chakra.div
__css={style}
minW={6}
minH={6}
w={6}
h={6}
ml="0.875rem"
mr={3}
data-highlighted={dataAttr(props.isChecked)}
data-testid="column-checkmark"
>
{props.isChecked && (
<MotionFlex {...animationConfig}>
<CgCheck size="24px" />
</MotionFlex>
)}
</chakra.div>
)
}

View File

@@ -0,0 +1,129 @@
import { useRsi } from "../../../hooks/useRsi"
import type { Column } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import type { Fields, Field } from "../../../types"
import {
Card,
CardContent,
CardHeader,
} from "@/components/ui/card"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Check } from "lucide-react"
type TemplateColumnProps<T extends string> = {
column: Column<T>
onChange: (value: T, columnIndex: number) => void
onSubChange: (value: string, columnIndex: number, entry: string) => void
}
const getAccordionTitle = <T extends string>(fields: Fields<T>, column: Column<T>, translations: any) => {
const fieldLabel = fields.find((field: Field<T>) => "value" in column && field.key === column.value)!.label
return `${translations.matchColumnsStep.matchDropdownTitle} ${fieldLabel} (${
"matchedOptions" in column && column.matchedOptions.filter((option) => !option.value).length
} ${translations.matchColumnsStep.unmatched})`
}
export const TemplateColumn = <T extends string>({ column, onChange, onSubChange }: TemplateColumnProps<T>) => {
const { translations, fields } = useRsi<T>()
const isIgnored = column.type === ColumnType.ignored
const isChecked =
column.type === ColumnType.matched ||
column.type === ColumnType.matchedCheckbox ||
column.type === ColumnType.matchedSelectOptions
const isSelect = "matchedOptions" in column
const selectOptions = fields.map(({ label, key }: { label: string; key: string }) => ({ value: key, label }))
const selectValue = column.type === ColumnType.empty ? undefined :
selectOptions.find(({ value }: { value: string }) => "value" in column && column.value === value)?.value
if (isIgnored) {
return null
}
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-x-2 p-4">
<div className="flex-1">
<Select
key={`select-${column.index}-${("value" in column ? column.value : "empty")}`}
value={selectValue}
onValueChange={(value) => onChange(value as T, column.index)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={translations.matchColumnsStep.selectPlaceholder} />
</SelectTrigger>
<SelectContent
side="bottom"
align="start"
className="z-[1500]"
>
{selectOptions.map((option: { value: string; label: string }) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isChecked && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-green-700 bg-green-300 dark:bg-green-900/20">
<Check className="h-4 w-4 text-green-700 dark:text-green-500" />
</div>
)}
</CardHeader>
{isSelect && (
<CardContent className="p-4">
<Accordion type="multiple" className="w-full">
<AccordionItem value="options" className="border-none">
<AccordionTrigger className="py-2 text-sm hover:no-underline">
{getAccordionTitle<T>(fields, column, translations)}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
{column.matchedOptions.map((option) => (
<div key={option.entry} className="space-y-1">
<p className="text-sm text-muted-foreground">
{option.entry}
</p>
<Select
value={option.value}
onValueChange={(value) => onSubChange(value, column.index, option.entry!)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={translations.matchColumnsStep.subSelectPlaceholder} />
</SelectTrigger>
<SelectContent
side="bottom"
align="start"
className="z-[1000]"
>
{fields
.find((field: Field<T>) => "value" in column && field.key === column.value)
?.fieldType.options.map((option: { value: string; label: string }) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
)}
</Card>
)
}

View File

@@ -0,0 +1,74 @@
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { X, RotateCcw } from "lucide-react"
import type { Column } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import type { RawData } from "../../../types"
type UserTableColumnProps<T extends string> = {
column: Column<T>
entries: RawData
onIgnore: (index: number) => void
onRevertIgnore: (index: number) => void
}
export const UserTableColumn = <T extends string>(props: UserTableColumnProps<T>) => {
const {
column: { header, index, type },
entries,
onIgnore,
onRevertIgnore,
} = props
const isIgnored = type === ColumnType.ignored
if (isIgnored) {
return (
<Card className="h-full w-12 bg-muted/50">
<CardHeader className="flex flex-col items-center space-y-4 p-2">
<Button
variant="ghost"
size="icon"
onClick={() => onRevertIgnore(index)}
className="h-8 w-8"
>
<RotateCcw className="h-4 w-4" />
</Button>
<div
className="vertical-text font-medium text-muted-foreground"
style={{ writingMode: 'vertical-rl', textOrientation: 'mixed', transform: 'rotate(180deg)' }}
>
{header}
</div>
</CardHeader>
</Card>
)
}
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-x-2 p-4">
<p className="font-medium">
{header}
</p>
<Button
variant="ghost"
size="icon"
onClick={() => onIgnore(index)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2 p-4">
{entries.map((entry, i) => (
<p
key={`${entry || ""}-${i}`}
className="truncate text-sm text-muted-foreground"
>
{entry}
</p>
))}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import lavenstein from "js-levenshtein"
import type { Fields } from "../../../types"
type AutoMatchAccumulator<T> = {
distance: number
value: T
}
export const findMatch = <T extends string>(
header: string,
fields: Fields<T>,
autoMapDistance: number,
): T | undefined => {
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
const distance = Math.min(
...[
lavenstein(field.key, header),
...(field.alternateMatches?.map((alternate) => lavenstein(alternate, header)) || []),
],
)
return distance < acc.distance || acc.distance === undefined
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
: acc
}, {} as AutoMatchAccumulator<T>)
return smallestValue.distance <= autoMapDistance ? smallestValue.value : undefined
}

View File

@@ -0,0 +1,8 @@
import type { Fields } from "../../../types"
import type { Columns } from "../MatchColumnsStep"
export const findUnmatchedRequiredFields = <T extends string>(fields: Fields<T>, columns: Columns<T>) =>
fields
.filter((field) => field.validations?.some((validation) => validation.rule === "required"))
.filter((field) => columns.findIndex((column) => "value" in column && column.value === field.key) === -1)
.map((field) => field.label) || []

View File

@@ -0,0 +1,6 @@
import type { Fields } from "../../../types"
export const getFieldOptions = <T extends string>(fields: Fields<T>, fieldKey: string) => {
const field = fields.find(({ key }) => fieldKey === key)!
return field.fieldType.type === "select" ? field.fieldType.options : []
}

View File

@@ -0,0 +1,41 @@
import lavenstein from "js-levenshtein"
import { findMatch } from "./findMatch"
import type { Field, Fields } from "../../../types"
import { setColumn } from "./setColumn"
import type { Column, Columns } from "../MatchColumnsStep"
import type { MatchColumnsProps } from "../MatchColumnsStep"
export const getMatchedColumns = <T extends string>(
columns: Columns<T>,
fields: Fields<T>,
data: MatchColumnsProps<T>["data"],
autoMapDistance: number,
autoMapSelectValues?: boolean,
) =>
columns.reduce<Column<T>[]>((arr, column) => {
const autoMatch = findMatch(column.header, fields, autoMapDistance)
if (autoMatch) {
const field = fields.find((field) => field.key === autoMatch) as Field<T>
const duplicateIndex = arr.findIndex((column) => "value" in column && column.value === field.key)
const duplicate = arr[duplicateIndex]
if (duplicate && "value" in duplicate) {
return lavenstein(duplicate.value, duplicate.header) < lavenstein(autoMatch, column.header)
? [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex], field, data, autoMapSelectValues),
...arr.slice(duplicateIndex + 1),
setColumn(column),
]
: [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex]),
...arr.slice(duplicateIndex + 1),
setColumn(column, field, data, autoMapSelectValues),
]
} else {
return [...arr, setColumn(column, field, data, autoMapSelectValues)]
}
} else {
return [...arr, column]
}
}, [])

View File

@@ -0,0 +1,13 @@
const booleanWhitelist: Record<string, boolean> = {
yes: true,
no: false,
true: true,
false: false,
}
export const normalizeCheckboxValue = (value: string | undefined): boolean => {
if (value && value.toLowerCase() in booleanWhitelist) {
return booleanWhitelist[value.toLowerCase()]
}
return false
}

View File

@@ -0,0 +1,67 @@
import type { Columns } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import type { Data, Fields, RawData } from "../../../types"
import { normalizeCheckboxValue } from "./normalizeCheckboxValue"
export const normalizeTableData = <T extends string>(columns: Columns<T>, data: RawData[], fields: Fields<T>) =>
data.map((row) =>
columns.reduce((acc, column, index) => {
const curr = row[index]
switch (column.type) {
case ColumnType.matchedCheckbox: {
const field = fields.find((field) => field.key === column.value)!
if ("booleanMatches" in field.fieldType && Object.keys(field.fieldType).length) {
const booleanMatchKey = Object.keys(field.fieldType.booleanMatches || []).find(
(key) => key.toLowerCase() === curr?.toLowerCase(),
)!
const booleanMatch = field.fieldType.booleanMatches?.[booleanMatchKey]
acc[column.value] = booleanMatchKey ? booleanMatch : normalizeCheckboxValue(curr)
} else {
acc[column.value] = normalizeCheckboxValue(curr)
}
return acc
}
case ColumnType.matched: {
acc[column.value] = curr === "" ? undefined : curr
return acc
}
case ColumnType.matchedMultiInput: {
const field = fields.find((field) => field.key === column.value)!
if (curr) {
const separator = field.fieldType.type === "multi-input" ? field.fieldType.separator || "," : ","
acc[column.value] = curr.split(separator).map(v => v.trim()).filter(Boolean)
} else {
acc[column.value] = undefined
}
return acc
}
case ColumnType.matchedSelect:
case ColumnType.matchedSelectOptions: {
const matchedOption = column.matchedOptions.find(({ entry, value }) => entry === curr)
acc[column.value] = matchedOption?.value || undefined
return acc
}
case ColumnType.matchedMultiSelect: {
const field = fields.find((field) => field.key === column.value)!
if (curr) {
const separator = field.fieldType.type === "multi-select" ? field.fieldType.separator || "," : ","
const entries = curr.split(separator).map(v => v.trim()).filter(Boolean)
const values = entries.map(entry => {
const matchedOption = column.matchedOptions.find(({ entry: optEntry }) => optEntry === entry)
return matchedOption?.value
}).filter(Boolean) as string[]
acc[column.value] = values.length ? values : undefined
} else {
acc[column.value] = undefined
}
return acc
}
case ColumnType.empty:
case ColumnType.ignored: {
return acc
}
default:
return acc
}
}, {} as Data<T>),
)

View File

@@ -0,0 +1,65 @@
import type { Field, MultiSelect } from "../../../types"
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
import { uniqueEntries } from "./uniqueEntries"
export const setColumn = <T extends string>(
oldColumn: Column<T>,
field?: Field<T>,
data?: MatchColumnsProps<T>["data"],
autoMapSelectValues?: boolean,
): Column<T> => {
switch (field?.fieldType.type) {
case "select":
const fieldOptions = field.fieldType.options
const uniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
const matchedOptions = autoMapSelectValues
? uniqueData.map((record) => {
const value = fieldOptions.find(
(fieldOption) => fieldOption.value === record.entry || fieldOption.label === record.entry,
)?.value
return value ? ({ ...record, value } as MatchedOptions<T>) : (record as MatchedOptions<T>)
})
: uniqueData
const allMatched = matchedOptions.filter((o) => o.value).length == uniqueData?.length
return {
...oldColumn,
type: allMatched ? ColumnType.matchedSelectOptions : ColumnType.matchedSelect,
value: field.key,
matchedOptions,
}
case "multi-select":
const multiSelectFieldType = field.fieldType as MultiSelect
const multiSelectFieldOptions = multiSelectFieldType.options
const multiSelectUniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
const multiSelectMatchedOptions = autoMapSelectValues
? multiSelectUniqueData.map((record) => {
// Split the entry by the separator (default to comma)
const entries = record.entry.split(multiSelectFieldType.separator || ",").map(e => e.trim())
// Try to match each entry to an option
const values = entries.map(entry => {
const value = multiSelectFieldOptions.find(
(fieldOption) => fieldOption.value === entry || fieldOption.label === entry,
)?.value
return value
}).filter(Boolean) as T[]
return { ...record, value: values.length ? values[0] : undefined } as MatchedOptions<T>
})
: multiSelectUniqueData
return {
...oldColumn,
type: ColumnType.matchedMultiSelect,
value: field.key,
matchedOptions: multiSelectMatchedOptions,
}
case "checkbox":
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }
case "input":
return { index: oldColumn.index, type: ColumnType.matched, value: field.key, header: oldColumn.header }
case "multi-input":
return { index: oldColumn.index, type: ColumnType.matchedMultiInput, value: field.key, header: oldColumn.header }
default:
return { index: oldColumn.index, header: oldColumn.header, type: ColumnType.empty }
}
}

View File

@@ -0,0 +1,7 @@
import { Column, ColumnType } from "../MatchColumnsStep"
export const setIgnoreColumn = <T extends string>({ header, index }: Column<T>): Column<T> => ({
header,
index,
type: ColumnType.ignored,
})

View File

@@ -0,0 +1,14 @@
import { ColumnType, MatchedOptions, MatchedSelectColumn, MatchedSelectOptionsColumn } from "../MatchColumnsStep"
export const setSubColumn = <T>(
oldColumn: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>,
entry: string,
value: string,
): MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> => {
const options = oldColumn.matchedOptions.map((option) => (option.entry === entry ? { ...option, value } : option))
const allMathced = options.every(({ value }) => !!value)
if (allMathced) {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelectOptions }
} else {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelect }
}
}

View File

@@ -0,0 +1,11 @@
import uniqBy from "lodash/uniqBy"
import type { MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
export const uniqueEntries = <T extends string>(
data: MatchColumnsProps<T>["data"],
index: number,
): Partial<MatchedOptions<T>>[] =>
uniqBy(
data.map((row) => ({ entry: row[index] })),
"entry",
).filter(({ entry }) => !!entry)

View File

@@ -0,0 +1,57 @@
import { useCallback, useState } from "react"
import { SelectHeaderTable } from "./components/SelectHeaderTable"
import { useRsi } from "../../hooks/useRsi"
import type { RawData } from "../../types"
import { Button } from "@/components/ui/button"
type SelectHeaderProps = {
data: RawData[]
onContinue: (headerValues: RawData, data: RawData[]) => Promise<void>
onBack?: () => void
}
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
const { translations } = useRsi()
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
const [isLoading, setIsLoading] = useState(false)
const handleContinue = useCallback(async () => {
const [selectedRowIndex] = selectedRows
// We consider data above header to be redundant
const trimmedData = data.slice(selectedRowIndex + 1)
setIsLoading(true)
await onContinue(data[selectedRowIndex], trimmedData)
setIsLoading(false)
}, [onContinue, data, selectedRows])
return (
<div className="flex flex-col">
<div className="px-8 py-6">
<h2 className="text-2xl font-semibold text-foreground">
{translations.selectHeaderStep.title}
</h2>
</div>
<div className="flex-1 px-8 mb-12 overflow-auto">
<SelectHeaderTable
data={data}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
/>
</div>
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-2">
{onBack && (
<Button variant="outline" onClick={onBack}>
{translations.selectHeaderStep.backButtonTitle}
</Button>
)}
<Button
className="ml-auto"
disabled={isLoading}
onClick={handleContinue}
>
{translations.selectHeaderStep.nextButtonTitle}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useMemo } from "react"
import type { RawData } from "../../../types"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
interface Props {
data: RawData[]
selectedRows: ReadonlySet<number>
setSelectedRows: (rows: ReadonlySet<number>) => void
}
export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props) => {
const columns = useMemo(() => {
const longestRowLength = data.reduce((acc, curr) => (acc > curr.length ? acc : curr.length), 0)
return Array.from(Array(longestRowLength), (_, index) => ({
key: index.toString(),
name: `Column ${index + 1}`,
}))
}, [data])
if (!data || data.length === 0) {
return (
<div className="p-4">
<p className="text-sm text-muted-foreground">No data available to select headers from.</p>
</div>
)
}
const selectedRowIndex = Array.from(selectedRows)[0]
const gridTemplateColumns = `60px repeat(${columns.length}, minmax(150px, 300px))`
return (
<div className="rounded-md border p-3">
<p className="mb-2 p-2 text-sm text-muted-foreground">
Select the row that contains your column headers
</p>
<div className="h-[calc(100vh-27rem)] overflow-auto">
<Table className="relative w-full" style={{ tableLayout: 'fixed' }}>
<TableHeader>
<TableRow className="grid" style={{ gridTemplateColumns }}>
<TableHead className="sticky top-0 z-20 bg-background overflow-hidden">
<div className="truncate">Select</div>
</TableHead>
{columns.map((column) => (
<TableHead
key={column.key}
className="sticky top-0 z-20 bg-background overflow-hidden"
>
<div className="truncate">
{column.name}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<RadioGroup
value={selectedRowIndex?.toString()}
onValueChange={(value) => setSelectedRows(new Set([parseInt(value)]))}
>
{data.map((row, rowIndex) => (
<TableRow
key={rowIndex}
className={cn(
"grid",
selectedRowIndex === rowIndex && "bg-muted",
"group hover:bg-muted/50"
)}
style={{ gridTemplateColumns }}
>
<TableCell className="overflow-hidden">
<div className="flex items-center">
<RadioGroupItem value={rowIndex.toString()} id={`row-${rowIndex}`} />
<Label htmlFor={`row-${rowIndex}`} className="sr-only">
Select as header row
</Label>
</div>
</TableCell>
{columns.map((column, colIndex) => (
<TableCell
key={`${rowIndex}-${column.key}`}
className="overflow-hidden"
>
<div className="truncate">
{row[colIndex] || ""}
</div>
</TableCell>
))}
</TableRow>
))}
</RadioGroup>
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,68 @@
import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import type { RawData } from "../../../types"
import { cn } from "@/lib/utils"
const SELECT_COLUMN_KEY = "select-row"
function SelectFormatter(props: FormatterProps<unknown>) {
const [isRowSelected, onRowSelectionChange] = useRowSelection()
return (
<div className="flex h-full items-center pl-2">
<RadioGroup defaultValue={isRowSelected ? "selected" : undefined}>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="selected"
id={`row-${props.rowIdx}`}
checked={isRowSelected}
onClick={(event) => {
onRowSelectionChange({
row: props.row,
checked: !isRowSelected,
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
})
}}
/>
<Label
htmlFor={`row-${props.rowIdx}`}
className="sr-only"
>
Select as header row
</Label>
</div>
</RadioGroup>
</div>
)
}
export const SelectColumn: Column<any, any> = {
key: SELECT_COLUMN_KEY,
name: "Select Header",
width: 100,
minWidth: 100,
maxWidth: 100,
resizable: false,
sortable: false,
frozen: true,
cellClass: "rdg-radio",
formatter: SelectFormatter,
}
export const generateSelectionColumns = (data: RawData[]) => {
const longestRowLength = data.reduce((acc, curr) => (acc > curr.length ? acc : curr.length), 0)
return [
SelectColumn,
...Array.from(Array(longestRowLength), (_, index) => ({
key: index.toString(),
name: `Column ${index + 1}`,
width: 150,
formatter: ({ row }) => (
<div className="p-2 overflow-hidden text-ellipsis whitespace-nowrap">
{row[index]}
</div>
),
})),
]
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useState } from "react"
import { useRsi } from "../../hooks/useRsi"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { ChevronLeft } from "lucide-react"
type SelectSheetProps = {
sheetNames: string[]
onContinue: (sheetName: string) => Promise<void>
onBack?: () => void
}
export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetProps) => {
const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi()
const [value, setValue] = useState(sheetNames[0])
const handleOnContinue = useCallback(
async (data: typeof value) => {
setIsLoading(true)
await onContinue(data)
setIsLoading(false)
},
[onContinue],
)
return (
<div className="flex h-[calc(100vh-10rem)] flex-col">
<div className="flex-1 overflow-hidden">
<div className="px-8 py-6">
<div className="mb-8">
<h2 className="text-3xl font-semibold text-foreground">
{translations.uploadStep.selectSheet.title}
</h2>
</div>
<RadioGroup
value={value}
onValueChange={setValue}
className="space-y-4"
>
{sheetNames.map((sheetName) => (
<div key={sheetName} className="flex items-center space-x-2">
<RadioGroupItem value={sheetName} id={sheetName} />
<Label
htmlFor={sheetName}
className="text-base"
>
{sheetName}
</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<div className="flex items-center justify-between border-t px-8 py-4 bg-muted -mb-1">
{onBack && (
<Button
variant="ghost"
onClick={onBack}
className="gap-2"
>
<ChevronLeft className="h-4 w-4" />
{translations.uploadStep.selectSheet.backButtonTitle}
</Button>
)}
<div className="flex-1" />
<Button
onClick={() => handleOnContinue(value)}
disabled={isLoading}
>
{translations.uploadStep.selectSheet.nextButtonTitle}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { StepState, StepType, UploadFlow } from "./UploadFlow"
import { useRsi } from "../hooks/useRsi"
import { useRef, useState } from "react"
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
import { CgCheck } from "react-icons/cg"
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="24" className={color} />
export const Steps = () => {
const { initialStepState, translations, isNavigationEnabled } = useRsi()
const initialStep = stepTypeToStepIndex(initialStepState?.type)
const [activeStep, setActiveStep] = useState(initialStep)
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
const history = useRef<StepState[]>([])
const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex)
const historyIdx = history.current.findIndex((v) => v.type === type)
if (historyIdx === -1) return
const nextHistory = history.current.slice(0, historyIdx + 1)
history.current = nextHistory
setState(nextHistory[nextHistory.length - 1])
setActiveStep(stepIndex)
}
const onBack = () => {
onClickStep(Math.max(activeStep - 1, 0))
}
const onNext = (v: StepState) => {
history.current.push(state)
setState(v)
v.type !== StepType.selectSheet && setActiveStep(activeStep + 1)
}
return (
<>
<div className="hidden border-b bg-muted px-4 py-6 md:block">
<nav className="mx-auto flex items-center justify-center gap-4 lg:gap-24" aria-label="Steps">
{steps.map((key, index) => {
const isActive = index === activeStep
const isCompleted = index < activeStep
return (
<div key={key} className="flex items-center">
<button
className={`group flex items-center ${isNavigationEnabled ? 'cursor-pointer' : 'cursor-default'}`}
onClick={isNavigationEnabled ? () => onClickStep(index) : undefined}
disabled={!isNavigationEnabled}
>
<div className={`flex shrink-0 h-10 w-10 items-center justify-center rounded-full border-2 ${
isActive
? 'border-primary bg-primary text-primary-foreground'
: isCompleted
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/20 bg-background'
}`}>
{isCompleted ? (
<CheckIcon color="text-primary-foreground" />
) : (
<span className={`text-sm font-medium ${
isActive ? 'text-primary-foreground' : 'text-muted-foreground'
}`}>
{index + 1}
</span>
)}
</div>
<span className={`ml-2 text-sm font-medium ${
isActive ? 'text-foreground' : 'text-muted-foreground'
}`}>
{translations[key].title}
</span>
</button>
</div>
)
})}
</nav>
</div>
<UploadFlow state={state} onNext={onNext} onBack={isNavigationEnabled ? onBack : undefined} />
</>
)
}

View File

@@ -0,0 +1,168 @@
import { useCallback, useState } from "react"
import type XLSX from "xlsx"
import { UploadStep } from "./UploadStep/UploadStep"
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep/ValidationStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
export enum StepType {
upload = "upload",
selectSheet = "selectSheet",
selectHeader = "selectHeader",
matchColumns = "matchColumns",
validateData = "validateData",
}
export type StepState =
| {
type: StepType.upload
}
| {
type: StepType.selectSheet
workbook: XLSX.WorkBook
}
| {
type: StepType.selectHeader
data: RawData[]
}
| {
type: StepType.matchColumns
data: RawData[]
headerValues: RawData
}
| {
type: StepType.validateData
data: any[]
}
interface Props {
state: StepState
onNext: (v: StepState) => void
onBack?: () => void
}
export const UploadFlow = ({ state, onNext, onBack }: Props) => {
const {
maxRecords,
translations,
uploadStepHook,
selectHeaderStepHook,
matchColumnsStepHook,
fields,
rowHook,
tableHook,
} = useRsi()
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const { toast } = useToast()
const errorToast = useCallback(
(description: string) => {
toast({
variant: "destructive",
title: translations.alerts.toast.error,
description,
})
},
[toast, translations],
)
switch (state.type) {
case StepType.upload:
return (
<UploadStep
onContinue={async (workbook, file) => {
setUploadedFile(file)
const isSingleSheet = workbook.SheetNames.length === 1
if (isSingleSheet) {
if (maxRecords && exceedsMaxRecords(workbook.Sheets[workbook.SheetNames[0]], maxRecords)) {
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
return
}
try {
const mappedWorkbook = await uploadStepHook(mapWorkbook(workbook))
onNext({
type: StepType.selectHeader,
data: mappedWorkbook,
})
} catch (e) {
errorToast((e as Error).message)
}
} else {
onNext({ type: StepType.selectSheet, workbook })
}
}}
/>
)
case StepType.selectSheet:
return (
<SelectSheetStep
sheetNames={state.workbook.SheetNames}
onContinue={async (sheetName) => {
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
return
}
try {
const mappedWorkbook = await uploadStepHook(mapWorkbook(state.workbook, sheetName))
onNext({
type: StepType.selectHeader,
data: mappedWorkbook,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.selectHeader:
return (
<SelectHeaderStep
data={state.data}
onContinue={async (...args) => {
try {
const { data, headerValues } = await selectHeaderStepHook(...args)
onNext({
type: StepType.matchColumns,
data,
headerValues,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.matchColumns:
return (
<MatchColumnsStep
data={state.data}
headerValues={state.headerValues}
onContinue={async (values, rawData, columns) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
onNext({
type: StepType.validateData,
data: dataWithMeta,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.validateData:
return <ValidationStep initialData={state.data} file={uploadedFile!} onBack={onBack} />
default:
return <Progress value={33} className="w-full" />
}
}

View File

@@ -0,0 +1,39 @@
import type XLSX from "xlsx"
import { useCallback, useState } from "react"
import { useRsi } from "../../hooks/useRsi"
import { DropZone } from "./components/DropZone"
import { ExampleTable } from "./components/ExampleTable"
import { FadingOverlay } from "./components/FadingOverlay"
type UploadProps = {
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
}
export const UploadStep = ({ onContinue }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false)
const { translations, fields } = useRsi()
const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => {
setIsLoading(true)
await onContinue(data, file)
setIsLoading(false)
},
[onContinue],
)
return (
<div className="p-6">
<h2 className="text-2xl font-semibold mb-4">{translations.uploadStep.title}</h2>
<p className="text-lg mb-2">{translations.uploadStep.manifestTitle}</p>
<p className="text-muted-foreground mb-6">{translations.uploadStep.manifestDescription}</p>
<div className="relative mb-0 border-t rounded-lg h-[80px]">
<div className="absolute inset-0">
<ExampleTable fields={fields} />
</div>
<FadingOverlay />
</div>
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useDropzone } from "react-dropzone"
import * as XLSX from "xlsx"
import { useState } from "react"
import { useRsi } from "../../../hooks/useRsi"
import { readFileAsync } from "../utils/readFilesAsync"
import { Button } from "@/components/ui/button"
import { useToast } from "@/hooks/use-toast"
import { cn } from "@/lib/utils"
type DropZoneProps = {
onContinue: (data: XLSX.WorkBook, file: File) => void
isLoading: boolean
}
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
noClick: true,
noKeyboard: true,
maxFiles: 1,
maxSize: maxFileSize,
accept: {
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"text/csv": [".csv"],
},
onDropRejected: (fileRejections) => {
setLoading(false)
fileRejections.forEach((fileRejection) => {
toast({
variant: "destructive",
title: `${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`,
description: fileRejection.errors[0].message,
})
})
},
onDropAccepted: async ([file]) => {
setLoading(true)
const arrayBuffer = await readFileAsync(file)
const workbook = XLSX.read(arrayBuffer, {
cellDates: true,
dateNF: dateFormat,
raw: parseRaw,
dense: true,
type: 'array',
codepage: 65001, // UTF-8
WTF: false // Don't throw on errors
})
setLoading(false)
onContinue(workbook, file)
},
})
return (
<div
{...getRootProps()}
className={cn(
"flex h-full w-full flex-1 flex-col items-center justify-center rounded-lg border-2 border-dashed border-secondary-foreground/30 bg-muted/90 p-12",
isDragActive && "border-primary bg-muted"
)}
>
<input {...getInputProps()} data-testid="rsi-dropzone" />
{isDragActive ? (
<p className="text-lg text-muted-foreground mb-1 py-6">
{translations.uploadStep.dropzone.activeDropzoneTitle}
</p>
) : loading || isLoading ? (
<p className="text-lg text-muted-foreground">
{translations.uploadStep.dropzone.loadingTitle}
</p>
) : (
<>
<p className="mb-4 text-lg text-muted-foreground">
{translations.uploadStep.dropzone.title}
</p>
<Button onClick={open} variant="default">
{translations.uploadStep.dropzone.buttonTitle}
</Button>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
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>
)
}

View File

@@ -0,0 +1,5 @@
export const FadingOverlay = () => (
<div
className="absolute inset-x-0 bottom-0 h-12 pointer-events-none bg-gradient-to-t from-background to-transparent"
/>
)

View File

@@ -0,0 +1,44 @@
import type { Column } from "react-data-grid"
import type { Fields } from "../../../types"
import { CgInfo } from "react-icons/cg"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export const generateColumns = <T extends string>(fields: Fields<T>) =>
fields.map(
(column): Column<any> => ({
key: column.key,
name: column.label,
minWidth: 150,
headerRenderer: () => (
<div className="flex items-center gap-1 relative">
<div className="flex-1 overflow-hidden text-ellipsis">
{column.label}
</div>
{column.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-none">
<CgInfo className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>
{column.description}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
),
formatter: ({ row }) => (
<div className="min-w-full min-h-full overflow-hidden text-ellipsis">
{row[column.key]}
</div>
),
}),
)

View File

@@ -0,0 +1,14 @@
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>),
]

View File

@@ -0,0 +1,9 @@
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",
}
}

View File

@@ -0,0 +1,13 @@
export const readFileAsync = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = reject
reader.readAsArrayBuffer(file)
})
}

View File

@@ -0,0 +1,823 @@
import { useCallback, useMemo, useState, useEffect } from "react"
import { useRsi } from "../../hooks/useRsi"
import type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations"
import type { Data, Field, SelectOption, MultiInput } from "../../types"
import { Check, ChevronsUpDown, ArrowDown } from "lucide-react"
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useReactTable,
getCoreRowModel,
type ColumnDef,
flexRender,
type RowSelectionState,
} from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { useToast } from "@/hooks/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogPortal,
AlertDialogOverlay,
} from "@/components/ui/alert-dialog"
type Props<T extends string> = {
initialData: (Data<T> & Meta)[]
file: File
onBack?: () => void
}
type CellProps = {
value: any,
onChange: (value: any) => void,
error?: { level: string, message: string },
field: Field<string>
}
const EditableCell = ({ value, onChange, error, field }: CellProps) => {
const [isEditing, setIsEditing] = useState(false)
const [inputValue, setInputValue] = useState(value ?? "")
const [validationError, setValidationError] = useState<{level: string, message: string} | undefined>(error)
const validateRegex = (val: string) => {
const regexValidation = field.validations?.find(v => v.rule === "regex")
if (regexValidation && val) {
const regex = new RegExp(regexValidation.value, regexValidation.flags)
if (!regex.test(val)) {
return { level: regexValidation.level || "error", message: regexValidation.errorMessage }
}
}
return undefined
}
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
if (fieldType.type === "select") {
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
}
if (fieldType.type === "multi-select") {
if (Array.isArray(value)) {
return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ")
}
return value
}
if (fieldType.type === "checkbox") {
if (typeof value === "boolean") return value ? "Yes" : "No"
return value
}
if (fieldType.type === "multi-input" && Array.isArray(value)) {
return value.join(", ")
}
return value
}
const isRequired = field.validations?.some(v => v.rule === "required")
// Determine the current validation state
const getValidationState = () => {
// Never show validation during editing
if (isEditing) return undefined
// Only show validation errors if there's a value
if (value) {
if (error) return error
if (validationError) return validationError
} else if (isRequired && !isEditing) {
// Only show required validation when not editing and empty
return { level: "error", message: "Required" }
}
return undefined
}
const currentError = getValidationState()
useEffect(() => {
// Update validation state when value changes externally (e.g. from copy down)
if (!isEditing) {
const newValidationError = value ? validateRegex(value) : undefined
setValidationError(newValidationError)
}
}, [value])
const validateAndCommit = (newValue: string) => {
const regexError = newValue ? validateRegex(newValue) : undefined
setValidationError(regexError)
// Always commit the value
onChange(newValue)
// Only exit edit mode if there are no errors (except required field errors)
if (!error && !regexError) {
setIsEditing(false)
}
}
// Handle blur for all input types
const handleBlur = () => {
validateAndCommit(inputValue)
setIsEditing(false)
}
// Show editing UI only when actually editing
const shouldShowEditUI = isEditing
if (shouldShowEditUI) {
switch (field.fieldType.type) {
case "select":
return (
<div className="space-y-1">
<Popover open={isEditing} onOpenChange={(open) => {
if (!open) handleBlur()
setIsEditing(open)
}}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isEditing}
className={cn(
"w-full justify-between",
currentError ? "border-destructive text-destructive" : "border-input"
)}
disabled={field.disabled}
>
{value
? field.fieldType.options.find((option) => option.value === value)?.label
: "Select..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." className="h-9" />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
onChange(currentValue)
if (field.onChange) {
field.onChange(currentValue)
}
setIsEditing(false)
}}
>
{option.label}
<Check
className={cn(
"ml-auto h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{currentError && (
<p className="text-xs text-destructive">{currentError.message}</p>
)}
</div>
)
case "multi-select":
const selectedValues = Array.isArray(value) ? value : value ? [value] : []
return (
<Popover open={isEditing} onOpenChange={(open) => {
if (!open) handleBlur()
setIsEditing(open)
}}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={isEditing}
className={cn(
"w-full justify-between",
currentError ? "border-destructive text-destructive" : "border-input"
)}
>
{selectedValues.length > 0
? `${selectedValues.length} selected`
: "Select multiple..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="Search options..." className="h-9" />
<CommandList>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{field.fieldType.options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(currentValue) => {
const valueIndex = selectedValues.indexOf(currentValue)
let newValues
if (valueIndex === -1) {
newValues = [...selectedValues, currentValue]
} else {
newValues = selectedValues.filter((_, i) => i !== valueIndex)
}
onChange(newValues)
// Don't close on selection for multi-select
}}
>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedValues.includes(option.value)}
className="pointer-events-none"
/>
{option.label}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
case "checkbox":
return (
<div className="flex items-center gap-2">
<Checkbox
checked={Boolean(value)}
onCheckedChange={(checked) => {
onChange(checked)
}}
/>
</div>
)
case "multi-input":
return (
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBlur()
}
}}
onBlur={handleBlur}
className={cn(
"w-full bg-transparent",
currentError ? "border-destructive text-destructive" : ""
)}
autoFocus={!error}
placeholder={`Enter values separated by ${(field.fieldType as MultiInput).separator || ","}`}
/>
)
default:
return (
<div className="space-y-1">
<Input
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleBlur()
}
}}
onBlur={handleBlur}
className={cn(
"w-full bg-transparent",
currentError ? "border-destructive text-destructive" : ""
)}
autoFocus={!error}
/>
{currentError && (
<p className="text-xs text-destructive">{currentError.message}</p>
)}
</div>
)
}
}
// Display mode
return (
<div
onClick={() => {
if (field.fieldType.type !== "checkbox" && !field.disabled) {
setIsEditing(true)
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
}
}}
className={cn(
"min-h-[36px] cursor-text p-2 rounded-md border bg-background",
currentError ? "border-destructive" : "border-input",
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
)}
>
<div className={cn(!value && "text-muted-foreground")}>
{value ? getDisplayValue(value, field.fieldType) : ""}
</div>
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
)}
{currentError && (
<div className="absolute left-0 -bottom-5 text-xs text-destructive">
{currentError.message}
</div>
)}
</div>
)
}
// Add this component for the column header with copy down functionality
const ColumnHeader = <T extends string>({
field,
data,
onCopyDown
}: {
field: Field<T>,
data: (Data<T> & Meta)[],
onCopyDown: (key: T) => void
}) => {
return (
<div className="flex items-center gap-2">
<div className="flex-1 overflow-hidden text-ellipsis">
{field.label}
</div>
{data.length > 1 && !field.disabled && (
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation()
onCopyDown(field.key as T)
}}
title="Copy first row's value down"
>
<ArrowDown className="h-3 w-3" />
</Button>
)}
</div>
)
}
// Add this component for the copy down confirmation dialog
const CopyDownDialog = ({
isOpen,
onClose,
onConfirm,
fieldLabel
}: {
isOpen: boolean
onClose: () => void
onConfirm: () => void
fieldLabel: string
}) => {
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>
Confirm Copy Down
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to copy the value from the first row's "{fieldLabel}" to all rows below? This will overwrite any existing values.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
onConfirm()
onClose()
}}>
Copy Down
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
)
}
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>()
const { toast } = useToast()
const [data, setData] = useState<(Data<T> & Meta)[]>(initialData)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [filterByErrors, setFilterByErrors] = useState(false)
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
const [isSubmitting, setSubmitting] = useState(false)
const [copyDownField, setCopyDownField] = useState<{key: T, label: string} | null>(null)
// Memoize filtered data to prevent recalculation on every render
const filteredData = useMemo(() => {
if (!filterByErrors) return data
return data.filter(row =>
row.__errors && Object.values(row.__errors).some(err => err.level === "error")
)
}, [data, filterByErrors])
const updateData = useCallback(
async (rows: typeof data, indexes?: number[]) => {
// Check if hooks are async - if they are we want to apply changes optimistically for better UX
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
setData(rows)
}
addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes).then((data) => setData(data))
},
[rowHook, tableHook, fields],
)
const updateRows = useCallback(
(rowIndex: number, columnId: string, value: string) => {
const newData = [...data]
// Get the actual row from the filtered or unfiltered data
const row = filteredData[rowIndex]
if (row) {
// Find the original index in the full dataset
const originalIndex = data.findIndex(r => r.__index === row.__index)
const updatedRow = {
...row,
[columnId]: value,
}
newData[originalIndex] = updatedRow
updateData(newData, [originalIndex])
}
},
[data, filteredData, updateData],
)
const copyValueDown = useCallback((key: T, label: string) => {
setCopyDownField({ key, label })
}, [])
const executeCopyDown = useCallback(() => {
if (!copyDownField || data.length <= 1) return
const firstRowValue = data[0][copyDownField.key]
const newData = data.map((row, index) => {
if (index === 0) return row
return {
...row,
[copyDownField.key]: firstRowValue
}
})
updateData(newData)
setCopyDownField(null)
}, [data, updateData, copyDownField])
const columns = useMemo<ColumnDef<Data<T> & Meta>[]>(() => {
const baseColumns: ColumnDef<Data<T> & Meta>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 50,
},
...fields.map((field: Field<T>): ColumnDef<Data<T> & Meta> => ({
accessorKey: field.key,
header: () => (
<div className="group">
<ColumnHeader
field={field}
data={data}
onCopyDown={(key) => copyValueDown(key, field.label)}
/>
</div>
),
cell: ({ row, column }) => {
const value = row.getValue(column.id)
const error = row.original.__errors?.[column.id]
const rowIndex = row.index
return (
<EditableCell
value={value}
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
error={error}
field={field}
/>
)
},
size: (field as any).width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
200
),
})),
]
return baseColumns
}, [fields, updateRows, data, copyValueDown])
const table = useReactTable({
data: filteredData,
columns,
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
})
const deleteSelectedRows = () => {
if (Object.keys(rowSelection).length) {
const selectedRows = Object.keys(rowSelection).map(Number)
const newData = data.filter((_, index) => !selectedRows.includes(index))
updateData(newData)
setRowSelection({})
}
}
const normalizeValue = useCallback((value: any, field: Field<T>) => {
if (field.fieldType.type === "checkbox") {
if (typeof value === "boolean") return value
if (typeof value === "string") {
const normalizedValue = value.toLowerCase().trim()
if (field.fieldType.booleanMatches) {
return !!field.fieldType.booleanMatches[normalizedValue]
}
return ["yes", "true", "1"].includes(normalizedValue)
}
return false
}
if (field.fieldType.type === "select") {
// Ensure the value matches one of the options
if (field.fieldType.options.some(opt => opt.value === value)) {
return value
}
// Try to match by label
const matchByLabel = field.fieldType.options.find(
opt => opt.label.toLowerCase() === String(value).toLowerCase()
)
return matchByLabel ? matchByLabel.value : value
}
return value
}, [])
const submitData = async () => {
const calculatedData = data.reduce(
(acc, value) => {
const { __index, __errors, ...values } = value
// Normalize values based on field types
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
const field = fields.find((f: Field<T>) => f.key === key)
if (field) {
obj[key as keyof Data<T>] = normalizeValue(val, field)
} else {
obj[key as keyof Data<T>] = val as string | boolean | undefined
}
return obj
}, {} as Data<T>)
if (__errors) {
for (const key in __errors) {
if (__errors[key].level === "error") {
acc.invalidData.push(normalizedValues)
return acc
}
}
}
acc.validData.push(normalizedValues)
return acc
},
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
)
setShowSubmitAlert(false)
setSubmitting(true)
const response = onSubmit(calculatedData, file)
if (response?.then) {
response
.then(() => {
onClose()
})
.catch((err: Error) => {
toast({
variant: "destructive",
title: translations.alerts.submitError.title,
description: err?.message || translations.alerts.submitError.defaultMessage,
})
})
.finally(() => {
setSubmitting(false)
})
} else {
onClose()
}
}
const onContinue = () => {
const invalidData = data.find((value) => {
if (value?.__errors) {
return !!Object.values(value.__errors)?.filter((err) => err.level === "error").length
}
return false
})
if (!invalidData) {
submitData()
} else {
setShowSubmitAlert(true)
}
}
return (
<div className="flex h-[calc(100vh-9.5rem)] flex-col">
<CopyDownDialog
isOpen={!!copyDownField}
onClose={() => setCopyDownField(null)}
onConfirm={executeCopyDown}
fieldLabel={copyDownField?.label || ""}
/>
<AlertDialog open={showSubmitAlert} onOpenChange={setShowSubmitAlert}>
<AlertDialogPortal>
<AlertDialogOverlay className="z-[1400]" />
<AlertDialogContent className="z-[1500]">
<AlertDialogHeader>
<AlertDialogTitle>
{translations.alerts.submitIncomplete.headerTitle}
</AlertDialogTitle>
<AlertDialogDescription>
{allowInvalidSubmit
? translations.alerts.submitIncomplete.bodyText
: translations.alerts.submitIncomplete.bodyTextSubmitForbidden}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{translations.alerts.submitIncomplete.cancelButtonTitle}
</AlertDialogCancel>
{allowInvalidSubmit && (
<AlertDialogAction onClick={submitData}>
{translations.alerts.submitIncomplete.finishButtonTitle}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialog>
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col">
<div className="px-8 pt-6">
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-3xl font-semibold text-foreground">
{translations.validationStep.title}
</h2>
<div className="flex flex-wrap items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={deleteSelectedRows}
>
{translations.validationStep.discardButtonTitle}
</Button>
<div className="flex items-center gap-2">
<Switch
checked={filterByErrors}
onCheckedChange={setFilterByErrors}
id="filter-errors"
/>
<label htmlFor="filter-errors" className="text-sm text-muted-foreground">
{translations.validationStep.filterSwitchTitle}
</label>
</div>
</div>
</div>
</div>
<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">
<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"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="p-2"
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">
{filterByErrors
? translations.validationStep.noRowsMessageWhenFiltered
: translations.validationStep.noRowsMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
</div>
<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}
</Button>
)}
<Button
className="ml-auto"
disabled={isSubmitting}
onClick={onContinue}
>
{translations.validationStep.nextButtonTitle}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -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 }

View File

@@ -0,0 +1,151 @@
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
import type { Meta, Error, Errors } from "../types"
import { v4 } from "uuid"
import { ErrorSources } from "../../../types"
export const addErrorsAndRunHooks = async <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
changedRowIndexes?: number[],
): Promise<(Data<T> & Meta)[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: T, error: Info) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source },
}
}
if (tableHook) {
data = await tableHook(data, (...props) => addError(ErrorSources.Table, ...props))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
data[index] = await rowHook(data[index], (...props) => addError(ErrorSources.Row, index, ...props), data)
}
} else {
data = await Promise.all(
data.map(async (value, index) =>
rowHook(value, (...props) => addError(ErrorSources.Row, index, ...props), data),
),
)
}
}
fields.forEach((field) => {
field.validations?.forEach((validation) => {
switch (validation.rule) {
case "unique": {
const values = data.map((entry) => entry[field.key as T])
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, field.key as T, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
})
}
})
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
if (entry[field.key as T] === null || entry[field.key as T] === undefined || entry[field.key as T] === "") {
addError(ErrorSources.Row, realIndex, field.key as T, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
})
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
const regex = new RegExp(validation.value, validation.flags)
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[field.key]?.toString() ?? ""
if (!value.match(regex)) {
addError(ErrorSources.Row, realIndex, field.key as T, {
level: validation.level || "error",
message:
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
})
}
})
break
}
}
})
})
return data.map((value, index) => {
// This is required only for table. Mutates to prevent needless rerenders
if (!("__index" in value)) {
value.__index = v4()
}
const newValue = value as Data<T> & Meta
// 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 { ...newValue, __errors: errors[index] }
}
if (!errors[index] && value?.__errors) {
return { ...newValue, __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 =
value.__errors && Object.values(value.__errors).some((error) => error.source === ErrorSources.Row)
if (!hasRowErrors) {
if (errors[index]) {
return { ...newValue, __errors: errors[index] }
}
return newValue
}
const errorsWithoutTableError = Object.entries(value.__errors!).reduce((acc, [key, value]) => {
if (value.source === ErrorSources.Row) {
acc[key] = value
}
return acc
}, {} as Error)
const newErrors = { ...errorsWithoutTableError, ...errors[index] }
return { ...newValue, __errors: newErrors }
}
return newValue
})
}

View File

@@ -0,0 +1,509 @@
import { StepsTheme } from "chakra-ui-steps"
import type { CSSObject } from "@chakra-ui/react"
import type { DeepPartial } from "ts-essentials"
import type { ChakraStylesConfig } from "chakra-react-select"
import type { SelectOption } from "./types"
const StepsComponent: typeof StepsTheme = {
...StepsTheme,
baseStyle: (props: any) => {
const navigationEnabled = !!props.onClickStep
return {
...StepsTheme.baseStyle(props),
stepContainer: {
...StepsTheme.baseStyle(props).stepContainer,
cursor: navigationEnabled ? "pointer" : "initial",
},
label: {
...StepsTheme.baseStyle(props).label,
color: "textColor",
},
}
},
variants: {
circles: (props: any) => ({
...StepsTheme.variants.circles(props),
step: {
...StepsTheme.variants.circles(props).step,
"&:not(:last-child):after": {
...StepsTheme.variants.circles(props).step["&:not(:last-child):after"],
backgroundColor: "background",
},
},
stepIconContainer: {
...StepsTheme.variants.circles(props).stepIconContainer,
flex: "0 0 auto",
bg: "background",
borderColor: "background",
},
}),
},
}
const MatchIconTheme: any = {
baseStyle: (props: any) => {
return {
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
borderWidth: "2px",
bg: "background",
borderColor: "yellow.500",
color: "background",
transitionDuration: "ultra-fast",
_highlighted: {
bg: "green.500",
borderColor: "green.500",
},
}
},
defaultProps: {
size: "md",
colorScheme: "green",
},
}
export const themeOverrides = {
colors: {
textColor: "#2D3748",
subtitleColor: "#718096",
inactiveColor: "#A0AEC0",
border: "#E2E8F0",
background: "white",
backgroundAlpha: "rgba(255,255,255,0)",
secondaryBackground: "#EDF2F7",
highlight: "#E2E8F0",
rsi: {
50: "#E6E6FF",
100: "#C4C6FF",
200: "#A2A5FC",
300: "#8888FC",
400: "#7069FA",
500: "#5D55FA",
600: "#4D3DF7",
700: "#3525E6",
800: "#1D0EBE",
900: "#0C008C",
},
},
shadows: {
outline: 0,
},
components: {
UploadStep: {
baseStyle: {
heading: {
fontSize: "3xl",
color: "textColor",
mb: "2rem",
},
title: {
fontSize: "2xl",
lineHeight: 8,
fontWeight: "semibold",
color: "textColor",
},
subtitle: {
fontSize: "md",
lineHeight: 6,
color: "subtitleColor",
mb: "1rem",
},
tableWrapper: {
mb: "0.5rem",
position: "relative",
h: "72px",
},
dropzoneText: {
size: "lg",
lineHeight: 7,
fontWeight: "semibold",
color: "textColor",
},
dropZoneBorder: "rsi.500",
dropzoneButton: {
mt: "1rem",
},
},
},
SelectSheetStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
radio: {},
radioLabel: {
color: "textColor",
},
},
},
SelectHeaderStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
},
},
MatchColumnsStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
title: {
color: "textColor",
fontSize: "2xl",
lineHeight: 8,
fontWeight: "semibold",
mb: 4,
},
userTable: {
header: {
fontSize: "xs",
lineHeight: 4,
fontWeight: "bold",
letterSpacing: "wider",
color: "textColor",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
["&[data-ignored]"]: {
color: "inactiveColor",
},
},
cell: {
fontSize: "sm",
lineHeight: 5,
fontWeight: "medium",
color: "textColor",
px: 6,
py: 4,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
["&[data-ignored]"]: {
color: "inactiveColor",
},
},
ignoreButton: {
size: "xs",
colorScheme: "gray",
color: "textColor",
},
},
selectColumn: {
text: {
fontSize: "sm",
lineHeight: 5,
fontWeight: "normal",
color: "inactiveColor",
px: 4,
},
accordionLabel: {
color: "blue.600",
fontSize: "sm",
lineHeight: 5,
pl: 1,
},
selectLabel: {
pt: "0.375rem",
pb: 2,
fontSize: "md",
lineHeight: 6,
fontWeight: "medium",
color: "textColor",
},
},
select: {
control: (provided) => ({
...provided,
borderColor: "border",
_hover: {
borderColor: "border",
},
["&[data-focus-visible]"]: {
borderColor: "border",
boxShadow: "none",
},
}),
menu: (provided) => ({
...provided,
p: 0,
mt: 0,
}),
menuList: (provided) => ({
...provided,
bg: "background",
borderColor: "border",
}),
option: (provided, state) => ({
...provided,
color: "textColor",
bg: state.isSelected || state.isFocused ? "highlight" : "background",
overflow: "hidden",
textOverflow: "ellipsis",
display: "block",
whiteSpace: "nowrap",
_hover: {
bg: "highlight",
},
}),
placeholder: (provided) => ({
...provided,
color: "inactiveColor",
}),
noOptionsMessage: (provided) => ({
...provided,
color: "inactiveColor",
}),
} as ChakraStylesConfig<SelectOption>,
},
},
ValidationStep: {
baseStyle: {
heading: {
color: "textColor",
fontSize: "3xl",
},
select: {
valueContainer: (provided) => ({
...provided,
py: 0,
px: 1.5,
}),
inputContainer: (provided) => ({ ...provided, py: 0 }),
control: (provided) => ({ ...provided, border: "none" }),
input: (provided) => ({ ...provided, color: "textColor" }),
menu: (provided) => ({
...provided,
p: 0,
mt: 0,
}),
menuList: (provided) => ({
...provided,
bg: "background",
borderColor: "border",
}),
option: (provided, state) => ({
...provided,
color: "textColor",
bg: state.isSelected || state.isFocused ? "highlight" : "background",
overflow: "hidden",
textOverflow: "ellipsis",
display: "block",
whiteSpace: "nowrap",
}),
noOptionsMessage: (provided) => ({
...provided,
color: "inactiveColor",
}),
} as ChakraStylesConfig<SelectOption>,
},
},
MatchIcon: MatchIconTheme,
Steps: StepsComponent,
Modal: {
baseStyle: {
dialog: {
borderRadius: "md",
bg: "background",
fontSize: "lg",
color: "textColor",
},
closeModalButton: {},
backButton: {
gridColumn: "1",
gridRow: "1",
justifySelf: "start",
},
continueButton: {
gridColumn: "1 / 3",
gridRow: "1",
justifySelf: "center",
},
},
variants: {
rsi: {
header: {
bg: "secondaryBackground",
px: "2rem",
py: "1.5rem",
},
body: {
bg: "background",
display: "flex",
paddingX: "2rem",
paddingY: "2rem",
flexDirection: "column",
flex: 1,
overflow: "auto",
height: "100%",
},
footer: {
bg: "secondaryBackground",
py: "1.5rem",
px: "2rem",
justifyContent: "center",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridTemplateRows: "1fr",
gap: "1rem",
},
dialog: {
outline: "unset",
minH: "calc(var(--chakra-vh) - 4rem)",
maxW: "calc(var(--chakra-vw) - 4rem)",
my: "2rem",
borderRadius: "lg",
overflow: "hidden",
},
},
},
},
Button: {
defaultProps: {
colorScheme: "rsi",
},
},
},
styles: {
global: {
// supporting older browsers but avoiding fill-available CSS as it doesn't work https://github.com/chakra-ui/chakra-ui/blob/073bbcd21a9caa830d71b61d6302f47aaa5c154d/packages/components/css-reset/src/css-reset.tsx#L5
":root": {
"--chakra-vh": "100vh",
"--chakra-vw": "100vw",
},
"@supports (height: 100dvh) and (width: 100dvw) ": {
":root": {
"--chakra-vh": "100dvh",
"--chakra-vw": "100dvw",
},
},
".rdg": {
contain: "size layout style paint",
borderRadius: "lg",
border: "none",
borderTop: "1px solid var(--rdg-border-color)",
blockSize: "100%",
flex: "1",
// we have to use vars here because chakra does not autotransform unknown props
"--rdg-row-height": "35px",
"--rdg-color": "var(--chakra-colors-textColor)",
"--rdg-background-color": "var(--chakra-colors-background)",
"--rdg-header-background-color": "var(--chakra-colors-background)",
"--rdg-row-hover-background-color": "var(--chakra-colors-background)",
"--rdg-selection-color": "var(--chakra-colors-blue-400)",
"--rdg-row-selected-background-color": "var(--chakra-colors-rsi-50)",
"--row-selected-hover-background-color": "var(--chakra-colors-rsi-100)",
"--rdg-error-cell-background-color": "var(--chakra-colors-red-50)",
"--rdg-warning-cell-background-color": "var(--chakra-colors-orange-50)",
"--rdg-info-cell-background-color": "var(--chakra-colors-blue-50)",
"--rdg-border-color": "var(--chakra-colors-border)",
"--rdg-frozen-cell-box-shadow": "none",
"--rdg-font-size": "var(--chakra-fontSizes-sm)",
},
".rdg-header-row .rdg-cell": {
color: "textColor",
fontSize: "xs",
lineHeight: 10,
fontWeight: "bold",
letterSpacing: "wider",
textTransform: "uppercase",
"&:first-of-type": {
borderTopLeftRadius: "lg",
},
"&:last-child": {
borderTopRightRadius: "lg",
},
},
".rdg-row:last-child .rdg-cell:first-of-type": {
borderBottomLeftRadius: "lg",
},
".rdg-row:last-child .rdg-cell:last-child": {
borderBottomRightRadius: "lg",
},
".rdg[dir='rtl']": {
".rdg-row:last-child .rdg-cell:first-of-type": {
borderBottomRightRadius: "lg",
borderBottomLeftRadius: "none",
},
".rdg-row:last-child .rdg-cell:last-child": {
borderBottomLeftRadius: "lg",
borderBottomRightRadius: "none",
},
},
".rdg-cell": {
contain: "size layout style paint",
borderRight: "none",
borderInlineEnd: "none",
borderBottom: "1px solid var(--rdg-border-color)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
"&[aria-selected='true']": {
boxShadow: "inset 0 0 0 1px var(--rdg-selection-color)",
},
"&:first-of-type": {
boxShadow: "none",
borderInlineStart: "1px solid var(--rdg-border-color)",
},
"&:last-child": {
borderInlineEnd: "1px solid var(--rdg-border-color)",
},
},
".rdg-cell-error": {
backgroundColor: "var(--rdg-error-cell-background-color)",
},
".rdg-cell-warning": {
backgroundColor: "var(--rdg-warning-cell-background-color)",
},
".rdg-cell-info": {
backgroundColor: "var(--rdg-info-cell-background-color)",
},
".rdg-static": {
cursor: "pointer",
},
".rdg-static .rdg-header-row": {
display: "none",
},
".rdg-static .rdg-cell": {
"--rdg-selection-color": "none",
},
".rdg-example .rdg-cell": {
"--rdg-selection-color": "none",
borderBottom: "none",
},
".rdg-radio": {
display: "flex",
alignItems: "center",
},
".rdg-checkbox": {
"--rdg-selection-color": "none",
display: "flex",
alignItems: "center",
},
},
},
} as const
export const rtlThemeSupport = {
components: {
Modal: {
baseStyle: {
dialog: {
direction: "rtl",
},
},
},
},
} as const
export type CustomTheme = DeepPartial<typeof themeOverrides>

View File

@@ -0,0 +1,82 @@
import type { DeepPartial } from "ts-essentials"
export const translations = {
uploadStep: {
title: "Upload file",
manifestTitle: "Data that we expect:",
manifestDescription: "(You will have a chance to rename or remove columns in next steps)",
maxRecordsExceeded: (maxRecords: string) => `Too many records. Up to ${maxRecords} allowed`,
dropzone: {
title: "Upload .xlsx, .xls or .csv file",
errorToastDescription: "upload rejected",
activeDropzoneTitle: "Drop file here...",
buttonTitle: "Select file",
loadingTitle: "Processing...",
},
selectSheet: {
title: "Select the sheet to use",
nextButtonTitle: "Next",
backButtonTitle: "Back",
},
},
selectHeaderStep: {
title: "Select header row",
nextButtonTitle: "Next",
backButtonTitle: "Back",
},
matchColumnsStep: {
title: "Match Columns",
nextButtonTitle: "Next",
backButtonTitle: "Back",
userTableTitle: "Your table",
templateTitle: "Will become",
selectPlaceholder: "Select column...",
ignoredColumnText: "Column ignored",
subSelectPlaceholder: "Select...",
matchDropdownTitle: "Match",
unmatched: "Unmatched",
duplicateColumnWarningTitle: "Another column unselected",
duplicateColumnWarningDescription: "Columns cannot duplicate",
},
validationStep: {
title: "Validate data",
nextButtonTitle: "Confirm",
backButtonTitle: "Back",
noRowsMessage: "No data found",
noRowsMessageWhenFiltered: "No data containing errors",
discardButtonTitle: "Discard selected rows",
filterSwitchTitle: "Show only rows with errors",
},
alerts: {
confirmClose: {
headerTitle: "Exit import flow",
bodyText: "Are you sure? Your current information will not be saved.",
cancelButtonTitle: "Cancel",
exitButtonTitle: "Exit flow",
},
submitIncomplete: {
headerTitle: "Errors detected",
bodyText: "There are still some rows that contain errors. Rows with errors will be ignored when submitting.",
bodyTextSubmitForbidden: "There are still some rows containing errors.",
cancelButtonTitle: "Cancel",
finishButtonTitle: "Submit",
},
submitError: {
title: "Error",
defaultMessage: "An error occurred while submitting data",
},
unmatchedRequiredFields: {
headerTitle: "Not all columns matched",
bodyText: "There are required columns that are not matched or ignored. Do you want to continue?",
listTitle: "Columns not matched:",
cancelButtonTitle: "Cancel",
continueButtonTitle: "Continue",
},
toast: {
error: "Error",
},
},
}
export type TranslationsRSIProps = DeepPartial<typeof translations>
export type Translations = typeof translations

View File

@@ -0,0 +1,174 @@
import type { Meta } from "./steps/ValidationStep/types"
import type { DeepReadonly } from "ts-essentials"
import type { TranslationsRSIProps } from "./translationsRSIProps"
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
import type { StepState } from "./steps/UploadFlow"
export type RsiProps<T extends string> = {
// Is modal visible.
isOpen: boolean
// callback when RSI is closed before final submit
onClose: () => void
// Field description for requested data
fields: Fields<T>
// Runs after file upload step, receives and returns raw sheet data
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>
// Runs after header selection step, receives and returns raw sheet data
selectHeaderStepHook?: (headerValues: RawData, data: RawData[]) => Promise<{ headerValues: RawData; data: RawData[] }>
// Runs once before validation step, used for data mutations and if you want to change how columns were matched
matchColumnsStepHook?: (table: Data<T>[], rawData: RawData[], columns: Columns<T>) => Promise<Data<T>[]>
// Runs after column matching and on entry change
rowHook?: RowHook<T>
// Runs after column matching and on entry change
tableHook?: TableHook<T>
// Function called after user finishes the flow. You can return a promise that will be awaited.
onSubmit: (data: Result<T>, file: File) => void | Promise<any>
// Allows submitting with errors. Default: true
allowInvalidSubmit?: boolean
// Enable navigation in stepper component and show back button. Default: false
isNavigationEnabled?: boolean
// Translations for each text
translations?: TranslationsRSIProps
// Theme configuration passed to underlying Chakra-UI
customTheme?: object
// Specifies maximum number of rows for a single import
maxRecords?: number
// Maximum upload filesize (in bytes)
maxFileSize?: number
// Automatically map imported headers to specified fields if possible. Default: true
autoMapHeaders?: boolean
// When field type is "select", automatically match values if possible. Default: false
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching
autoMapDistance?: number
// Initial Step state to be rendered on load
initialStepState?: StepState
// Sets SheetJS dateNF option. If date parsing is applied, date will be formatted e.g. "yyyy-mm-dd hh:mm:ss", "m/d/yy h:mm", 'mmm-yy', etc.
dateFormat?: string
// Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields.
parseRaw?: boolean
// Use for right-to-left (RTL) support
rtl?: boolean
}
export type RawData = Array<string | undefined>
export type Data<T extends string> = { [key in T]: string | boolean | undefined }
// Data model RSI uses for spreadsheet imports
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
export type Field<T extends string = string> = {
// UI-facing field label
label: string
// Field's unique identifier
key: T
// UI-facing additional information displayed via tooltip and ? icon
description?: string
// Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName"
alternateMatches?: string[]
// Validations used for field entries
validations?: Validation[]
// Field entry component
fieldType: Checkbox | Select | Input | MultiInput | MultiSelect
// UI-facing values shown to user as field examples pre-upload phase
example?: string
width?: number
disabled?: boolean
onChange?: (value: string) => void
}
export type Checkbox = {
type: "checkbox"
// Alternate values to be treated as booleans, e.g. {yes: true, no: false}
booleanMatches?: { [key: string]: boolean }
}
export type Select = {
type: "select"
// Options displayed in Select component
options: SelectOption[]
}
export type SelectOption = {
// UI-facing option label
label: string
// Field entry matching criteria as well as select output
value: string
}
export type Input = {
type: "input"
}
export type MultiInput = {
type: "multi-input"
separator?: string // Optional separator for parsing multiple values, defaults to comma
}
export type MultiSelect = {
type: "multi-select"
options: SelectOption[]
separator?: string // Optional separator for parsing multiple values, defaults to comma
}
export type Validation = RequiredValidation | UniqueValidation | RegexValidation
export type RequiredValidation = {
rule: "required"
errorMessage?: string
level?: ErrorLevel
}
export type UniqueValidation = {
rule: "unique"
allowEmpty?: boolean
errorMessage?: string
level?: ErrorLevel
}
export type RegexValidation = {
rule: "regex"
value: string
flags?: string
errorMessage: string
level?: ErrorLevel
}
export type RowHook<T extends string> = (
row: Data<T>,
addError: (fieldKey: T, error: Info) => void,
table: Data<T>[],
) => Data<T> | Promise<Data<T>>
export type TableHook<T extends string> = (
table: Data<T>[],
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
) => Data<T>[] | Promise<Data<T>[]>
export type ErrorLevel = "info" | "warning" | "error"
export type Info = {
message: string
level: ErrorLevel
}
export enum ErrorSources {
Table = "table",
Row = "row",
}
/*
Source determines whether the error is from the full table or row validation
Table validation is tableHook and "unique" validation
Row validation is rowHook and all other validations
it is used to determine if row.__errors should be updated or not depending on different validations
*/
export type InfoWithSource = Info & {
source: ErrorSources
}
export type Result<T extends string> = {
validData: Data<T>[]
invalidData: Data<T>[]
all: (Data<T> & Meta)[]
}

View File

@@ -0,0 +1,6 @@
import type XLSX from "xlsx-ugnis"
export const exceedsMaxRecords = (workSheet: XLSX.WorkSheet, maxRecords: number) => {
const [top, bottom] = workSheet["!ref"]?.split(":").map((position) => parseInt(position.replace(/\D/g, ""), 10)) || []
return bottom - top > maxRecords
}

View File

@@ -0,0 +1,10 @@
export const mapData = (data: string[][], valueMap: string[]) =>
data.map((row) =>
row.reduce<{ [k: string]: string }>((obj, value, index) => {
if (valueMap[index]) {
obj[valueMap[index]] = `${value}`
return obj
}
return obj
}, {}),
)

View File

@@ -0,0 +1,15 @@
import * as XLSX from "xlsx"
import type { RawData } from "../types"
export const mapWorkbook = (workbook: XLSX.WorkBook): RawData[] => {
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const data = XLSX.utils.sheet_to_json<string[]>(worksheet, {
header: 1,
raw: false,
defval: "",
})
return data
}

View File

@@ -0,0 +1,26 @@
import { StepType } from "../steps/UploadFlow"
export const steps = ["uploadStep", "selectHeaderStep", "matchColumnsStep", "validationStep"] as const
const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
[StepType.upload]: "uploadStep",
[StepType.selectSheet]: "uploadStep",
[StepType.selectHeader]: "selectHeaderStep",
[StepType.matchColumns]: "matchColumnsStep",
[StepType.validateData]: "validationStep",
}
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {
uploadStep: StepType.upload,
selectHeaderStep: StepType.selectHeader,
matchColumnsStep: StepType.matchColumns,
validationStep: StepType.validateData,
}
export const stepIndexToStepType = (stepIndex: number) => {
const step = steps[stepIndex]
return StepToStepTypeRecord[step] || StepType.upload
}
export const stepTypeToStepIndex = (type?: StepType) => {
const step = StepTypeToStepRecord[type || StepType.upload]
return Math.max(0, steps.indexOf(step))
}

View File

@@ -0,0 +1,591 @@
import { useState } from "react";
import { ReactSpreadsheetImport } from "@/lib/react-spreadsheet-import/src";
import type { Field, Fields, Validation, ErrorLevel } from "@/lib/react-spreadsheet-import/src/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Code } from "@/components/ui/code";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { useQuery } from "@tanstack/react-query";
import config from "@/config";
import { StepType } from "@/lib/react-spreadsheet-import/src/steps/UploadFlow";
// Define base fields without dynamic options
const BASE_IMPORT_FIELDS = [
{
label: "Supplier",
key: "supplier",
description: "Primary supplier/manufacturer of the product",
fieldType: {
type: "select" as const,
options: [], // Will be populated from API
},
width: 200,
validations: [{ rule: "required" as const, errorMessage: "Required", level: "error" as ErrorLevel }],
},
{
label: "UPC",
key: "upc",
description: "Universal Product Code/Barcode",
alternateMatches: ["barcode", "bar code", "JAN", "EAN"],
fieldType: { type: "input" },
width: 150,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Supplier #",
key: "supplier_no",
description: "Supplier's product identifier",
alternateMatches: ["sku", "item#", "mfg item #", "item"],
fieldType: { type: "input" },
width: 120,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
],
},
{
label: "Notions #",
key: "notions_no",
description: "Internal notions number",
fieldType: { type: "input" },
width: 120,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Name",
key: "name",
description: "Product name/title",
alternateMatches: ["sku description"],
fieldType: { type: "input" },
width: 300,
validations: [
{ rule: "required", errorMessage: "Required", 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: 120,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "unique", errorMessage: "Must be unique", level: "error" },
],
},
{
label: "Image URL",
key: "image_url",
description: "Product image URL(s)",
fieldType: { type: "multi-input" },
width: 250,
},
{
label: "MSRP",
key: "msrp",
description: "Manufacturer's Suggested Retail Price",
alternateMatches: ["retail", "retail price", "sugg retail", "price", "sugg. Retail"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Qty Per Unit",
key: "qty_per_unit",
description: "Quantity of items per individual unit",
alternateMatches: ["inner pack", "inner", "min qty", "unit qty", "min. order qty"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Cost Each",
key: "cost_each",
description: "Wholesale cost per unit",
alternateMatches: ["wholesale", "wholesale price"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Case Pack",
key: "case_qty",
description: "Number of units per case",
alternateMatches: ["mc qty"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Tax Category",
key: "tax_cat",
description: "Product tax category",
fieldType: {
type: "multi-select",
options: [], // Will be populated from API
},
width: 150,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Company",
key: "company",
description: "Company/Brand name",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 200,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Line",
key: "line",
description: "Product line",
fieldType: {
type: "select",
options: [], // Will be populated dynamically based on company selection
},
width: 150,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Sub Line",
key: "subline",
description: "Product sub-line",
fieldType: {
type: "select",
options: [], // Will be populated dynamically based on line selection
},
width: 150,
},
{
label: "Artist",
key: "artist",
description: "Artist/Designer name",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 200,
},
{
label: "ETA Date",
key: "eta",
description: "Estimated arrival date",
alternateMatches: ["shipping month"],
fieldType: { type: "input" },
width: 120,
},
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Length",
key: "length",
description: "Product length (in inches)",
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Width",
key: "width",
description: "Product width (in inches)",
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Height",
key: "height",
description: "Product height (in inches)",
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Shipping Restrictions",
key: "ship_restrictions",
description: "Product shipping restrictions",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 150,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Country Of Origin",
key: "coo",
description: "2-letter country code (ISO)",
alternateMatches: ["coo"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "regex", value: "^[A-Z]{2}$", errorMessage: "Must be 2 letters", level: "error" },
],
},
{
label: "HTS Code",
key: "hts_code",
description: "Harmonized Tariff Schedule code",
alternateMatches: ["taric"],
fieldType: { type: "input" },
width: 120,
validations: [
{ rule: "regex", value: "^[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{
label: "Size Category",
key: "size_cat",
description: "Product size category",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 150,
},
{
label: "Description",
key: "description",
description: "Detailed product description",
fieldType: { type: "input" },
width: 400,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Private Notes",
key: "priv_notes",
description: "Internal notes about the product",
fieldType: { type: "input" },
width: 300,
},
{
label: "Categories",
key: "categories",
description: "Product categories",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 200,
validations: [{ rule: "required", errorMessage: "Required", level: "error" }],
},
{
label: "Themes",
key: "themes",
description: "Product themes/styles",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 200,
},
{
label: "Colors",
key: "colors",
description: "Product colors",
fieldType: {
type: "select",
options: [], // Will be populated from API
},
width: 150,
},
] as const;
type ImportField = typeof BASE_IMPORT_FIELDS[number];
type ImportFieldKey = ImportField["key"];
export function Import() {
const [isOpen, setIsOpen] = useState(false);
const [importedData, setImportedData] = useState<any[] | null>(null);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLine, setSelectedLine] = useState<string | null>(null);
const [startFromScratch, setStartFromScratch] = useState(false);
// Fetch initial field options from the API
const { data: fieldOptions, isLoading: isLoadingOptions } = useQuery({
queryKey: ["import-field-options"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error("Failed to fetch field options");
}
return response.json();
},
});
// Fetch product lines when company is selected
const { data: productLines } = useQuery({
queryKey: ["product-lines", selectedCompany],
queryFn: async () => {
if (!selectedCompany) return [];
const response = await fetch(`${config.apiUrl}/import/product-lines/${selectedCompany}`);
if (!response.ok) {
throw new Error("Failed to fetch product lines");
}
return response.json();
},
enabled: !!selectedCompany,
});
// Fetch sublines when line is selected
const { data: sublines } = useQuery({
queryKey: ["sublines", selectedLine],
queryFn: async () => {
if (!selectedLine) return [];
const response = await fetch(`${config.apiUrl}/import/sublines/${selectedLine}`);
if (!response.ok) {
throw new Error("Failed to fetch sublines");
}
return response.json();
},
enabled: !!selectedLine,
});
// Handle field value changes
const handleFieldChange = (field: string, value: any) => {
console.log('Field change:', field, value);
if (field === "company") {
setSelectedCompany(value);
setSelectedLine(null); // Reset line when company changes
} else if (field === "line") {
setSelectedLine(value);
}
};
// Merge base fields with dynamic options
const importFields = BASE_IMPORT_FIELDS.map(field => {
if (!fieldOptions) return field;
switch (field.key) {
case "company":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.companies || [],
},
onChange: (value: string) => {
console.log('Company selected:', value);
handleFieldChange("company", value);
},
};
case "line":
return {
...field,
fieldType: {
type: "select" as const,
options: productLines || [],
},
onChange: (value: string) => {
console.log('Line selected:', value);
handleFieldChange("line", value);
},
disabled: !selectedCompany,
};
case "subline":
return {
...field,
fieldType: {
type: "select" as const,
options: sublines || [],
},
disabled: !selectedLine,
};
case "colors":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.colors || [],
},
};
case "tax_cat":
return {
...field,
fieldType: {
type: "multi-select" as const,
options: fieldOptions.taxCategories || [],
},
};
case "ship_restrictions":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.shippingRestrictions || [],
},
};
case "supplier":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.suppliers || [],
},
};
case "artist":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.artists || [],
},
};
case "categories":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.categories || [],
},
};
case "themes":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.themes || [],
},
};
case "size_cat":
return {
...field,
fieldType: {
type: "select" as const,
options: fieldOptions.sizes || [],
},
};
default:
return field;
}
});
const handleData = async (data: any, file: File) => {
try {
console.log("Imported Data:", data);
console.log("File:", file);
setImportedData(data);
setIsOpen(false);
toast.success("Data imported successfully");
} catch (error) {
toast.error("Failed to import data");
console.error("Import error:", error);
}
};
if (isLoadingOptions) {
return (
<div className="container mx-auto py-6">
<h1 className="text-3xl font-bold tracking-tight">Loading import options...</h1>
</div>
);
}
return (
<motion.div
layout
transition={{
layout: {
duration: 0.15,
ease: [0.4, 0, 0.2, 1]
}
}}
className="container mx-auto py-6 space-y-4"
>
<motion.div
layout="position"
transition={{ duration: 0.15 }}
className="flex items-center justify-between"
>
<h1 className="text-3xl font-bold tracking-tight">Add New Products</h1>
</motion.div>
<Card className="max-w-[400px]">
<CardHeader>
<CardTitle>Import Data</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => setIsOpen(true)} className="w-full">
Upload Spreadsheet
</Button>
<Button
onClick={() => {
setStartFromScratch(true);
setIsOpen(true);
}}
variant="outline"
className="w-full"
>
Start From Scratch
</Button>
</CardContent>
</Card>
{importedData && (
<Card>
<CardHeader>
<CardTitle>Preview Imported Data</CardTitle>
</CardHeader>
<CardContent>
<Code className="p-4 w-full rounded-md border">
{JSON.stringify(importedData, null, 2)}
</Code>
</CardContent>
</Card>
)}
<ReactSpreadsheetImport
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
setStartFromScratch(false);
}}
onSubmit={handleData}
fields={importFields}
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
/>
</motion.div>
);
}

View File

@@ -1,7 +1,7 @@
#!/bin/zsh
#Clear previous mount in case its still there
umount /Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server
umount '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server'
#Mount
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 /Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server/
sshfs matt@dashboard.kent.pw:/var/www/html/inventory -p 22122 '/Users/matt/Library/Mobile Documents/com~apple~CloudDocs/Dev/inventory/inventory-server/'

19
package-lock.json generated
View File

@@ -4,9 +4,11 @@
"requires": true,
"packages": {
"": {
"name": "inventory",
"dependencies": {
"shadcn": "^1.0.0"
},
"devDependencies": {
"ts-essentials": "^10.0.4"
}
},
"node_modules/shadcn": {
@@ -14,6 +16,21 @@
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-1.0.0.tgz",
"integrity": "sha512-kCxBIBiPS83WxrWkOQHamWpr9XlLtOtOlJM6QX90h9A5xZCBMhxu4ibcNT2ZnzZLdexkYbQrnijfPKdOsZxOpA==",
"license": "ISC"
},
"node_modules/ts-essentials": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.0.4.tgz",
"integrity": "sha512-lwYdz28+S4nicm+jFi6V58LaAIpxzhg9rLdgNC1VsdP/xiFBseGhF1M/shwCk6zMmwahBZdXcl34LVHrEang3A==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
}
}

View File

@@ -1,5 +1,8 @@
{
"dependencies": {
"shadcn": "^1.0.0"
},
"devDependencies": {
"ts-essentials": "^10.0.4"
}
}