Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions apps/src/components/file-upload.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Ladle stories for FileUpload component
*/

import { useState } from 'react';
import type { Story } from '@ladle/react';
import { FileUpload } from './file-upload';
import { introspectFile } from '../lib/shapefile/file-introspection';

export const Basic: Story = () => {
const [file, setFile] = useState<File | null>(null);

return (
<div style={{ padding: '2rem' }}>
<h2>Basic File Upload</h2>
<FileUpload
accept="application/zip"
onChange={setFile}
/>
{file && (
<div style={{ marginTop: '1rem' }}>
<strong>Selected:</strong> {file.name} ({file.size} bytes)
</div>
)}
</div>
);
};

export const WithValidation: Story = () => {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string>();

const handleFileChange = async (selectedFile: File | null) => {
setFile(selectedFile);

if (!selectedFile) {
setError(undefined);
return;
}

try {
const inspection = await introspectFile(selectedFile);

if (inspection.isEmpty) {
setError('File is empty');
} else if (!inspection.isZip) {
setError('File must be a ZIP archive');
} else {
setError(undefined);
}
} catch (err) {
setError('Failed to read file');
}
};

return (
<div style={{ padding: '2rem' }}>
<h2>File Upload with Validation</h2>
<p>Select a file to validate it's a non-empty ZIP archive.</p>
<FileUpload
accept="application/zip"
onChange={handleFileChange}
error={error}
/>
{file && !error && (
<div style={{ marginTop: '1rem', color: 'green' }}>
✓ Valid ZIP file: {file.name}
</div>
)}
</div>
);
};

export const WithCustomStyling: Story = () => {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string>();

const handleFileChange = async (selectedFile: File | null) => {
setFile(selectedFile);

if (!selectedFile) {
setError(undefined);
return;
}

// Simulate validation
if (!selectedFile.name.toLowerCase().endsWith('.zip')) {
setError('Please select a ZIP file');
} else {
setError(undefined);
}
};

return (
<div style={{ padding: '2rem' }}>
<h2>Custom Styled File Upload</h2>
<style>{`
.custom-input {
padding: 0.5rem;
border: 2px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.custom-input:hover {
border-color: #999;
}
.custom-input-error {
border-color: #dc2626;
background-color: #fef2f2;
}
.custom-error-message {
color: #dc2626;
margin-top: 0.5rem;
font-size: 0.875rem;
}
`}</style>
<FileUpload
accept="application/zip"
onChange={handleFileChange}
error={error}
className="custom-input"
errorClassName="custom-input-error"
errorMessageClassName="custom-error-message"
/>
{file && !error && (
<div style={{ marginTop: '1rem', color: '#059669' }}>
Selected: {file.name}
</div>
)}
</div>
);
};

export const MultipleStates: Story = () => {
return (
<div style={{ padding: '2rem' }}>
<h2>File Upload States</h2>

<div style={{ marginBottom: '2rem' }}>
<h3>Default State</h3>
<FileUpload accept="application/zip" />
</div>

<div style={{ marginBottom: '2rem' }}>
<h3>Error State</h3>
<FileUpload
accept="application/zip"
error="File must be a ZIP archive"
/>
</div>

<div style={{ marginBottom: '2rem' }}>
<h3>Disabled State</h3>
<FileUpload
accept="application/zip"
disabled
/>
</div>

<div style={{ marginBottom: '2rem' }}>
<h3>With Custom Styling</h3>
<style>{`
.styled-input {
padding: 0.75rem;
border: 2px dashed #3b82f6;
border-radius: 8px;
background: #eff6ff;
}
.styled-input-error {
border-color: #dc2626;
border-style: solid;
background: #fef2f2;
}
.styled-error {
color: #dc2626;
margin-top: 0.5rem;
padding: 0.5rem;
background: #fee2e2;
border-radius: 4px;
}
`}</style>
<FileUpload
accept="application/zip"
error="Invalid file type"
className="styled-input"
errorClassName="styled-input-error"
errorMessageClassName="styled-error"
/>
</div>
</div>
);
};
139 changes: 139 additions & 0 deletions apps/src/components/file-upload.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Tests for FileUpload component
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FileUpload } from './file-upload';

describe('FileUpload', () => {
it('renders a file input', () => {
render(<FileUpload />);

const input = screen.getByRole('button', { name: /choose file|browse|select/i });
expect(input).toBeInTheDocument();
});

it('applies accept attribute', () => {
render(<FileUpload accept="application/zip" />);

const input = document.querySelector('input[type="file"]');
expect(input).toHaveAttribute('accept', 'application/zip');
});

it('calls onChange when file is selected', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
const file = new File(['content'], 'test.zip', { type: 'application/zip' });

render(<FileUpload onChange={handleChange} />);

const input = document.querySelector('input[type="file"]') as HTMLInputElement;

await user.upload(input, file);

expect(handleChange).toHaveBeenCalledWith(file);
});

it('calls onChange with null when selection is cleared', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();

render(<FileUpload onChange={handleChange} />);

const input = document.querySelector('input[type="file"]') as HTMLInputElement;

// Simulate clearing the selection
await user.click(input);
// In real browser, user can cancel the file picker
// Simulate by triggering change with no files
Object.defineProperty(input, 'files', {
value: null,
writable: true,
});
input.dispatchEvent(new Event('change', { bubbles: true }));

expect(handleChange).toHaveBeenCalledWith(null);
});

it('displays error message when error prop is set', () => {
const errorMessage = 'File must be a ZIP archive';

render(<FileUpload error={errorMessage} />);

const error = screen.getByRole('alert');
expect(error).toHaveTextContent(errorMessage);
});

it('sets aria-invalid when error is present', () => {
render(<FileUpload error="Invalid file" />);

const input = document.querySelector('input[type="file"]');
expect(input).toHaveAttribute('aria-invalid', 'true');
});

it('links error message with aria-describedby', () => {
render(<FileUpload id="test-upload" error="Invalid file" />);

const input = document.querySelector('input[type="file"]');
const errorId = input?.getAttribute('aria-describedby');

expect(errorId).toBe('test-upload-error');

const error = document.getElementById(errorId!);
expect(error).toHaveTextContent('Invalid file');
});

it('applies error className when error is present', () => {
render(
<FileUpload
className="base-class"
errorClassName="error-class"
error="Invalid file"
/>,
);

const input = document.querySelector('input[type="file"]');
expect(input).toHaveClass('base-class', 'error-class');
});

it('does not apply error className when no error', () => {
render(
<FileUpload
className="base-class"
errorClassName="error-class"
/>,
);

const input = document.querySelector('input[type="file"]');
expect(input).toHaveClass('base-class');
expect(input).not.toHaveClass('error-class');
});

it('applies custom error message className', () => {
render(
<FileUpload
error="Invalid file"
errorMessageClassName="custom-error-message"
/>,
);

const error = screen.getByRole('alert');
expect(error).toHaveClass('custom-error-message');
});

it('generates unique id when not provided', () => {
const { container } = render(<FileUpload />);

const input = container.querySelector('input[type="file"]');
expect(input?.id).toMatch(/^file-upload-/);
});

it('uses provided id', () => {
render(<FileUpload id="custom-id" />);

const input = document.querySelector('input[type="file"]');
expect(input?.id).toBe('custom-id');
});
});
Loading