sanity-plugin-smart-asset-manager 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1356 -0
- package/dist/index.mjs +1336 -0
- package/package.json +72 -0
- package/src/assets/images/all-assets.png +0 -0
- package/src/assets/images/details-popup.png +0 -0
- package/src/assets/images/size-analyzer.png +0 -0
- package/src/assets/images/unused-assets.png +0 -0
- package/src/components/AssetCard.tsx +177 -0
- package/src/components/AssetDetailsDialog.tsx +423 -0
- package/src/components/SmartAssetManagerTool.tsx +284 -0
- package/src/components/TopToolbar.tsx +129 -0
- package/src/components/__tests__/AssetCard.test.tsx +64 -0
- package/src/components/common/Icons.tsx +239 -0
- package/src/components/tabs/SizeAnalyzer.tsx +142 -0
- package/src/components/tabs/UnusedAssets.tsx +129 -0
- package/src/components/tabs/__tests__/SizeAnalyzer.test.tsx +73 -0
- package/src/hooks/useAssets.ts +75 -0
- package/src/index.ts +17 -0
- package/src/setupTests.ts +1 -0
- package/src/types/index.ts +29 -0
- package/src/utils/__tests__/formatBytes.test.ts +25 -0
- package/src/utils/assetQueries.ts +59 -0
- package/src/utils/formatBytes.ts +10 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import {useState, useMemo, useEffect} from 'react'
|
|
2
|
+
import {Box, Grid, Text, Stack, Flex, Spinner, Card, Button, useToast} from '@sanity/ui'
|
|
3
|
+
import {useClient} from 'sanity'
|
|
4
|
+
import {useAssets} from '@/hooks/useAssets'
|
|
5
|
+
import {findUnusedAssets, deleteAssets} from '@/utils/assetQueries'
|
|
6
|
+
import type {Asset, AssetTab, AssetTypeFilter, SizeFilter, SortOrder} from '@/types'
|
|
7
|
+
import {AssetCard} from '@/components/AssetCard'
|
|
8
|
+
import {AssetDetailsDialog} from '@/components/AssetDetailsDialog'
|
|
9
|
+
import {TopToolbar} from '@/components/TopToolbar'
|
|
10
|
+
import {SizeAnalyzer} from '@/components/tabs/SizeAnalyzer'
|
|
11
|
+
import {UnusedAssets} from '@/components/tabs/UnusedAssets'
|
|
12
|
+
import styled from 'styled-components'
|
|
13
|
+
|
|
14
|
+
const AppContainer = styled(Card)`
|
|
15
|
+
height: 100vh;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
const ScrollableContent = styled(Box)`
|
|
21
|
+
flex: 1;
|
|
22
|
+
overflow-y: auto;
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const TabNav = styled(Card)`
|
|
26
|
+
z-index: 10;
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
export function SmartAssetManagerTool() {
|
|
30
|
+
const sanityClient = useClient({apiVersion: '2025-02-07'})
|
|
31
|
+
const [activeTab, setActiveTab] = useState<AssetTab>('all')
|
|
32
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
33
|
+
const [sortBy, setSortBy] = useState<SortOrder>('_createdAt')
|
|
34
|
+
const [assetType, setAssetType] = useState<AssetTypeFilter>('all')
|
|
35
|
+
const [sizeFilter, setSizeFilter] = useState<SizeFilter>('all')
|
|
36
|
+
|
|
37
|
+
const limit = 20
|
|
38
|
+
const [currentPage, setCurrentPage] = useState(1)
|
|
39
|
+
const {assets, loading, total, refreshAssets} = useAssets(
|
|
40
|
+
sanityClient,
|
|
41
|
+
searchQuery,
|
|
42
|
+
sortBy,
|
|
43
|
+
assetType,
|
|
44
|
+
(currentPage - 1) * limit,
|
|
45
|
+
limit,
|
|
46
|
+
)
|
|
47
|
+
const [scanning, setScanning] = useState(false)
|
|
48
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
49
|
+
const toast = useToast()
|
|
50
|
+
|
|
51
|
+
const [unusedAssets, setUnusedAssets] = useState<Asset[]>([])
|
|
52
|
+
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null)
|
|
53
|
+
|
|
54
|
+
const filteredAssets = useMemo(() => {
|
|
55
|
+
return assets.filter((asset) => {
|
|
56
|
+
if (sizeFilter === 'small') return asset.size < 102400
|
|
57
|
+
if (sizeFilter === 'medium') return asset.size >= 102400 && asset.size < 1048576
|
|
58
|
+
if (sizeFilter === 'large') return asset.size >= 1048576
|
|
59
|
+
return true
|
|
60
|
+
})
|
|
61
|
+
}, [assets, sizeFilter])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
setCurrentPage(1)
|
|
65
|
+
}, [searchQuery, assetType, sizeFilter, activeTab])
|
|
66
|
+
|
|
67
|
+
const handleFindUnused = async () => {
|
|
68
|
+
setScanning(true)
|
|
69
|
+
const unused = await findUnusedAssets(sanityClient)
|
|
70
|
+
setUnusedAssets(unused)
|
|
71
|
+
setScanning(false)
|
|
72
|
+
setActiveTab('unused')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handleDeleteAsset = async (id: string | string[]) => {
|
|
76
|
+
const ids = Array.isArray(id) ? id : [id]
|
|
77
|
+
await deleteAssets(sanityClient, ids)
|
|
78
|
+
|
|
79
|
+
refreshAssets()
|
|
80
|
+
|
|
81
|
+
setUnusedAssets((prev) => prev.filter((a) => !ids.includes(a._id)))
|
|
82
|
+
|
|
83
|
+
if (activeTab === 'unused') handleFindUnused()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handleResetFilters = () => {
|
|
87
|
+
setSearchQuery('')
|
|
88
|
+
setAssetType('all')
|
|
89
|
+
setSizeFilter('all')
|
|
90
|
+
setCurrentPage(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const handleUpload = async (files: FileList) => {
|
|
94
|
+
if (files.length > 5) {
|
|
95
|
+
toast.push({
|
|
96
|
+
status: 'error',
|
|
97
|
+
title: 'Upload limit exceeded',
|
|
98
|
+
description: 'You can only upload up to 5 files at a time.',
|
|
99
|
+
})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setIsUploading(true)
|
|
104
|
+
const fileArray = Array.from(files)
|
|
105
|
+
const filenames = fileArray.map((f) => f.name)
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// 1. Batch check which files already exist
|
|
109
|
+
const existingAssetNames = await sanityClient.fetch<string[]>(
|
|
110
|
+
`*[_type in ["sanity.imageAsset", "sanity.fileAsset"] && originalFilename in $filenames].originalFilename`,
|
|
111
|
+
{filenames},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const filesToUpload = fileArray.filter((f) => !existingAssetNames.includes(f.name))
|
|
115
|
+
const skippedCount = fileArray.length - filesToUpload.length
|
|
116
|
+
|
|
117
|
+
if (filesToUpload.length === 0) {
|
|
118
|
+
setIsUploading(false)
|
|
119
|
+
if (skippedCount > 0) {
|
|
120
|
+
toast.push({
|
|
121
|
+
status: 'info',
|
|
122
|
+
title: 'No new files were added',
|
|
123
|
+
description: `All ${skippedCount} file(s) already exist in your library.`,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Upload only the new files
|
|
130
|
+
const uploadPromises = filesToUpload.map(async (file) => {
|
|
131
|
+
const type = file.type.startsWith('image/') ? 'image' : 'file'
|
|
132
|
+
return sanityClient.assets.upload(type, file, {
|
|
133
|
+
filename: file.name,
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await Promise.all(uploadPromises)
|
|
138
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
139
|
+
|
|
140
|
+
// 3. Refresh and Notify
|
|
141
|
+
refreshAssets()
|
|
142
|
+
setIsUploading(false)
|
|
143
|
+
|
|
144
|
+
toast.push({
|
|
145
|
+
status: 'success',
|
|
146
|
+
title: 'Upload successful',
|
|
147
|
+
description: `Uploaded ${filesToUpload.length} new file(s). ${skippedCount > 0 ? `Skipped ${skippedCount} existing files.` : ''}`,
|
|
148
|
+
})
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error('Upload failed:', err)
|
|
151
|
+
setIsUploading(false)
|
|
152
|
+
toast.push({
|
|
153
|
+
status: 'error',
|
|
154
|
+
title: 'Upload failed',
|
|
155
|
+
description: 'An error occurred while uploading your files.',
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<AppContainer height="fill">
|
|
162
|
+
{/* Tab Navigation */}
|
|
163
|
+
<TabNav borderBottom padding={2}>
|
|
164
|
+
<Flex gap={1}>
|
|
165
|
+
{[
|
|
166
|
+
{id: 'all', label: 'All Assets'},
|
|
167
|
+
{id: 'analysis', label: 'Size Analyzer'},
|
|
168
|
+
{id: 'unused', label: 'Unused Assets', onClick: handleFindUnused},
|
|
169
|
+
].map((tab) => (
|
|
170
|
+
<Button
|
|
171
|
+
key={tab.id}
|
|
172
|
+
mode={activeTab === tab.id ? 'default' : 'bleed'}
|
|
173
|
+
padding={3}
|
|
174
|
+
text={tab.label}
|
|
175
|
+
onClick={() => {
|
|
176
|
+
setActiveTab(tab.id as AssetTab)
|
|
177
|
+
if (tab.onClick) tab.onClick()
|
|
178
|
+
}}
|
|
179
|
+
selected={activeTab === tab.id}
|
|
180
|
+
/>
|
|
181
|
+
))}
|
|
182
|
+
</Flex>
|
|
183
|
+
</TabNav>
|
|
184
|
+
|
|
185
|
+
<ScrollableContent>
|
|
186
|
+
{activeTab === 'all' && (
|
|
187
|
+
<TopToolbar
|
|
188
|
+
searchQuery={searchQuery}
|
|
189
|
+
setSearchQuery={setSearchQuery}
|
|
190
|
+
sortBy={sortBy}
|
|
191
|
+
setSortBy={setSortBy}
|
|
192
|
+
assetType={assetType}
|
|
193
|
+
setAssetType={setAssetType}
|
|
194
|
+
sizeFilter={sizeFilter}
|
|
195
|
+
setSizeFilter={setSizeFilter}
|
|
196
|
+
onReset={handleResetFilters}
|
|
197
|
+
onUpload={handleUpload}
|
|
198
|
+
isUploading={isUploading}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<Box padding={4}>
|
|
203
|
+
{loading || scanning ? (
|
|
204
|
+
<Flex align="center" justify="center" style={{minHeight: '400px'}}>
|
|
205
|
+
<Stack space={4}>
|
|
206
|
+
<Flex align="center" justify="center">
|
|
207
|
+
<Spinner muted />
|
|
208
|
+
</Flex>
|
|
209
|
+
<Text size={1} muted>
|
|
210
|
+
Syncing with media library...
|
|
211
|
+
</Text>
|
|
212
|
+
</Stack>
|
|
213
|
+
</Flex>
|
|
214
|
+
) : (
|
|
215
|
+
<Box>
|
|
216
|
+
{activeTab === 'all' && (
|
|
217
|
+
<Box>
|
|
218
|
+
{filteredAssets.length === 0 ? (
|
|
219
|
+
<Card padding={5} border radius={3} style={{textAlign: 'center'}}>
|
|
220
|
+
<Text muted>No assets found matching your filters.</Text>
|
|
221
|
+
</Card>
|
|
222
|
+
) : (
|
|
223
|
+
<Grid columns={[2, 3, 4, 5, 6]} gap={3}>
|
|
224
|
+
{filteredAssets.map((asset) => (
|
|
225
|
+
<AssetCard key={asset._id} asset={asset} onClick={setSelectedAsset} />
|
|
226
|
+
))}
|
|
227
|
+
</Grid>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{total > limit && (
|
|
231
|
+
<Flex justify="center" align="center" gap={3} marginTop={5} marginBottom={2}>
|
|
232
|
+
<Button
|
|
233
|
+
padding={3}
|
|
234
|
+
mode="ghost"
|
|
235
|
+
text="Previous"
|
|
236
|
+
disabled={currentPage === 1 || loading}
|
|
237
|
+
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
238
|
+
/>
|
|
239
|
+
<Card padding={3} radius={2} border>
|
|
240
|
+
<Text size={1} weight="semibold">
|
|
241
|
+
Page {currentPage} of {Math.ceil(total / limit)}
|
|
242
|
+
</Text>
|
|
243
|
+
</Card>
|
|
244
|
+
<Button
|
|
245
|
+
padding={3}
|
|
246
|
+
mode="ghost"
|
|
247
|
+
text="Next"
|
|
248
|
+
disabled={currentPage >= Math.ceil(total / limit) || loading}
|
|
249
|
+
onClick={() => setCurrentPage((p) => p + 1)}
|
|
250
|
+
/>
|
|
251
|
+
</Flex>
|
|
252
|
+
)}
|
|
253
|
+
</Box>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{activeTab === 'analysis' && <SizeAnalyzer assets={assets} />}
|
|
257
|
+
|
|
258
|
+
{activeTab === 'unused' && (
|
|
259
|
+
<UnusedAssets
|
|
260
|
+
unusedAssets={unusedAssets}
|
|
261
|
+
onBulkDelete={handleDeleteAsset}
|
|
262
|
+
onAssetClick={setSelectedAsset}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
</Box>
|
|
266
|
+
)}
|
|
267
|
+
</Box>
|
|
268
|
+
</ScrollableContent>
|
|
269
|
+
|
|
270
|
+
{selectedAsset && (
|
|
271
|
+
<AssetDetailsDialog
|
|
272
|
+
asset={selectedAsset}
|
|
273
|
+
sanityClient={sanityClient}
|
|
274
|
+
onClose={() => setSelectedAsset(null)}
|
|
275
|
+
onUpdate={(_id, newName) => {
|
|
276
|
+
refreshAssets()
|
|
277
|
+
setSelectedAsset((prev) => (prev ? {...prev, originalFilename: newName} : null))
|
|
278
|
+
}}
|
|
279
|
+
onDelete={handleDeleteAsset}
|
|
280
|
+
/>
|
|
281
|
+
)}
|
|
282
|
+
</AppContainer>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {Flex, Box, TextInput, Select, Button, Card} from '@sanity/ui'
|
|
3
|
+
import {SearchIcon, ResetIcon, UploadIcon} from '@/components/common/Icons'
|
|
4
|
+
import type {AssetTypeFilter, SizeFilter, SortOrder} from '@/types'
|
|
5
|
+
import styled from 'styled-components'
|
|
6
|
+
|
|
7
|
+
const StickyCard = styled(Card)`
|
|
8
|
+
position: sticky;
|
|
9
|
+
top: 0;
|
|
10
|
+
z-index: 10;
|
|
11
|
+
`
|
|
12
|
+
|
|
13
|
+
const SearchBox = styled(Box)`
|
|
14
|
+
flex: 1;
|
|
15
|
+
`
|
|
16
|
+
|
|
17
|
+
const FilterBox = styled(Box)`
|
|
18
|
+
width: 140px;
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
interface TopToolbarProps {
|
|
22
|
+
searchQuery: string
|
|
23
|
+
setSearchQuery: (val: string) => void
|
|
24
|
+
sortBy: SortOrder
|
|
25
|
+
setSortBy: (val: SortOrder) => void
|
|
26
|
+
assetType: AssetTypeFilter
|
|
27
|
+
setAssetType: (val: AssetTypeFilter) => void
|
|
28
|
+
sizeFilter: SizeFilter
|
|
29
|
+
setSizeFilter: (val: SizeFilter) => void
|
|
30
|
+
onReset: () => void
|
|
31
|
+
onUpload: (files: FileList) => void
|
|
32
|
+
isUploading: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TopToolbar: React.FC<TopToolbarProps> = ({
|
|
36
|
+
searchQuery,
|
|
37
|
+
setSearchQuery,
|
|
38
|
+
sortBy,
|
|
39
|
+
setSortBy,
|
|
40
|
+
assetType,
|
|
41
|
+
setAssetType,
|
|
42
|
+
sizeFilter,
|
|
43
|
+
setSizeFilter,
|
|
44
|
+
onReset,
|
|
45
|
+
onUpload,
|
|
46
|
+
isUploading,
|
|
47
|
+
}) => {
|
|
48
|
+
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
|
49
|
+
|
|
50
|
+
const handleUploadClick = () => {
|
|
51
|
+
fileInputRef.current?.click()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
55
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
56
|
+
onUpload(e.target.files)
|
|
57
|
+
// Reset input so the same file can be uploaded again
|
|
58
|
+
e.target.value = ''
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<StickyCard padding={3} borderBottom>
|
|
64
|
+
<Flex align="center" gap={3}>
|
|
65
|
+
<input
|
|
66
|
+
type="file"
|
|
67
|
+
ref={fileInputRef}
|
|
68
|
+
style={{display: 'none'}}
|
|
69
|
+
onChange={handleFileChange}
|
|
70
|
+
multiple
|
|
71
|
+
accept="image/*,video/*,audio/*,application/*,text/*"
|
|
72
|
+
/>
|
|
73
|
+
<SearchBox>
|
|
74
|
+
<TextInput
|
|
75
|
+
icon={SearchIcon}
|
|
76
|
+
placeholder="Search name, extension, ID..."
|
|
77
|
+
value={searchQuery}
|
|
78
|
+
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
|
79
|
+
fontSize={1}
|
|
80
|
+
/>
|
|
81
|
+
</SearchBox>
|
|
82
|
+
<Box>
|
|
83
|
+
<Select
|
|
84
|
+
fontSize={1}
|
|
85
|
+
value={sortBy}
|
|
86
|
+
onChange={(e) => setSortBy(e.currentTarget.value as SortOrder)}
|
|
87
|
+
>
|
|
88
|
+
<option value="_createdAt">Upload Date</option>
|
|
89
|
+
<option value="size">File Size</option>
|
|
90
|
+
<option value="originalFilename">Name A-Z</option>
|
|
91
|
+
</Select>
|
|
92
|
+
</Box>
|
|
93
|
+
<FilterBox>
|
|
94
|
+
<Select
|
|
95
|
+
fontSize={1}
|
|
96
|
+
value={assetType}
|
|
97
|
+
onChange={(e) => setAssetType(e.currentTarget.value as AssetTypeFilter)}
|
|
98
|
+
>
|
|
99
|
+
<option value="all">All Types</option>
|
|
100
|
+
<option value="image">Images</option>
|
|
101
|
+
<option value="video">Videos</option>
|
|
102
|
+
<option value="audio">Audio</option>
|
|
103
|
+
<option value="file">Other Files</option>
|
|
104
|
+
</Select>
|
|
105
|
+
</FilterBox>
|
|
106
|
+
<FilterBox>
|
|
107
|
+
<Select
|
|
108
|
+
fontSize={1}
|
|
109
|
+
value={sizeFilter}
|
|
110
|
+
onChange={(e) => setSizeFilter(e.currentTarget.value as SizeFilter)}
|
|
111
|
+
>
|
|
112
|
+
<option value="all">All Sizes</option>
|
|
113
|
+
<option value="small">< 100 KB</option>
|
|
114
|
+
<option value="medium">100KB - 1MB</option>
|
|
115
|
+
<option value="large">> 1MB</option>
|
|
116
|
+
</Select>
|
|
117
|
+
</FilterBox>
|
|
118
|
+
<Button icon={ResetIcon} mode="ghost" onClick={onReset} title="Reset filters" />
|
|
119
|
+
<Button
|
|
120
|
+
icon={UploadIcon}
|
|
121
|
+
text={isUploading ? 'Uploading...' : 'Upload'}
|
|
122
|
+
tone="primary"
|
|
123
|
+
onClick={handleUploadClick}
|
|
124
|
+
loading={isUploading}
|
|
125
|
+
/>
|
|
126
|
+
</Flex>
|
|
127
|
+
</StickyCard>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {render, screen, fireEvent} from '@testing-library/react'
|
|
2
|
+
import {describe, it, expect, vi} from 'vitest'
|
|
3
|
+
import {AssetCard} from '../AssetCard'
|
|
4
|
+
import {ThemeProvider} from '@sanity/ui'
|
|
5
|
+
import {buildTheme} from '@sanity/ui/theme'
|
|
6
|
+
import React from 'react'
|
|
7
|
+
|
|
8
|
+
vi.mock('@/components/common/Icons', () => ({
|
|
9
|
+
DocumentsIcon: () => <div data-testid="documents-icon" />,
|
|
10
|
+
DownloadIcon: () => <div data-testid="download-icon" />,
|
|
11
|
+
PdfIcon: () => <div data-testid="pdf-icon" />,
|
|
12
|
+
AudioIcon: () => <div data-testid="audio-icon" />,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const mockAsset = {
|
|
16
|
+
_id: 'asset-1',
|
|
17
|
+
_type: 'sanity.imageAsset',
|
|
18
|
+
url: 'https://example.com/image.jpg',
|
|
19
|
+
originalFilename: 'test-image.jpg',
|
|
20
|
+
size: 1024,
|
|
21
|
+
extension: 'jpg',
|
|
22
|
+
mimeType: 'image/jpeg',
|
|
23
|
+
metadata: {
|
|
24
|
+
dimensions: {width: 100, height: 100},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('AssetCard', () => {
|
|
29
|
+
const renderWithTheme = (ui: React.ReactElement) => {
|
|
30
|
+
return render(<ThemeProvider theme={buildTheme()}>{ui}</ThemeProvider>)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('renders correctly with image asset', () => {
|
|
34
|
+
renderWithTheme(<AssetCard asset={mockAsset} onClick={vi.fn()} />)
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText('test-image.jpg')).toBeDefined()
|
|
37
|
+
expect(screen.getByText('1 KB')).toBeDefined()
|
|
38
|
+
const img = screen.getByAltText('test-image.jpg')
|
|
39
|
+
expect(img).toBeDefined()
|
|
40
|
+
|
|
41
|
+
expect(img.getAttribute('src')).toContain('w=400')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('calls onClick when clicked', () => {
|
|
45
|
+
const handleClick = vi.fn()
|
|
46
|
+
renderWithTheme(<AssetCard asset={mockAsset} onClick={handleClick} />)
|
|
47
|
+
|
|
48
|
+
fireEvent.click(screen.getByText('test-image.jpg'))
|
|
49
|
+
expect(handleClick).toHaveBeenCalledWith(mockAsset)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('shows checkbox when onSelect is provided', () => {
|
|
53
|
+
const handleSelect = vi.fn()
|
|
54
|
+
renderWithTheme(
|
|
55
|
+
<AssetCard asset={mockAsset} onClick={vi.fn()} isSelected={false} onSelect={handleSelect} />,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const checkbox = screen.getByRole('checkbox')
|
|
59
|
+
expect(checkbox).toBeDefined()
|
|
60
|
+
expect(checkbox).not.toBeChecked()
|
|
61
|
+
|
|
62
|
+
fireEvent.click(checkbox)
|
|
63
|
+
})
|
|
64
|
+
})
|