603 lines
22 KiB
JavaScript
603 lines
22 KiB
JavaScript
/** @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(
|
|
`<ul>${result["warning"]["documents"]
|
|
.map((d) => `<li>${escape(d)}</li>`)
|
|
.join("")}</ul>`
|
|
),
|
|
{
|
|
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();
|
|
}
|
|
},
|
|
};
|
|
}
|