From a43900ba1ab678cb83eff985cc7718c350d85121 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 2 May 2026 17:54:26 +0200 Subject: [PATCH] feat(files): batchFiles action for one-shot publish/depublish/delete/label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `batchFiles(type, objectId, action, fileIds, params?)` to the files plugin. Replaces the N-sequential-calls 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; per-file outcomes live in `data.results` and aggregate counts in `data.summary` ({ succeeded, failed, total }). Both 200 and 207 are treated as valid responses — the caller inspects data.summary to decide whether to surface a partial-failure UI. Backwards compatible: existing publishFile / unpublishFile / deleteFile actions are unchanged. Consumers that haven't migrated continue to work. Closes OpenRegister file-actions task 136 (ViewObject.vue migration to the batch endpoint, paired in a separate openregister commit). --- docs/store/plugins/files.md | 1 + src/store/plugins/files.js | 54 ++++++++++++++++++++++++- tests/store/useObjectStore.spec.js | 64 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/docs/store/plugins/files.md b/docs/store/plugins/files.md index 453705e..fd62637 100644 --- a/docs/store/plugins/files.md +++ b/docs/store/plugins/files.md @@ -70,6 +70,7 @@ Tags: | `publishFile` | `(type, objectId, fileId) => Promise` | POST `/files//publish`. Re-fetches on success. | | `unpublishFile` | `(type, objectId, fileId) => Promise` | POST `/files//depublish`. Re-fetches on success. | | `deleteFile` | `(type, objectId, fileId) => Promise` | DELETE `/files/`. Re-fetches on success. | +| `batchFiles` | `(type, objectId, action, fileIds, params?) => Promise` | POST `/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` | 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. | diff --git a/src/store/plugins/files.js b/src/store/plugins/files.js index b31bafb..8373f12 100644 --- a/src/store/plugins/files.js +++ b/src/store/plugins/files.js @@ -9,7 +9,7 @@ import { parseResponseError, networkError } from '../../utils/errors.js' * 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 @@ -245,6 +245,58 @@ export function filesPlugin(options = {}) { 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) + * @return {Promise} 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 + } + }, }, } } diff --git a/tests/store/useObjectStore.spec.js b/tests/store/useObjectStore.spec.js index e115a14..8c70086 100644 --- a/tests/store/useObjectStore.spec.js +++ b/tests/store/useObjectStore.spec.js @@ -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') @@ -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()],