Skip to content

Commit 80e05ed

Browse files
Merge pull request #37 from zentered/fix/critical-bugs-and-testing
fix: resolve critical bugs and add comprehensive testing infrastructure
2 parents 7beacae + 478477d commit 80e05ed

File tree

11 files changed

+5290
-2127
lines changed

11 files changed

+5290
-2127
lines changed

package-lock.json

Lines changed: 4769 additions & 2098 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,16 @@
2727
],
2828
"type": "module",
2929
"main": "src/index.jsx",
30+
"types": "types/index.d.ts",
3031
"files": [
31-
"src"
32+
"src",
33+
"types"
3234
],
3335
"scripts": {
3436
"prepare": "husky install",
35-
"test": "echo \"Error: no test specified\" && exit 1",
37+
"test": "vitest run",
38+
"test:watch": "vitest",
39+
"test:coverage": "vitest run --coverage",
3640
"lint": "eslint . -c eslint.config.js",
3741
"codestyle": "prettier --check \"./**/*.{js,md}\""
3842
},
@@ -55,21 +59,26 @@
5559
]
5660
},
5761
"dependencies": {
58-
"cropperjs": "^2.0.0",
62+
"cropperjs": "^2.1.0",
5963
"solid-heroicons": "^3.2.4",
6064
"solid-js": ">=1.6.0",
6165
"terracotta": "^1.0.6"
6266
},
6367
"devDependencies": {
64-
"@commitlint/cli": "^19.8.0",
65-
"@commitlint/config-conventional": "^19.8.0",
66-
"eslint": "^9.22.0",
67-
"eslint-config-prettier": "^10.1.1",
68+
"@commitlint/cli": "^20.1.0",
69+
"@commitlint/config-conventional": "^20.0.0",
70+
"@solidjs/testing-library": "^0.8.10",
71+
"@testing-library/user-event": "^14.6.1",
72+
"eslint": "^9.39.1",
73+
"eslint-config-prettier": "^10.1.8",
6874
"eslint-plugin-solid": "^0.14.5",
69-
"globals": "^16.0.0",
75+
"globals": "^16.5.0",
7076
"husky": "^9.1.7",
71-
"lint-staged": "^15.5.0",
72-
"prettier": "^3.5.3"
77+
"jsdom": "^27.2.0",
78+
"lint-staged": "^16.2.7",
79+
"prettier": "^3.6.2",
80+
"vite-plugin-solid": "^2.11.10",
81+
"vitest": "^4.0.13"
7382
},
7483
"peerDependencies": {
7584
"solid-js": ">=1.6.0"

src/Dialog.jsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ import {
99
import ImageDrop from './ImageDrop.jsx'
1010
import { createSignal } from 'solid-js'
1111

12+
/**
13+
* ImageUploadDialog component provides a modal dialog for image upload and cropping with aspect ratio selection.
14+
*
15+
* @param {Object} props - Component props
16+
* @param {string} props.title - Title displayed in the dialog header
17+
* @param {Function} props.isOpen - Accessor function that returns whether the dialog is open
18+
* @param {Function} props.closeModal - Callback function to close the dialog
19+
* @param {Function} props.openModal - Callback function to open the dialog
20+
* @param {Function} props.saveImage - Callback function called when the user saves the cropped image. Receives the state object containing error, loading, file, and croppedImage.
21+
* @returns {JSX.Element} The ImageUploadDialog component
22+
*/
1223
export default function ImageUploadDialog(props) {
1324
const [width, setWidth] = createSignal(16)
1425
const [height, setHeight] = createSignal(9)

src/ImageDrop.jsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
createEffect,
3-
createRenderEffect,
43
createSignal,
54
Show,
65
on,
@@ -11,9 +10,27 @@ import 'cropperjs'
1110
import { Icon } from 'solid-heroicons'
1211
import { cloudArrowUp, photo } from 'solid-heroicons/solid'
1312

13+
/**
14+
* ImageDrop component provides a drag-and-drop interface for uploading and cropping images.
15+
*
16+
* @param {Object} props - Component props
17+
* @param {Function} props.saveImage - Callback function called when the user saves the cropped image. Receives the state object containing error, loading, file, and croppedImage.
18+
* @param {number} [props.aspectRatioWidth=1] - Initial aspect ratio width
19+
* @param {number} [props.aspectRatioHeight=1] - Initial aspect ratio height
20+
* @param {string[]} [props.acceptedFileTypes=['image/jpeg', 'image/png', 'image/webp', 'image/gif']] - Array of accepted file MIME types
21+
* @param {number} [props.maxFileSizeMB=10] - Maximum file size in megabytes
22+
* @param {Function} [props.onError] - Optional callback function called when an error occurs. Receives the error message as a string.
23+
* @returns {JSX.Element} The ImageDrop component
24+
*/
1425
export default function ImageDrop(props) {
1526
let cropperImage
1627
let cropperSelection
28+
29+
// Set default values for validation props
30+
const acceptedFileTypes = props.acceptedFileTypes || ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
31+
const maxFileSizeMB = props.maxFileSizeMB || 10
32+
const maxFileSizeBytes = maxFileSizeMB * 1024 * 1024
33+
1734
const [state, setState] = createStore({
1835
error: null,
1936
loading: false,
@@ -28,8 +45,32 @@ export default function ImageDrop(props) {
2845
noPropagate = (e) => {
2946
e.preventDefault()
3047
},
48+
handleError = (message) => {
49+
setState('error', message)
50+
if (props.onError) {
51+
props.onError(message)
52+
}
53+
},
3154
uploadFile = async (file) => {
3255
if (!file) return
56+
57+
// Reset error state
58+
setState('error', null)
59+
60+
// Validate file type
61+
if (!acceptedFileTypes.includes(file.type)) {
62+
const message = `Invalid file type. Please upload one of: ${acceptedFileTypes.join(', ')}`
63+
handleError(message)
64+
return
65+
}
66+
67+
// Validate file size
68+
if (file.size > maxFileSizeBytes) {
69+
const message = `File size exceeds ${maxFileSizeMB}MB limit. Current file: ${(file.size / 1024 / 1024).toFixed(2)}MB`
70+
handleError(message)
71+
return
72+
}
73+
3374
setUploading(true)
3475
setState('loading', true)
3576
setState('file', file)
@@ -38,12 +79,11 @@ export default function ImageDrop(props) {
3879
reader.onload = (e) => {
3980
setPreview(e.target.result)
4081
}
41-
createRenderEffect(() => {})
4282
reader.readAsDataURL(file)
4383
} catch (e) {
4484
console.error('upload failed', e)
4585
const message = e instanceof Error ? e.message : String(e)
46-
setState('error', message)
86+
handleError(message)
4787
}
4888
setState('loading', false)
4989
setUploading(false)
@@ -73,8 +113,8 @@ export default function ImageDrop(props) {
73113

74114
createEffect(
75115
on(preview, () => {
76-
if (cropperImage) {
77-
cropperImage.src = `${import.meta.env.BASE_URL}picture1.png`
116+
if (cropperImage && preview()) {
117+
cropperImage.src = preview()
78118
}
79119
})
80120
)
@@ -166,12 +206,18 @@ export default function ImageDrop(props) {
166206
id="image-upload"
167207
name="file"
168208
type="file"
209+
accept={acceptedFileTypes.join(',')}
169210
disabled={uploading()}
170211
multiple={false}
171212
onInput={handleFileInput}
172213
class="sr-only"
173214
/>
174215
</div>
216+
<Show when={state.error}>
217+
<div class="mt-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded-md">
218+
<p class="text-sm">{state.error}</p>
219+
</div>
220+
</Show>
175221
<div class="h-8" />
176222
</form>
177223
</Show>

src/__tests__/Dialog.test.jsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { render, screen, fireEvent } from '@solidjs/testing-library'
2+
import { describe, it, expect, vi } from 'vitest'
3+
import { createSignal } from 'solid-js'
4+
import Dialog from '../Dialog.jsx'
5+
6+
// Mock cropperjs web components
7+
globalThis.customElements = {
8+
define: vi.fn()
9+
}
10+
11+
describe('Dialog', () => {
12+
it('should render when isOpen is true', () => {
13+
const [isOpen] = createSignal(true)
14+
const mockCloseModal = vi.fn()
15+
const mockOpenModal = vi.fn()
16+
const mockSaveImage = vi.fn()
17+
18+
render(() => (
19+
<Dialog
20+
title="Test Dialog"
21+
isOpen={isOpen}
22+
closeModal={mockCloseModal}
23+
openModal={mockOpenModal}
24+
saveImage={mockSaveImage}
25+
/>
26+
))
27+
28+
expect(screen.getByText('Test Dialog')).toBeTruthy()
29+
})
30+
31+
it('should not render when isOpen is false', () => {
32+
const [isOpen] = createSignal(false)
33+
const mockCloseModal = vi.fn()
34+
const mockOpenModal = vi.fn()
35+
const mockSaveImage = vi.fn()
36+
37+
const { container } = render(() => (
38+
<Dialog
39+
title="Test Dialog"
40+
isOpen={isOpen}
41+
closeModal={mockCloseModal}
42+
openModal={mockOpenModal}
43+
saveImage={mockSaveImage}
44+
/>
45+
))
46+
47+
// The dialog should not be visible
48+
expect(screen.queryByText('Test Dialog')).toBeNull()
49+
})
50+
51+
it('should render aspect ratio selector with default options', () => {
52+
const [isOpen] = createSignal(true)
53+
const mockCloseModal = vi.fn()
54+
const mockOpenModal = vi.fn()
55+
const mockSaveImage = vi.fn()
56+
57+
render(() => (
58+
<Dialog
59+
title="Upload Image"
60+
isOpen={isOpen}
61+
closeModal={mockCloseModal}
62+
openModal={mockOpenModal}
63+
saveImage={mockSaveImage}
64+
/>
65+
))
66+
67+
const select = document.querySelector('select')
68+
expect(select).toBeTruthy()
69+
70+
const options = select.querySelectorAll('option')
71+
expect(options.length).toBe(3)
72+
expect(options[0].value).toBe('16:9')
73+
expect(options[1].value).toBe('4:3')
74+
expect(options[2].value).toBe('1:1')
75+
})
76+
77+
it('should update aspect ratio when selector changes', () => {
78+
const [isOpen] = createSignal(true)
79+
const mockCloseModal = vi.fn()
80+
const mockOpenModal = vi.fn()
81+
const mockSaveImage = vi.fn()
82+
83+
render(() => (
84+
<Dialog
85+
title="Upload Image"
86+
isOpen={isOpen}
87+
closeModal={mockCloseModal}
88+
openModal={mockOpenModal}
89+
saveImage={mockSaveImage}
90+
/>
91+
))
92+
93+
const select = document.querySelector('select')
94+
95+
// Change to 1:1 aspect ratio
96+
fireEvent.change(select, { target: { value: '1:1' } })
97+
98+
// The component should handle the change without errors
99+
expect(select.value).toBe('1:1')
100+
})
101+
102+
it('should pass saveImage callback to ImageDrop', () => {
103+
const [isOpen] = createSignal(true)
104+
const mockCloseModal = vi.fn()
105+
const mockOpenModal = vi.fn()
106+
const mockSaveImage = vi.fn()
107+
108+
render(() => (
109+
<Dialog
110+
title="Upload Image"
111+
isOpen={isOpen}
112+
closeModal={mockCloseModal}
113+
openModal={mockOpenModal}
114+
saveImage={mockSaveImage}
115+
/>
116+
))
117+
118+
// The ImageDrop component should be rendered inside the dialog
119+
const dropzone = document.getElementById('dropzone')
120+
expect(dropzone).toBeTruthy()
121+
})
122+
123+
it('should render with correct title', () => {
124+
const [isOpen] = createSignal(true)
125+
const mockCloseModal = vi.fn()
126+
const mockOpenModal = vi.fn()
127+
const mockSaveImage = vi.fn()
128+
129+
render(() => (
130+
<Dialog
131+
title="Custom Title"
132+
isOpen={isOpen}
133+
closeModal={mockCloseModal}
134+
openModal={mockOpenModal}
135+
saveImage={mockSaveImage}
136+
/>
137+
))
138+
139+
expect(screen.getByText('Custom Title')).toBeTruthy()
140+
})
141+
})

0 commit comments

Comments
 (0)