From 8f7c6997e8ab381563a9fa8d06feef4e89fff4aa Mon Sep 17 00:00:00 2001 From: "Guillaume (gdi)" Date: Sun, 16 Jan 2022 22:15:08 +0000 Subject: [PATCH] [IMP] website: propose a new social media snippet This commit introduces a new social_media snippet that allows social networks to be displayed. This snippet shares the same options as the share snippet, but also provides a list to edit the URLs of the social networks in the company data (social_facebook, ...). This list also allows to manage links to custom social networks (which are not stored in the DB). Headers and footers now use this new social_media snippet instead of the share snippet and in the settings, the urls to the DB social networks are editable/visible only in developer mode. task-2687447 closes odoo/odoo#80217 Signed-off-by: Romain Derie (rde) Co-authored-by: Guillaume (gdi) Co-authored-by: Romain Derie --- addons/web_editor/views/snippets.xml | 2 +- addons/website/__manifest__.py | 2 + .../src/snippets/s_social_media/000.scss | 65 +++++ .../src/snippets/s_social_media/options.js | 192 ++++++++++++++ .../tests/tours/snippet_social_media.js | 113 +++++++++ addons/website/tests/test_snippets.py | 8 + .../views/res_config_settings_views.xml | 2 +- addons/website/views/snippets/s_share.xml | 5 +- .../website/views/snippets/s_social_media.xml | 45 ++++ addons/website/views/snippets/snippets.xml | 5 +- addons/website/views/website_templates.xml | 236 ++++++++++++------ 11 files changed, 590 insertions(+), 85 deletions(-) create mode 100644 addons/website/static/src/snippets/s_social_media/000.scss create mode 100644 addons/website/static/src/snippets/s_social_media/options.js create mode 100644 addons/website/static/tests/tours/snippet_social_media.js create mode 100644 addons/website/views/snippets/s_social_media.xml diff --git a/addons/web_editor/views/snippets.xml b/addons/web_editor/views/snippets.xml index 83e017bd6ba..38e4625c722 100644 --- a/addons/web_editor/views/snippets.xml +++ b/addons/web_editor/views/snippets.xml @@ -380,7 +380,7 @@ -
+
Replace diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py index a7b7e338b3d..0929e88a907 100644 --- a/addons/website/__manifest__.py +++ b/addons/website/__manifest__.py @@ -50,6 +50,7 @@ 'views/snippets/s_alert.xml', 'views/snippets/s_card.xml', 'views/snippets/s_share.xml', + 'views/snippets/s_social_media.xml', 'views/snippets/s_rating.xml', 'views/snippets/s_hr.xml', 'views/snippets/s_facebook_page.xml', @@ -205,6 +206,7 @@ 'website/static/src/snippets/s_embed_code/options.js', 'website/static/src/snippets/s_website_form/options.js', 'website/static/src/snippets/s_searchbar/options.js', + 'website/static/src/snippets/s_social_media/options.js', 'website/static/src/snippets/s_process_steps/options.js', 'website/static/src/js/editor/wysiwyg.js', 'website/static/src/js/editor/widget_link.js', diff --git a/addons/website/static/src/snippets/s_social_media/000.scss b/addons/website/static/src/snippets/s_social_media/000.scss new file mode 100644 index 00000000000..1bba940de5a --- /dev/null +++ b/addons/website/static/src/snippets/s_social_media/000.scss @@ -0,0 +1,65 @@ + +.s_social_media { + > * { + display: inline-block; + vertical-align: middle; + } + .s_social_media_title { + margin: 0 .4rem 0 0; + } + a { + i.fa { + display: flex; + justify-content: center; + align-items: center; + } + margin: .2rem; + } + &:not(.no_icon_color) { + .s_social_media_facebook { + &, &:hover, &:focus { + @extend .text-facebook; + } + } + .s_social_media_twitter { + &, &:hover, &:focus { + @extend .text-twitter; + } + } + .s_social_media_linkedin { + &, &:hover, &:focus { + @extend .text-linkedin; + } + } + .s_social_media_google { + &, &:hover, &:focus { + @extend .text-google-plus; + } + } + .s_social_media_whatsapp { + &, &:hover, &:focus { + @extend .text-whatsapp; + } + } + .s_social_media_pinterest { + &, &:hover, &:focus { + @extend .text-pinterest; + } + } + .s_social_media_github { + &, &:hover, &:focus { + @extend .text-github; + } + } + .s_social_media_instagram { + &, &:hover, &:focus { + @extend .text-instagram; + } + } + .s_social_media_youtube { + &, &:hover, &:focus { + @extend .text-youtube; + } + } + } +} diff --git a/addons/website/static/src/snippets/s_social_media/options.js b/addons/website/static/src/snippets/s_social_media/options.js new file mode 100644 index 00000000000..709098a0b72 --- /dev/null +++ b/addons/website/static/src/snippets/s_social_media/options.js @@ -0,0 +1,192 @@ +/** @odoo-module **/ + +import fonts from 'wysiwyg.fonts'; +import {generateHTMLId} from 'web_editor.utils'; +import options from 'web_editor.snippets.options'; + +options.registry.SocialMedia = options.Class.extend({ + /** + * @override + */ + async willStart() { + this.$target[0].querySelectorAll(':scope > a').forEach(el => el.setAttribute('id', generateHTMLId(30))); + await this._super(...arguments); + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + // Fetch URLs for db links. + [this.dbSocialValues] = await this._rpc({ + model: 'website', + method: 'read', + args: [websiteId, ['social_facebook', 'social_twitter', 'social_youtube', + 'social_instagram', 'social_linkedin', 'social_github']], + }); + delete this.dbSocialValues.id; + }, + /** + * @override + */ + async cleanForSave() { + this.$target[0].querySelectorAll(':scope > a').forEach(el => el.removeAttribute('id')); + // Update the DB links. + let websiteId; + this.trigger_up('context_get', { + callback: function (ctx) { + websiteId = ctx['website_id']; + }, + }); + this._rpc({ + model: 'website', + method: 'write', + args: [[websiteId], this.dbSocialValues], + }); + }, + + //-------------------------------------------------------------------------- + // Options + //-------------------------------------------------------------------------- + + /** + * Applies the we-list on the target and rebuilds the social links. + * + * @see this.selectClass for parameters + */ + async renderListItems(previewMode, widgetValue, params) { + const entries = JSON.parse(widgetValue); + // Handle element deletation. + const entriesIds = entries.map(entry => entry.id); + const anchorsEls = this.$target[0].querySelectorAll(':scope > a'); + const deletedEl = Array.from(anchorsEls).find(aEl => !entriesIds.includes(aEl.id)); + if (deletedEl) { + deletedEl.remove(); + } + for (const entry of entries) { + let anchorEl = this.$target[0].querySelector(`#${entry.id}`); + if (!anchorEl) { + // It's a new social media. + anchorEl = this.$target[0].querySelector(':scope > a').cloneNode(true); + anchorEl.href = '#'; + anchorEl.setAttribute('id', entry.id); + } + // Handle visibility of the link + anchorEl.classList.toggle('d-none', !entry.selected); + + const dbField = anchorEl.href.split('/website/social/')[1]; + if (dbField) { + // Handle URL change for DB links. + this.dbSocialValues['social_' + dbField] = entry.display_name; + } else { + // Handle URL change for custom links. + const href = anchorEl.getAttribute('href'); + if (href !== entry.display_name) { + if (this._isValidURL(entry.display_name)) { + // Propose an icon only for valid URLs (no mailto). + const socialMedia = this._findRelevantSocialMedia(entry.display_name); + + // Remove social media social media classes + let regx = new RegExp('\\b' + "s_social_media_" + '[^1-9][^ ]*[ ]?\\b', 'g'); + anchorEl.className = anchorEl.className.replace(regx, ''); + + // Remove every fa classes except fa-x sizes + const iEl = anchorEl.querySelector('i'); + regx = new RegExp('\\b' + "fa-" + '[^1-9][^ ]*[ ]?\\b', 'g'); + iEl.className = iEl.className.replace(regx, ''); + + if (socialMedia) { + anchorEl.classList.add(`s_social_media_${socialMedia}`); + iEl.classList.add(`fa-${socialMedia}`); + } else { + iEl.classList.add(`fa-pencil`); + } + } + anchorEl.setAttribute('href', entry.display_name); + } + } + // Place the link at the correct position + this.$target[0].appendChild(anchorEl); + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + _computeWidgetState: function (methodName, params) { + if (methodName !== 'renderListItems') { + return this._super(methodName, params); + } + const listEntries = []; + for (const anchorEl of this.$target[0].querySelectorAll(':scope > a')) { + const dbField = anchorEl.href.split('/website/social/')[1]; + const entry = { + id: anchorEl.id, + selected: !anchorEl.classList.contains('d-none'), + display_name: dbField ? this.dbSocialValues['social_' + dbField] : anchorEl.getAttribute('href'), + undeletable: Boolean(dbField), + placeholder: `https://${dbField || 'example'}.com/yourPage`, + }; + listEntries.push(entry); + } + return JSON.stringify(listEntries); + }, + /** + * Finds the social network for the given url. + * + * @param {String} url + * @return {String} The social network to which the url leads to. + */ + _findRelevantSocialMedia(url) { + const supportedSocialMedia = [ + ['facebook', /^(https?:\/\/)(www\.)?(facebook|fb|m\.facebook)\.(com|me)\/.+$/gm], + ['twitter', /^(https?:\/\/)((www\.)?twitter\.com)\/.+$/gm], + ['youtube', /^(https?:\/\/)(www\.)?(youtube.com|youtu.be)\/.+$/gm], + ['instagram', /^(https?:\/\/)(www\.)?(instagram.com|instagr.am|instagr.com)\/.+$/gm], + ['linkedin', /^(https?:\/\/)((www\.)?linkedin\.com)\/.+$/gm], + ['github', /^(https?:\/\/)((www\.)?github\.com)\/.+$/gm], + ]; + for (const [socialMedia, regex] of supportedSocialMedia) { + if (regex.test(url)) { + return socialMedia; + } + } + // Check if an icon matches the URL domain + try { + const domain = new URL(url).hostname.split('.').slice(-2)[0]; + fonts.computeFonts(); + return fonts.fontIcons[0].alias.find(el => el.includes(domain)).split('fa-').pop(); + } catch (error) { + return false; + } + }, + /** + * @param {String} str + * @returns {boolean} is the string a valid URL. + */ + _isValidURL(str) { + try { + new URL(str); + } catch (error) { + return false; + } + return true; + }, + /** + * @override + */ + _renderCustomXML(uiFragment) { + const anchorEls = this.$target[0].querySelectorAll(':scope > a:not(.d-none)'); + uiFragment.querySelector('we-list').dataset.defaults = JSON.stringify( + Array.from(anchorEls).map(el => el.id) + ); + } +}); + +export default { + SocialMedia: options.registry.SocialMedia, +}; diff --git a/addons/website/static/tests/tours/snippet_social_media.js b/addons/website/static/tests/tours/snippet_social_media.js new file mode 100644 index 00000000000..4fa959bee6d --- /dev/null +++ b/addons/website/static/tests/tours/snippet_social_media.js @@ -0,0 +1,113 @@ +/** @odoo-module */ + +import tour from 'web_tour.tour'; +import wTourUtils from 'website.tour_utils'; + +tour.register('snippet_social_media', { + test: true, + url: '/?enable_editor=1', +}, [ + wTourUtils.dragNDrop({id: 's_social_media', name: 'Social Media'}), + wTourUtils.clickOnSnippet({id: 's_social_media', name: 'Social Media'}), + { + content: 'Click on Add New Social Network', + trigger: 'we-list we-button.o_we_list_add_optional', + }, + { + content: 'Change added Option label', + trigger: 'we-list table input:eq(6)', + run: 'text https://www.youtu.be/y7TlnAv6cto', + }, + { + content: 'Click on the toggle to hide Facebook', + trigger: 'we-list table we-button.o_we_user_value_widget', + run: 'click', + }, + { + content: 'Drag the facebook link at the end of the list', + trigger: 'we-list table we-button.o_we_drag_handle', + position: 'bottom', + run: "drag_and_drop we-list table tr:last-child", + }, + // Create a Link for which we don't have an icon to propose. + { + content: 'Click on Add New Social Network', + trigger: 'we-list we-button.o_we_list_add_optional', + }, + { + content: 'Change added Option label (2)', + trigger: 'we-list table input:eq(7)', + run: 'text https://whatever.it/1EdSw9X', + }, + // Create a custom instagram link. + { + content: 'Click on Add New Social Network', + trigger: 'we-list we-button.o_we_list_add_optional', + }, + { + content: 'Change added Option label (3)', + trigger: 'we-list table input:eq(8)', + run: 'text https://instagr.am/odoo.official/', + }, + { + content: "Check if the result is correct before removing", + trigger: ".s_social_media" + + ":has(a:eq(0)[href='/website/social/twitter'])" + + ":has(a:eq(1)[href='/website/social/linkedin'])" + + ":has(a:eq(2)[href='/website/social/youtube'])" + + ":has(a:eq(3)[href='/website/social/instagram'])" + + ":has(a:eq(4)[href='/website/social/github'])" + + ":has(a:eq(5)[href='https://www.youtu.be/y7TlnAv6cto']:has(i.fa-youtube))" + + ":has(a:eq(6)[href='/website/social/facebook'].d-none)" + + ":has(a:eq(7)[href='https://whatever.it/1EdSw9X']:has(i.fa-pencil))" + + ":has(a:eq(8)[href='https://instagr.am/odoo.official/']:has(i.fa-instagram))", + }, + // Create a custom link, not officially supported, ensure icon is found. + { + content: 'Change custom social to unsupported link', + trigger: 'we-list table input:eq(5)', + run: 'text https://www.paypal.com/abc', + }, + { + content: "Ensure paypal icon is found", + trigger: ".s_social_media" + + ":has(a:eq(5)[href='https://www.paypal.com/abc']:has(i.fa-paypal))", + }, + { + content: 'Delete the custom link', + trigger: 'we-list we-button.o_we_select_remove_option', + run: 'click', + }, + { + content: 'Click on the toggle to show Facebook', + trigger: 'we-list table we-button.o_we_user_value_widget:not(.active)', + run: 'click', + }, + { + content: "Check if the result is correct after removing", + trigger: ".s_social_media" + + ":has(a:eq(0)[href='/website/social/twitter'])" + + ":has(a:eq(1)[href='/website/social/linkedin'])" + + ":has(a:eq(2)[href='/website/social/youtube'])" + + ":has(a:eq(3)[href='/website/social/instagram'])" + + ":has(a:eq(4)[href='/website/social/github'])" + + ":has(a:eq(5)[href='/website/social/facebook'])" + + ":has(a:eq(6)[href='https://whatever.it/1EdSw9X']:has(i.fa-pencil))" + + ":has(a:eq(7)[href='https://instagr.am/odoo.official/']:has(i.fa-instagram))", + }, + { + content: 'Change url of the DB instagram link', + trigger: 'we-list table input:eq(3)', + run: 'text https://instagram.com/odoo.official/', + }, + { + content: 'Save', + trigger: 'button[data-action=save]', + run: 'click', + }, + { + content: "Wait until save's calls are finished", + trigger: "body:not(.editor_enable)", + run: function () {}, // it's a check + } +]); diff --git a/addons/website/tests/test_snippets.py b/addons/website/tests/test_snippets.py index f4b6875f836..e651d04a32c 100644 --- a/addons/website/tests/test_snippets.py +++ b/addons/website/tests/test_snippets.py @@ -30,3 +30,11 @@ class TestSnippets(HttpCase): def test_04_countdown_preview(self): self.start_tour("/?enable_editor=1", "snippet_countdown", login='admin') + + def test_05_social_media(self): + self.start_tour("/?enable_editor=1", 'snippet_social_media', login="admin") + self.assertEqual( + self.env['website'].browse(1).social_instagram, + 'https://instagram.com/odoo.official/', + 'Social media should have been updated' + ) diff --git a/addons/website/views/res_config_settings_views.xml b/addons/website/views/res_config_settings_views.xml index 1983d52b14c..2f1fda19ccf 100644 --- a/addons/website/views/res_config_settings_views.xml +++ b/addons/website/views/res_config_settings_views.xml @@ -165,7 +165,7 @@
-
+
diff --git a/addons/website/views/snippets/s_share.xml b/addons/website/views/snippets/s_share.xml index aaf48d46cf9..168f44d8023 100644 --- a/addons/website/views/snippets/s_share.xml +++ b/addons/website/views/snippets/s_share.xml @@ -27,8 +27,9 @@