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 @@
-
Social Media
+ + + + + + + + + + + + + + + + + + +Follow us
- +Follow us
+ - + - + - + + + + + + +Follow us
- +Follow us
+ - + - + - + + + + + + +Follow us
- +Follow us
+ - + - + - + + + + + + +Follow us
- +Follow us
+ - + - + + + + + + + + + +Follow us
- +Follow us
+ - + - + - + - + - +We help you grow your business
Follow us
- +Follow us
+ - + - + - + + + + + + + + + +Follow us
- +Follow us
+ - + - + - + + + + + + +Follow us
- +Follow us
+ - + - + + + + + + + + + +250 Executive Park Blvd, Suite 3400 • San Francisco CA 94134 • United States
@@ -1596,17 +1647,26 @@
Follow us
- +Follow us
+ - + - + + + + + + + + + +hello@mycompany.com
Follow us
- +Follow us
+ - + - + + + + + + + + + +Share
- +Follow Us
+ - + - + + + + + + + + + +