Skip to content

Commit 1747fa3

Browse files
author
RobJellinghaus
committed
NAILED IT. CSV upload is a thing
1 parent daf119a commit 1747fa3

4 files changed

Lines changed: 310 additions & 2 deletions

File tree

backend/graphql/mutation.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,34 @@ impl MutationRoot {
124124
.map(|count| count > 0)
125125
.map_err(|e| async_graphql::Error::new(format!("Database error: {}", e)))
126126
}
127+
128+
async fn import_suppliers(&self, ctx: &Context<'_>, suppliers: Vec<CreateSupplierInput>) -> Result<Vec<Suppliers>> {
129+
let mut con = get_connection(ctx)?;
130+
let mut created_suppliers = Vec::new();
131+
132+
for supplier_input in suppliers {
133+
let new_supplier = CreateSuppliers {
134+
name: supplier_input.name,
135+
address: supplier_input.address,
136+
city: supplier_input.city,
137+
state: supplier_input.state,
138+
zip_code: supplier_input.zip_code,
139+
country: supplier_input.country,
140+
contact_name: supplier_input.contact_name,
141+
contact_email: supplier_input.contact_email,
142+
contact_phone: supplier_input.contact_phone,
143+
website: supplier_input.website,
144+
};
145+
146+
match Suppliers::create(&mut con, &new_supplier) {
147+
Ok(supplier) => created_suppliers.push(supplier),
148+
Err(e) => {
149+
// Log the error but continue with other suppliers
150+
eprintln!("Error creating supplier {}: {}", new_supplier.name, e);
151+
}
152+
}
153+
}
154+
155+
Ok(created_suppliers)
156+
}
127157
}

frontend/src/containers/SupplierList.tsx

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useRef } from 'react';
22
import { graphql, usePreloadedQuery, useMutation, useQueryLoader } from 'react-relay';
33
import type { SupplierListQuery } from '../__generated__/SupplierListQuery.graphql.ts';
44

@@ -57,6 +57,27 @@ const deleteSupplierMutation = graphql`
5757
}
5858
`;
5959

