diff --git a/addons/html_builder/__manifest__.py b/addons/html_builder/__manifest__.py index 5f354c6d6e9e7..2f846dc1d76b1 100644 --- a/addons/html_builder/__manifest__.py +++ b/addons/html_builder/__manifest__.py @@ -60,6 +60,9 @@ ('replace', 'website/static/src/core/website_edit_service.js', 'html_builder/static/src/website_builder/plugins/website_edit_service.js'), # this imports website_edit_service from its old location, let's get rid of it ('remove', 'website/static/src/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.js'), + # the google map edit interaction was reimplemented locally to replace this + 'website/static/src/snippets/s_google_map/google_map.js', + ('remove', 'website/static/src/snippets/s_google_map/google_map.edit.js'), ], 'html_builder.iframe_add_dialog': [ ('include', 'web.assets_frontend'), @@ -75,6 +78,8 @@ ], 'website.assets_edit_frontend': [ 'html_builder/static/src/interactions/**/*.edit.*', + # todo: fix: this breaks website by adding google_maps.edit.js + # instead of letting it use its own version. ], }, 'license': 'LGPL-3', diff --git a/addons/html_builder/static/src/interactions/google_map.edit.js b/addons/html_builder/static/src/interactions/google_map.edit.js new file mode 100644 index 0000000000000..c125806fe2390 --- /dev/null +++ b/addons/html_builder/static/src/interactions/google_map.edit.js @@ -0,0 +1,161 @@ +/* global google */ + +import { GoogleMap } from "@website/snippets/s_google_map/google_map"; +import { registry } from "@web/core/registry"; + +let id = 0; +const GoogleMapsEdit = (I) => + class extends I { + setup() { + super.setup(); + this.canSpecifyKey = true; + this.websiteEditService = this.services.website_edit; + this.websiteMapService = this.services.website_map; + } + + async willStart() { + id++; + console.warn(id, "----- WILLSTART -----"); + const currentId = id; + if (typeof google !== "object" || typeof google.maps !== "object") { + console.warn("no google => load"); + await this.loadGoogleMaps(false, currentId); + } else { + console.warn(currentId, "api loaded => initialize google maps"); + this.canStart = await this.websiteEditService.callShared( + "googleMapsOption", + "initializeGoogleMaps", + [this.el, google.maps, currentId] + ); + } + } + + start() { + console.warn(id, "----- START -----"); + console.warn(id, `start ${this.canStart ? "is about to work" : "won't do anything"}`); + super.start(); + } + + /** + * Get the stored API key if any (or open a dialog to ask the user for one), + * load and configure the Google Maps API. + * + * @param {boolean} [forceReconfigure=false] + * @returns {Promise} + */ + async loadGoogleMaps(forceReconfigure = false, currentId, iteration = 0) { + /** @type {string | undefined} */ + const apiKey = await this.websiteMapService.getGMapAPIKey(true); + const apiKeyValidation = await this.websiteMapService.validateGMapApiKey(apiKey); + const shouldReconfigure = forceReconfigure || !apiKeyValidation.isValid; + console.warn( + currentId + iteration / 10, + shouldReconfigure ? "should reconfigure" : "should not reconfigure", + apiKey || '""', + apiKeyValidation + ); + let didReconfigure = false; + if (shouldReconfigure) { + console.warn( + currentId + iteration / 10, + "reconfigure", + apiKey || '""', + apiKeyValidation + ); + didReconfigure = await this.websiteEditService.callShared( + "googleMapsOption", + "configureGMapsAPI", + apiKey + ); + if (didReconfigure) { + console.warn( + currentId + iteration / 10, + "did reconfigure => retry", + apiKey || '""', + apiKeyValidation + ); + // Restart interactions to retry loading. + // this.websiteEditService.update(this.el, true); + } else { + console.warn( + currentId + iteration / 10, + "did not reconfigure => remove", + apiKey || '""', + apiKeyValidation + ); + this.websiteEditService.callShared("remove", "removeElement", this.el); + } + } + if (!shouldReconfigure || didReconfigure) { + console.warn( + currentId + iteration / 10, + "did not reconfigure => load from service", + apiKey || '""', + apiKeyValidation + ); + const shouldRefetch = this.websiteEditService.callShared( + "googleMapsOption", + "shouldRefetchApiKey" + ); + const isDone = !!(await this.loadGoogleMapsAPIFromService( + shouldRefetch || didReconfigure, + currentId, + iteration + )); + if (!isDone) { + console.warn( + currentId + iteration / 10, + "loading failed", + apiKey || '""', + apiKeyValidation + ); + // Restart interactions to retry loading. + // this.websiteEditService.update(this.el, true); + } else { + console.warn( + currentId + iteration / 10, + "loading succeeded => don't do anything since the service is restarting the interaction already", + apiKey || '""', + apiKeyValidation + ); + /** + * From here, we should stop everything. + * The service restarts the interaction => back in willStart. + */ + } + return isDone; + } else { + return false; + } + } + + /** + * Load the Google Maps API from the Google Map Service. + * This method is set apart so it can be overridden for testing. + * + * @param {boolean} [shouldRefetch] + * @returns {Promise} A promise that resolves to an API + * key if found. + */ + async loadGoogleMapsAPIFromService(shouldRefetch, currentId, iteration) { + console.warn( + currentId + iteration / 10, + shouldRefetch ? "api should be refetched" : "api should NOT be refetched", + window.google ? "HAS GOOGLE API ALREADY" : "doesn't have google api yet" + ); + const apiKey = await this.websiteMapService.loadGMapAPI(true, shouldRefetch); + this.websiteEditService.callShared("googleMapsOption", "shouldNotRefetchApiKey"); + console.warn( + currentId + iteration / 10, + "loading returned", + apiKey, + window.google ? "google api loaded" : "google api not yet loaded" + ); + return !!apiKey; + } + }; + +registry.category("public.interactions.edit").add("html_builder.google_map", { + Interaction: GoogleMap, + mixin: GoogleMapsEdit, +}); diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.js b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.js new file mode 100644 index 0000000000000..196c9ca1500a3 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.js @@ -0,0 +1,58 @@ +import { _t } from "@web/core/l10n/translation"; +import { Dialog } from "@web/core/dialog/dialog"; +import { useChildRef, useService } from "@web/core/utils/hooks"; +import { Component, useState, useRef } from "@odoo/owl"; + +/** + * @typedef {import('./google_map_option_plugin.js').ApiKeyValidation} ApiKeyValidation + */ + +export class GoogleMapsApiKeyDialog extends Component { + static template = "website.s_google_map_modal"; + static components = { Dialog }; + static props = { + originalApiKey: String, + onSave: Function, + close: Function, + }; + + setup() { + this.modalRef = useChildRef(); + /** @type {{ apiKey?: string, apiKeyValidation: ApiKeyValidation }} */ + this.state = useState({ + apiKey: this.props.originalApiKey, + apiKeyValidation: { isValid: false }, + }); + this.apiKeyInput = useRef("apiKeyInput"); + // @TODO mysterious-egg: the `google_map service` is a duplicate of the + // `website_map_service`, but without the dependency on public + // interactions. These are used only to restart the interactions once + // the API is loaded. We do this in the plugin instead. Once + // `html_builder` replaces `website`, we should be able to remove + // `website_map_service` since only google_map service will be used. + this.googleMapsService = useService("google_maps"); + } + + async onClickSave() { + if (this.state.apiKey) { + /** @type {NodeList} */ + const buttons = this.modalRef.el.querySelectorAll("button"); + buttons.forEach((button) => button.setAttribute("disabled", true)); + /** @type {ApiKeyValidation} */ + const apiKeyValidation = await this.googleMapsService.validateGMapsApiKey( + this.state.apiKey + ); + this.state.apiKeyValidation = apiKeyValidation; + if (apiKeyValidation.isValid) { + await this.props.onSave(this.state.apiKey); + this.props.close(); + } + buttons.forEach((button) => button.removeAttribute("disabled")); + } else { + this.state.apiKeyValidation = { + isValid: false, + message: _t("Enter an API Key"), + }; + } + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.xml b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.xml new file mode 100644 index 0000000000000..9b90387f0ab86 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_api_key_dialog.xml @@ -0,0 +1,56 @@ + + +

Use Google Map on your website (Contact Us page, snippets, etc).

+
+ +
+
+
+ +
+ + + +
+ Hint: How to use Google Map on your website (Contact Us page and as a snippet) +
+ + + Create a Google Project and Get a Key + +
+ + + Enable billing on your Google Project + +
+
+ Make sure your settings are properly configured: +
    +
  • + Enable the right google map APIs in your google account +
      +
    • Maps Static API
    • +
    • Maps JavaScript API
    • +
    • Places API
    • +
    +
  • +
  • + Make sure billing is enabled +
  • +
  • + Make sure to wait if errors keep being shown: sometimes enabling an API allows to use it immediately but Google keeps triggering errors for a while +
  • +
