/** @odoo-module **/ import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; import { _t } from "@web/core/l10n/translation"; import { user } from "@web/core/user"; import { useBus, useService } from "@web/core/utils/hooks"; import { escape } from "@web/core/utils/strings"; import { memoize } from "@web/core/utils/functions"; import { useSetupAction } from "@web/search/action_hook"; import { formatFloat } from "@web/views/fields/formatters"; import { DocumentsPermissionPanel } from "@documents/components/documents_permission_panel/documents_permission_panel"; import { PdfManager } from "@documents/owl/components/pdf_manager/pdf_manager"; import { EventBus, onMounted, onWillStart, markup, useComponent, useEnv, useRef, useSubEnv } from "@odoo/owl"; /** * Controller/View hooks */ export function openDeleteConfirmationDialog(model, isPermanent) { return new Promise((resolve, reject) => { const root = model.root; const dialogProps = { title: isPermanent ? _t("Delete permanently") : _t("Move to trash"), body: isPermanent ? root.isDomainSelected || root.selection.length > 1 ? _t("Are you sure you want to permanently erase the documents?") : _t("Are you sure you want to permanently erase the document?") : _t( "Items moved to the trash will be deleted forever after %s days.", model.env.searchModel.deletionDelay ), confirmLabel: isPermanent ? _t("Delete permanently") : _t("Move to trash"), cancelLabel: _t("Discard"), confirm: async () => { resolve(true); }, cancel: () => { resolve(false); }, }; model.dialog.add(ConfirmationDialog, dialogProps); }); } export async function toggleArchive(model, resModel, resIds, doArchive) { if (doArchive && !(await openDeleteConfirmationDialog(model, false))) { return; } const action = await model.orm.call( resModel, doArchive ? "action_archive" : "action_unarchive", [resIds] ); if (action && Object.keys(action).length !== 0) { model.action.doAction(action); } if (doArchive) { await model.env.documentsView.bus.trigger("documents-close-preview"); } } export function preSuperSetupFolder() { const component = useComponent(); const orm = useService("orm"); onWillStart(async () => { component._deletionDelay = await orm.call("documents.document", "get_deletion_delay", [[]]); }); } // Small hack, memoize uses the first argument as cache key, but we need the orm which will not be the same. const loadMaxUploadSize = memoize((_null, orm) => orm.call("documents.document", "get_document_max_upload_limit") ); /** * To be executed before calling super.setup in view controllers. */ export function preSuperSetup() { // Otherwise not available in model.env useSubEnv({ documentsView: { bus: new EventBus(), }, }); const component = useComponent(); const props = component.props; // Root state is shared between views to keep the selection if (props.globalState && props.globalState.sharedSelection) { if (!props.state) { props.state = {}; } if (!props.state.modelState) { props.state.modelState = {}; } props.state.modelState.sharedSelection = props.globalState.sharedSelection; } } /** * Sets up the env required by documents view, as well as any other hooks. * Returns properties to be applied to the calling component. The code assumes that those properties are assigned to the component. */ export function useDocumentView(helpers) { const component = useComponent(); const props = component.props; const root = useRef("root"); const orm = useService("orm"); const notification = useService("notification"); const dialogService = useService("dialog"); const action = useService("action"); const documentService = useService("document.document"); // Env setup useSubEnv({ model: component.model, }); const env = useEnv(); const bus = env.documentsView.bus; // Opens Share Dialog const _openShareDialog = async ({ id, shortcut_document_id }) => { const document = shortcut_document_id ? { id: shortcut_document_id[0] } : { id }; dialogService.add(DocumentsPermissionPanel, { document, onChangesSaved: () => env.searchModel.trigger("update"), }); }; // Keep selection between views useSetupAction({ rootRef: root, getGlobalState: () => ({ sharedSelection: component.model.exportSelection(), }), }); useBus(bus, "documents-open-share", (ev) => { _openShareDialog(ev.detail); }); let maxUploadSize; Object.defineProperty(component, "maxUploadSize", { get: () => maxUploadSize, set: (newVal) => { maxUploadSize = newVal; }, }); onWillStart(async () => { component.maxUploadSize = await loadMaxUploadSize(null, orm); component.isDocumentsManager = await user.hasGroup("documents.group_documents_manager"); }); onMounted(async() => { documentService.updateDocumentURLRefresh(); }); return { // Refs root, // Services orm, notification, dialogService, actionService: action, // Document preview ...useDocumentsViewFilePreviewer(helpers), // Document upload canUploadInFolder: (folder) => documentService.canUploadInFolder(folder), ...useDocumentsViewFileUpload(), // Trigger rule ...useEmbeddedAction(), // Helpers hasShareDocuments: () => { const folder = env.searchModel.getSelectedFolder(); const selectedRecords = env.model.root.selection.length; return typeof folder.id !== "number" && !selectedRecords; }, userIsInternal: documentService.userIsInternal, userIsDocumentManager: documentService.userIsDocumentManager, // Listeners onClickDocumentsRequest: () => { action.doAction("documents.action_request_form", { additionalContext: { default_partner_id: props.context.default_partner_id || false, default_folder_id: env.searchModel.getSelectedFolderId() || env.searchModel.getFolders()[1].id, default_res_id: props.context.default_res_id || false, default_res_model: props.context.default_res_model || false, default_requestee_id: props.context.default_partner_id || false, }, fullscreen: env.isSmall, onClose: async () => { await env.model.load(); env.model.useSampleModel = env.model.root.records.length === 0; env.model.notify(); }, }); }, onClickDocumentsAddUrl: () => { const folderId = env.searchModel.getSelectedFolderId(); action.doAction("documents.action_url_form", { additionalContext: { default_type: "url", default_partner_id: props.context.default_partner_id || false, default_folder_id: env.searchModel.getSelectedFolderId(), default_res_id: props.context.default_res_id || false, default_res_model: props.context.default_res_model || false, ...(folderId === "COMPANY" ? { default_owner_id: documentService.store.odoobot.userId } : {}), }, fullscreen: env.isSmall, onClose: async () => { await env.model.load(); env.model.useSampleModel = env.model.root.records.length === 0; env.model.notify(); }, }); }, onClickAddFolder: () => { const currentFolder = env.searchModel.getSelectedFolderId(); action.doAction("documents.action_folder_form", { additionalContext: { default_type: "folder", default_folder_id: currentFolder || false, ...(currentFolder === "COMPANY" ? { default_access_internal: "edit", default_owner_id: documentService.store.odoobot.userId, } : {}), }, fullscreen: env.isSmall, onClose: async () => { await env.searchModel._reloadSearchModel(true); bus.trigger("documents-expand-folder", { folderId: [false, "COMPANY"].includes(currentFolder) ? "MY" : currentFolder, }); }, }); }, onClickShareFolder: async () => { if (env.model.root.selection.length > 0) { if (env.model.root.selection.length !== 1) { return; } const rec = env.model.root.selection[0]; await _openShareDialog({ id: rec.resId, name: rec._values.name, shortcut_document_id: rec._values.shortcut_document_id, }); } else { const folder = env.searchModel.getSelectedFolder(); await _openShareDialog({ id: folder.id, shortcut_document_id: folder.shortcut_document_id, }); } }, }; } /** * Hook to setup the file previewer */ function useDocumentsViewFilePreviewer({ getSelectedDocumentsElements, setPreviewStore, isRecordPreviewable = () => true, }) { const component = useComponent(); const env = useEnv(); const bus = env.documentsView.bus; /** @type {import("@documents/core/document_service").DocumentService} */ const documentService = useService("document.document"); /** @type {import("@mail/core/common/store_service").Store} */ const store = useService("mail.store"); const onOpenDocumentsPreview = async ({ documents, mainDocument, isPdfSplit, embeddedActions, hasPdfSplit, }) => { const openPdfSplitter = (documents) => { let newDocumentIds = []; let forceDelete = false; component.dialogService.add( PdfManager, { documents: documents.map((doc) => doc.data), embeddedActions, onProcessDocuments: ({ documentIds, actionId, exit, isForcingDelete }) => { forceDelete = isForcingDelete; if (documentIds && documentIds.length) { newDocumentIds = [...new Set(newDocumentIds.concat(documentIds))]; } if (actionId) { component.embeddedAction(documentIds, actionId, !exit); } }, }, { onClose: async () => { if (!newDocumentIds.length && !forceDelete) { return; } await component.model.load(); let count = 0; for (const record of documents) { if (!newDocumentIds.includes(record.resId)) { record.model.root.deleteRecords(record); continue; } record.onRecordClick(null, { isKeepSelection: count++ !== 0, isRangeSelection: false, }); } }, } ); }; if (isPdfSplit) { openPdfSplitter(documents); return; } const documentsRecords = ( (documents.length === 1 && component.model.root.records) || documents ) .filter((rec) => isRecordPreviewable(rec) && rec.isViewable()) .map((rec) => { const getRecordAttachment = (rec) => { rec = rec.shortcutTarget; return { id: rec.data.attachment_id[0], name: rec.data.attachment_id[1], mimetype: rec.data.mimetype, url: rec.data.url, documentId: rec.resId, documentData: rec.data, }; }; return store.Document.insert({ id: rec.resId, attachment: getRecordAttachment(rec), name: rec.data.name, mimetype: rec.data.mimetype, url: rec.data.url, displayName: rec.data.display_name, record: rec, }); }); // If there is a scrollbar we don't want it whenever the previewer is opened if (component.root.el) { component.root.el.querySelector(".o_documents_view").classList.add("overflow-hidden"); } const selectedDocument = documentsRecords.find( (rec) => rec.id === (mainDocument || documents[0]).resId ); documentService.documentList = { documents: documentsRecords || [], initialRecordSelectionLength: documents.length, pdfManagerOpenCallback: (documents) => { openPdfSplitter(documents); }, onDeleteCallback: () => { // We want to focus on the first selected document's element const elements = getSelectedDocumentsElements(); if (elements.length) { elements[0].focus(); } if (component.root.el) { component.root.el .querySelector(".o_documents_view") .classList.remove("overflow-hidden"); } setPreviewStore({}); }, hasPdfSplit, selectedDocument, }; const previewStore = { documentList: documentService.documentList, startIndex: documentsRecords.indexOf(selectedDocument), attachments: documentsRecords.map((doc) => doc.attachment), }; documentService.setPreviewedDocument(selectedDocument); setPreviewStore({ ...previewStore }); }; useBus(bus, "documents-open-preview", async (ev) => { component.onOpenDocumentsPreview(ev.detail); }); useBus(bus, "documents-close-preview", () => { documentService.setPreviewedDocument(null); setPreviewStore({}); }); return { onOpenDocumentsPreview, }; } /** * Hook to setup file upload */ function useDocumentsViewFileUpload() { const component = useComponent(); const env = useEnv(); const bus = env.documentsView.bus; const notification = useService("notification"); const fileUpload = useService("file_upload"); const documentService = useService("document.document"); const handleUploadError = (result) => { notification.add(result.error, { title: _t("Error"), sticky: true, }); }; let wasUsingSampleModel = false; useBus(fileUpload.bus, "FILE_UPLOAD_ADDED", () => { if (env.model.useSampleModel) { wasUsingSampleModel = true; env.model.useSampleModel = false; } }); useBus(fileUpload.bus, "FILE_UPLOAD_ERROR", async (ev) => { const { upload } = ev.detail; if (wasUsingSampleModel) { wasUsingSampleModel = false; env.model.useSampleModel = true; } if (upload.state !== "error") { return; } handleUploadError({ error: _t("An error occured while uploading."), }); }); useBus(documentService.bus, "DOCUMENT_RELOAD", async (ev) => { await env.searchModel._reloadSearchModel(true); await env.model.load(); await env.model.notify(); }); useBus(fileUpload.bus, "FILE_UPLOAD_LOADED", async (ev) => { wasUsingSampleModel = false; const { upload } = ev.detail; const xhr = upload.xhr; if (xhr.status !== 200) { handleUploadError(_t("status code: %(status)s, message: %(message)s", { status: xhr.status, message: xhr.response, })); return } // Depending on the controller called, the response is different: // /documents/upload/xx: returns an array of document ids // /mail/attachment/upload: returns an object { "ir.attachment": ... } const response = JSON.parse(xhr.response); const newDocumentIds = Array.isArray(response) ? response : undefined; env.model.useSampleModel = false; await env.model.load(component.props); if (!newDocumentIds) { return; } const records = env.model.root.records; let count = 0; for (const record of records) { if (!newDocumentIds.includes(record.resId)) { continue; } record.onRecordClick(null, { isKeepSelection: count++ !== 0, isRangeSelection: false, }); } }); /** * Create several new documents inside a given folder (folder accessToken) or replace * the document's attachment by the given single file (binary accessToken). */ const uploadFiles = async ({ files, accessToken, context }) => { if (env.searchModel.getSelectedFolderId() === "COMPANY") { // to upload in the COMPANY folder, we need to set Odoobot as owner context.default_owner_id = documentService.store.odoobot.userId; } const validFiles = [...files].filter((file) => file.size <= component.maxUploadSize); if (validFiles.length !== 0) { await fileUpload.upload(`/documents/upload/${accessToken || ""}`, validFiles, { buildFormData: (formData) => { if (context) { for (const key of [ "default_owner_id", "default_partner_id", "default_res_id", "default_res_model", ]) { if (context[key]) { formData.append(key.replace("default_", ""), context[key]); } } } }, displayErrorNotification: false, }); } if (validFiles.length < files.length) { const message = _t( "Some files could not be uploaded (max size: %s).", formatFloat(component.maxUploadSize, { humanReadable: true }) ); return notification.add(message, { type: "danger" }); } }; useBus(bus, "documents-upload-files", (ev) => { component.uploadFiles({ context: component.props.context, ...ev.detail }); }); return { uploadFiles, onFileInputChange: async (ev) => { if (!ev.target.files.length) { return; } await component.uploadFiles({ files: ev.target.files, accessToken: documentService.currentFolderAccessToken, context: component.props.context, }); ev.target.value = ""; }, }; } /** * Trigger embedded action hook. * NOTE: depends on env.model being set */ export function useEmbeddedAction() { const env = useEnv(); const orm = useService("orm"); const notification = useService("notification"); const action = useService("action"); return { embeddedAction: async (documentIds, actionId, preventReload = false) => { const context = { active_model: "documents.document", active_ids: documentIds, }; const result = await orm.call( "documents.document", "action_execute_embedded_action", [actionId], { context, } ); if (result && typeof result === "object") { if (Object.prototype.hasOwnProperty.call(result, "warning")) { notification.add( markup( `` ), { title: result["warning"]["title"], type: "danger", } ); if (!preventReload) { await env.model.load(); } } else if (!preventReload) { await action.doAction(result, { onClose: async () => await env.model.load(), }); return; } } else if (!preventReload) { await env.model.load(); } }, }; }