diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 2d0ffc47e06..33523366e21 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -181,6 +181,12 @@ + + + /web/login?redirect={{request.httprequest.path}} + + + diff --git a/addons/spreadsheet/__manifest__.py b/addons/spreadsheet/__manifest__.py index 980d03510ed..507b5f2a4f0 100644 --- a/addons/spreadsheet/__manifest__.py +++ b/addons/spreadsheet/__manifest__.py @@ -6,9 +6,12 @@ 'category': 'Hidden', 'summary': 'Spreadsheet', 'description': 'Spreadsheet', - 'depends': ['bus', 'web'], + 'depends': ['bus', 'web', 'portal'], 'installable': True, 'license': 'LGPL-3', + 'data': [ + 'views/public_readonly_spreadsheet_templates.xml', + ], 'assets': { 'spreadsheet.o_spreadsheet': [ 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js', @@ -16,7 +19,49 @@ # Load all o_spreadsheet templates first to allow to inherit them 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml', 'spreadsheet/static/src/**/*.xml', - ('remove', 'spreadsheet/static/src/assets_backend/**/*') + ('remove', 'spreadsheet/static/src/assets_backend/**/*'), + ('remove', 'spreadsheet/static/src/public_readonly_app/**/*'), + ], + 'spreadsheet.public_spreadsheet': [ + ('include', 'web.assets_frontend_minimal'), + ('include', 'web._assets_helpers'), # bootstrap variables + 'web/static/src/scss/bootstrap_overridden.scss', + 'web/static/src/scss/pre_variables.scss', + 'web/static/lib/bootstrap/scss/_variables.scss', + ('include', 'web._assets_bootstrap'), + 'web/static/lib/popper/popper.js', + 'web/static/lib/bootstrap/js/dist/dom/data.js', + 'web/static/lib/bootstrap/js/dist/dom/event-handler.js', + 'web/static/lib/bootstrap/js/dist/dom/manipulator.js', + 'web/static/lib/bootstrap/js/dist/dom/selector-engine.js', + 'web/static/lib/bootstrap/js/dist/base-component.js', + 'web/static/lib/bootstrap/js/dist/collapse.js', + 'web/static/lib/bootstrap/js/dist/dropdown.js', + + 'web/static/src/libs/fontawesome/css/font-awesome.css', + 'web/static/lib/owl/owl.js', + 'web/static/lib/luxon/luxon.js', + 'web/static/lib/owl/odoo_module.js', + 'web/static/src/core/utils/**/*.js', + 'web/static/src/core/browser/browser.js', + 'web/static/src/core/registry.js', + 'web/static/src/core/assets.js', + 'web/static/src/session.js', + 'web/static/src/env.js', + 'web/static/src/core/network/http_service.js', + 'web/static/src/core/network/rpc_service.js', + 'web/static/src/core/user_service.js', + 'web/static/src/core/l10n/**/*.js', + 'web/static/src/core/network/download.js', + 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js', + 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml', + 'spreadsheet/static/src/o_spreadsheet/o_spreadsheet_extended.scss', + 'spreadsheet/static/src/o_spreadsheet/migration.js', + 'spreadsheet/static/src/o_spreadsheet/odoo_module.js', + 'spreadsheet/static/src/helpers/helpers.js', + 'spreadsheet/static/src/public_readonly_app/**/*.xml', + 'spreadsheet/static/src/public_readonly_app/**/*.scss', + 'spreadsheet/static/src/public_readonly_app/**/*', ], 'web.assets_backend': [ 'spreadsheet/static/src/**/*.scss', diff --git a/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js b/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js index fa252b1f857..d2cad14b424 100644 --- a/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js +++ b/addons/spreadsheet/static/src/actions/spreadsheet_download_action.js @@ -1,16 +1,12 @@ /** @odoo-module */ -import { DataSources } from "@spreadsheet/data_sources/data_sources"; -import { migrate } from "@spreadsheet/o_spreadsheet/migration"; import { download } from "@web/core/network/download"; import { registry } from "@web/core/registry"; -import { Model } from "@odoo/o-spreadsheet"; -import { _t } from "@web/core/l10n/translation"; +import { createSpreadsheetModel, waitForDataLoaded } from "@spreadsheet/helpers/model"; async function downloadSpreadsheet(env, action) { const { orm, name, data, stateUpdateMessages } = action.params; - const dataSources = new DataSources(orm); - const model = new Model(migrate(data), { custom: { dataSources } }, stateUpdateMessages); + const model = await createSpreadsheetModel({ orm, data, revisions: stateUpdateMessages }); await waitForDataLoaded(model); const { files } = model.exportXLSX(); await download({ @@ -22,37 +18,6 @@ async function downloadSpreadsheet(env, action) { }); } -/** - * Ensure that the spreadsheet does not contains cells that are in loading state - * @param {Model} model - * @returns {Promise} - */ -async function waitForDataLoaded(model) { - const dataSources = model.config.custom.dataSources; - return new Promise((resolve, reject) => { - function check() { - model.dispatch("EVALUATE_CELLS"); - if (isLoaded(model)) { - dataSources.removeEventListener("data-source-updated", check); - resolve(); - } - } - dataSources.addEventListener("data-source-updated", check); - check(); - }); -} - -function isLoaded(model) { - for (const sheetId of model.getters.getSheetIds()) { - for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) { - if (cell.type === "error" && cell.error.message === _t("Data is loading")) { - return false; - } - } - } - return true; -} - registry .category("actions") .add("action_download_spreadsheet", downloadSpreadsheet, { force: true }); diff --git a/addons/spreadsheet/static/src/components/share_button/share_button.js b/addons/spreadsheet/static/src/components/share_button/share_button.js new file mode 100644 index 00000000000..d5452bc40a7 --- /dev/null +++ b/addons/spreadsheet/static/src/components/share_button/share_button.js @@ -0,0 +1,40 @@ +/** @odoo-module **/ + +import { Component, useState } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { _t } from "@web/core/l10n/translation"; + +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { CopyButton } from "@web/views/fields/copy_clipboard/copy_button"; +import { waitForDataLoaded, freezeOdooData } from "@spreadsheet/helpers/model"; +import { Model } from "@odoo/o-spreadsheet"; + +/** + * Share button to share a spreadsheet + */ +export class SpreadsheetShareButton extends Component { + static template = "spreadsheet.ShareButton"; + static components = { Dropdown, DropdownItem, CopyButton }; + static props = { + model: Model, + onSpreadsheetShared: Function, + }; + + setup() { + this.copiedText = _t("Copied"); + this.state = useState({ url: undefined }); + } + + async onOpened() { + if (this.state.url) { + return; + } + const model = this.props.model; + await waitForDataLoaded(model); + const data = await freezeOdooData(model); + const url = await this.props.onSpreadsheetShared(data, model.exportXLSX()); + this.state.url = url; + browser.navigator.clipboard.writeText(url); + } +} diff --git a/addons/spreadsheet/static/src/components/share_button/share_button.scss b/addons/spreadsheet/static/src/components/share_button/share_button.scss new file mode 100644 index 00000000000..6e89b506315 --- /dev/null +++ b/addons/spreadsheet/static/src/components/share_button/share_button.scss @@ -0,0 +1,4 @@ +.spreadsheet_share_dropdown { + width: 320px; + height: 100px; +} diff --git a/addons/spreadsheet/static/src/components/share_button/share_button.xml b/addons/spreadsheet/static/src/components/share_button/share_button.xml new file mode 100644 index 00000000000..3bd9458878f --- /dev/null +++ b/addons/spreadsheet/static/src/components/share_button/share_button.xml @@ -0,0 +1,37 @@ + + + + + + + + Share + + + + + + + + Spreadsheet published + Frozen version - Anyone can view + + + + + + + + + + + Generating sharing link + + + + diff --git a/addons/spreadsheet/static/src/helpers/model.js b/addons/spreadsheet/static/src/helpers/model.js new file mode 100644 index 00000000000..412c4633b9c --- /dev/null +++ b/addons/spreadsheet/static/src/helpers/model.js @@ -0,0 +1,167 @@ +/** @odoo-module */ +import { DataSources } from "@spreadsheet/data_sources/data_sources"; +import * as spreadsheet from "@odoo/o-spreadsheet"; +import { migrate } from "@spreadsheet/o_spreadsheet/migration"; +import { _t } from "@web/core/l10n/translation"; +import { loadJS } from "@web/core/assets"; + +const { Model, tokenize } = spreadsheet; +const { toCartesian } = spreadsheet.helpers; + +export async function fetchSpreadsheetModel(orm, resModel, resId) { + const { data, revisions } = await orm.call(resModel, "join_spreadsheet_session", [resId]); + return createSpreadsheetModel({ orm, data, revisions }); +} + +export function createSpreadsheetModel({ orm, data, revisions }) { + const dataSources = new DataSources(orm); + const model = new Model(migrate(data), { custom: { dataSources } }, revisions); + return model; +} + +/** + * Ensure that the spreadsheet does not contains cells that are in loading state + * @param {Model} model + * @returns {Promise} + */ +export async function waitForDataLoaded(model) { + const dataSources = model.config.custom.dataSources; + await dataSources.waitForAllLoaded(); + return new Promise((resolve, reject) => { + function check() { + model.dispatch("EVALUATE_CELLS"); + if (isLoaded(model)) { + dataSources.removeEventListener("data-source-updated", check); + resolve(); + } + } + dataSources.addEventListener("data-source-updated", check); + check(); + }); +} + +/** + * @param {Model} model + * @returns {object} + */ +export async function freezeOdooData(model) { + await waitForDataLoaded(model); + const data = model.exportData(); + for (const sheet of Object.values(data.sheets)) { + for (const [xc, cell] of Object.entries(sheet.cells)) { + if (containsOdooFunction(cell.content)) { + const { col, row } = toCartesian(xc); + const sheetId = sheet.id; + const evaluatedCell = model.getters.getEvaluatedCell({ + sheetId, + col, + row, + }); + cell.content = evaluatedCell.formattedValue; + if (evaluatedCell.format) { + cell.format = getItemId(evaluatedCell.format, data.formats); + } + } + } + for (const figure of sheet.figures) { + if (figure.tag === "chart" && figure.data.type.startsWith("odoo_")) { + await loadJS("/web/static/lib/Chart/Chart.js"); + const img = odooChartToImage(model, figure); + figure.tag = "image"; + figure.data = { + path: img, + size: { width: figure.width, height: figure.height }, + }; + } + } + } + return data; +} + +/** + * copy-pasted from o-spreadsheet. Should be exported + * Get the id of the given item (its key in the given dictionnary). + * If the given item does not exist in the dictionary, it creates one with a new id. + */ +export function getItemId(item, itemsDic) { + for (const [key, value] of Object.entries(itemsDic)) { + if (value === item) { + return parseInt(key, 10); + } + } + + // Generate new Id if the item didn't exist in the dictionary + const ids = Object.keys(itemsDic); + const maxId = ids.length === 0 ? 0 : Math.max(...ids.map((id) => parseInt(id, 10))); + + itemsDic[maxId + 1] = item; + return maxId + 1; +} + +/** + * + * @param {string | undefined} content + * @returns {boolean} + */ +function containsOdooFunction(content) { + if (!content || !content.startsWith("=") || !content.includes("ODOO.")) { + return false; + } + const tokens = tokenize(content); + return tokens.some((token) => token.type === "FUNCTION" && token.value.startsWith("ODOO.")); +} + +function isLoaded(model) { + for (const sheetId of model.getters.getSheetIds()) { + for (const cell of Object.values(model.getters.getEvaluatedCells(sheetId))) { + if (cell.type === "error" && cell.error.message === _t("Data is loading")) { + return false; + } + } + } + return true; +} + +/** + * Return the chart figure as a base64 image. + * "data:image/png;base64,iVBORw0KGg..." + * @param {Model} model + * @param {object} figure + * @returns {string} + */ +function odooChartToImage(model, figure) { + const runtime = model.getters.getChartRuntime(figure.id); + // wrap the canvas in a div with a fixed size because chart.js would + // fill the whole page otherwise + const div = document.createElement("div"); + div.style.width = `${figure.width}px`; + div.style.height = `${figure.height}px`; + const canvas = document.createElement("canvas"); + div.append(canvas); + canvas.setAttribute("width", figure.width); + canvas.setAttribute("height", figure.height); + // we have to add the canvas to the DOM otherwise it won't be rendered + document.body.append(div); + runtime.chartJsConfig.plugins = [backgroundColorPlugin]; + const chart = new Chart(canvas, runtime.chartJsConfig); + const img = chart.toBase64Image(); + chart.destroy(); + div.remove(); + return img; +} + +/** + * Custom chart.js plugin to set the background color of the canvas + * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md + */ +const backgroundColorPlugin = { + id: "customCanvasBackgroundColor", + beforeDraw: (chart, args, options) => { + const { ctx } = chart; + ctx.save(); + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, chart.width, chart.height); + ctx.restore(); + }, +}; diff --git a/addons/spreadsheet/static/src/public_readonly_app/main.js b/addons/spreadsheet/static/src/public_readonly_app/main.js new file mode 100644 index 00000000000..1be180c48dd --- /dev/null +++ b/addons/spreadsheet/static/src/public_readonly_app/main.js @@ -0,0 +1,32 @@ +/** @odoo-module **/ +import { App, whenReady } from "@odoo/owl"; +import { PublicReadonlySpreadsheet } from "./public_readonly"; +import { setLoadXmlDefaultApp, templates } from "@web/core/assets"; +import { makeEnv, startServices } from "@web/env"; +import { session } from "@web/session"; + +(async function boot() { + odoo.info = { + db: session.db, + server_version: session.server_version, + server_version_info: session.server_version_info, + isEnterprise: session.server_version_info.slice(-1)[0] === "e", + }; + odoo.isReady = false; + const env = makeEnv(); + await startServices(env); + await whenReady(); + const app = new App(PublicReadonlySpreadsheet, { + env, + props: session.spreadsheet_public_props, + templates, + translateFn: env._t, + dev: env.debug, + warnIfNoStaticProps: env.debug, + translatableAttributes: ["data-tooltip"], + }); + setLoadXmlDefaultApp(app); + const root = await app.mount(document.getElementById("spreadsheet-mount-anchor")); + odoo.__WOWL_DEBUG__ = { root }; + odoo.isReady = true; +})(); diff --git a/addons/spreadsheet/static/src/public_readonly_app/public_readonly.js b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.js new file mode 100644 index 00000000000..f43f5a5d543 --- /dev/null +++ b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.js @@ -0,0 +1,52 @@ +/** @odoo-module **/ + +import { Component, onWillStart, useChildSubEnv } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { download } from "@web/core/network/download"; + +import * as spreadsheet from "@odoo/o-spreadsheet"; +import { Spreadsheet, Model, registries } from "@odoo/o-spreadsheet"; +import { _lt } from "@web/core/l10n/translation"; +import { loadSpreadsheetDependencies } from "../helpers/helpers"; +import { migrate } from "../o_spreadsheet/migration"; + +registries.topbarMenuRegistry.addChild("download_public_excel", ["file"], { + name: _lt("Download"), + execute: (env) => env.downloadExcel(), + isReadonlyAllowed: true, +}); + +export class PublicReadonlySpreadsheet extends Component { + static props = { + dataUrl: String, + downloadExcelUrl: String, + }; + + setup() { + this.http = useService("http"); + useChildSubEnv({ + downloadExcel: () => + download({ + url: this.props.downloadExcelUrl, + data: {}, + }), + }); + onWillStart(loadSpreadsheetDependencies); + onWillStart(this.createModel.bind(this)); + } + + async createModel() { + const data = await this.http.get(this.props.dataUrl); + this.model = new Model(migrate(data), { + mode: "readonly", + }); + if (this.env.debug) { + // eslint-disable-next-line no-import-assign + spreadsheet.__DEBUG__ = spreadsheet.__DEBUG__ || {}; + spreadsheet.__DEBUG__.model = this.model; + } + } +} + +PublicReadonlySpreadsheet.template = "spreadsheet.PublicReadonlySpreadsheet"; +PublicReadonlySpreadsheet.components = { Spreadsheet }; diff --git a/addons/spreadsheet/static/src/public_readonly_app/public_readonly.scss b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.scss new file mode 100644 index 00000000000..19075d230a2 --- /dev/null +++ b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.scss @@ -0,0 +1,12 @@ +#spreadsheet-mount-anchor { + background-color: #DDDDDD; + flex-grow : 1; +} +.o-spreadsheet { + font-size: 12px; + height: 100%; + + .o-topbar-toolbar .o-readonly-toolbar.text-muted { + color: $info !important; + } +} diff --git a/addons/spreadsheet/static/src/public_readonly_app/public_readonly.xml b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.xml new file mode 100644 index 00000000000..7a990ef8bee --- /dev/null +++ b/addons/spreadsheet/static/src/public_readonly_app/public_readonly.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/addons/spreadsheet/views/public_readonly_spreadsheet_templates.xml b/addons/spreadsheet/views/public_readonly_spreadsheet_templates.xml new file mode 100644 index 00000000000..42286ca9624 --- /dev/null +++ b/addons/spreadsheet/views/public_readonly_spreadsheet_templates.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + Frozen and copied on + + + + + + + + + + + + + + + Try Odoo + + + + + + + + + +