diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositApiClient.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositApiClient.js index de71caddf..6f11cd956 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositApiClient.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositApiClient.js @@ -335,6 +335,10 @@ export class DepositFileApiClient { throw new Error("Not implemented."); } + updateFileMetadata(updateUrl, fileMeta) { + throw new Error("Not implemented."); + } + deleteFile(fileLinks) { throw new Error("Not implemented."); } @@ -385,6 +389,12 @@ export class RDMDepositFileApiClient extends DepositFileApiClient { return this.axiosWithConfig.post(finalizeUploadUrl, {}); } + updateFileMetadata(updateUrl, fileMeta) { + return this.axiosWithConfig.put(updateUrl, fileMeta, { + headers: { "Content-Type": "application/json" }, + }); + } + importParentRecordFiles(draftLinks) { const link = `${draftLinks.self}/actions/files-import`; return this.axiosWithConfig.post(link, {}); diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.js index 738f723c5..95eba201a 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.js @@ -102,6 +102,10 @@ export class DepositFilesService { throw new Error("Not implemented."); } + async updateFileMetadata(updateUrl, fileMeta) { + throw new Error("Not implemented."); + } + async delete(fileLinks) { throw new Error("Not implemented."); } @@ -225,6 +229,10 @@ export class RDMDepositFilesService extends DepositFilesService { return (await this.fileApiClient.finalizeFileUpload(commitFileURL)).data; }; + updateFileMetadata = async (updateUrl, fileMeta) => { + return (await this.fileApiClient.updateFileMetadata(updateUrl, fileMeta)).data; + } + importParentRecordFiles = async (draftLinks) => { const response = await this.fileApiClient.importParentRecordFiles(draftLinks); diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.test.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.test.js index 3d17597ff..4fb123506 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.test.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/api/DepositFilesService.test.js @@ -13,6 +13,7 @@ let fakeApiIsCancelled; let fakeApiInitializeFileUpload; let fakeApiUploadFile; let fakeApiFinalizeFileUpload; +let fakeApiUpdateFileMetadata; let fakeApiDeleteFile; class FakeFileApiClient extends DepositFileApiClient { isCancelled(error) { @@ -31,6 +32,10 @@ class FakeFileApiClient extends DepositFileApiClient { return fakeApiFinalizeFileUpload(finalizeUploadUrl); } + updateFileMetadata(updateUrl, fileMeta) { + return fakeApiUpdateFileMetadata(updateUrl, fileMeta); + } + deleteFile(fileLinks) { return fakeApiDeleteFile(fileLinks); } @@ -75,6 +80,7 @@ beforeEach(() => { progressFn(20); }); fakeApiFinalizeFileUpload = jest.fn(); + fakeApiUpdateFileMetadata = jest.fn(); fakeApiDeleteFile = jest.fn(); fakeOnUploadAdded = jest.fn(); @@ -211,6 +217,14 @@ describe("DepositFilesService tests", () => { expect(fakeOnUploadCompleted).not.toHaveBeenCalled(); expect(fakeOnUploadFailed).not.toHaveBeenCalled(); }); + + it("it should call updateFileMetadata without errors", async () => { + fakeApiUpdateFileMetadata.mockReturnValueOnce({ data: { message: "ok" } }); + const updateUrl = "updateUrl"; + const fileMeta = { id: "file1", links: {}, metadata: { description: "123" } }; + await filesService.updateFileMetadata(updateUrl, fileMeta); + expect(fakeApiUpdateFileMetadata).toHaveBeenCalledWith(updateUrl, fileMeta); + }); }); describe("Concurrent uploads tests", () => { diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/utils.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/utils.js index ed3ca2a91..56f24090d 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/utils.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/FileUploader/utils.js @@ -18,6 +18,7 @@ export const getFilesList = (filesState) => { name: fileState.name, size: fileState.size, checksum: fileState.checksum, + metadata: fileState.metadata, links: fileState.links, uploadState: { // initial: fileState.status === UploadState.initial, diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/RDMUppyUploaderPlugin.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/RDMUppyUploaderPlugin.js index 941a3b470..7e10a82ce 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/RDMUppyUploaderPlugin.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/RDMUppyUploaderPlugin.js @@ -219,6 +219,7 @@ export class RDMUppyUploaderPlugin extends AwsS3Multipart { try { const response = await this.opts.initializeFileUpload(this.draftRecord, file); this.uppy.setFileMeta(file.id, { + ...file.meta, file_id: response.file_id, links: response.links, }); @@ -322,6 +323,7 @@ export class RDMUppyUploaderPlugin extends AwsS3Multipart { // Map any links to Uppy file state for further use (e.g. to fetch signed part URLs) this.uppy.setFileMeta(file.id, { + ...file.meta, file_id: response.file_id, links: response.links, }); @@ -367,6 +369,7 @@ export class RDMUppyUploaderPlugin extends AwsS3Multipart { file.meta.links = response.links; this.uppy.setFileMeta(file.id, { + ...file.meta, links: response.links, }); @@ -395,6 +398,9 @@ export class RDMUppyUploaderPlugin extends AwsS3Multipart { * - The default implementation calls out to Companion’s S3 signing endpoints. */ async completeMultipartUpload(file) { + if (file.meta?.metadata && Object.keys(file.meta.metadata).length > 0 && this.opts.updateFileMetadata) { + await this.opts.updateFileMetadata(this.draftRecord, file); + } const response = await this.opts.finalizeUpload(file); return response.links.content; } diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/UppyUploader.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/UppyUploader.js index 0cdd65c02..d0c86bbaf 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/UppyUploader.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/UppyUploader.js @@ -21,6 +21,15 @@ import { UploadState } from "../../state/reducers/files"; import { i18next } from "@translations/invenio_rdm_records/i18next"; import { getFilesList, FilesListTable, FileUploaderToolbar } from "../FileUploader"; import { useUppyLocale } from "./locale"; +import { + defaultAllowedMetaFields, + getMetaFieldsRenderers, + onBeforeUploadProcessMetaFields, +} from "./metaFields"; +import { + UPPY_EVENTS, + getUppyDashboardEventsProps, +} from "./events"; const defaultDashboardProps = { showRemoveButtonAfterComplete: false, @@ -79,6 +88,7 @@ export const UppyUploaderComponent = ({ permissions, record, initializeFileUpload, + updateFileMetadata, finalizeUpload, deleteFile, uploadPart, @@ -92,6 +102,8 @@ export const UppyUploaderComponent = ({ decimalSizeDisplay, filesLocked, allowEmptyFiles, + allowedMetaFields, + startEvent, ...uiProps }) => { // We extract the working copy of the draft stored as `values` in formik @@ -99,6 +111,7 @@ export const UppyUploaderComponent = ({ const { filesList } = getFilesList(files ?? {}); const hasError = (errors.files || initialErrors?.files) && files; const locale = useUppyLocale(); + const activeAllowedMetaFields = config?.allowedMetaFields || allowedMetaFields || defaultAllowedMetaFields; const filesEnabled = _get(formikDraft, "files.enabled", false); const filesSize = filesList.reduce((totalSize, file) => (totalSize += file.size), 0); const lockFileUploader = !isDraftRecord && filesLocked; @@ -147,6 +160,7 @@ export const UppyUploaderComponent = ({ quota, // Bind Redux file actions to the uploader plugin initializeFileUpload, + updateFileMetadata, finalizeUpload, saveAndFetchDraft, setUploadProgress, @@ -169,6 +183,16 @@ export const UppyUploaderComponent = ({ uppy.setOptions({ onBeforeFileAdded }); }, [uppy, filesList]); + React.useEffect(() => { + uppy.setOptions({ + onBeforeUpload: (uppyFiles) => { + return activeAllowedMetaFields && activeAllowedMetaFields.length > 0 + ? onBeforeUploadProcessMetaFields(uppyFiles, files, activeAllowedMetaFields) + : uppyFiles; + } + }); + }, [uppy, activeAllowedMetaFields]); + React.useEffect(() => { const uploaderPlugin = uppy.getPlugin("RDMUppyUploaderPlugin"); if (uploaderPlugin) { @@ -304,10 +328,10 @@ export const UppyUploaderComponent = ({ disabled={!filesLeft || lockFileUploader} // `null` means "do not display a Done button in a status bar" doneButtonHandler={null} - note={i18next.t( - "File addition, removal or modification are not allowed after you have published your upload." - )} + note={i18next.t("File addition, removal or modification are not allowed after you have published your upload.")} + metaFields={activeAllowedMetaFields && activeAllowedMetaFields.length > 0 ? getMetaFieldsRenderers(activeAllowedMetaFields) : undefined} {...defaultDashboardProps} + {...(startEvent && getUppyDashboardEventsProps(startEvent))} {...uiProps} /> )} @@ -379,6 +403,7 @@ UppyUploaderComponent.propTypes = { isFileImportInProgress: PropTypes.bool, importParentFiles: PropTypes.func.isRequired, initializeFileUpload: PropTypes.func.isRequired, + updateFileMetadata: PropTypes.func.isRequired, finalizeUpload: PropTypes.func.isRequired, uploadPart: PropTypes.func.isRequired, setUploadProgress: PropTypes.func.isRequired, @@ -388,6 +413,20 @@ UppyUploaderComponent.propTypes = { filesLocked: PropTypes.bool, permissions: PropTypes.object, allowEmptyFiles: PropTypes.bool, + allowedMetaFields: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultValue: PropTypes.any, + name: PropTypes.string, + placeholder: PropTypes.string, + render: PropTypes.func, + condition: PropTypes.func, + }) + ), + startEvent: PropTypes.shape({ + event: PropTypes.oneOf(Object.values(UPPY_EVENTS)).isRequired, + payload: PropTypes.object, + }), }; UppyUploaderComponent.defaultProps = { @@ -408,4 +447,6 @@ UppyUploaderComponent.defaultProps = { decimalSizeDisplay: true, filesLocked: false, allowEmptyFiles: true, + allowedMetaFields: undefined, + startEvent: undefined, }; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/events.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/events.js new file mode 100644 index 000000000..c43bea155 --- /dev/null +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/events.js @@ -0,0 +1,27 @@ +// This file is part of Invenio-RDM-Records +// Copyright (C) 2020-2025 CERN. +// Copyright (C) 2025 CESNET. +// +// Invenio-RDM-Records is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. + +/** + * Enumeration of possible supported Uppy dashboard events. + * @constant {Object} + * @property {string} UPLOAD_FILE_WITHOUT_EDIT - Event triggered for auto-uploading without explicit editing user interaction. + */ +export const UPPY_EVENTS = { + UPLOAD_FILE_WITHOUT_EDIT: "upload-file-without-edit", +}; + +/** + * Computes conditional UI properties for the Uppy Dashboard based on the active event state. + * + * @param {Object} startEvent - The currently active event, if any (e.g. { event: UPPY_EVENTS.EDIT_FILE }). + * @returns {Object} A dictionary of props specifically mapped to the `` component dynamically. + */ +export const getUppyDashboardEventsProps = (startEvent) => { + return { + showSelectedFiles: startEvent.event === UPPY_EVENTS.UPLOAD_FILE_WITHOUT_EDIT ? false : true, + }; +}; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/index.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/index.js index d19ab6201..f25f30d1b 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/index.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/index.js @@ -10,6 +10,7 @@ import { deleteFile, importParentFiles, initializeFileUpload, + updateFileMetadata, uploadPart, finalizeUpload, saveAndFetchDraft, @@ -34,6 +35,7 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => ({ initializeFileUpload: (draft, file) => dispatch(initializeFileUpload(draft, file)), + updateFileMetadata: (draft, serverFile, file) => dispatch(updateFileMetadata(draft, serverFile, file)), finalizeUpload: (file) => dispatch(finalizeUpload(file.meta.links.commit, file)), importParentFiles: () => dispatch(importParentFiles()), setUploadProgress: (file, percent) => dispatch(setUploadProgress(file, percent)), @@ -46,3 +48,6 @@ export const UppyUploader = connect( mapStateToProps, mapDispatchToProps )(UppyUploaderComponent); + +export { UPPY_EVENTS } from "./events"; +export { defaultAllowedMetaFields } from "./metaFields"; \ No newline at end of file diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/metaFields.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/metaFields.js new file mode 100644 index 000000000..ec038b207 --- /dev/null +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/fields/UppyUploader/metaFields.js @@ -0,0 +1,149 @@ +// This file is part of Invenio-RDM-Records +// Copyright (C) 2020-2025 CERN. +// Copyright (C) 2025 CESNET. +// +// Invenio-RDM-Records is free software; you can redistribute it and/or modify it +// under the terms of the MIT License; see LICENSE file for more details. + +/** + * Default allowed metadata fields configuration. + * @example + * [ + * { + * id: "caption", + * defaultValue: "", + * name: i18next.t("Caption"), + * placeholder: i18next.t("Set the Caption here"), + * condition: (file) => file.type && file.type.startsWith("image/") + * }, + * { + * id: "featured", + * defaultValue: false, + * name: i18next.t("Feature Image"), + * render: ({ value, onChange, required, form }, h) => { + * return h("input", { + * type: "checkbox", + * onChange: (ev) => onChange(ev.target.checked), + * checked: value, + * defaultChecked: value, + * required, + * form, + * }); + * }, + * condition: (file) => file.type && file.type.startsWith("image/") + * }, + * { + * id: "fileNote", + * defaultValue: "", + * name: i18next.t("File Note"), + * placeholder: i18next.t("Set the file Note here"), + * }, + * { + * id: "fileType", + * defaultValue: (file) => { + * if (file.type) { + * if (file.type.startsWith("image/")) return "image"; + * } + * return "other"; + * }, + * }, + * ] + * + * @type {Object[]} metaFields - Array of metadata field configuration objects. Each object defines how a specific metadata field should be handled and rendered in the Uppy Dashboard. + * @property {string} metaFields[].id - The unique identifier of the metadata field. Used as the key in the file.meta.metadata dictionary. + * @property {any|function(Object): any} [metaFields[].defaultValue] - Default value or a function resolving a default value based on the file object. + * @property {string} [metaFields[].name] - Optional display name of the field. If provided, it will be used to render a standard input field, otherwise it won't be editable in UI. + * @property {string} [metaFields[].placeholder] - Optional placeholder text for the input UI. + * @property {function(Object, Function): Object} [metaFields[].render] - Optional custom render function for advanced UI rendering of the field using Preact `h` function. If not provided, a standard text input will be rendered when `name` is set. See {@link https://uppy.io/docs/dashboard/#metafields|Uppy Dashboard metaFields documentation}. + * @property {function(Object): Boolean} [metaFields[].condition] - Optional function to conditionally attach or render the field based on the respective file properties (e.g. file.type). + */ +export const defaultAllowedMetaFields = [ + // { + // id: "description", + // defaultValue: "", + // name: i18next.t("Description"), + // placeholder: i18next.t("Set the file description here"), + // }, +]; + +/** + * Higher-order function that generates a metaFields configuration function for Uppy Dashboard. + * Evaluates custom fields passed in `allowedMetaFields` to render them. + * + * @param {Array} allowedMetaFields - Configuration for allowed meta fields. + * Expected shape: `{ id, defaultValue, name?, placeholder?, render?, condition? }`. + * Users can provide custom UI fields by explicitly setting `name` and/or `render` attributes. + * `defaultValue` can either be a static mapped value, or a dynamic function `(file) => any`. + * + * @returns {Function} Function `(file) => Array` required by Uppy Dashboard `metaFields` prop. + */ +export const getMetaFieldsRenderers = (allowedMetaFields) => { + return (file) => { + const fields = []; + (allowedMetaFields || []).forEach((field) => { + // Evaluate if field has rendering properties (name, render). + if (field.name || field.render) { + const shouldRender = typeof field.condition === "function" ? field.condition(file) : true; + if (shouldRender) { + fields.push({ + id: field.id, + name: field.name, + placeholder: field.placeholder || "", + // Render is optional, if missing Uppy falls back to standard text input + ...(field.render && { render: field.render }), + }); + } + } + }); + + return fields; + }; +}; + +/** + * Extends file metadata prior to upload with configured default values. + * This ensures that required parameters are not lost and metadata is + * consistently structured before uploading. + * + * @param {Object} uppyFiles - The Uppy files object dictionary. + * @param {Object} invenioFiles - The files object from Invenio. + * @param {Array} allowedMetaFields - Configuration for metadata fields. + * @returns {Object} Updated files dictionary. + */ +export const onBeforeUploadProcessMetaFields = (uppyFiles, invenioFiles, allowedMetaFields) => { + const updatedFiles = {}; + + Object.keys(uppyFiles).forEach((fileID) => { + const uppyFile = uppyFiles[fileID]; + const invenioFile = invenioFiles[uppyFile.meta?.file_id ?? uppyFile.id] || {}; + uppyFile.meta["metadata"] = uppyFile.meta?.metadata || invenioFile?.metadata || {}; + const metadataDefaults = {}; + + (allowedMetaFields || []).forEach((field) => { + // Determine if this field should apply defaults to this file + const isApplicable = typeof field.condition === "function" ? field.condition(uppyFile) : true; + + if (isApplicable) { + if (uppyFile.meta.metadata?.[field.id] === undefined && uppyFile.meta?.[field.id] === undefined) { + const value = typeof field.defaultValue === "function" ? field.defaultValue(uppyFile) : field?.defaultValue; + metadataDefaults[field.id] = value; + return; + } + metadataDefaults[field.id] = uppyFile.meta.metadata?.[field.id] || uppyFile.meta?.[field.id]; + } + }); + + updatedFiles[fileID] = { + ...uppyFile, + meta: { + ...uppyFile.meta, + metadata: { + ...uppyFile.meta.metadata, + ...metadataDefaults, + } + } + }; + }); + + return updatedFiles; +}; diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/state/actions/files.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/state/actions/files.js index ee83aca47..e572bf3fd 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/state/actions/files.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/state/actions/files.js @@ -96,6 +96,24 @@ export const initializeFileUpload = (draft, file) => { }; }; +export const updateFileMetadata = (draft, file) => { + return async (dispatch, _, config) => { + try { + const updateUrl = file.meta.links.self; + const newFileMetadata = { + metadata: file.meta.metadata, + }; + return await config.service.files.updateFileMetadata(updateUrl, newFileMetadata); + } catch (error) { + const axiosError = error?.t0 && error.t0.isAxiosError ? error.t0 : error; + console.error("Error updating file metadata", axiosError, draft, file); + const errorMessage = + axiosError?.response?.data?.message || axiosError?.message || "Metadata update failed"; + throw new Error(errorMessage); + } + }; +}; + export const uploadPart = (uploadParams) => { return async (dispatch, _, config) => { return config.service.files.uploadPart(uploadParams); diff --git a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/store.js b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/store.js index 16b28c3e5..a01fba451 100644 --- a/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/store.js +++ b/invenio_rdm_records/assets/semantic-ui/js/invenio_rdm_records/src/deposit/store.js @@ -25,6 +25,7 @@ const preloadFiles = (files) => { name: file.key, size: file.size || 0, checksum: file.checksum || "", + metadata: file.metadata || {}, links: file.links || {}, mimetype: file.mimetype || "application/octet-stream", status: UploadState[file.status],