60+
// Define the bulk import suppliers mutation
61+
const importSuppliersMutation = graphql`
62+
mutation SupplierListImportMutation($suppliers: [CreateSupplierInput!]!) {
63+
importSuppliers(suppliers: $suppliers) {
64+
id
65+
name
66+
address
67+
city
68+
state
69+
zipCode
70+
country
71+
contactName
72+
contactEmail
73+
contactPhone
74+
website
75+
createdAt
76+
updatedAt
77+
}
78+
}
79+
`;
80+
6081
interface Supplier {
6182
id: string;
6283
name: string;
@@ -95,6 +116,8 @@ const SupplierListContent = ({
95116
loadQuery: (variables: { page: number; pageSize: number }, options?: { fetchPolicy?: string }) => void;
96117
}) => {
97118
const [showCreateForm, setShowCreateForm] = useState<boolean>(false);
119+
const [isImportPending, setIsImportPending] = useState<boolean>(false);
120+
const fileInputRef = useRef<HTMLInputElement>(null);
98121
const [createForm, setCreateForm] = useState<CreateSupplierInput>({
99122
name: '',
100123
address: '',
@@ -112,6 +135,7 @@ const SupplierListContent = ({
112135

113136
const [createSupplier, isCreatePending] = useMutation(createSupplierMutation);
114137
const [deleteSupplier, isDeletePending] = useMutation(deleteSupplierMutation);
138+
const [importSuppliers] = useMutation(importSuppliersMutation);
115139

116140
const suppliers = data.suppliers?.items || [];
117141
const totalItems = data.suppliers?.totalItems || 0;
@@ -188,6 +212,149 @@ const SupplierListContent = ({
188212
});
189213
};
190214

215+
const parseCSV = (csvText: string): CreateSupplierInput[] => {
216+
const lines = csvText.trim().split('\n');
217+
if (lines.length < 2) {
218+
throw new Error('CSV must have header row and at least one data row');
219+
}
220+
221+
const headers = lines[0].split(',').map(h => h.replace(/"/g, '').trim());
222+
const suppliers: CreateSupplierInput[] = [];
223+
224+
for (let i = 1; i < lines.length; i++) {
225+
const line = lines[i];
226+
if (line.trim() === '') continue;
227+
228+
// Simple CSV parsing (handles quoted fields)
229+
const values: string[] = [];
230+
let currentValue = '';
231+
let inQuotes = false;
232+
233+
for (let j = 0; j < line.length; j++) {
234+
const char = line[j];
235+
if (char === '"') {
236+
inQuotes = !inQuotes;
237+
} else if (char === ',' && !inQuotes) {
238+
values.push(currentValue.trim());
239+
currentValue = '';
240+
} else {
241+
currentValue += char;
242+
}
243+
}
244+
values.push(currentValue.trim()); // Push the last value
245+
246+
const supplier: CreateSupplierInput = {
247+
name: '',
248+
address: '',
249+
city: '',
250+
state: '',
251+
zipCode: '',
252+
country: '',
253+
contactName: '',
254+
contactEmail: '',
255+
contactPhone: '',
256+
website: ''
257+
};
258+
259+
// Map CSV columns to supplier fields
260+
headers.forEach((header, index) => {
261+
const value = values[index] || '';
262+
switch (header.toLowerCase()) {
263+
case 'name':
264+
supplier.name = value;
265+
break;
266+
case 'address':
267+
supplier.address = value;
268+
break;
269+
case 'city':
270+
supplier.city = value;
271+
break;
272+
case 'state':
273+
supplier.state = value;
274+
break;
275+
case 'zipcode':
276+
case 'zip_code':
277+
supplier.zipCode = value;
278+
break;
279+
case 'country':
280+
supplier.country = value;
281+
break;
282+
case 'contactname':
283+
case 'contact_name':
284+
supplier.contactName = value;
285+
break;
286+
case 'contactemail':
287+
case 'contact_email':
288+
supplier.contactEmail = value;
289+
break;
290+
case 'contactphone':
291+
case 'contact_phone':
292+
supplier.contactPhone = value;
293+
break;
294+
case 'website':
295+
supplier.website = value || undefined;
296+
break;
297+
}
298+
});
299+
300+
// Validate required fields
301+
if (supplier.name && supplier.contactEmail) {
302+
suppliers.push(supplier);
303+
}
304+
}
305+
306+
return suppliers;
307+
};
308+
309+
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
310+
const file = event.target.files?.[0];
311+
if (!file) return;
312+
313+
if (!file.name.toLowerCase().endsWith('.csv')) {
314+
alert('Please select a CSV file');
315+
return;
316+
}
317+
318+
setIsImportPending(true);
319+
320+
try {
321+
const text = await file.text();
322+
const suppliersData = parseCSV(text);
323+
324+
if (suppliersData.length === 0) {
325+
alert('No valid supplier data found in CSV file');
326+
setIsImportPending(false);
327+
return;
328+
}
329+
330+
importSuppliers({
331+
variables: { suppliers: suppliersData },
332+
onCompleted: (response) => {
333+
alert(`Successfully imported ${response.importSuppliers?.length || 0} suppliers`);
334+
setIsImportPending(false);
335+
// Clear the file input
336+
if (fileInputRef.current) {
337+
fileInputRef.current.value = '';
338+
}
339+
// Refresh the list
340+
loadQuery(
341+
{ page: 0, pageSize: 10 },
342+
{ fetchPolicy: 'network-only' }
343+
);
344+
},
345+
onError: (error) => {
346+
console.error('Error importing suppliers:', error);
347+
alert('Error importing suppliers. Please check the file format and try again.');
348+
setIsImportPending(false);
349+
}
350+
});
351+
} catch (error) {
352+
console.error('Error parsing CSV:', error);
353+
alert('Error parsing CSV file. Please check the file format.');
354+
setIsImportPending(false);
355+
}
356+
};
357+
191358
return (
192359
<div style={{ display: 'flex', flexFlow: 'column', textAlign: 'left' }}>
193360
<h1>Suppliers</h1>
@@ -196,10 +363,24 @@ const SupplierListContent = ({
196363
<button
197364
onClick={() => setShowCreateForm(true)}
198365
disabled={showCreateForm}
199-
style={{ marginBottom: '20px' }}
366+
style={{ marginBottom: '20px', marginRight: '10px' }}
200367
>
201368
Add New Supplier
202369
</button>
370+
<input
371+
type="file"
372+
accept=".csv"
373+
onChange={handleFileUpload}
374+
style={{ display: 'none' }}
375+
ref={fileInputRef}
376+
/>
377+
<button
378+
onClick={() => fileInputRef.current?.click()}
379+
disabled={isImportPending}
380+
style={{ marginBottom: '20px' }}
381+
>
382+
{isImportPending ? 'Importing...' : 'Import CSV'}
383+
</button>
203384
</div>
204385

205386
{showCreateForm && (

frontend/src/schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type Mutation {
8080
createSupplier(input: CreateSupplierInput!): Supplier!
8181
updateSupplier(id: ID!, input: UpdateSupplierInput!): Supplier!
8282
deleteSupplier(id: ID!): Boolean!
83+
importSuppliers(suppliers: [CreateSupplierInput!]!): [Supplier!]!
8384
}
8485

8586
schema {

tests/suppliers.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,100 @@ test.describe('Supplier Tests', () => {
329329
await expect(page.locator('text=Page 1 of 1')).toBeVisible();
330330
});
331331

332+
test('should upload and import CSV file successfully', async ({ page }) => {
333+
await page.click('a:has-text("Suppliers")');
334+
335+
// Verify we start with empty state
336+
await expect(page.locator('text=No suppliers found. Create one to get started!')).toBeVisible();
337+
338+
// Create a small test CSV file
339+
const csvContent = `name,address,city,state,zipCode,country,contactName,contactEmail,contactPhone,website
340+
"Test Corp","123 Test St","Test City","CA","12345","USA","John Test","john@test.com","555-1234","https://test.com"
341+
"Another Corp","456 Another Ave","Another City","NY","67890","USA","Jane Another","jane@another.com","555-5678","https://another.com"`;
342+
343+
// Create a File object for the CSV
344+
const file = new File([csvContent], 'test-suppliers.csv', { type: 'text/csv' });
345+
346+
// Find the hidden file input and set the file
347+
const fileInput = page.locator('input[type="file"][accept=".csv"]');
348+
await fileInput.setInputFiles({
349+
name: 'test-suppliers.csv',
350+
mimeType: 'text/csv',
351+
buffer: Buffer.from(csvContent)
352+
});
353+
354+
// Set up dialog handler to accept the success message
355+
page.on('dialog', dialog => {
356+
expect(dialog.message()).toContain('Successfully imported 2 suppliers');
357+
dialog.accept();
358+
});
359+
360+
// Click the import button to trigger the upload
361+
await page.click('button:has-text("Import CSV")');
362+
363+
// Wait for the import to complete and page to update
364+
await page.waitForLoadState('networkidle');
365+
366+
// Verify suppliers were imported
367+
await expect(page.locator('text=Test Corp')).toBeVisible();
368+
await expect(page.locator('text=Another Corp')).toBeVisible();
369+
await expect(page.locator('text=john@test.com')).toBeVisible();
370+
await expect(page.locator('text=jane@another.com')).toBeVisible();
371+
372+
// Verify empty state is no longer visible
373+
await expect(page.locator('text=No suppliers found. Create one to get started!')).not.toBeVisible();
374+
375+
// Verify we have supplier rows with proper data
376+
await expect(page.locator('text=Contact: John Test (john@test.com)')).toBeVisible();
377+
await expect(page.locator('text=Address: 123 Test St, Test City, CA 12345, USA')).toBeVisible();
378+
await expect(page.locator('text=Contact: Jane Another (jane@another.com)')).toBeVisible();
379+
await expect(page.locator('text=Address: 456 Another Ave, Another City, NY 67890, USA')).toBeVisible();
380+
});
381+
382+
test('should handle invalid CSV file gracefully', async ({ page }) => {
383+
await page.click('a:has-text("Suppliers")');
384+
385+
// Create an invalid CSV file (missing required headers)
386+
const invalidCsvContent = `invalid,headers,here
387+
"Some","Data","Here"`;
388+
389+
// Find the hidden file input and set the invalid file
390+
const fileInput = page.locator('input[type="file"][accept=".csv"]');
391+
await fileInput.setInputFiles({
392+
name: 'invalid.csv',
393+
mimeType: 'text/csv',
394+
buffer: Buffer.from(invalidCsvContent)
395+
});
396+
397+
// Set up dialog handler to catch the error message
398+
page.on('dialog', dialog => {
399+
expect(dialog.message()).toContain('No valid supplier data found in CSV file');
400+
dialog.accept();
401+
});
402+
403+
// Click the import button
404+
await page.click('button:has-text("Import CSV")');
405+
406+
// Wait for the error handling
407+
await page.waitForLoadState('networkidle');
408+
409+
// Verify empty state is still visible (no suppliers were imported)
410+
await expect(page.locator('text=No suppliers found. Create one to get started!')).toBeVisible();
411+
});
412+
413+
test('should show import button and accept CSV files only', async ({ page }) => {
414+
await page.click('a:has-text("Suppliers")');
415+
416+
// Verify import button is visible and enabled
417+
await expect(page.locator('button:has-text("Import CSV")')).toBeVisible();
418+
await expect(page.locator('button:has-text("Import CSV")')).toBeEnabled();
419+
420+
// Verify file input accepts only CSV files
421+
const fileInput = page.locator('input[type="file"][accept=".csv"]');
422+
await expect(fileInput).toHaveAttribute('accept', '.csv');
423+
424+
// Verify file input is hidden
425+
await expect(fileInput).toHaveCSS('display', 'none');
426+
});
427+
332428
});

0 commit comments

Comments
 (0)