Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/store/plugins/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Tags:
| `publishFile` | `(type, objectId, fileId) => Promise<boolean>` | POST `<object>/files/<fileId>/publish`. Re-fetches on success. |
| `unpublishFile` | `(type, objectId, fileId) => Promise<boolean>` | POST `<object>/files/<fileId>/depublish`. Re-fetches on success. |
| `deleteFile` | `(type, objectId, fileId) => Promise<boolean>` | DELETE `<object>/files/<fileId>`. Re-fetches on success. |
| `batchFiles` | `(type, objectId, action, fileIds, params?) => Promise<object \| null>` | POST `<object>/files/batch` to apply `publish` / `depublish` / `delete` / `label` across many files in ONE round-trip. Returns `{ results, summary: { succeeded, failed, total } }` for both 200 (all OK) and 207 (partial). Re-fetches on success. |
| `fetchTags` | `() => Promise<string[]>` | GET the tags endpoint (derived from `baseUrl` by replacing `/objects` with `/tags`). Returns the array (also stored in `state.tags`). |
| `clearFiles` | `() => void` | From `createSubResourcePlugin`. Reset files state. |

Expand Down
54 changes: 53 additions & 1 deletion src/store/plugins/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* upload (multipart), publish, unpublish, and delete.
*
* State: files, filesLoading, filesError, tags, tagsLoading, tagsError
* Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, clearFiles, fetchTags
* Actions: fetchFiles, uploadFiles, publishFile, unpublishFile, deleteFile, batchFiles, clearFiles, fetchTags
* Getters: getFiles, isFilesLoading, getFilesError, getTags, isTagsLoading, getTagsError
*
* @param {object} [options={}] Plugin options
Expand Down Expand Up @@ -245,6 +245,58 @@
this.filesLoading = false
}
},

/**
* Apply a batch action across multiple files in ONE request.
*
* Replaces the N-sequential-call pattern (loop calling
* publishFile/unpublishFile/deleteFile per id) with a single POST
* to /files/batch. The backend returns 200 when every operation
* succeeds, or 207 (multi-status) when some fail; the per-file
* outcomes live in `data.results` and the aggregate counts in
* `data.summary` (`{ succeeded, failed, total }`).
*
* @param {string} type The registered object type slug
* @param {string} objectId The parent object ID
* @param {('publish'|'depublish'|'delete'|'label')} action The batch action to apply
* @param {(string|number)[]} fileIds File IDs to act on (max 100, validated server-side)
* @param {object} [params={}] Action-specific parameters (e.g. labels for the 'label' action)

Check warning on line 263 in src/store/plugins/files.js

View workflow job for this annotation

GitHub Actions / Frontend Quality

Defaults are not permitted on @param
* @return {Promise<object|null>} Response body `{ results, summary }`, or null on transport error
*/
async batchFiles(type, objectId, action, fileIds, params = {}) {
this.filesLoading = true
this.filesError = null

try {
const url = this._buildUrl(type, objectId) + '/files/batch'

const response = await fetch(url, {
method: 'POST',
headers: buildHeaders(),
body: JSON.stringify({
action,
fileIds,
...params,
}),
})

// 200 = all succeeded, 207 = partial success — both are
// valid responses; the caller inspects data.summary.
if (!response.ok && response.status !== 207) {
this.filesError = await parseResponseError(response, 'files')
return null
}

const data = await response.json()
await this.fetchFiles(type, objectId)
return data
} catch (error) {
this.filesError = networkError(error)
return null
} finally {
this.filesLoading = false
}
},
},
}
}
64 changes: 64 additions & 0 deletions tests/store/useObjectStore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ describe('createObjectStore with plugins', () => {
expect(typeof store.uploadFiles).toBe('function')
expect(typeof store.publishFile).toBe('function')
expect(typeof store.deleteFile).toBe('function')
expect(typeof store.batchFiles).toBe('function')
expect(typeof store.fetchTags).toBe('function')
expect(typeof store.lockObject).toBe('function')
expect(typeof store.unlockObject).toBe('function')
Expand All @@ -324,6 +325,69 @@ describe('createObjectStore with plugins', () => {
expect(store.getTagsError).toBeNull()
})

it('batchFiles posts to /files/batch with one round-trip for many ids', async () => {
const useStore = createObjectStore('test-batch-files', {
plugins: [filesPlugin()],
})
store = useStore()
store.registerObjectType('case', '28', '5')

// Two responses: the batch POST + the follow-up fetchFiles refresh.
global.fetch = jest.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({
results: [{ fileId: 1, ok: true }, { fileId: 2, ok: true }],
summary: { succeeded: 2, failed: 0, total: 2 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ results: [], total: 0, page: 1, pages: 1, limit: 20, offset: 0 }),
})

const result = await store.batchFiles('case', 'obj-uuid', 'publish', [1, 2])

expect(global.fetch).toHaveBeenCalledTimes(2)
const [url, opts] = global.fetch.mock.calls[0]
expect(url).toContain('/files/batch')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({
action: 'publish',
fileIds: [1, 2],
})
expect(result.summary).toEqual({ succeeded: 2, failed: 0, total: 2 })
})

it('batchFiles accepts 207 partial-success without flagging error', async () => {
const useStore = createObjectStore('test-batch-207', {
plugins: [filesPlugin()],
})
store = useStore()
store.registerObjectType('case', '28', '5')

global.fetch = jest.fn()
.mockResolvedValueOnce({
ok: false,
status: 207,
json: () => Promise.resolve({
results: [{ fileId: 1, ok: true }, { fileId: 2, ok: false, error: 'locked' }],
summary: { succeeded: 1, failed: 1, total: 2 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ results: [], total: 0, page: 1, pages: 1, limit: 20, offset: 0 }),
})

const result = await store.batchFiles('case', 'obj-uuid', 'delete', [1, 2])

expect(result).not.toBeNull()
expect(result.summary.failed).toBe(1)
expect(store.filesError).toBeNull()
})

it('fetchTags calls tags API and stores array of strings', async () => {
const useStore = createObjectStore('test-fetch-tags', {
plugins: [filesPlugin()],
Expand Down
Loading