+
+
+
+ + + + +
+
diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.js b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.js new file mode 100644 index 0000000000000..7b025a3c646b0 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.js @@ -0,0 +1,93 @@ +import { useRef, onMounted, useState, useEffect, onWillDestroy } from "@odoo/owl"; +import { BaseOptionComponent } from "@html_builder/core/utils"; + +/** @import { Coordinates, Place } from './google_maps_option_plugin.js' */ +/** + * @typedef {Object} Props + * @property {Function():Object} mapsAPI + * @property {function(Element, Coordinates):Promise} getPlace + * @property {function(Element, Place)} onPlaceChanged + */ + +export class GoogleMapsOption extends BaseOptionComponent { + static template = "html_builder.GoogleMapsOption"; + /** @type {Props} */ + static props = { + getMapsAPI: { type: Function }, + getPlace: { type: Function }, + onPlaceChanged: { type: Function }, + }; + + async setup() { + super.setup(); + /** @type {Props} */ + this.props; + /** @type {{ getEditingElement: function():Element }} */ + this.env; + this.inputRef = useRef("inputRef"); + /** @type {{ formattedAddress: string }} */ + this.state = useState({ + formattedAddress: this.env.getEditingElement().dataset.pinAddress || "", + }); + useEffect( + () => { + this.env.getEditingElement().dataset.pinAddress = this.state.formattedAddress; + }, + () => [this.state.formattedAddress] + ); + onMounted(async () => { + this.initializeAutocomplete(this.inputRef.el); + }); + onWillDestroy(() => { + if (this.autocompleteListener) { + this.props.getMapsAPI().event.removeListener(this.autocompleteListener); + } + // Without this, the Google library injects elements inside the + // DOM but does not remove them once the option is closed. + for (const container of document.body.querySelectorAll(".pac-container")) { + container.remove(); + } + }); + } + + /** + * Initialize Google Places API's autocompletion on the option's input. + * + * @param {Element} inputEl + */ + initializeAutocomplete(inputEl) { + if (!this.googleMapsAutocomplete && this.props.getMapsAPI()) { + const mapsAPI = this.props.getMapsAPI(); + this.googleMapsAutocomplete = new mapsAPI.places.Autocomplete(inputEl, { + types: ["geocode"], + }); + this.autocompleteListener = mapsAPI.event.addListener( + this.googleMapsAutocomplete, + "place_changed", + this.onPlaceChanged.bind(this) + ); + if (!this.state.formattedAddress) { + const editingElement = this.env.getEditingElement(); + /** @type {Coordinates} */ + const coordinates = editingElement.dataset.mapGps; + this.props.getPlace(editingElement, coordinates).then((place) => { + if (place?.formatted_address) { + this.state.formattedAddress = place.formatted_address; + } + }); + } + } + } + + /** + * Retrieve the new place given by Google Places API's autocompletion + * whenever it sends a signal that the place changed, and send it to the + * plugin. + */ + onPlaceChanged() { + /** @type {Place | undefined} */ + const place = this.googleMapsAutocomplete.getPlace(); + this.props.onPlaceChanged(this.env.getEditingElement(), place); + this.state.formattedAddress = place?.formatted_address || ""; + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.scss b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.scss new file mode 100644 index 0000000000000..05579191b1ba4 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.scss @@ -0,0 +1,55 @@ +.pac-container { // Google Maps' Autocomplete + z-index: $zindex-modal-backdrop; // > $o-we-zindex + width: ceil($o-we-sidebar-width * 0.9) !important; + font-size: $o-we-sidebar-font-size; + margin-left: -$o-we-sidebar-width/2; + border: $o-we-sidebar-content-field-border-width solid $o-we-sidebar-content-field-dropdown-border-color; + border-top: none; + border-radius: $o-we-item-border-radius; + overflow: hidden; + background-color: $o-we-sidebar-content-field-dropdown-bg; + box-shadow: $o-we-sidebar-content-field-dropdown-shadow; + margin-top: $o-we-sidebar-content-field-dropdown-spacing; + transform: translate(41px); + + &:after { + display: none; + } + + .pac-item { + @include o-text-overflow(block); + line-height: $o-we-sidebar-content-field-dropdown-item-height; + color: $o-we-sidebar-content-field-clickable-color; + padding: 0 1em 0 (2 * $o-we-sidebar-content-field-control-item-spacing + $o-we-sidebar-content-field-control-item-size); + border-top: $o-we-sidebar-content-field-border-width solid lighten($o-we-sidebar-content-field-dropdown-border-color, 15%); + border-radius: $o-we-sidebar-content-field-border-radius; + background-color: $o-we-sidebar-content-field-clickable-bg; + color: $o-we-sidebar-content-field-clickable-color; + font-size: $o-we-sidebar-font-size; + + &:hover, &:focus, &.pac-item-selected { + background-color: $o-we-sidebar-content-field-dropdown-item-bg-hover; + cursor: pointer; + } + + /* Remove Google Maps' own icon. */ + .pac-icon { + all: revert; + } + + .pac-icon-marker { + position: absolute; + margin-left: -1em; + + &::after { + content: '\f041'; + font-family: FontAwesome; + } + } + + .pac-item-query { + margin-right: 0.4em; + color: inherit; + } + } +} diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.xml b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.xml new file mode 100644 index 0000000000000..57ad6b49f85e8 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option.xml @@ -0,0 +1,77 @@ + + + + +
+ Visit us: + Our office is located in the northeast of Brussels. TEL (555) 432 2365 +
+
+ + + + + + + + Default + Flat + + + + + RoadMap + Terrain + Satellite + Hybrid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option_plugin.js b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option_plugin.js new file mode 100644 index 0000000000000..8dfb9e2159996 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_option_plugin.js @@ -0,0 +1,315 @@ +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { renderToElement } from "@web/core/utils/render"; +import { Plugin } from "@html_editor/plugin"; +import { GoogleMapsApiKeyDialog } from "./google_maps_api_key_dialog"; +import { GoogleMapsOption } from "./google_maps_option"; + +/** + * A `google.maps.places.PlaceResult` object. + * Here listed are only the few properties used here. For a full list, see: + * {@link https://developers.google.com/maps/documentation/javascript/reference/places-service#PlaceResult} + * + * @typedef {Object} Place + * @property {string} [formatted_address] + * @property {Object} [geometry] + * @property {Object} [geometry.location] + * @property {function():number} geometry.location.lat + * @property {function():number} geometry.location.lng + */ +/** + * A string defining GPS coordinates in the form "`Latitude`,`Longitude`". + * @typedef {`${number},${number}`} Coordinates + */ +/** + * @typedef {{ isValid: boolean, message?: string }} ApiKeyValidation + */ + +export class GoogleMapsOptionPlugin extends Plugin { + static id = "googleMapsOption"; + static dependencies = ["history", "remove"]; + static shared = [ + "configureGMapsAPI", + "initializeGoogleMaps", + "shouldRefetchApiKey", + "shouldNotRefetchApiKey", + ]; + resources = { + builder_options: [ + { + OptionComponent: GoogleMapsOption, + selector: ".s_google_map", + props: { + getMapsAPI: this.getMapsAPI.bind(this), + getPlace: this.getPlace.bind(this), + onPlaceChanged: this.commitPlace.bind(this), + }, + }, + ], + builder_actions: this.getActions(), + restore_savepoint_handlers: () => { + // Restart interactions to re-render the map. + this.dispatchTo("content_manually_updated_handlers"); + }, + }; + + setup() { + this.websiteService = this.services.website; + this.dialog = this.services.dialog; + this.orm = this.services.orm; + this.notification = this.services.notification; + + /** @type {Map} */ + this.gpsMapCache = new Map(); + + // RESET KEY FOR TESTING + this.orm.write("website", [this.websiteService.currentWebsite.id], { + google_maps_api_key: "", + }); + } + + getActions() { + return { + resetMapColor: { + apply: ({ editingElement }) => { + editingElement.dataset.mapColor = ""; + }, + }, + showDescription: { + isApplied: ({ editingElement }) => !!editingElement.querySelector(".description"), + apply: ({ editingElement }) => { + editingElement.append(renderToElement("html_builder.GoogleMapsDescription")); + }, + clean: ({ editingElement }) => { + editingElement.querySelector(".description").remove(); + }, + }, + }; + } + + getMapsAPI() { + return this.mapsAPI; + } + + async initializeGoogleMaps(editingElement, mapsAPI, id) { + if (this.isGoogleMapsReady) { + console.warn("initialize won't do anything"); + return; + } + this.mapsAPI = mapsAPI; + this.placesAPI = mapsAPI.places; + // Try to fail early if there is a configuration issue. + console.warn(id, "----- try initialize google maps -----"); + this.isGoogleMapsReady = !!(await this.getPlace( + editingElement, + editingElement.dataset.mapGps, + id + )); + if (!this.isGoogleMapsReady) { + console.warn("initialize failed"); + // this.dispatchTo("content_manually_updated_handlers"); + } else { + console.warn("initialize succeeded"); + } + return this.isGoogleMapsReady; + } + + /** + * Take a set of coordinates and perform a search on them to return a + * place's formatted address. If it failed, there must be an issue with the + * API so remove the snippet. + * + * @param {Element} editingElement + * @param {Coordinates} coordinates + * @returns {Promise} + */ + async getPlace(editingElement, coordinates, id) { + console.warn(id, "get place"); + const place = await this.nearbySearch(coordinates); + if (place?.error && !this.isGoogleMapsErrorBeingHandled) { + this.notifyGMapsError(editingElement, id); + } else if (!place && !this.isGoogleMapsErrorBeingHandled) { + // Somehow the search failed but Google didn't trigger an error. + // @TODO mysterious-egg should we keep this? Seems radical. Not sure + // we even ever get to this in the new flow. + this.dependencies.remove.removeElement(editingElement); + } else { + return place; + } + } + + /** + * Commit a place's coordinates and address to the cache and to the editing + * element's dataset, then re-render the map to reflect it. + * + * @param {Element} editingElement + * @param {Place} place + */ + commitPlace(editingElement, place) { + if (place?.geometry) { + const location = place.geometry.location; + /** @type {Coordinates} */ + const coordinates = `(${location.lat()},${location.lng()})`; + this.gpsMapCache.set(coordinates, place); + /** @type {{mapGps: Coordinates, pinAddress: string}} */ + const currentMapData = editingElement.dataset; + const { mapGps, pinAddress } = currentMapData; + if (mapGps !== coordinates || pinAddress !== place.formatted_address) { + editingElement.dataset.mapGps = coordinates; + editingElement.dataset.pinAddress = place.formatted_address; + // Restart interactions to re-render the map. + this.dispatchTo("content_manually_updated_handlers"); + this.dependencies.history.addStep(); + } + } + } + + /** + * Test the validity of the API key provided if any. If none was provided, + * or the key was invalid, or the `force` argument is `true`, open the API + * key dialog to prompt the user to provide a new API key. + * + * @param {Object} param + * @param {string} [param.apiKey] + * @returns {Promise} true if a new API key was written to db. + */ + async configureGMapsAPI(apiKey) { + /** @type {number} */ + const websiteId = this.websiteService.currentWebsite.id; + + /** @type {boolean} */ + const didReconfigure = await new Promise((resolve) => { + let isInvalidated = false; + // Open the Google API Key Dialog. + this.dialog.add( + GoogleMapsApiKeyDialog, + { + originalApiKey: apiKey, + onSave: async (newApiKey) => { + console.warn("dialog writes new key", newApiKey); + await this.orm.write("website", [websiteId], { + google_maps_api_key: newApiKey, + }); + this.shouldRefetchApiKey = false; + console.warn("new key written", newApiKey); + isInvalidated = true; + }, + }, + { + onClose: () => resolve(isInvalidated), + } + ); + }); + return didReconfigure; + } + + /** + * @param {Coordinates} coordinates + * @returns {Promise} + */ + async nearbySearch(coordinates) { + const place = this.gpsMapCache.get(coordinates); + if (place) { + return place; + } + + const p = coordinates.substring(1).slice(0, -1).split(","); + const location = new this.mapsAPI.LatLng(p[0] || 0, p[1] || 0); + return new Promise((resolve) => { + const placesService = new this.placesAPI.PlacesService(document.createElement("div")); + placesService.nearbySearch( + { + // Do a 'nearbySearch' followed by 'getDetails' to avoid using + // GMaps Geocoder which the user may not have enabled... but + // ideally Geocoder should be used to get the exact location at + // those coordinates and to limit billing query count. + location, + radius: 1, + }, + (results, status) => { + const GMAPS_CRITICAL_ERRORS = [ + this.placesAPI.PlacesServiceStatus.REQUEST_DENIED, + this.placesAPI.PlacesServiceStatus.UNKNOWN_ERROR, + ]; + if (status === this.placesAPI.PlacesServiceStatus.OK) { + placesService.getDetails( + { + placeId: results[0].place_id, + fields: ["geometry", "formatted_address"], + }, + (place, status) => { + if (status === this.placesAPI.PlacesServiceStatus.OK) { + this.gpsMapCache.set(coordinates, place); + resolve(place); + } else if (GMAPS_CRITICAL_ERRORS.includes(status)) { + resolve({ error: status }); + } else { + resolve(); + } + } + ); + } else if (GMAPS_CRITICAL_ERRORS.includes(status)) { + resolve({ error: status }); + } else { + resolve(); + } + } + ); + }); + } + + /** + * Indicates to the user there is an error with the google map API and + * re-opens the configuration dialog. For good measure, this also removes + * the related snippet entirely as this is what is done in case of critical + * error. + */ + notifyGMapsError(editingElement, id) { + // TODO this should be better to detect all errors. This is random. + // When misconfigured (wrong APIs enabled), sometimes Google throws + // errors immediately (which then reaches this code), sometimes it + // throws them later (which then induces an error log in the console + // and random behaviors). + console.warn(id, "----- notifyGMapsError -----", this.isGoogleMapsErrorBeingHandled); + if (!this.isGoogleMapsErrorBeingHandled) { + this.isGoogleMapsErrorBeingHandled = true; + + this.notification.add( + _t( + "A Google Maps error occurred. Make sure to read the key configuration popup carefully." + ), + { type: "danger", sticky: true } + ); + // Try again: invalidate the API key then restart interactions. + console.warn("notifyGMapsError deletes the key"); + this.wasApiKeyInvalidated = true; + this.orm + .write("website", [this.websiteService.currentWebsite.id], { + google_maps_api_key: "", + }) + .then(() => { + // console.warn("critical error => remove element"); + // this.dependencies.remove.removeElement(editingElement); + this.isGoogleMapsErrorBeingHandled = false; + this.dispatchTo("content_manually_updated_handlers"); + /** + * This causes an infinite loop, I don't know why. + * Figure out how to change the API key without actually + * reloading the google object. + * The previous commit is better than this one but there + * might be some useful fixes in the flow here that ensure + * we don't go twice through the same loop for no good + * reason. Investigate. + */ + }); + } + } + shouldRefetchApiKey() { + return this.wasApiKeyInvalidated || false; + } + shouldNotRefetchApiKey() { + this.wasApiKeyInvalidated = false; + } +} + +registry.category("website-plugins").add(GoogleMapsOptionPlugin.id, GoogleMapsOptionPlugin); diff --git a/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_service.js b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_service.js new file mode 100644 index 0000000000000..63a3450ad8d11 --- /dev/null +++ b/addons/html_builder/static/src/website_builder/plugins/options/google_maps_option/google_maps_service.js @@ -0,0 +1,132 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable no-async-promise-executor */ + +import { loadJS } from "@web/core/assets"; +import { registry } from "@web/core/registry"; +import { _t } from "@web/core/l10n/translation"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { markup } from "@odoo/owl"; +import { escape } from "@web/core/utils/strings"; + +registry.category("services").add("google_maps", { + dependencies: [ "notification" ], + start(env, deps) { + const notification = deps["notification"]; + let gMapsAPIKeyProm; + let gMapsAPILoading; + const promiseKeys = {}; + const promiseKeysResolves = {}; + let lastKey; + window.odoo_gmaps_api_post_load = (async function odoo_gmaps_api_post_load() { + promiseKeysResolves[lastKey]?.(); + }).bind(this); + return { + /** + * @param {boolean} [refetch=false] + */ + async getGMapsAPIKey(refetch) { + if (refetch || !gMapsAPIKeyProm) { + gMapsAPIKeyProm = new Promise(async resolve => { + const data = await rpc('/website/google_maps_api_key'); + resolve(JSON.parse(data).google_maps_api_key || ''); + }); + } + return gMapsAPIKeyProm; + }, + /** + * @param {boolean} [editableMode=false] + * @param {boolean} [refetch=false] + */ + async loadGMapsAPI(editableMode, refetch) { + // Note: only need refetch to reload a configured key and load + // the library. If the library was loaded with a correct key and + // that the key changes meanwhile... it will not work but we can + // agree the user can bother to reload the page at that moment. + if (refetch || !gMapsAPILoading) { + gMapsAPILoading = new Promise(async resolve => { + const key = await this.getGMapsAPIKey(refetch); + lastKey = key; + + if (key) { + if (!promiseKeys[key]) { + promiseKeys[key] = new Promise((resolve) => { + promiseKeysResolves[key] = resolve; + }); + await loadJS( + `https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmaps_api_post_load&key=${encodeURIComponent( + key + )}` + ); + } + await promiseKeys[key]; + resolve(key); + } else { + if (!editableMode && user.isAdmin) { + const message = _t("Cannot load google map."); + const urlTitle = _t("Check your configuration."); + notification.add( + markup(`
+ ${escape(message)}
+ ${escape(urlTitle)} +
`), + { type: 'warning', sticky: true } + ); + } + resolve(false); + } + }); + } + return gMapsAPILoading; + }, + /** + * Send a request to the Google Maps API to test the validity of the given + * API key. Return an object with the error message if any, and a boolean + * that is true if the response from the API had a status of 200. + * + * Note: The response will be 200 so long as the API key has billing, Static + * API and Javascript API enabled. However, for our purposes, we also need + * the Places API enabled. To deal with that case, we perform a nearby + * search immediately after validation. If it fails, the error is handled + * and the dialog is re-opened. + * @see nearbySearch + * @see notifyGMapsError + * + * @param {string} key + * @returns {Promise} + */ + async validateGMapsApiKey(key) { + if (key) { + try { + const response = await this.fetchGoogleMaps(key); + const isValid = (response.status === 200); + return { + isValid, + message: isValid + ? undefined + : _t("Invalid API Key. The following error was returned by Google: %(error)s", { error: await response.text() }), + }; + } catch { + return { + isValid: false, + message: _t("Check your connection and try again"), + }; + } + } else { + return { isValid: false }; + } + }, + /** + * Send a request to the Google Maps API, using the given API key, so as to + * get a response which can be used to test the validity of said key. + * This method is set apart so it can be overridden for testing. + * + * @param {string} key + * @returns {Promise<{ status: number }>} + */ + async fetchGoogleMaps(key) { + return await fetch(`https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${encodeURIComponent(key)}`); + }, + } + } +}); diff --git a/addons/html_builder/static/src/website_builder/plugins/website_edit_service.js b/addons/html_builder/static/src/website_builder/plugins/website_edit_service.js index 55986aff4ca07..fa629a27f6147 100644 --- a/addons/html_builder/static/src/website_builder/plugins/website_edit_service.js +++ b/addons/html_builder/static/src/website_builder/plugins/website_edit_service.js @@ -54,6 +54,7 @@ registry.category("services").add("website_edit", { const shared = {}; const update = (target, mode) => { + console.warn("----- interaction update -----"); // editMode = true; // const currentEditMode = this.website_edit.mode === "edit"; const shouldActivateEditInteractions = editMode !== mode; @@ -164,6 +165,20 @@ registry.category("services").add("website_edit", { } ); }; + const callShared = (pluginName, methodName, args = []) => { + if (!Array.isArray(args)) { + args = [args]; + } + if (shared[pluginName]) { + if (shared[pluginName][methodName]) { + return shared[pluginName][methodName](...args); + } else { + console.error(`Method "${methodName}" not found on plugin "${pluginName}".`); + } + } else { + console.error(`Plugin "${pluginName}" not found.`); + } + }; const websiteEditService = { isEditingTranslations, @@ -172,6 +187,7 @@ registry.category("services").add("website_edit", { installPatches, uninstallPatches, applyAction, + callShared, }; // Transfer the iframe website_edit service to the EditInteractionPlugin diff --git a/addons/html_builder/static/tests/options/google_maps_option.test.js b/addons/html_builder/static/tests/options/google_maps_option.test.js new file mode 100644 index 0000000000000..e1729248f6e9f --- /dev/null +++ b/addons/html_builder/static/tests/options/google_maps_option.test.js @@ -0,0 +1,143 @@ +import { describe, test, expect } from "@odoo/hoot"; +import { defineWebsiteModels, setupWebsiteBuilderWithSnippet } from "../website_helpers"; +import { contains, onRpc, patchWithCleanup } from "@web/../tests/web_test_helpers"; +import { GoogleMapsOptionPlugin } from "@html_builder/website_builder/plugins/options/google_maps_option/google_maps_option_plugin"; +import { queryOne, waitFor, waitForNone } from "@odoo/hoot-dom"; + +defineWebsiteModels(); + +describe("API key validation", () => { + /** + * - No billing or invalid key: returns 403. + * - Billing + No API: returns 403. + * - Billing + Places + Javascript: returns 403. + * -> Should not be able to validate the key from editor panel. + * -> If key added via backend, adding a map snippet later will open + * the key config panel automatically. + * -> If clicking on existing map, should open the API key dialog. + * - Billing + Static + Javascript: return 200. + * -> Should still wrongly allow to save the key configuration but it + * should reopen immediately. + * - Billing + Static + Javascript + Places: return 200. + * -> Should work. + * + * Notes: + * - Google errors triggered while there no misconfig should not open + * any notification or dialog. + * - There might be some weird behavior when saving multiple considered + * valid key during the same edition (which is an acceptable trade-off in + * real life). + */ + + /** + * Stringify an API configuration so it can be used as a bogus API key that + * is descriptive of what the API key is supposed to give access to. + */ + const makeKey = ( + config = { billing: false, staticApi: false, placesApi: false, javascriptApi: false } + ) => JSON.stringify(config); + + const setupGoogleMapsSnippetWithKey = async (key, isFakeValidKey = false) => { + onRpc("/website/google_maps_api_key", async () => + JSON.stringify({ + google_maps_api_key: key, + }) + ); + patchWithCleanup(GoogleMapsOptionPlugin.prototype, { + /** + * Return the key again instead of actually loading the API, which + * wouldn't work given we don't have a real key. + */ + async loadGoogleMapsAPIFromService() { + return key; + }, + /** + * Mock a fetch call to the Google Maps API supposed to return a + * status code informing us of whether an API key is valid or not + * for our purposes. + */ + fetchGoogleMaps(key) { + const { billing, staticApi, javascriptApi, placesApi } = JSON.parse(key); + return { + status: billing && staticApi && javascriptApi ? 200 : 403, + billing, + staticApi, + javascriptApi, + placesApi, + }; + }, + /** + * Mock a successful call to Google Maps' nearby search returning a + * place so we don't need a valid API key. + * OR, mock a failed call to Google Maps' nearby search calling the + * error handler. + */ + async nearbySearch() { + if (isFakeValidKey) { + return { error: "CRITICAL" }; + } else { + return { + formatted_address: + "9 Rue des Bourlottes, 1367 Grand-Rosière-Hottomont, Belgium", + geometry: { + location: { + lat: () => "50.62994", + lng: () => "4.86374", + }, + }, + }; + } + }, + }); + await setupWebsiteBuilderWithSnippet("s_google_map"); + if (isFakeValidKey) { + await waitForNone(":iframe .s_google_map"); // It was removed. + } else { + await contains(":iframe .s_google_map").click(); + } + }; + const keyShouldTriggerDialog = async (config, isFakeValidKey = false) => { + const key = makeKey(config); + await setupGoogleMapsSnippetWithKey(key, isFakeValidKey); + if (isFakeValidKey) { + // An error should appear since nearbySearch failed. + await waitFor(".o_notification_manager div[role=alert]"); + } + const apiKeyInput = await queryOne(".modal-dialog #api_key_input"); + expect(apiKeyInput.value).toBe(key); + if (!isFakeValidKey) { + await waitFor(".modal-dialog #api_key_help.text-danger"); + } + }; + + test("having an API key with no billing or API should trigger the opening of the API key dialog", async () => { + await keyShouldTriggerDialog(); // invalid key (has nothing) + }); + + test("having an API key with no billing should trigger the opening of the API key dialog", async () => { + await keyShouldTriggerDialog({ staticApi: true, javascriptApi: true, placesApi: true }); + }); + + test("having an API key with no static API should trigger the opening of the API key dialog", async () => { + await keyShouldTriggerDialog({ billing: true, javascriptApi: true, placesApi: true }); + }); + + test("having an API key with billing, static API, javascript API and places API should not trigger the opening of the API key dialog", async () => { + const key = makeKey({ + billing: true, + staticApi: true, + javascriptApi: true, + placesApi: true, + }); + await setupGoogleMapsSnippetWithKey(key); + await waitForNone(".modal-dialog #api_key_input"); + await contains(":iframe .s_google_map").click(); + }); + + test("having an API key with billing, static API and javascript API but no places API should trigger the opening of the API key dialog even though the response is 200", async () => { + await keyShouldTriggerDialog( + { billing: true, staticApi: true, javascriptApi: true, placesApi: false }, + true + ); + }); +}); diff --git a/addons/website/static/src/core/website_map_service.js b/addons/website/static/src/core/website_map_service.js index f50b37af62823..835d70da71bc3 100644 --- a/addons/website/static/src/core/website_map_service.js +++ b/addons/website/static/src/core/website_map_service.js @@ -1,3 +1,6 @@ +/* eslint-disable prettier/prettier */ +/* eslint-disable no-async-promise-executor */ + import { loadJS } from "@web/core/assets"; import { registry } from "@web/core/registry"; import { _t } from "@web/core/l10n/translation"; @@ -13,16 +16,32 @@ registry.category("services").add("website_map", { const notification = deps["notification"]; let gmapAPIKeyProm; let gmapAPILoading; + const promiseKeys = {}; + const promiseKeysResolves = {}; + let lastKey; + window.odoo_gmap_api_post_load = (async function odoo_gmap_api_post_load() { + console.warn(`promise for ${lastKey} resolving => first restart`); + for (const el of document.querySelectorAll("section.s_google_map")) { + publicInteractions.stopInteractions(el); + publicInteractions.startInteractions(el); + } + promiseKeysResolves[lastKey]?.(); + }).bind(this); return { /** * @param {boolean} [refetch=false] */ async getGMapAPIKey(refetch) { + console.warn("getting key from service"); if (refetch || !gmapAPIKeyProm) { + console.warn("initialize key getting promise"); gmapAPIKeyProm = new Promise(async resolve => { const data = await rpc('/website/google_maps_api_key'); + console.warn("key found:", JSON.parse(data).google_maps_api_key || '""'); resolve(JSON.parse(data).google_maps_api_key || ''); }); + } else { + console.warn("key getting not doing anything:", refetch, gmapAPIKeyProm, !gmapAPIKeyProm); } return gmapAPIKeyProm; }, @@ -31,6 +50,7 @@ registry.category("services").add("website_map", { * @param {boolean} [refetch=false] */ async loadGMapAPI(editableMode, refetch) { + console.warn("load from service", refetch || false); // Note: only need refetch to reload a configured key and load the // library. If the library was loaded with a correct key and that the // key changes meanwhile... it will not work but we can agree the user @@ -38,16 +58,26 @@ registry.category("services").add("website_map", { if (refetch || !gmapAPILoading) { gmapAPILoading = new Promise(async resolve => { const key = await this.getGMapAPIKey(refetch); + lastKey = key; + console.warn("load from service (inside promise)", key || '""'); - window.odoo_gmap_api_post_load = (async function odoo_gmap_api_post_load() { - for (const el of document.querySelectorAll("section.s_google_map")) { - publicInteractions.stopInteractions(el); - publicInteractions.startInteractions(el); + if (key) { + if (!promiseKeys[key]) { + promiseKeys[key] = new Promise((resolve) => { + promiseKeysResolves[key] = resolve; + }); + await loadJS( + `https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmap_api_post_load&key=${encodeURIComponent( + key + )}` + ); } + console.warn("returning promise for key", key); + await promiseKeys[key]; + console.warn("promise for key", key, "resolved"); resolve(key); - }).bind(this); - - if (!key) { + } else { + console.warn("will resolve to false because no key"); if (!editableMode && user.isAdmin) { const message = _t("Cannot load google map."); const urlTitle = _t("Check your configuration."); @@ -61,13 +91,68 @@ registry.category("services").add("website_map", { } resolve(false); gmapAPILoading = false; - return; } - await loadJS(`https://maps.googleapis.com/maps/api/js?v=3.exp&libraries=places&callback=odoo_gmap_api_post_load&key=${encodeURIComponent(key)}`); }); + } else { + console.warn("won't load", refetch || false, gmapAPILoading, !gmapAPILoading); } return gmapAPILoading; }, + /** + * Send a request to the Google Maps API to test the validity of the given + * API key. Return an object with the error message if any, and a boolean + * that is true if the response from the API had a status of 200. + * + * Note: The response will be 200 so long as the API key has billing, Static + * API and Javascript API enabled. However, for our purposes, we also need + * the Places API enabled. To deal with that case, we perform a nearby + * search immediately after validation. If it fails, the error is handled + * and the dialog is re-opened. + * @see nearbySearch + * @see notifyGMapsError + * + * @param {string} key + * @returns {Promise} + */ + async validateGMapApiKey(key) { + if (key) { + try { + const response = await this.fetchGoogleMap(key); + const isValid = response.status === 200; + return { + isValid, + message: isValid + ? undefined + : _t( + "Invalid API Key. The following error was returned by Google: %(error)s", + { error: await response.text() } + ), + }; + } catch { + return { + isValid: false, + message: _t("Check your connection and try again"), + }; + } + } else { + return { isValid: false }; + } + }, + /** + * Send a request to the Google Maps API, using the given API key, so as to + * get a response which can be used to test the validity of said key. + * This method is set apart so it can be overridden for testing. + * + * @param {string} key + * @returns {Promise<{ status: number }>} + */ + async fetchGoogleMap(key) { + return await fetch( + `https://maps.googleapis.com/maps/api/staticmap?center=belgium&size=10x10&key=${encodeURIComponent( + key + )}` + ); + }, } } }); diff --git a/addons/website/static/src/js/editor/snippets.editor.js b/addons/website/static/src/js/editor/snippets.editor.js index 3bbb588700e39..523974a54d9a8 100644 --- a/addons/website/static/src/js/editor/snippets.editor.js +++ b/addons/website/static/src/js/editor/snippets.editor.js @@ -1,3 +1,4 @@ +/* eslint-disable prettier/prettier */ import { _t } from "@web/core/l10n/translation"; import { Dialog } from "@web/core/dialog/dialog"; import { useChildRef, useService } from "@web/core/utils/hooks"; @@ -273,11 +274,11 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { onMounted(() => this.props.onMounted(this.modalRef)); } onClickSave() { - this.props.confirm(this.modalRef, this.state.apiKey); - this.props.close(); + this.props.confirm(this.modalRef, this.state.apiKey).then(() => this.props.close()); } }; + console.warn("requesting key"); return new Promise(resolve => { let invalidated = false; this.dialog.add(GoogleMapAPIKeyDialog, { @@ -293,18 +294,25 @@ export class WebsiteSnippetsMenu extends weSnippetEditor.SnippetsMenu { } const $button = $(modalRef.el).find("button"); $button.prop('disabled', true); + console.warn("confirming", valueAPIKey); const res = await this._validateGMapAPIKey(valueAPIKey); if (res.isValid) { + console.warn(valueAPIKey, "valid"); await this.orm.write("website", [websiteId], {google_maps_api_key: valueAPIKey}); invalidated = true; + console.warn(valueAPIKey, "saved"); return true; } else { + console.warn(valueAPIKey, "invalid"); applyError.call($(modalRef.el), res.message); } $button.prop("disabled", false); } }, { - onClose: () => resolve(invalidated), + onClose: () => { + console.warn("resolving"); + return resolve(invalidated); + }, }); }); } diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js index ffd6bb58ee3f0..15d5283c1760b 100644 --- a/addons/website/static/src/js/editor/snippets.options.js +++ b/addons/website/static/src/js/editor/snippets.options.js @@ -18,27 +18,24 @@ import "@website/snippets/s_popup/options"; import { range } from "@web/core/utils/numbers"; import { _t } from "@web/core/l10n/translation"; import { pyToJsLocale } from "@web/core/l10n/utils"; -import {Domain} from "@web/core/domain"; +import { Domain } from "@web/core/domain"; import { isCSSColor, convertCSSColorToRgba, convertRgbaToCSSColor, convertRgbToHsl, convertHslToRgb, - } from '@web/core/utils/colors'; +} from "@web/core/utils/colors"; import { renderToElement, renderToFragment } from "@web/core/utils/render"; import { browser } from "@web/core/browser/browser"; -import { - removeTextHighlight, - drawTextHighlightSVG, -} from "@website/js/text_processing"; +import { removeTextHighlight, drawTextHighlightSVG } from "@website/js/text_processing"; import { throttleForAnimation } from "@web/core/utils/timing"; import { Component, markup, useEffect, useRef, useState } from "@odoo/owl"; -const InputUserValueWidget = options.userValueWidgetsRegistry['we-input']; -const SelectUserValueWidget = options.userValueWidgetsRegistry['we-select']; -const Many2oneUserValueWidget = options.userValueWidgetsRegistry['we-many2one']; +const InputUserValueWidget = options.userValueWidgetsRegistry["we-input"]; +const SelectUserValueWidget = options.userValueWidgetsRegistry["we-select"]; +const Many2oneUserValueWidget = options.userValueWidgetsRegistry["we-many2one"]; options.UserValueWidget.include({ loadMethodsData() { @@ -49,12 +46,12 @@ options.UserValueWidget.include({ // customizeWebsiteViews so that the variable is used to show to active // value when both methods are used at the same time. // TODO find a better way. - const indexVariable = this._methodsNames.indexOf('customizeWebsiteVariable'); + const indexVariable = this._methodsNames.indexOf("customizeWebsiteVariable"); if (indexVariable >= 0) { - const indexView = this._methodsNames.indexOf('customizeWebsiteViews'); + const indexView = this._methodsNames.indexOf("customizeWebsiteViews"); if (indexView >= 0) { - this._methodsNames[indexVariable] = 'customizeWebsiteViews'; - this._methodsNames[indexView] = 'customizeWebsiteVariable'; + this._methodsNames[indexVariable] = "customizeWebsiteViews"; + this._methodsNames[indexView] = "customizeWebsiteVariable"; } } }, @@ -77,9 +74,11 @@ Many2oneUserValueWidget.include({ fieldNames: ["website_id"], }); const modelHasWebsiteId = !!websiteIdField["website_id"]; - if (modelHasWebsiteId && !this.options.domain.find(arr => arr[0] === "website_id")) { - this.options.domain = - Domain.and([this.options.domain, wUtils.websiteDomain(this)]).toList(); + if (modelHasWebsiteId && !this.options.domain.find((arr) => arr[0] === "website_id")) { + this.options.domain = Domain.and([ + this.options.domain, + wUtils.websiteDomain(this), + ]).toList(); } return this.options.domain; }, @@ -87,7 +86,7 @@ Many2oneUserValueWidget.include({ const UrlPickerUserValueWidget = InputUserValueWidget.extend({ events: Object.assign({}, InputUserValueWidget.prototype.events || {}, { - 'click .o_we_redirect_to': '_onRedirectTo', + "click .o_we_redirect_to": "_onRedirectTo", }), /** @@ -95,18 +94,18 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({ */ start: async function () { await this._super(...arguments); - const linkButton = document.createElement('we-button'); - const icon = document.createElement('i'); - icon.classList.add('fa', 'fa-fw', 'fa-external-link'); - linkButton.classList.add('o_we_redirect_to', 'o_we_link', 'ms-1'); + const linkButton = document.createElement("we-button"); + const icon = document.createElement("i"); + icon.classList.add("fa", "fa-fw", "fa-external-link"); + linkButton.classList.add("o_we_redirect_to", "o_we_link", "ms-1"); linkButton.title = _t("Preview this URL in a new tab"); linkButton.appendChild(icon); this.containerEl.after(linkButton); - this.el.classList.add('o_we_large'); - this.inputEl.classList.add('text-start'); + this.el.classList.add("o_we_large"); + this.inputEl.classList.add("text-start"); const options = { classes: { - "ui-autocomplete": 'o_website_ui_autocomplete' + "ui-autocomplete": "o_website_ui_autocomplete", }, body: this.getParent().$target[0].ownerDocument.body, urlChosen: this._onWebsiteURLChosen.bind(this), @@ -145,14 +144,14 @@ const UrlPickerUserValueWidget = InputUserValueWidget.extend({ */ _onRedirectTo: function () { if (this._value) { - window.open(this._value, '_blank'); + window.open(this._value, "_blank"); } }, destroy() { this.unmountAutocompleteWithPages?.(); this.unmountAutocompleteWithPages = null; this._super(...arguments); - } + }, }); class GoogleFontAutoComplete extends AutoComplete { @@ -160,9 +159,12 @@ class GoogleFontAutoComplete extends AutoComplete { super.setup(); this.inputRef = useRef("input"); this.sourcesListRef = useRef("sourcesList"); - useEffect((el) => { - el.setAttribute("id", "google_font"); - }, () => [this.inputRef.el]); + useEffect( + (el) => { + el.setAttribute("id", "google_font"); + }, + () => [this.inputRef.el] + ); } get dropdownOptions() { @@ -182,8 +184,8 @@ class GoogleFontAutoComplete extends AutoComplete { const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ events: Object.assign({}, SelectUserValueWidget.prototype.events || {}, { - 'click .o_we_add_font_btn': '_onAddFontClick', - 'click .o_we_delete_font_btn': '_onDeleteFontClick', + "click .o_we_add_font_btn": "_onAddFontClick", + "click .o_we_delete_font_btn": "_onDeleteFontClick", }), fontVariables: [], // Filled by editor menu when all options are loaded @@ -200,23 +202,28 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ */ start: async function () { const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); - const nbFonts = parseInt(weUtils.getCSSVariableValue('number-of-fonts', style)); + const nbFonts = parseInt(weUtils.getCSSVariableValue("number-of-fonts", style)); // User fonts served by google server. - const googleFontsProperty = weUtils.getCSSVariableValue('google-fonts', style); + const googleFontsProperty = weUtils.getCSSVariableValue("google-fonts", style); this.googleFonts = googleFontsProperty ? googleFontsProperty.split(/\s*,\s*/g) : []; - this.googleFonts = this.googleFonts.map(font => font.substring(1, font.length - 1)); // Unquote + this.googleFonts = this.googleFonts.map((font) => font.substring(1, font.length - 1)); // Unquote // Local user fonts. - const googleLocalFontsProperty = weUtils.getCSSVariableValue('google-local-fonts', style); - this.googleLocalFonts = googleLocalFontsProperty ? - googleLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) : []; - const uploadedLocalFontsProperty = weUtils.getCSSVariableValue('uploaded-local-fonts', style); - this.uploadedLocalFonts = uploadedLocalFontsProperty ? - uploadedLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) : []; + const googleLocalFontsProperty = weUtils.getCSSVariableValue("google-local-fonts", style); + this.googleLocalFonts = googleLocalFontsProperty + ? googleLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) + : []; + const uploadedLocalFontsProperty = weUtils.getCSSVariableValue( + "uploaded-local-fonts", + style + ); + this.uploadedLocalFonts = uploadedLocalFontsProperty + ? uploadedLocalFontsProperty.slice(1, -1).split(/\s*,\s*/g) + : []; // If a same font exists both remotely and locally, we remove the remote // font to prioritize the local font. The remote one will never be // displayed or loaded as long as the local one exists. - this.googleFonts = this.googleFonts.filter(font => { - const localFonts = this.googleLocalFonts.map(localFont => localFont.split(":")[0]); + this.googleFonts = this.googleFonts.filter((font) => { + const localFonts = this.googleLocalFonts.map((localFont) => localFont.split(":")[0]); return localFonts.indexOf(`'${font}'`) === -1; }); this.allFonts = []; @@ -225,7 +232,9 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const fontsToLoad = []; for (const font of this.googleFonts) { - const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent(font).replace(/%20/g, '+')}`; + const fontURL = `https://fonts.googleapis.com/css?family=${encodeURIComponent( + font + ).replace(/%20/g, "+")}`; fontsToLoad.push(fontURL); } for (const font of this.googleLocalFonts) { @@ -236,13 +245,17 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ // TODO ideally, remove the elements created once this widget // instance is destroyed (although it should not hurt to keep them for // the whole backend lifecycle). - const proms = fontsToLoad.map(async fontURL => loadCSS(fontURL)); + const proms = fontsToLoad.map(async (fontURL) => loadCSS(fontURL)); const fontsLoadingProm = Promise.all(proms); const fontEls = []; - const methodName = this.el.dataset.methodName || 'customizeWebsiteVariable'; + const methodName = this.el.dataset.methodName || "customizeWebsiteVariable"; const variable = this.el.dataset.variable; - const themeFontsNb = nbFonts - (this.googleLocalFonts.length + this.googleFonts.length + this.uploadedLocalFonts.length); + const themeFontsNb = + nbFonts - + (this.googleLocalFonts.length + + this.googleFonts.length + + this.uploadedLocalFonts.length); for (let fontNb = 0; fontNb < nbFonts; fontNb++) { const realFontNb = fontNb + 1; const fontKey = weUtils.getCSSVariableValue(`font-number-${realFontNb}`, style); @@ -252,24 +265,28 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const isSystemFonts = fontName === "SYSTEM_FONTS"; if (isSystemFonts) { fontName = _t("System Fonts"); - fontFamily = 'var(--o-system-fonts)'; + fontFamily = "var(--o-system-fonts)"; } - const fontEl = document.createElement('we-button'); - fontEl.setAttribute('string', fontName); + const fontEl = document.createElement("we-button"); + fontEl.setAttribute("string", fontName); fontEl.dataset.variable = variable; fontEl.dataset[methodName] = fontKey; fontEl.dataset.fontFamily = fontFamily; const iconWrapperEl = document.createElement("div"); iconWrapperEl.classList.add("text-end"); fontEl.appendChild(iconWrapperEl); - if ((realFontNb <= themeFontsNb) && !isSystemFonts) { + if (realFontNb <= themeFontsNb && !isSystemFonts) { // Add the "cloud" icon next to the theme's default fonts // because they are served by Google. - iconWrapperEl.appendChild(Object.assign(document.createElement('i'), { - role: 'button', - className: 'text-info me-2 fa fa-cloud', - title: _t("This font is hosted and served to your visitors by Google servers"), - })); + iconWrapperEl.appendChild( + Object.assign(document.createElement("i"), { + role: "button", + className: "text-info me-2 fa fa-cloud", + title: _t( + "This font is hosted and served to your visitors by Google servers" + ), + }) + ); } fontEls.push(fontEl); this.menuEl.appendChild(fontEl); @@ -278,35 +295,51 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ if (this.uploadedLocalFonts.length) { const uploadedLocalFontsEls = fontEls.splice(-this.uploadedLocalFonts.length); uploadedLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "uploaded", - })); + $(el) + .find(".text-end") + .append( + renderToFragment("website.delete_font_btn", { + index: index, + local: "uploaded", + }) + ); }); } if (this.googleLocalFonts.length) { const googleLocalFontsEls = fontEls.splice(-this.googleLocalFonts.length); googleLocalFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - local: "google", - })); + $(el) + .find(".text-end") + .append( + renderToFragment("website.delete_font_btn", { + index: index, + local: "google", + }) + ); }); } if (this.googleFonts.length) { const googleFontsEls = fontEls.splice(-this.googleFonts.length); googleFontsEls.forEach((el, index) => { - $(el).find(".text-end").append(renderToFragment('website.delete_font_btn', { - index: index, - })); + $(el) + .find(".text-end") + .append( + renderToFragment("website.delete_font_btn", { + index: index, + }) + ); }); } - $(this.menuEl).append($(renderToElement('website.add_font_btn', { - variable: variable, - }))); + $(this.menuEl).append( + $( + renderToElement("website.add_font_btn", { + variable: variable, + }) + ) + ); return fontsLoadingProm; }, @@ -321,8 +354,10 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ async setValue() { await this._super(...arguments); - this.menuTogglerEl.style.fontFamily = ''; - const activeWidget = this._userValueWidgets.find(widget => !widget.isPreviewed() && widget.isActive()); + this.menuTogglerEl.style.fontFamily = ""; + const activeWidget = this._userValueWidgets.find( + (widget) => !widget.isPreviewed() && widget.isActive() + ); if (activeWidget) { this.menuTogglerEl.style.fontFamily = activeWidget.el.dataset.fontFamily; } @@ -341,9 +376,13 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ static components = { GoogleFontAutoComplete, Dialog }; static props = { close: Function, title: String, onClickSave: Function }; state = useState({ - valid: true, loading: false, - googleFontFamily: undefined, googleServe: true, - uploadedFontName: undefined, uploadedFonts: [], uploadedFontFaces: undefined, + valid: true, + loading: false, + googleFontFamily: undefined, + googleServe: true, + uploadedFontName: undefined, + uploadedFonts: [], + uploadedFontFaces: undefined, previewText: _t("The quick brown fox jumps over the lazy dog."), }); fileInput = useRef("fileInput"); @@ -363,21 +402,27 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ this.props.close(); } get getGoogleFontList() { - return [{options: async (term) => { - if (!this.googleFontList) { - await rpc("/website/google_font_metadata").then((data) => { - this.googleFontList = data.familyMetadataList.map((font) => font.family); - }); - } - const lowerCaseTerm = term.toLowerCase(); - const filtered = this.googleFontList.filter((value) => value.toLowerCase().includes(lowerCaseTerm)); - return filtered.map((fontFamilyName) => { - return { - label: fontFamilyName, - value: fontFamilyName, - }; - }); - }}]; + return [ + { + options: async (term) => { + if (!this.googleFontList) { + await rpc("/website/google_font_metadata").then((data) => { + this.googleFontList = data.familyMetadataList.map( + (font) => font.family + ); + }); + } + const lowerCaseTerm = term.toLowerCase(); + const filtered = this.googleFontList.filter((value) => + value.toLowerCase().includes(lowerCaseTerm) + ); + return filtered.map((fontFamilyName) => ({ + label: fontFamilyName, + value: fontFamilyName, + })); + }, + }, + ]; } async onGoogleFontSelect(selected) { this.fileInput.el.value = ""; @@ -386,7 +431,12 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ this.state.uploadedFontFaces = undefined; try { const fontFamily = selected.value; - const result = await fetch(`https://fonts.googleapis.com/css?family=${encodeURIComponent(fontFamily)}:300,300i,400,400i,700,700i`, {method: 'HEAD'}); + const result = await fetch( + `https://fonts.googleapis.com/css?family=${encodeURIComponent( + fontFamily + )}:300,300i,400,400i,700,700i`, + { method: "HEAD" } + ); // Google fonts server returns a 400 status code if family is not valid. if (result.ok) { const linkId = `previewFont${fontFamily}`; @@ -417,11 +467,11 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ } const reader = new FileReader(); reader.onload = (e) => { - const base64 = e.target.result.split(',')[1]; + const base64 = e.target.result.split(",")[1]; rpc("/website/theme_upload_font", { name: file.name, data: base64, - }).then(result => { + }).then((result) => { this.state.uploadedFonts = result; this.updateFontStyle(file.name.substr(0, file.name.lastIndexOf("."))); }); @@ -447,7 +497,13 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ font.isLight = /light|300/i.test(font.name); font.isBold = /bold|700/i.test(font.name); font.isRegular = /regular|400/i.test(font.name); - font.weight = font.isRegular ? 400 : font.isLight ? 300 : font.isBold ? 700 : undefined; + font.weight = font.isRegular + ? 400 + : font.isLight + ? 300 + : font.isBold + ? 700 + : undefined; if (font.isItalic && !font.weight) { if (!/00|thin|medium|black|condense|extrude/i.test(font.name)) { font.isRegular = true; @@ -474,7 +530,9 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ src:url("${font.url}"); }`); } - let styleEl = document.head.querySelector(`style[id='WebsiteThemeFontPreview-${baseFontName}']`); + let styleEl = document.head.querySelector( + `style[id='WebsiteThemeFontPreview-${baseFontName}']` + ); if (!styleEl) { styleEl = document.createElement("style"); styleEl.id = `WebsiteThemeFontPreview-${baseFontName}`; @@ -487,106 +545,133 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ this.state.uploadedFontFaces = previewFontFaces; } }; - const variable = $(ev.currentTarget).data('variable'); - this.dialog.add(addFontDialog, { - title: _t("Add a Google font or upload a custom font"), - onClickSave: async (state) => { - const uploadedFontName = state.uploadedFontName; - const uploadedFontFaces = state.uploadedFontFaces; - let font = undefined; - if (uploadedFontName && uploadedFontFaces) { - const fontExistsLocally = this.uploadedLocalFonts.some(localFont => localFont.split(':')[0] === `'${uploadedFontName}'`); - if (fontExistsLocally) { - this.dialog.add(ConfirmationDialog, { - title: _t("Font exists"), - body: _t("This uploaded font already exists.\nTo replace an existing font, remove it first."), - }); - return; - } - const homonymGoogleFontExists = - this.googleFonts.some(font => font === uploadedFontName) || - this.googleLocalFonts.some(font => font.split(':')[0] === `'${uploadedFontName}'`); - if (homonymGoogleFontExists) { - this.dialog.add(ConfirmationDialog, { - title: _t("Font name already used"), - body: _t("A font with the same name already exists.\nTry renaming the uploaded file."), - }); - return; - } - // Create attachment. - const [fontCssId] = await this.orm.call("ir.attachment", "create_unique", [[{ - name: uploadedFontName, - description: `CSS font face for ${uploadedFontName}`, - datas: btoa(uploadedFontFaces), - res_model: "ir.attachment", - mimetype: "text/css", - "public": true, - }]]); - this.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); - font = uploadedFontName; - } else { - let isValidFamily = false; - font = state.googleFontFamily; - - try { - const result = await fetch("https://fonts.googleapis.com/css?family=" + encodeURIComponent(font) + ':300,300i,400,400i,700,700i', {method: 'HEAD'}); - // Google fonts server returns a 400 status code if family is not valid. - if (result.ok) { - isValidFamily = true; + const variable = $(ev.currentTarget).data("variable"); + this.dialog.add( + addFontDialog, + { + title: _t("Add a Google font or upload a custom font"), + onClickSave: async (state) => { + const uploadedFontName = state.uploadedFontName; + const uploadedFontFaces = state.uploadedFontFaces; + let font = undefined; + if (uploadedFontName && uploadedFontFaces) { + const fontExistsLocally = this.uploadedLocalFonts.some( + (localFont) => localFont.split(":")[0] === `'${uploadedFontName}'` + ); + if (fontExistsLocally) { + this.dialog.add(ConfirmationDialog, { + title: _t("Font exists"), + body: _t( + "This uploaded font already exists.\nTo replace an existing font, remove it first." + ), + }); + return; + } + const homonymGoogleFontExists = + this.googleFonts.some((font) => font === uploadedFontName) || + this.googleLocalFonts.some( + (font) => font.split(":")[0] === `'${uploadedFontName}'` + ); + if (homonymGoogleFontExists) { + this.dialog.add(ConfirmationDialog, { + title: _t("Font name already used"), + body: _t( + "A font with the same name already exists.\nTry renaming the uploaded file." + ), + }); + return; + } + // Create attachment. + const [fontCssId] = await this.orm.call("ir.attachment", "create_unique", [ + [ + { + name: uploadedFontName, + description: `CSS font face for ${uploadedFontName}`, + datas: btoa(uploadedFontFaces), + res_model: "ir.attachment", + mimetype: "text/css", + public: true, + }, + ], + ]); + this.uploadedLocalFonts.push(`'${uploadedFontName}': ${fontCssId}`); + font = uploadedFontName; + } else { + let isValidFamily = false; + font = state.googleFontFamily; + + try { + const result = await fetch( + "https://fonts.googleapis.com/css?family=" + + encodeURIComponent(font) + + ":300,300i,400,400i,700,700i", + { method: "HEAD" } + ); + // Google fonts server returns a 400 status code if family is not valid. + if (result.ok) { + isValidFamily = true; + } + } catch (error) { + console.error(error); } - } catch (error) { - console.error(error); - } - if (!isValidFamily) { - this.dialog.add(ConfirmationDialog, { - title: _t("Font access"), - body: _t("The selected font cannot be accessed."), - }); - return; - } + if (!isValidFamily) { + this.dialog.add(ConfirmationDialog, { + title: _t("Font access"), + body: _t("The selected font cannot be accessed."), + }); + return; + } - const googleFontServe = state.googleServe; - const fontName = `'${font}'`; - // If the font already exists, it will only be added if - // the user chooses to add it locally when it is already - // imported from the Google Fonts server. - const fontExistsLocally = this.googleLocalFonts.some(localFont => localFont.split(':')[0] === fontName); - const fontExistsOnServer = this.allFonts.includes(fontName); - const preventFontAddition = fontExistsLocally || (fontExistsOnServer && googleFontServe); - if (preventFontAddition) { - this.dialog.add(ConfirmationDialog, { - title: _t("Font exists"), - body: _t("This font already exists, you can only add it as a local font to replace the server version."), - }); - return; + const googleFontServe = state.googleServe; + const fontName = `'${font}'`; + // If the font already exists, it will only be added if + // the user chooses to add it locally when it is already + // imported from the Google Fonts server. + const fontExistsLocally = this.googleLocalFonts.some( + (localFont) => localFont.split(":")[0] === fontName + ); + const fontExistsOnServer = this.allFonts.includes(fontName); + const preventFontAddition = + fontExistsLocally || (fontExistsOnServer && googleFontServe); + if (preventFontAddition) { + this.dialog.add(ConfirmationDialog, { + title: _t("Font exists"), + body: _t( + "This font already exists, you can only add it as a local font to replace the server version." + ), + }); + return; + } + if (googleFontServe) { + this.googleFonts.push(font); + } else { + this.googleLocalFonts.push(`'${font}': ''`); + } } - if (googleFontServe) { - this.googleFonts.push(font); - } else { - this.googleLocalFonts.push(`'${font}': ''`); + this.trigger_up("fonts_custo_request", { + values: { [variable]: `'${font}'` }, + googleFonts: this.googleFonts, + googleLocalFonts: this.googleLocalFonts, + uploadedLocalFonts: this.uploadedLocalFonts, + }); + const styleEl = document.head.querySelector( + `[id='WebsiteThemeFontPreview-${font}']` + ); + if (styleEl) { + delete styleEl.dataset.fontPreview; } - } - this.trigger_up('fonts_custo_request', { - values: {[variable]: `'${font}'`}, - googleFonts: this.googleFonts, - googleLocalFonts: this.googleLocalFonts, - uploadedLocalFonts: this.uploadedLocalFonts, - }); - let styleEl = document.head.querySelector(`[id='WebsiteThemeFontPreview-${font}']`); - if (styleEl) { - delete styleEl.dataset.fontPreview; - } - return true; - }, - }, - { - onClose: () => { - for (const el of document.head.querySelectorAll("[data-font-preview]")) { - el.remove(); - } + return true; + }, }, - }); + { + onClose: () => { + for (const el of document.head.querySelectorAll("[data-font-preview]")) { + el.remove(); + } + }, + } + ); }, /** * @private @@ -596,9 +681,11 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ ev.preventDefault(); const values = {}; - const save = await new Promise(resolve => { + const save = await new Promise((resolve) => { this.dialog.add(ConfirmationDialog, { - body: _t("Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?"), + body: _t( + "Deleting a font requires a reload of the page. This will save all your changes and reload the page, are you sure you want to proceed?" + ), confirm: () => resolve(true), cancel: () => resolve(false), }); @@ -611,17 +698,17 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ const fontIndex = parseInt(ev.target.dataset.fontIndex); const localFont = ev.target.dataset.localFont; let fontName; - if (localFont === 'uploaded') { - const font = this.uploadedLocalFonts[fontIndex].split(':'); + if (localFont === "uploaded") { + const font = this.uploadedLocalFonts[fontIndex].split(":"); // Remove double quotes fontName = font[0].substring(1, font[0].length - 1); - values['delete-font-attachment-id'] = font[1]; + values["delete-font-attachment-id"] = font[1]; this.uploadedLocalFonts.splice(fontIndex, 1); - } else if (localFont === 'google') { - const googleFont = this.googleLocalFonts[fontIndex].split(':'); + } else if (localFont === "google") { + const googleFont = this.googleLocalFonts[fontIndex].split(":"); // Remove double quotes fontName = googleFont[0].substring(1, googleFont[0].length - 1); - values['delete-font-attachment-id'] = googleFont[1]; + values["delete-font-attachment-id"] = googleFont[1]; this.googleLocalFonts.splice(fontIndex, 1); } else { fontName = this.googleFonts[fontIndex]; @@ -635,11 +722,11 @@ const FontFamilyPickerUserValueWidget = SelectUserValueWidget.extend({ if (value.substring(1, value.length - 1) === fontName) { // If an element is using the google font being removed, reset // it to the theme default. - values[variable] = 'null'; + values[variable] = "null"; } }); - this.trigger_up('fonts_custo_request', { + this.trigger_up("fonts_custo_request", { values: values, googleFonts: this.googleFonts, googleLocalFonts: this.googleLocalFonts, @@ -674,11 +761,11 @@ const GPSPicker = InputUserValueWidget.extend({ */ async willStart() { await this._super(...arguments); - this._gmapLoaded = await new Promise(resolve => { - this.trigger_up('gmap_api_request', { + this._gmapLoaded = await new Promise((resolve) => { + this.trigger_up("gmap_api_request", { editableMode: true, configureIfNecessary: true, - onSuccess: key => { + onSuccess: (key) => { if (!key) { resolve(false); return; @@ -686,13 +773,14 @@ const GPSPicker = InputUserValueWidget.extend({ // TODO see _notifyGMapError, this tries to trigger an error // early but this is not consistent with new gmap keys. - this._nearbySearch('(50.854975,4.3753899)', !!key) - .then(place => resolve(!!place)); + this._nearbySearch("(50.854975,4.3753899)", !!key).then((place) => + resolve(!!place) + ); }, }); }); if (!this._gmapLoaded && !this._gmapErrorNotified) { - this.trigger_up('user_value_widget_critical'); + this.trigger_up("user_value_widget_critical"); return; } }, @@ -701,13 +789,20 @@ const GPSPicker = InputUserValueWidget.extend({ */ async start() { await this._super(...arguments); - this.el.classList.add('o_we_large'); + this.el.classList.add("o_we_large"); if (!this._gmapLoaded) { return; } - this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete(this.inputEl, {types: ['geocode']}); - this.contentWindow.google.maps.event.addListener(this._gmapAutocomplete, 'place_changed', this._onPlaceChanged.bind(this)); + this._gmapAutocomplete = new this.contentWindow.google.maps.places.Autocomplete( + this.inputEl, + { types: ["geocode"] } + ); + this.contentWindow.google.maps.event.addListener( + this._gmapAutocomplete, + "place_changed", + this._onPlaceChanged.bind(this) + ); }, /** * @override @@ -720,7 +815,7 @@ const GPSPicker = InputUserValueWidget.extend({ // this is also done when the widget is destroyed for another reason // than leaving the editor, but if the google API needs that container // again afterwards, it will simply recreate it. - for (const el of document.body.querySelectorAll('.pac-container')) { + for (const el of document.body.querySelectorAll(".pac-container")) { el.remove(); } }, @@ -733,7 +828,7 @@ const GPSPicker = InputUserValueWidget.extend({ * @override */ getMethodsParams: function (methodName) { - return Object.assign({gmapPlace: this._gmapPlace || {}}, this._super(...arguments)); + return Object.assign({ gmapPlace: this._gmapPlace || {} }, this._super(...arguments)); }, /** * @override @@ -766,46 +861,57 @@ const GPSPicker = InputUserValueWidget.extend({ return this._gmapCacheGPSToPlace[gps]; } - const p = gps.substring(1).slice(0, -1).split(','); + const p = gps.substring(1).slice(0, -1).split(","); const location = new this.contentWindow.google.maps.LatLng(p[0] || 0, p[1] || 0); - return new Promise(resolve => { - const service = new this.contentWindow.google.maps.places.PlacesService(document.createElement('div')); - service.nearbySearch({ - // Do a 'nearbySearch' followed by 'getDetails' to avoid using - // GMap Geocoder which the user may not have enabled... but - // ideally Geocoder should be used to get the exact location at - // those coordinates and to limit billing query count. - location: location, - radius: 1, - }, (results, status) => { - const GMAP_CRITICAL_ERRORS = [ - this.contentWindow.google.maps.places.PlacesServiceStatus.REQUEST_DENIED, - this.contentWindow.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR - ]; - if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { - service.getDetails({ - placeId: results[0].place_id, - fields: ['geometry', 'formatted_address'], - }, (place, status) => { - if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { - this._gmapCacheGPSToPlace[gps] = place; - resolve(place); - } else if (GMAP_CRITICAL_ERRORS.includes(status)) { - if (notify) { - this._notifyGMapError(); + return new Promise((resolve) => { + const service = new this.contentWindow.google.maps.places.PlacesService( + document.createElement("div") + ); + service.nearbySearch( + { + // Do a 'nearbySearch' followed by 'getDetails' to avoid using + // GMap Geocoder which the user may not have enabled... but + // ideally Geocoder should be used to get the exact location at + // those coordinates and to limit billing query count. + location: location, + radius: 1, + }, + (results, status) => { + const GMAP_CRITICAL_ERRORS = [ + this.contentWindow.google.maps.places.PlacesServiceStatus.REQUEST_DENIED, + this.contentWindow.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR, + ]; + if (status === this.contentWindow.google.maps.places.PlacesServiceStatus.OK) { + service.getDetails( + { + placeId: results[0].place_id, + fields: ["geometry", "formatted_address"], + }, + (place, status) => { + if ( + status === + this.contentWindow.google.maps.places.PlacesServiceStatus.OK + ) { + this._gmapCacheGPSToPlace[gps] = place; + resolve(place); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); + } + resolve(); + } } - resolve(); + ); + } else if (GMAP_CRITICAL_ERRORS.includes(status)) { + if (notify) { + this._notifyGMapError(); } - }); - } else if (GMAP_CRITICAL_ERRORS.includes(status)) { - if (notify) { - this._notifyGMapError(); + resolve(); + } else { + resolve(); } - resolve(); - } else { - resolve(); } - }); + ); }); }, /** @@ -824,13 +930,16 @@ const GPSPicker = InputUserValueWidget.extend({ if (this._gmapErrorNotified) { return; } + console.warn("error"); this._gmapErrorNotified = true; this.notification.add( - _t("A Google Map error occurred. Make sure to read the key configuration popup carefully."), - { type: 'danger', sticky: true } + _t( + "A Google Map error occurred. Make sure to read the key configuration popup carefully." + ), + { type: "danger", sticky: true } ); - this.trigger_up('gmap_api_request', { + this.trigger_up("gmap_api_request", { editableMode: true, reconfigure: true, onSuccess: () => { @@ -838,7 +947,7 @@ const GPSPicker = InputUserValueWidget.extend({ }, }); - setTimeout(() => this.trigger_up('user_value_widget_critical')); + setTimeout(() => this.trigger_up("user_value_widget_critical")); }, //-------------------------------------------------------------------------- @@ -863,17 +972,21 @@ const GPSPicker = InputUserValueWidget.extend({ } }, }); -options.userValueWidgetsRegistry['we-urlpicker'] = UrlPickerUserValueWidget; -options.userValueWidgetsRegistry['we-fontfamilypicker'] = FontFamilyPickerUserValueWidget; -options.userValueWidgetsRegistry['we-gpspicker'] = GPSPicker; +options.userValueWidgetsRegistry["we-urlpicker"] = UrlPickerUserValueWidget; +options.userValueWidgetsRegistry["we-fontfamilypicker"] = FontFamilyPickerUserValueWidget; +options.userValueWidgetsRegistry["we-gpspicker"] = GPSPicker; //:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: options.Class.include({ custom_events: Object.assign({}, options.Class.prototype.custom_events || {}, { - 'fonts_custo_request': '_onFontsCustoRequest', + fonts_custo_request: "_onFontsCustoRequest", }), - specialCheckAndReloadMethodsNames: ['customizeWebsiteViews', 'customizeWebsiteVariable', 'customizeWebsiteColor'], + specialCheckAndReloadMethodsNames: [ + "customizeWebsiteViews", + "customizeWebsiteVariable", + "customizeWebsiteColor", + ], /** * @override @@ -900,31 +1013,31 @@ options.Class.include({ * @see this.selectClass for parameters */ customizeWebsiteViews: async function (previewMode, widgetValue, params) { - await this._customizeWebsite(previewMode, widgetValue, params, 'views'); + await this._customizeWebsite(previewMode, widgetValue, params, "views"); }, /** * @see this.selectClass for parameters */ customizeWebsiteVariable: async function (previewMode, widgetValue, params) { - await this._customizeWebsite(previewMode, widgetValue, params, 'variable'); + await this._customizeWebsite(previewMode, widgetValue, params, "variable"); }, /** * @see this.selectClass for parameters */ customizeWebsiteVariables: async function (previewMode, widgetValue, params) { - await this._customizeWebsite(previewMode, widgetValue, params, 'variables'); + await this._customizeWebsite(previewMode, widgetValue, params, "variables"); }, /** * @see this.selectClass for parameters */ customizeWebsiteColor: async function (previewMode, widgetValue, params) { - await this._customizeWebsite(previewMode, widgetValue, params, 'color'); + await this._customizeWebsite(previewMode, widgetValue, params, "color"); }, /** * @see this.selectClass for parameters */ async customizeWebsiteAssets(previewMode, widgetValue, params) { - await this._customizeWebsite(previewMode, widgetValue, params, 'assets'); + await this._customizeWebsite(previewMode, widgetValue, params, "assets"); }, //-------------------------------------------------------------------------- @@ -943,8 +1056,8 @@ options.Class.include({ const methodsNames = widget.getMethodsNames(); const methodNamesToCheck = this.data.pageOptions ? methodsNames - : methodsNames.filter(m => this.specialCheckAndReloadMethodsNames.includes(m)); - if (methodNamesToCheck.some(m => widget.getMethodsParams(m).reload)) { + : methodsNames.filter((m) => this.specialCheckAndReloadMethodsNames.includes(m)); + if (methodNamesToCheck.some((m) => widget.getMethodsParams(m).reload)) { return true; } } @@ -955,12 +1068,14 @@ options.Class.include({ */ _computeWidgetState: async function (methodName, params) { switch (methodName) { - case 'customizeWebsiteViews': { + case "customizeWebsiteViews": { return this._getEnabledCustomizeValues(params.possibleValues, true); } - case 'customizeWebsiteVariable': { + case "customizeWebsiteVariable": { const ownerDocument = this.$target[0].ownerDocument; - const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + const style = ownerDocument.defaultView.getComputedStyle( + ownerDocument.documentElement + ); let finalValue = weUtils.getCSSVariableValue(params.variable, style); if (!params.colorNames) { return finalValue; @@ -968,16 +1083,18 @@ options.Class.include({ let tempValue = finalValue; while (tempValue) { finalValue = tempValue; - tempValue = weUtils.getCSSVariableValue(tempValue.replaceAll("'", ''), style); + tempValue = weUtils.getCSSVariableValue(tempValue.replaceAll("'", ""), style); } return finalValue; } - case 'customizeWebsiteColor': { + case "customizeWebsiteColor": { const ownerDocument = this.$target[0].ownerDocument; - const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); + const style = ownerDocument.defaultView.getComputedStyle( + ownerDocument.documentElement + ); return weUtils.getCSSVariableValue(params.color, style); } - case 'customizeWebsiteAssets': { + case "customizeWebsiteAssets": { return this._getEnabledCustomizeValues(params.possibleValues, false); } } @@ -993,31 +1110,37 @@ options.Class.include({ } switch (type) { - case 'views': + case "views": await this._customizeWebsiteData(widgetValue, params, true); break; - case 'variable': + case "variable": // Color values (e.g. "header-text-color") must be saved as // string. TODO: Color values should be added to the color map. if (params.colorNames?.includes(widgetValue)) { - widgetValue =`'${widgetValue}'`; + widgetValue = `'${widgetValue}'`; } await this._customizeWebsiteVariable(widgetValue, params); break; case "variables": - const defaultVariables = params.defaultVariables ? - Object.fromEntries(params.defaultVariables.split(",") - .map((variable) => variable.split(":").map(v => v.trim()))) : - {}; - const overriddenVariables = Object.fromEntries(widgetValue.split(",") - .map((variable) => variable.split(":").map(v => v.trim()))); + const defaultVariables = params.defaultVariables + ? Object.fromEntries( + params.defaultVariables + .split(",") + .map((variable) => variable.split(":").map((v) => v.trim())) + ) + : {}; + const overriddenVariables = Object.fromEntries( + widgetValue + .split(",") + .map((variable) => variable.split(":").map((v) => v.trim())) + ); const variables = Object.assign(defaultVariables, overriddenVariables); await this._customizeWebsiteVariables(variables, params.nullValue); break; - case 'color': + case "color": await this._customizeWebsiteColor(widgetValue, params); break; - case 'assets': + case "assets": await this._customizeWebsiteData(widgetValue, params, false); break; default: @@ -1042,7 +1165,7 @@ options.Class.include({ // Some public widgets may depend on the variables that were // customized, so we have to restart them *all*. await new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.trigger_up("widgets_start_request", { editableMode: true, onSuccess: () => resolve(), onFailure: () => reject(), @@ -1053,16 +1176,16 @@ options.Class.include({ * @private */ async _customizeWebsiteColor(color, params) { - await this._customizeWebsiteColors({[params.color]: color}, params); + await this._customizeWebsiteColors({ [params.color]: color }, params); }, /** * @private */ - async _customizeWebsiteColors(colors, params) { + async _customizeWebsiteColors(colors, params) { colors = colors || {}; - const baseURL = '/website/static/src/scss/options/colors/'; - const colorType = params.colorType ? (params.colorType + '_') : ''; + const baseURL = "/website/static/src/scss/options/colors/"; + const colorType = params.colorType ? params.colorType + "_" : ""; const url = `${baseURL}user_${colorType}color_palette.scss`; const finalColors = {}; @@ -1082,9 +1205,13 @@ options.Class.include({ * @private */ _customizeWebsiteVariable: async function (value, params) { - return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', { - [params.variable]: value, - }, params.nullValue); + return this._makeSCSSCusto( + "/website/static/src/scss/options/user_values.scss", + { + [params.variable]: value, + }, + params.nullValue + ); }, /** * Customizes several website variables at the same time. @@ -1094,7 +1221,11 @@ options.Class.include({ * @param {string} nullValue: string that represent null */ _customizeWebsiteVariables: async function (values, nullValue) { - await this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values, nullValue); + await this._makeSCSSCusto( + "/website/static/src/scss/options/user_values.scss", + values, + nullValue + ); await this._refreshBundles(); }, /** @@ -1103,15 +1234,15 @@ options.Class.include({ async _customizeWebsiteData(value, params, isViewData) { const allDataKeys = this._getDataKeysFromPossibleValues(params.possibleValues); const keysToEnable = value.split(/\s*,\s*/); - const enableDataKeys = allDataKeys.filter(value => keysToEnable.includes(value)); - const disableDataKeys = allDataKeys.filter(value => !enableDataKeys.includes(value)); + const enableDataKeys = allDataKeys.filter((value) => keysToEnable.includes(value)); + const disableDataKeys = allDataKeys.filter((value) => !enableDataKeys.includes(value)); const resetViewArch = !!params.resetViewArch; - return rpc('/website/theme_customize_data', { - 'is_view_data': isViewData, - 'enable': enableDataKeys, - 'disable': disableDataKeys, - 'reset_view_arch': resetViewArch, + return rpc("/website/theme_customize_data", { + is_view_data: isViewData, + enable: enableDataKeys, + disable: disableDataKeys, + reset_view_arch: resetViewArch, }); }, /** @@ -1133,16 +1264,18 @@ options.Class.include({ */ async _getEnabledCustomizeValues(possibleValues, isViewData) { const allDataKeys = this._getDataKeysFromPossibleValues(possibleValues); - const enabledValues = await rpc('/website/theme_customize_data_get', { - 'keys': allDataKeys, - 'is_view_data': isViewData, + const enabledValues = await rpc("/website/theme_customize_data_get", { + keys: allDataKeys, + is_view_data: isViewData, }); - let mostValuesStr = ''; + let mostValuesStr = ""; let mostValuesNb = 0; for (const valuesStr of possibleValues) { const enableValues = valuesStr.split(/\s*,\s*/); - if (enableValues.length > mostValuesNb - && enableValues.every(value => enabledValues.includes(value))) { + if ( + enableValues.length > mostValuesNb && + enableValues.every((value) => enabledValues.includes(value)) + ) { mostValuesStr = valuesStr; mostValuesNb = enableValues.length; } @@ -1152,7 +1285,7 @@ options.Class.include({ /** * @private */ - _makeSCSSCusto: async function (url, values, defaultValue = 'null') { + _makeSCSSCusto: async function (url, values, defaultValue = "null") { Object.keys(values).forEach((key) => { values[key] = values[key] || defaultValue; }); @@ -1167,7 +1300,7 @@ options.Class.include({ */ _refreshPublicWidgets: async function ($el) { return new Promise((resolve, reject) => { - this.trigger_up('widgets_start_request', { + this.trigger_up("widgets_start_request", { editableMode: true, $target: $el || this.$target, onSuccess: resolve, @@ -1178,9 +1311,9 @@ options.Class.include({ /** * @private */ - _reloadBundles: async function() { + _reloadBundles: async function () { return new Promise((resolve, reject) => { - this.trigger_up('reload_bundles', { + this.trigger_up("reload_bundles", { onSuccess: () => resolve(), onFailure: () => reject(), }); @@ -1197,8 +1330,11 @@ options.Class.include({ const targetNoRefreshSelector = ".s_instagram_page"; // TODO: we should review the way public widgets are restarted when // converting to OWL and a new API. - if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length - && !this.$target[0].matches(targetNoRefreshSelector)) { + if ( + this.options.isWebsite && + !widget.$el.closest('[data-no-widget-refresh="true"]').length && + !this.$target[0].matches(targetNoRefreshSelector) + ) { // TODO the flag should be retrieved through widget params somehow await this._refreshPublicWidgets(); } @@ -1218,24 +1354,25 @@ options.Class.include({ const googleLocalFonts = ev.data.googleLocalFonts; const uploadedLocalFonts = ev.data.uploadedLocalFonts; if (googleFonts.length) { - values['google-fonts'] = "('" + googleFonts.join("', '") + "')"; + values["google-fonts"] = "('" + googleFonts.join("', '") + "')"; } else { - values['google-fonts'] = 'null'; + values["google-fonts"] = "null"; } if (googleLocalFonts.length) { - values['google-local-fonts'] = "(" + googleLocalFonts.join(", ") + ")"; + values["google-local-fonts"] = "(" + googleLocalFonts.join(", ") + ")"; } else { - values['google-local-fonts'] = 'null'; + values["google-local-fonts"] = "null"; } if (uploadedLocalFonts.length) { - values['uploaded-local-fonts'] = "(" + uploadedLocalFonts.join(", ") + ")"; + values["uploaded-local-fonts"] = "(" + uploadedLocalFonts.join(", ") + ")"; } else { - values['uploaded-local-fonts'] = 'null'; + values["uploaded-local-fonts"] = "null"; } - this.trigger_up('snippet_edition_request', {exec: async () => { - return this._makeSCSSCusto('/website/static/src/scss/options/user_values.scss', values); - }}); - this.trigger_up('request_save', { + this.trigger_up("snippet_edition_request", { + exec: async () => + this._makeSCSSCusto("/website/static/src/scss/options/user_values.scss", values), + }); + this.trigger_up("request_save", { reloadEditor: true, }); }, @@ -1244,11 +1381,11 @@ options.Class.include({ function _getLastPreFilterLayerElement($el) { // Make sure parallax and video element are considered to be below the // color filters / shape - const $bgVideo = $el.find('> .o_bg_video_container'); + const $bgVideo = $el.find("> .o_bg_video_container"); if ($bgVideo.length) { return $bgVideo[0]; } - const $parallaxEl = $el.find('> .s_parallax_bg'); + const $parallaxEl = $el.find("> .s_parallax_bg"); if ($parallaxEl.length) { return $parallaxEl[0]; } @@ -1263,14 +1400,14 @@ options.registry.BackgroundToggler.include({ */ toggleBgVideo(previewMode, widgetValue, params) { if (!widgetValue) { - this.$target.find('> .o_we_bg_filter').remove(); + this.$target.find("> .o_we_bg_filter").remove(); // TODO: use setWidgetValue instead of calling background directly when possible - const [bgVideoWidget] = this._requestUserValueWidgets('bg_video_opt'); + const [bgVideoWidget] = this._requestUserValueWidgets("bg_video_opt"); const bgVideoOpt = bgVideoWidget.getParent(); - return bgVideoOpt._setBgVideo(false, ''); + return bgVideoOpt._setBgVideo(false, ""); } else { // TODO: use trigger instead of el.click when possible - this._requestUserValueWidgets('bg_video_opt')[0].el.click(); + this._requestUserValueWidgets("bg_video_opt")[0].el.click(); } }, @@ -1282,8 +1419,8 @@ options.registry.BackgroundToggler.include({ * @override */ _computeWidgetState(methodName, params) { - if (methodName === 'toggleBgVideo') { - return this.$target[0].classList.contains('o_background_video'); + if (methodName === "toggleBgVideo") { + return this.$target[0].classList.contains("o_background_video"); } return this._super(...arguments); }, @@ -1318,7 +1455,7 @@ options.registry.BackgroundShape.include({ * @override */ _removeShapeEl(shapeEl) { - this.trigger_up('widgets_stop_request', { + this.trigger_up("widgets_stop_request", { $target: $(shapeEl), }); return this._super(...arguments); @@ -1334,9 +1471,9 @@ options.registry.ReplaceMedia.include({ */ setAnchor(previewMode, widgetValue, params) { const linkEl = this.$target[0].parentElement; - let url = linkEl.getAttribute('href'); - url = url.split('#')[0]; - linkEl.setAttribute('href', url + widgetValue); + let url = linkEl.getAttribute("href"); + url = url.split("#")[0]; + linkEl.setAttribute("href", url + widgetValue); }, //-------------------------------------------------------------------------- @@ -1347,13 +1484,13 @@ options.registry.ReplaceMedia.include({ * @override */ _computeWidgetState(methodName, params) { - if (methodName === 'setAnchor') { + if (methodName === "setAnchor") { const parentEl = this.$target[0].parentElement; - if (parentEl.tagName === 'A') { - const href = parentEl.getAttribute('href') || ''; - return href ? `#${href.split('#')[1]}` : ''; + if (parentEl.tagName === "A") { + const href = parentEl.getAttribute("href") || ""; + return href ? `#${href.split("#")[1]}` : ""; } - return ''; + return ""; } return this._super(...arguments); }, @@ -1361,11 +1498,11 @@ options.registry.ReplaceMedia.include({ * @override */ async _computeWidgetVisibility(widgetName, params) { - if (widgetName === 'media_link_anchor_opt') { + if (widgetName === "media_link_anchor_opt") { const parentEl = this.$target[0].parentElement; - const linkEl = parentEl.tagName === 'A' ? parentEl : null; - const href = linkEl ? linkEl.getAttribute('href') : false; - return href && href.startsWith('/'); + const linkEl = parentEl.tagName === "A" ? parentEl : null; + const href = linkEl ? linkEl.getAttribute("href") : false; + return href && href.startsWith("/"); } return this._super(...arguments); }, @@ -1381,32 +1518,32 @@ options.registry.ReplaceMedia.include({ } await this._super(...arguments); - - const oldURLWidgetEl = uiFragment.querySelector('[data-name="media_url_opt"]'); - const URLWidgetEl = document.createElement('we-urlpicker'); + const URLWidgetEl = document.createElement("we-urlpicker"); // Copy attributes - for (const {name, value} of oldURLWidgetEl.attributes) { + for (const { name, value } of oldURLWidgetEl.attributes) { URLWidgetEl.setAttribute(name, value); } - URLWidgetEl.title = _t("Hint: Type '/' to search an existing page and '#' to link to an anchor."); + URLWidgetEl.title = _t( + "Hint: Type '/' to search an existing page and '#' to link to an anchor." + ); oldURLWidgetEl.replaceWith(URLWidgetEl); - const hrefValue = this.$target[0].parentElement.getAttribute('href'); - if (!hrefValue || !hrefValue.startsWith('/')) { + const hrefValue = this.$target[0].parentElement.getAttribute("href"); + if (!hrefValue || !hrefValue.startsWith("/")) { return; } - const urlWithoutAnchor = hrefValue.split('#')[0]; - const selectEl = document.createElement('we-select'); - selectEl.dataset.name = 'media_link_anchor_opt'; - selectEl.dataset.dependencies = 'media_url_opt'; - selectEl.dataset.noPreview = 'true'; - selectEl.classList.add('o_we_sublevel_1'); - selectEl.setAttribute('string', _t("Page Anchor")); + const urlWithoutAnchor = hrefValue.split("#")[0]; + const selectEl = document.createElement("we-select"); + selectEl.dataset.name = "media_link_anchor_opt"; + selectEl.dataset.dependencies = "media_url_opt"; + selectEl.dataset.noPreview = "true"; + selectEl.classList.add("o_we_sublevel_1"); + selectEl.setAttribute("string", _t("Page Anchor")); const anchors = await wUtils.loadAnchors(urlWithoutAnchor); for (const anchor of anchors) { - const weButtonEl = document.createElement('we-button'); + const weButtonEl = document.createElement("we-button"); weButtonEl.dataset.setAnchor = anchor; weButtonEl.textContent = anchor; selectEl.append(weButtonEl); @@ -1417,9 +1554,11 @@ options.registry.ReplaceMedia.include({ options.registry.ImageTools.include({ async _computeWidgetVisibility(widgetName, params) { - if (params.optionsPossibleValues.selectStyle - && params.cssProperty === 'width' - && this.$target[0].classList.contains('o_card_img')) { + if ( + params.optionsPossibleValues.selectStyle && + params.cssProperty === "width" && + this.$target[0].classList.contains("o_card_img") + ) { return false; } return this._super(...arguments); @@ -1427,7 +1566,6 @@ options.registry.ImageTools.include({ }); options.registry.BackgroundVideo = options.Class.extend({ - //-------------------------------------------------------------------------- // Options //-------------------------------------------------------------------------- @@ -1438,7 +1576,7 @@ options.registry.BackgroundVideo = options.Class.extend({ * @see this.selectClass for parameters */ background: function (previewMode, widgetValue, params) { - if (previewMode === 'reset' && this.videoSrc) { + if (previewMode === "reset" && this.videoSrc) { return this._setBgVideo(false, this.videoSrc); } return this._setBgVideo(previewMode, widgetValue); @@ -1452,11 +1590,11 @@ options.registry.BackgroundVideo = options.Class.extend({ * @override */ _computeWidgetState: function (methodName, params) { - if (methodName === 'background') { - if (this.$target[0].classList.contains('o_background_video')) { - return this.$('> .o_bg_video_container iframe').attr('src'); + if (methodName === "background") { + if (this.$target[0].classList.contains("o_background_video")) { + return this.$("> .o_bg_video_container iframe").attr("src"); } - return ''; + return ""; } return this._super(...arguments); }, @@ -1468,7 +1606,7 @@ options.registry.BackgroundVideo = options.Class.extend({ * @returns {Promise} */ _setBgVideo: async function (previewMode, value) { - this.$('> .o_bg_video_container').toggleClass('d-none', previewMode === true); + this.$("> .o_bg_video_container").toggleClass("d-none", previewMode === true); if (previewMode !== false) { return; @@ -1476,7 +1614,7 @@ options.registry.BackgroundVideo = options.Class.extend({ this.videoSrc = value; var target = this.$target[0]; - target.classList.toggle('o_background_video', !!(value && value.length)); + target.classList.toggle("o_background_video", !!(value && value.length)); if (value && value.length) { target.dataset.bgVideoSrc = value; } else { @@ -1487,8 +1625,10 @@ options.registry.BackgroundVideo = options.Class.extend({ }); options.registry.WebsiteLevelColor = options.Class.extend({ - specialCheckAndReloadMethodsNames: options.Class.prototype.specialCheckAndReloadMethodsNames - .concat(['customizeWebsiteLayer2Color']), + specialCheckAndReloadMethodsNames: + options.Class.prototype.specialCheckAndReloadMethodsNames.concat([ + "customizeWebsiteLayer2Color", + ]), /** * @constructor */ @@ -1508,11 +1648,11 @@ options.registry.WebsiteLevelColor = options.Class.extend({ let color = undefined; let gradient = undefined; if (weUtils.isColorGradient(widgetValue)) { - color = ''; + color = ""; gradient = widgetValue; } else { color = widgetValue; - gradient = ''; + gradient = ""; } await this.customizeWebsiteVariable(previewMode, gradient, params); params.noBundleReload = false; @@ -1527,14 +1667,14 @@ options.registry.WebsiteLevelColor = options.Class.extend({ * @override */ async _computeWidgetState(methodName, params) { - if (methodName === 'customizeWebsiteLayer2Color') { + if (methodName === "customizeWebsiteLayer2Color") { params.variable = params.layerGradient; - const gradient = await this._computeWidgetState('customizeWebsiteVariable', params); + const gradient = await this._computeWidgetState("customizeWebsiteVariable", params); if (gradient) { return gradient.substring(1, gradient.length - 1); // Unquote } params.color = params.layerColor; - return this._computeWidgetState('customizeWebsiteColor', params); + return this._computeWidgetState("customizeWebsiteColor", params); } return this._super(...arguments); }, @@ -1544,10 +1684,9 @@ options.registry.WebsiteLevelColor = options.Class.extend({ async _computeWidgetVisibility(widgetName, params) { const _super = this._super.bind(this); if ( - [ - "footer_language_selector_label_opt", - "footer_language_selector_opt", - ].includes(widgetName) + ["footer_language_selector_label_opt", "footer_language_selector_opt"].includes( + widgetName + ) ) { this._languages = await this._rpc.call("/website/get_languages"); if (this._languages.length === 1) { @@ -1559,7 +1698,7 @@ options.registry.WebsiteLevelColor = options.Class.extend({ }); options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ - GRAY_PARAMS: {EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue"}, + GRAY_PARAMS: { EXTRA_SATURATION: "gray-extra-saturation", HUE: "gray-hue" }, /** * @override @@ -1585,7 +1724,7 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ const style = ownerDocument.defaultView.getComputedStyle(ownerDocument.documentElement); const grayPreviewEls = this.$el.find(".o_we_gray_preview span"); for (const e of grayPreviewEls) { - const bgValue = weUtils.getCSSVariableValue(e.getAttribute('variable'), style); + const bgValue = weUtils.getCSSVariableValue(e.getAttribute("variable"), style); e.style.setProperty("background-color", bgValue, "important"); } @@ -1606,7 +1745,11 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ const baseGray = weUtils.getCSSVariableValue(`base-${id}`, baseStyle); const baseGrayRGB = convertCSSColorToRgba(baseGray); - const baseGrayHSL = convertRgbToHsl(baseGrayRGB.red, baseGrayRGB.green, baseGrayRGB.blue); + const baseGrayHSL = convertRgbToHsl( + baseGrayRGB.red, + baseGrayRGB.green, + baseGrayRGB.blue + ); if (grayHSL.saturation > 0.01) { if (grayHSL.lightness > 0.01 && grayHSL.lightness < 99.99) { @@ -1628,16 +1771,29 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ // allows to represent more colors that the RGB hexadecimal // notation (also: hue 360 = hue 0 and should not be averaged to 180). // This also better support random gray palettes. - this.grayParams[this.GRAY_PARAMS.HUE] = (!hues.length) ? 0 : Math.round((Math.atan2( - hues.map(hue => Math.sin(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length, - hues.map(hue => Math.cos(hue * Math.PI / 180)).reduce((memo, value) => memo + value, 0) / hues.length - ) * 180 / Math.PI) + 360) % 360; + this.grayParams[this.GRAY_PARAMS.HUE] = !hues.length + ? 0 + : Math.round( + (Math.atan2( + hues + .map((hue) => Math.sin((hue * Math.PI) / 180)) + .reduce((memo, value) => memo + value, 0) / hues.length, + hues + .map((hue) => Math.cos((hue * Math.PI) / 180)) + .reduce((memo, value) => memo + value, 0) / hues.length + ) * + 180) / + Math.PI + + 360 + ) % 360; // Average of found saturation diffs, or all grays have no // saturation, or all grays are fully saturated. this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION] = saturationDiffs.length ? saturationDiffs.reduce((memo, value) => memo + value, 0) / saturationDiffs.length - : (oneHasNoSaturation ? -100 : 100); + : oneHasNoSaturation + ? -100 + : 100; await this._super(...arguments); }, @@ -1666,24 +1822,35 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ // Preview UI update this.$el.find(".o_we_gray_preview").each((_, e) => { - e.style.setProperty("background-color", this.grays[e.getAttribute('variable')], "important"); + e.style.setProperty( + "background-color", + this.grays[e.getAttribute("variable")], + "important" + ); }); // Save all computed (JS side) grays in database - await this._customizeWebsite(previewMode, undefined, Object.assign({}, params, { - customCustomization: () => { // TODO this could be prettier - return this._customizeWebsiteColors(this.grays, Object.assign({}, params, { - colorType: 'gray', - })); - }, - })); + await this._customizeWebsite( + previewMode, + undefined, + Object.assign({}, params, { + customCustomization: () => + // TODO this could be prettier + this._customizeWebsiteColors( + this.grays, + Object.assign({}, params, { + colorType: "gray", + }) + ), + }) + ); }, /** * @see this.selectClass for parameters */ async configureApiKey(previewMode, widgetValue, params) { - return new Promise(resolve => { - this.trigger_up('gmap_api_key_request', { + return new Promise((resolve) => { + this.trigger_up("gmap_api_key_request", { editableMode: true, reconfigure: true, onSuccess: () => resolve(), @@ -1694,9 +1861,9 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ * @see this.selectClass for parameters */ async customizeBodyBgType(previewMode, widgetValue, params) { - if (widgetValue === 'NONE') { - this.bodyImageType = 'image'; - return this.customizeBodyBg(previewMode, '', params); + if (widgetValue === "NONE") { + this.bodyImageType = "image"; + return this.customizeBodyBg(previewMode, "", params); } // TODO improve: hack to click on external image picker this.bodyImageType = widgetValue; @@ -1707,14 +1874,17 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ * @override */ async customizeBodyBg(previewMode, widgetValue, params) { - await this._customizeWebsiteVariables({ - 'body-image-type': this.bodyImageType, - 'body-image': widgetValue ? `'${widgetValue}'` : '', - }, params.nullValue); + await this._customizeWebsiteVariables( + { + "body-image-type": this.bodyImageType, + "body-image": widgetValue ? `'${widgetValue}'` : "", + }, + params.nullValue + ); }, async openCustomCodeDialog(previewMode, widgetValue, params) { - return new Promise(resolve => { - this.trigger_up('open_edit_head_body_dialog', { + return new Promise((resolve) => { + this.trigger_up("open_edit_head_body_dialog", { onSuccess: resolve, }); }); @@ -1723,9 +1893,11 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ * @see this.selectClass for parameters */ async switchTheme(previewMode, widgetValue, params) { - const save = await new Promise(resolve => { + const save = await new Promise((resolve) => { this.dialog.add(ConfirmationDialog, { - body: _t("Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations."), + body: _t( + "Changing theme requires to leave the editor. This will save all your changes, are you sure you want to proceed? Be careful that changing the theme will reset all your color customizations." + ), confirm: () => resolve(true), cancel: () => resolve(false), }); @@ -1733,9 +1905,9 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ if (!save) { return; } - this.trigger_up('request_save', { + this.trigger_up("request_save", { reload: false, - action: 'website.theme_install_kanban_action', + action: "website.theme_install_kanban_action", }); }, /** @@ -1747,7 +1919,9 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ const websiteId = this.options.context.website_id; const save = await new Promise((resolve) => { this.dialog.add(ConfirmationDialog, { - body: _t("Adding a language requires to leave the editor. This will save all your changes, are you sure you want to proceed?"), + body: _t( + "Adding a language requires to leave the editor. This will save all your changes, are you sure you want to proceed?" + ), confirm: () => resolve(true), cancel: () => resolve(false), }); @@ -1763,19 +1937,22 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ params: { website_id: websiteId, url_return: "[lang]", - } + }, }, - } + }, }); }, /** * @see this.selectClass for parameters */ async customizeButtonStyle(previewMode, widgetValue, params) { - await this._customizeWebsiteVariables({ - [`btn-${params.button}-outline`]: widgetValue === "outline" ? "true" : "false", - [`btn-${params.button}-flat`]: widgetValue === "flat" ? "true" : "false", - }, params.nullValue); + await this._customizeWebsiteVariables( + { + [`btn-${params.button}-outline`]: widgetValue === "outline" ? "true" : "false", + [`btn-${params.button}-flat`]: widgetValue === "flat" ? "true" : "false", + }, + params.nullValue + ); }, //-------------------------------------------------------------------------- @@ -1789,34 +1966,50 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ */ _buildGray(id) { // Getting base grays defined in color_palette.scss - const gray = weUtils.getCSSVariableValue(`base-${id}`, getComputedStyle(document.documentElement)); + const gray = weUtils.getCSSVariableValue( + `base-${id}`, + getComputedStyle(document.documentElement) + ); const grayRGB = convertCSSColorToRgba(gray); const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); - const adjustedGrayRGB = convertHslToRgb(this.grayParams[this.GRAY_PARAMS.HUE], - Math.min(Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), 100), - hsl.lightness); - return convertRgbaToCSSColor(adjustedGrayRGB.red, adjustedGrayRGB.green, adjustedGrayRGB.blue); + const adjustedGrayRGB = convertHslToRgb( + this.grayParams[this.GRAY_PARAMS.HUE], + Math.min( + Math.max(hsl.saturation + this.grayParams[this.GRAY_PARAMS.EXTRA_SATURATION], 0), + 100 + ), + hsl.lightness + ); + return convertRgbaToCSSColor( + adjustedGrayRGB.red, + adjustedGrayRGB.green, + adjustedGrayRGB.blue + ); }, /** * @override */ async _renderCustomXML(uiFragment) { await this._super(...arguments); - const extraSaturationRangeEl = uiFragment.querySelector(`we-range[data-param=${this.GRAY_PARAMS.EXTRA_SATURATION}]`); + const extraSaturationRangeEl = uiFragment.querySelector( + `we-range[data-param=${this.GRAY_PARAMS.EXTRA_SATURATION}]` + ); if (extraSaturationRangeEl) { - const baseGrays = range(100, 1000, 100).map(id => { + const baseGrays = range(100, 1000, 100).map((id) => { const gray = weUtils.getCSSVariableValue(`base-${id}`); const grayRGB = convertCSSColorToRgba(gray); const hsl = convertRgbToHsl(grayRGB.red, grayRGB.green, grayRGB.blue); - return {id: id, hsl: hsl}; + return { id: id, hsl: hsl }; }); const first = baseGrays[0]; - const maxValue = baseGrays.reduce((gray, value) => { - return gray.hsl.saturation > value.hsl.saturation ? gray : value; - }, first); - const minValue = baseGrays.reduce((gray, value) => { - return gray.hsl.saturation < value.hsl.saturation ? gray : value; - }, first); + const maxValue = baseGrays.reduce( + (gray, value) => (gray.hsl.saturation > value.hsl.saturation ? gray : value), + first + ); + const minValue = baseGrays.reduce( + (gray, value) => (gray.hsl.saturation < value.hsl.saturation ? gray : value), + first + ); extraSaturationRangeEl.dataset.max = 100 - minValue.hsl.saturation; extraSaturationRangeEl.dataset.min = -maxValue.hsl.saturation; } @@ -1830,32 +2023,39 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ return warningMessage; } for (const widget of widgets) { - if (widget.getMethodsNames().includes('customizeWebsiteVariable') - && widget.getMethodsParams('customizeWebsiteVariable').variable === 'color-palettes-name') { - const hasCustomizedColors = weUtils.getCSSVariableValue('has-customized-colors'); - if (hasCustomizedColors && hasCustomizedColors !== 'false') { - return _t("Changing the color palette will reset all your color customizations, are you sure you want to proceed?"); + if ( + widget.getMethodsNames().includes("customizeWebsiteVariable") && + widget.getMethodsParams("customizeWebsiteVariable").variable === + "color-palettes-name" + ) { + const hasCustomizedColors = weUtils.getCSSVariableValue("has-customized-colors"); + if (hasCustomizedColors && hasCustomizedColors !== "false") { + return _t( + "Changing the color palette will reset all your color customizations, are you sure you want to proceed?" + ); } } } - return ''; + return ""; }, /** * @override */ async _computeWidgetState(methodName, params) { - if (methodName === 'customizeBodyBgType') { - const bgImage = getComputedStyle(this.ownerDocument.querySelector('#wrapwrap'))['background-image']; - if (bgImage === 'none') { + if (methodName === "customizeBodyBgType") { + const bgImage = getComputedStyle(this.ownerDocument.querySelector("#wrapwrap"))[ + "background-image" + ]; + if (bgImage === "none") { return "NONE"; } - return weUtils.getCSSVariableValue('body-image-type'); + return weUtils.getCSSVariableValue("body-image-type"); } - if (methodName === 'customizeGray') { + if (methodName === "customizeGray") { // See updateUI override return this.grayParams[params.param]; } - if (methodName === 'customizeButtonStyle') { + if (methodName === "customizeButtonStyle") { const isOutline = weUtils.getCSSVariableValue(`btn-${params.button}-outline`); const isFlat = weUtils.getCSSVariableValue(`btn-${params.button}-flat`); return isFlat === "true" ? "flat" : isOutline === "true" ? "outline" : "fill"; @@ -1866,14 +2066,14 @@ options.registry.OptionsTab = options.registry.WebsiteLevelColor.extend({ * @override */ async _computeWidgetVisibility(widgetName, params) { - if (widgetName === 'body_bg_image_opt') { + if (widgetName === "body_bg_image_opt") { return false; } if (params.param === this.GRAY_PARAMS.HUE) { return this.grayHueIsDefined; } if (params.removeFont) { - const font = await this._computeWidgetState('customizeWebsiteVariable', { + const font = await this._computeWidgetState("customizeWebsiteVariable", { variable: params.removeFont, }); return !!font; @@ -1889,8 +2089,10 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ async start() { // Checks for support of the old color system const style = window.getComputedStyle(this.$target[0].ownerDocument.documentElement); - const supportOldColorSystem = weUtils.getCSSVariableValue('support-13-0-color-system', style) === 'true'; - const hasCustomizedOldColorSystem = weUtils.getCSSVariableValue('has-customized-13-0-color-system', style) === 'true'; + const supportOldColorSystem = + weUtils.getCSSVariableValue("support-13-0-color-system", style) === "true"; + const hasCustomizedOldColorSystem = + weUtils.getCSSVariableValue("has-customized-13-0-color-system", style) === "true"; this._showOldColorSystemWarning = supportOldColorSystem && hasCustomizedOldColorSystem; return this._super(...arguments); @@ -1905,8 +2107,8 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ */ async updateUIVisibility() { await this._super(...arguments); - const oldColorSystemEl = this.el.querySelector('.o_old_color_system_warning'); - oldColorSystemEl.classList.toggle('d-none', !this._showOldColorSystemWarning); + const oldColorSystemEl = this.el.querySelector(".o_old_color_system_warning"); + oldColorSystemEl.classList.toggle("d-none", !this._showOldColorSystemWarning); }, //-------------------------------------------------------------------------- @@ -1919,31 +2121,46 @@ options.registry.ThemeColors = options.registry.OptionsTab.extend({ async _renderCustomXML(uiFragment) { const paletteSelectorEl = uiFragment.querySelector('[data-variable="color-palettes-name"]'); const style = window.getComputedStyle(document.documentElement); - const allPaletteNames = weUtils.getCSSVariableValue('palette-names', style).split(', ').map((name) => { - return name.replace(/'/g, ""); - }); + const allPaletteNames = weUtils + .getCSSVariableValue("palette-names", style) + .split(", ") + .map((name) => name.replace(/'/g, "")); for (const paletteName of allPaletteNames) { - const btnEl = document.createElement('we-button'); - btnEl.classList.add('o_palette_color_preview_button'); + const btnEl = document.createElement("we-button"); + btnEl.classList.add("o_palette_color_preview_button"); btnEl.dataset.customizeWebsiteVariable = `'${paletteName}'`; - [1, 3, 2].forEach(c => { - const colorPreviewEl = document.createElement('span'); - colorPreviewEl.classList.add('o_palette_color_preview'); - const color = weUtils.getCSSVariableValue(`o-palette-${paletteName}-o-color-${c}`, style); + [1, 3, 2].forEach((c) => { + const colorPreviewEl = document.createElement("span"); + colorPreviewEl.classList.add("o_palette_color_preview"); + const color = weUtils.getCSSVariableValue( + `o-palette-${paletteName}-o-color-${c}`, + style + ); colorPreviewEl.style.backgroundColor = color; btnEl.appendChild(colorPreviewEl); }); paletteSelectorEl.appendChild(btnEl); } - const presetCollapseEl = uiFragment.querySelector('we-collapse.o_we_theme_presets_collapse'); - let ccPreviewEls = []; + const presetCollapseEl = uiFragment.querySelector( + "we-collapse.o_we_theme_presets_collapse" + ); + const ccPreviewEls = []; for (let i = 1; i <= 5; i++) { - const collapseEl = document.createElement('we-collapse'); - const ccPreviewEl = $(renderToElement('web_editor.color.combination.preview.legacy'))[0]; - ccPreviewEl.classList.add('text-center', `o_cc${i}`, 'o_colored_level', 'o_we_collapse_toggler'); + const collapseEl = document.createElement("we-collapse"); + const ccPreviewEl = $( + renderToElement("web_editor.color.combination.preview.legacy") + )[0]; + ccPreviewEl.classList.add( + "text-center", + `o_cc${i}`, + "o_colored_level", + "o_we_collapse_toggler" + ); collapseEl.appendChild(ccPreviewEl); - collapseEl.appendChild(renderToFragment('website.color_combination_edition', {number: i})); + collapseEl.appendChild( + renderToFragment("website.color_combination_edition", { number: i }) + ); ccPreviewEls.push(ccPreviewEl); presetCollapseEl.appendChild(collapseEl); } @@ -1968,8 +2185,8 @@ options.registry.menu_data = options.Class.extend({ * @override */ start: function () { - const wysiwyg = $(this.ownerDocument.getElementById('wrapwrap')).data('wysiwyg'); - const popoverContainer = this.ownerDocument.getElementById('oe_manipulators'); + const wysiwyg = $(this.ownerDocument.getElementById("wrapwrap")).data("wysiwyg"); + const popoverContainer = this.ownerDocument.getElementById("oe_manipulators"); NavbarLinkPopoverWidget.createFor({ target: this.$target[0], wysiwyg, @@ -1977,39 +2194,39 @@ options.registry.menu_data = options.Class.extend({ notify: this.notification.add, checkIsWebsiteDesigner: () => user.hasGroup("website.group_website_designer"), onEditLinkClick: (widget) => { - var $menu = widget.$target.find('[data-oe-id]'); - this.trigger_up('menu_dialog', { + var $menu = widget.$target.find("[data-oe-id]"); + this.trigger_up("menu_dialog", { name: $menu.text(), - url: $menu.parent().attr('href'), + url: $menu.parent().attr("href"), save: (name, url) => { let websiteId; - this.trigger_up('context_get', { - callback: ctx => websiteId = ctx['website_id'], + this.trigger_up("context_get", { + callback: (ctx) => (websiteId = ctx["website_id"]), }); const data = { - id: $menu.data('oe-id'), + id: $menu.data("oe-id"), name, url, }; - return this.orm.call( - "website.menu", - "save", - [websiteId, {'data': [data]}] - ).then(function () { - widget.wysiwyg.odooEditor.observerUnactive(); - widget.$target.attr('href', url); - $menu.text(name); - widget.wysiwyg.odooEditor.observerActive(); - }); + return this.orm + .call("website.menu", "save", [websiteId, { data: [data] }]) + .then(function () { + widget.wysiwyg.odooEditor.observerUnactive(); + widget.$target.attr("href", url); + $menu.text(name); + widget.wysiwyg.odooEditor.observerActive(); + }); }, }); widget.popover.hide(); }, onEditMenuClick: (widget) => { - const contentMenu = widget.target.closest('[data-content_menu_id]'); - const rootID = contentMenu ? parseInt(contentMenu.dataset.content_menu_id, 10) : undefined; - this.trigger_up('action_demand', { - actionName: 'edit_menu', + const contentMenu = widget.target.closest("[data-content_menu_id]"); + const rootID = contentMenu + ? parseInt(contentMenu.dataset.content_menu_id, 10) + : undefined; + this.trigger_up("action_demand", { + actionName: "edit_menu", params: [rootID], }); }, @@ -2017,13 +2234,13 @@ options.registry.menu_data = options.Class.extend({ return this._super(...arguments); }, /** - * When the users selects another element on the page, makes sure the - * popover is closed. - * - * @override - */ + * When the users selects another element on the page, makes sure the + * popover is closed. + * + * @override + */ onBlur: function () { - this.$target.popover('hide'); + this.$target.popover("hide"); }, }); @@ -2032,13 +2249,15 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ * @override */ start: function () { - this.$indicators = this.$target.find('.carousel-indicators'); - this.$controls = this.$target.find('.carousel-control-prev, .carousel-control-next, .carousel-indicators'); + this.$indicators = this.$target.find(".carousel-indicators"); + this.$controls = this.$target.find( + ".carousel-control-prev, .carousel-control-next, .carousel-indicators" + ); // Prevent enabling the carousel overlay when clicking on the carousel // controls (indeed we want it to change the carousel slide then enable // the slide overlay) + See "CarouselItem" option. - this.$controls.addClass('o_we_no_overlay'); + this.$controls.addClass("o_we_no_overlay"); // Handle the sliding manually. this.__onControlClick = throttleForAnimation(this._onControlClick.bind(this)); @@ -2054,7 +2273,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ */ destroy: function () { this._super.apply(this, arguments); - this.$bsTarget.off('.carousel_option'); + this.$bsTarget.off(".carousel_option"); this.$controls.off(".carousel_option"); for (const controlEl of this.$controls) { controlEl.removeEventListener("keydown", this._onControlKeyDown); @@ -2077,7 +2296,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ */ notify(name, data) { this._super(...arguments); - if (name === 'add_slide') { + if (name === "add_slide") { this._addSlide().then(data.onSuccess); } else if (name === "slide") { this._slide(data.direction).then(data.onSuccess); @@ -2101,7 +2320,9 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ toggleControllers() { const carouselEl = this.$target[0].closest(".carousel"); const indicatorsWrapEl = carouselEl.querySelector(".carousel-indicators"); - const areControllersHidden = carouselEl.classList.contains("s_carousel_arrows_hidden") && indicatorsWrapEl.classList.contains("s_carousel_indicators_hidden"); + const areControllersHidden = + carouselEl.classList.contains("s_carousel_arrows_hidden") && + indicatorsWrapEl.classList.contains("s_carousel_indicators_hidden"); carouselEl.classList.toggle("s_carousel_controllers_hidden", areControllersHidden); }, @@ -2117,7 +2338,7 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ cardEl.insertAdjacentElement("afterbegin", imageWrapperEl); } } else { - carouselEl.querySelectorAll("figure").forEach(el => el.remove()); + carouselEl.querySelectorAll("figure").forEach((el) => el.remove()); } }, @@ -2132,17 +2353,20 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ * @private */ _assignUniqueID: function () { - const id = 'myCarousel' + Date.now(); - this.$target.attr('id', id); - this.$target.find('[data-bs-target]').attr('data-bs-target', '#' + id); - this.$target.find('[data-bs-slide], [data-bs-slide-to]').toArray().forEach((el) => { - var $el = $(el); - if ($el.attr('data-bs-target')) { - $el.attr('data-bs-target', '#' + id); - } else if ($el.attr('href')) { - $el.attr('href', '#' + id); - } - }); + const id = "myCarousel" + Date.now(); + this.$target.attr("id", id); + this.$target.find("[data-bs-target]").attr("data-bs-target", "#" + id); + this.$target + .find("[data-bs-slide], [data-bs-slide-to]") + .toArray() + .forEach((el) => { + var $el = $(el); + if ($el.attr("data-bs-target")) { + $el.attr("data-bs-target", "#" + id); + } else if ($el.attr("href")) { + $el.attr("href", "#" + id); + } + }); }, /** * Adds a slide. @@ -2151,18 +2375,18 @@ options.registry.Carousel = options.registry.CarouselHandler.extend({ */ async _addSlide() { this.options.wysiwyg.odooEditor.historyPauseSteps(); - const $items = this.$target.find('.carousel-item'); - this.$controls.removeClass('d-none'); - const $active = $items.filter('.active'); - this.$indicators.append($('