Skip to content

Commit 294852f

Browse files
genuclaude
andauthored
feat: add appendExistingFiles to unify multi-source file tracking (#45)
Adds appendExistingFiles method to useUploadKit for appending pre-existing remote files without replacing current files. Eliminates need for separate file arrays when combining files from local picker, media library, and other sources into a single managed collection. Key features: - Deduplicates by storageKey to prevent duplicates - Respects maxFiles limit - Emits file:added for consistency with other file operations - Returns files actually added for UI feedback Includes comprehensive tests covering edge cases and integration with existing features like removeFile and upload. Documentation updated with usage examples and comparison to initializeExistingFiles. Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent b745047 commit 294852f

File tree

4 files changed

+306
-14
lines changed

4 files changed

+306
-14
lines changed

docs/content/2.usage/1.overview.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,15 +206,35 @@ uploader.reorderFile(0, 2) // Move first file to third position
206206

207207
### Initialization
208208

209-
#### `initializeExistingFiles(files: Partial<UploadFile>[]): Promise<void>`
209+
#### `initializeExistingFiles(files: InitialFileInput[]): Promise<void>`
210210

211-
Load existing files (e.g., from a database) into the uploader.
211+
Load existing files (e.g., from a database) into the uploader. **Replaces** the current file list.
212212

213213
```ts
214214
// Load previously uploaded files
215-
await uploader.initializeExistingFiles([{ id: "existing-file-1.jpg" }, { id: "existing-file-2.png" }])
215+
await uploader.initializeExistingFiles([{ storageKey: "uploads/file-1.jpg" }, { storageKey: "uploads/file-2.png" }])
216216
```
217217

218+
#### `appendExistingFiles(files: InitialFileInput[]): Promise<UploadFile[]>`
219+
220+
Append pre-existing remote files **without replacing** current files. Returns the files that were actually added.
221+
222+
- Skips files already present (matched by `storageKey`)
223+
- Respects `maxFiles` limit
224+
- Emits `file:added` for each appended file
225+
226+
```ts
227+
// User picks files from a media library — inject them into the upload manager
228+
const added = await uploader.appendExistingFiles([
229+
{ storageKey: "library/photo-1.jpg" },
230+
{ storageKey: "library/photo-2.jpg" },
231+
])
232+
233+
console.log(`${added.length} files added`)
234+
```
235+
236+
This is useful when files come from multiple sources (local picker, media library, external integrations) and you want all files managed as first-class citizens in a single `files` ref.
237+
218238
### Plugins
219239

220240
#### `addPlugin(plugin: Plugin): void`

docs/content/5.advanced/4.lifecycle.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,11 @@ This:
180180
Load files from your database:
181181

182182
```ts
183-
// Provide file IDs
184-
await uploader.initializeExistingFiles([{ id: "path/to/file1.jpg" }, { id: "path/to/file2.png", preview: "custom-preview-url" }])
183+
// Replaces the file list with these remote files
184+
await uploader.initializeExistingFiles([
185+
{ storageKey: "path/to/file1.jpg" },
186+
{ storageKey: "path/to/file2.png" },
187+
])
185188
```
186189

187190
The storage plugin's `getRemoteFile` hook fetches metadata:
@@ -196,6 +199,33 @@ The storage plugin's `getRemoteFile` hook fetches metadata:
196199
}
197200
```
198201

202+
## Appending Existing Files
203+
204+
Use `appendExistingFiles` when you need to add remote files **alongside** files the user has already selected — for example, when a user picks items from a media library:
205+
206+
```ts
207+
// User already added local files via file picker
208+
await uploader.addFiles(localFiles)
209+
210+
// Later, user picks files from a media library
211+
const added = await uploader.appendExistingFiles([
212+
{ storageKey: "library/photo-1.jpg" },
213+
{ storageKey: "library/photo-2.jpg" },
214+
])
215+
```
216+
217+
Key differences from `initializeExistingFiles`:
218+
219+
| | `initializeExistingFiles` | `appendExistingFiles` |
220+
|---|---|---|
221+
| **Behavior** | Replaces all files | Adds to existing files |
222+
| **Deduplication** | No | Skips files already present by `storageKey` |
223+
| **maxFiles** | Not checked | Respected (truncates to fit) |
224+
| **Events** | None | Emits `file:added` per file |
225+
| **Returns** | `void` | `UploadFile[]` (files actually added) |
226+
227+
This lets all files — local, remote, from any source — live as first-class citizens in a single `files` ref, eliminating the need for separate tracking arrays.
228+
199229
## Memory Management
200230

201231
Nuxt Upload Kit automatically manages memory:

src/runtime/composables/useUploadKit/index.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,20 @@ export const useUploadKit = <TUploadResult = any>(_options: UploadOptions = {})
132132
files.value = files.value.map((file) => (file.id === fileId ? ({ ...file, ...updatedFile } as UploadFile) : file))
133133
}
134134

135-
const initializeExistingFiles = async (initialFiles: InitialFileInput[]) => {
136-
const initializedfiles = await Promise.all(
135+
/**
136+
* Resolve an array of InitialFileInput into RemoteUploadFile objects via the storage plugin.
137+
*/
138+
const resolveRemoteFiles = async (initialFiles: InitialFileInput[]): Promise<UploadFile<TUploadResult>[]> => {
139+
const storagePlugin = getStoragePlugin()
140+
if (!storagePlugin?.hooks.getRemoteFile) {
141+
throw new Error("Storage plugin with getRemoteFile hook is required to resolve remote files")
142+
}
143+
144+
const resolved = await Promise.all(
137145
initialFiles.map(async (file) => {
138146
const storageKey = file.storageKey
139147
if (!storageKey) return null
140148

141-
const storagePlugin = getStoragePlugin()
142-
if (!storagePlugin?.hooks.getRemoteFile) {
143-
throw new Error("Storage plugin with getRemoteFile hook is required to initialize existing files")
144-
}
145-
146149
const context = createPluginContext(storagePlugin.id, files.value, options, emitter, storagePlugin)
147150
const remoteFileData = await storagePlugin.hooks.getRemoteFile(storageKey, context)
148151

@@ -170,8 +173,41 @@ export const useUploadKit = <TUploadResult = any>(_options: UploadOptions = {})
170173
}),
171174
)
172175

173-
const filteredFiles = initializedfiles.filter((f) => f !== null) as UploadFile[]
174-
files.value = [...filteredFiles]
176+
return resolved.filter((f) => f !== null) as UploadFile<TUploadResult>[]
177+
}
178+
179+
const initializeExistingFiles = async (initialFiles: InitialFileInput[]) => {
180+
const resolvedFiles = await resolveRemoteFiles(initialFiles)
181+
files.value = [...resolvedFiles]
182+
}
183+
184+
/**
185+
* Append pre-existing remote files without replacing current files.
186+
* Skips files already present (matched by storageKey) and respects maxFiles.
187+
*/
188+
const appendExistingFiles = async (initialFiles: InitialFileInput[]): Promise<UploadFile<TUploadResult>[]> => {
189+
// Deduplicate: skip files already present by storageKey
190+
const existingKeys = new Set(files.value.map((f) => f.storageKey).filter(Boolean))
191+
let filesToAdd = initialFiles.filter((f) => f.storageKey && !existingKeys.has(f.storageKey))
192+
193+
if (filesToAdd.length === 0) return []
194+
195+
// Respect maxFiles limit
196+
if (options.maxFiles !== false && options.maxFiles !== undefined) {
197+
const available = options.maxFiles - files.value.length
198+
if (available <= 0) return []
199+
filesToAdd = filesToAdd.slice(0, available)
200+
}
201+
202+
const resolvedFiles = await resolveRemoteFiles(filesToAdd)
203+
204+
files.value.push(...resolvedFiles)
205+
206+
resolvedFiles.forEach((file) => {
207+
emitter.emit("file:added", file)
208+
})
209+
210+
return resolvedFiles
175211
}
176212

177213
const addFile = async (file: File) => {
@@ -359,6 +395,7 @@ export const useUploadKit = <TUploadResult = any>(_options: UploadOptions = {})
359395
replaceFileData: fileOps.replaceFileData,
360396
updateFile,
361397
initializeExistingFiles,
398+
appendExistingFiles,
362399

363400
// Utilities
364401
addPlugin,

test/unit/useUploadKit.test.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,211 @@ describe("useUploadKit", () => {
789789
})
790790
})
791791

792+
describe("appendExistingFiles", () => {
793+
const defaultGetRemoteFileFn = async (storageKey: string) => ({
794+
size: 2048,
795+
mimeType: storageKey.endsWith(".png") ? "image/png" : "image/jpeg",
796+
remoteUrl: `https://storage.example.com/${storageKey}`,
797+
})
798+
799+
it("should append remote files without replacing existing local files", async () => {
800+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn })
801+
const uploader = useUploadKit({ storage })
802+
803+
await uploader.addFile(createMockFile("local.jpg"))
804+
const added = await uploader.appendExistingFiles([{ storageKey: "remote-1.png" }, { storageKey: "remote-2.png" }])
805+
806+
expect(added).toHaveLength(2)
807+
expect(uploader.files.value).toHaveLength(3)
808+
809+
// Original local file preserved at index 0
810+
expect(uploader.files.value[0]!.source).toBe("local")
811+
expect(uploader.files.value[0]!.name).toBe("local.jpg")
812+
813+
// Appended files are complete remote files
814+
const appended = uploader.files.value.slice(1)
815+
for (const file of appended) {
816+
expect(file.source).toBe("storage")
817+
expect(file.status).toBe("complete")
818+
expect(file.progress.percentage).toBe(100)
819+
expect(file.data).toBeNull()
820+
expect(file.remoteUrl).toMatch(/^https:\/\/storage\.example\.com\//)
821+
}
822+
})
823+
824+
it("should set correct metadata from storage plugin on appended files", async () => {
825+
const storage = createMockStoragePlugin({
826+
getRemoteFileFn: async (storageKey) => ({
827+
size: 4096,
828+
mimeType: "image/webp",
829+
remoteUrl: `https://cdn.example.com/${storageKey}`,
830+
preview: `https://cdn.example.com/thumbs/${storageKey}`,
831+
uploadResult: { storageKey, bucket: "media" },
832+
}),
833+
})
834+
const uploader = useUploadKit({ storage })
835+
836+
const added = await uploader.appendExistingFiles([{ storageKey: "photos/pic.webp" }])
837+
838+
expect(added).toHaveLength(1)
839+
const file = added[0]!
840+
expect(file.size).toBe(4096)
841+
expect(file.mimeType).toBe("image/webp")
842+
expect(file.name).toBe("pic.webp")
843+
expect(file.storageKey).toBe("photos/pic.webp")
844+
expect(file.remoteUrl).toBe("https://cdn.example.com/photos/pic.webp")
845+
expect(file.preview).toBe("https://cdn.example.com/thumbs/photos/pic.webp")
846+
expect(file.uploadResult).toEqual({ storageKey: "photos/pic.webp", bucket: "media" })
847+
})
848+
849+
it("should skip duplicates already present by storageKey", async () => {
850+
const getRemoteFileFn = vi.fn(defaultGetRemoteFileFn)
851+
const storage = createMockStoragePlugin({ getRemoteFileFn })
852+
const uploader = useUploadKit({ storage })
853+
854+
await uploader.initializeExistingFiles([{ storageKey: "existing.png" }])
855+
getRemoteFileFn.mockClear()
856+
857+
const added = await uploader.appendExistingFiles([{ storageKey: "existing.png" }, { storageKey: "new.png" }])
858+
859+
expect(added).toHaveLength(1)
860+
expect(added[0]!.storageKey).toBe("new.png")
861+
expect(uploader.files.value).toHaveLength(2)
862+
// Should only call getRemoteFile for the non-duplicate
863+
expect(getRemoteFileFn).toHaveBeenCalledTimes(1)
864+
expect(getRemoteFileFn).toHaveBeenCalledWith("new.png")
865+
})
866+
867+
it("should deduplicate against uploaded local files that have storageKey", async () => {
868+
const storage = createMockStoragePlugin({
869+
getRemoteFileFn: defaultGetRemoteFileFn,
870+
uploadFn: async () => ({
871+
url: "https://storage.example.com/uploads/local.jpg",
872+
storageKey: "uploads/local.jpg",
873+
}),
874+
})
875+
const uploader = useUploadKit({ storage })
876+
877+
// Add and upload a local file so it gets a storageKey
878+
await uploader.addFile(createMockFile("local.jpg"))
879+
await uploader.upload()
880+
expect(uploader.files.value[0]!.storageKey).toBe("uploads/local.jpg")
881+
882+
// Appending the same storageKey should be deduplicated
883+
const added = await uploader.appendExistingFiles([{ storageKey: "uploads/local.jpg" }])
884+
885+
expect(added).toHaveLength(0)
886+
expect(uploader.files.value).toHaveLength(1)
887+
})
888+
889+
it("should respect maxFiles limit and preserve insertion order", async () => {
890+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn })
891+
const uploader = useUploadKit({ storage, maxFiles: 3 })
892+
893+
await uploader.addFile(createMockFile("file1.jpg"))
894+
await uploader.addFile(createMockFile("file2.jpg"))
895+
896+
// Only 1 slot available — should take the first from the input
897+
const added = await uploader.appendExistingFiles([
898+
{ storageKey: "remote-1.jpg" },
899+
{ storageKey: "remote-2.jpg" },
900+
{ storageKey: "remote-3.jpg" },
901+
])
902+
903+
expect(added).toHaveLength(1)
904+
expect(added[0]!.storageKey).toBe("remote-1.jpg")
905+
expect(uploader.files.value).toHaveLength(3)
906+
})
907+
908+
it("should return empty array when maxFiles is already reached", async () => {
909+
const getRemoteFileFn = vi.fn(defaultGetRemoteFileFn)
910+
const storage = createMockStoragePlugin({ getRemoteFileFn })
911+
const uploader = useUploadKit({ storage, maxFiles: 1 })
912+
913+
await uploader.addFile(createMockFile("file1.jpg"))
914+
getRemoteFileFn.mockClear()
915+
916+
const added = await uploader.appendExistingFiles([{ storageKey: "remote-1.jpg" }])
917+
918+
expect(added).toHaveLength(0)
919+
// Should not make any network calls when limit is reached
920+
expect(getRemoteFileFn).not.toHaveBeenCalled()
921+
})
922+
923+
it("should emit file:added for each appended file", async () => {
924+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn })
925+
const uploader = useUploadKit({ storage })
926+
const handler = vi.fn()
927+
928+
uploader.on("file:added", handler)
929+
await uploader.appendExistingFiles([{ storageKey: "remote-1.jpg" }, { storageKey: "remote-2.jpg" }])
930+
931+
expect(handler).toHaveBeenCalledTimes(2)
932+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ storageKey: "remote-1.jpg" }))
933+
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ storageKey: "remote-2.jpg" }))
934+
})
935+
936+
it("should not emit file:added when all files are duplicates", async () => {
937+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn })
938+
const uploader = useUploadKit({ storage })
939+
940+
await uploader.initializeExistingFiles([{ storageKey: "file-a.jpg" }])
941+
942+
const handler = vi.fn()
943+
uploader.on("file:added", handler)
944+
const added = await uploader.appendExistingFiles([{ storageKey: "file-a.jpg" }])
945+
946+
expect(added).toHaveLength(0)
947+
expect(handler).not.toHaveBeenCalled()
948+
})
949+
950+
it("should handle multiple sequential appends correctly", async () => {
951+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn })
952+
const uploader = useUploadKit({ storage })
953+
954+
await uploader.appendExistingFiles([{ storageKey: "batch-1.jpg" }])
955+
await uploader.appendExistingFiles([{ storageKey: "batch-2.jpg" }])
956+
await uploader.appendExistingFiles([{ storageKey: "batch-1.jpg" }, { storageKey: "batch-3.jpg" }])
957+
958+
expect(uploader.files.value).toHaveLength(3)
959+
expect(uploader.files.value.map((f) => f.storageKey)).toEqual(["batch-1.jpg", "batch-2.jpg", "batch-3.jpg"])
960+
})
961+
962+
it("should throw if no storage plugin with getRemoteFile is configured", async () => {
963+
const uploader = useUploadKit()
964+
965+
await expect(uploader.appendExistingFiles([{ storageKey: "remote.jpg" }])).rejects.toThrow(
966+
"Storage plugin with getRemoteFile hook is required",
967+
)
968+
})
969+
970+
it("should skip entries with empty storageKey without calling storage", async () => {
971+
const getRemoteFileFn = vi.fn(defaultGetRemoteFileFn)
972+
const storage = createMockStoragePlugin({ getRemoteFileFn })
973+
const uploader = useUploadKit({ storage })
974+
975+
const added = await uploader.appendExistingFiles([{ storageKey: "" }, { storageKey: "valid.jpg" }])
976+
977+
expect(added).toHaveLength(1)
978+
expect(added[0]!.storageKey).toBe("valid.jpg")
979+
expect(getRemoteFileFn).toHaveBeenCalledTimes(1)
980+
})
981+
982+
it("should allow removal of appended files via removeFile", async () => {
983+
const removeHook = vi.fn()
984+
const storage = createMockStoragePlugin({ getRemoteFileFn: defaultGetRemoteFileFn, removeFn: removeHook })
985+
const uploader = useUploadKit({ storage })
986+
987+
const added = await uploader.appendExistingFiles([{ storageKey: "library/photo.jpg" }])
988+
expect(uploader.files.value).toHaveLength(1)
989+
990+
await uploader.removeFile(added[0]!.id)
991+
992+
expect(uploader.files.value).toHaveLength(0)
993+
expect(removeHook).toHaveBeenCalledWith(expect.objectContaining({ storageKey: "library/photo.jpg" }))
994+
})
995+
})
996+
792997
describe("event system", () => {
793998
it("should allow registering and receiving events", async () => {
794999
const uploader = useUploadKit()

0 commit comments

Comments
 (0)