From 971319f8c3ba7a4a32c2f32a141886dd566a1cab Mon Sep 17 00:00:00 2001 From: Anjeel Haria Date: Fri, 27 Mar 2026 14:27:45 +0530 Subject: [PATCH 1/5] [UPD] website_membership_group: Added snippet options to customize the membership group page Updates --- website_membership_group/__manifest__.py | 12 +- .../models/membership_group.py | 18 +- .../src/js/membership_group_frontend.esm.js | 38 ++++ .../src/js/membership_group_options.esm.js | 127 ++++++++++++ .../static/src/scss/membership_group.scss | 183 ++++++++++++++++++ .../templates/website.xml | 170 +++++++++------- .../views/snippets/snippet_options.xml | 35 ++++ 7 files changed, 508 insertions(+), 75 deletions(-) create mode 100644 website_membership_group/static/src/js/membership_group_frontend.esm.js create mode 100644 website_membership_group/static/src/js/membership_group_options.esm.js create mode 100644 website_membership_group/static/src/scss/membership_group.scss create mode 100644 website_membership_group/views/snippets/snippet_options.xml diff --git a/website_membership_group/__manifest__.py b/website_membership_group/__manifest__.py index dbb5b53c..187603fe 100644 --- a/website_membership_group/__manifest__.py +++ b/website_membership_group/__manifest__.py @@ -4,7 +4,7 @@ { "name": "Website Membership Group", "category": "Membership", - "version": "18.0.1.0.0", + "version": "18.0.1.0.3", "author": "Onestein", "license": "AGPL-3", "website": "https://www.onestein.nl", @@ -15,5 +15,15 @@ "data": [ "views/membership_group_view.xml", "templates/website.xml", + "views/snippets/snippet_options.xml", ], + "assets": { + "web.assets_frontend": [ + "website_membership_group/static/src/scss/membership_group.scss", + "website_membership_group/static/src/js/membership_group_frontend.esm.js", + ], + "website.assets_wysiwyg": [ + "website_membership_group/static/src/js/membership_group_options.esm.js", + ], + }, } diff --git a/website_membership_group/models/membership_group.py b/website_membership_group/models/membership_group.py index 55a3f300..ac8c2f8e 100644 --- a/website_membership_group/models/membership_group.py +++ b/website_membership_group/models/membership_group.py @@ -7,7 +7,7 @@ class MembershipGroup(models.Model): _name = "membership.group" - _inherit = ["membership.group", "website.published.mixin"] + _inherit = ["membership.group", "website.published.mixin", "website.seo.metadata"] website_top = fields.Html(strip_style=True, translate=html_translate) website_bottom = fields.Html(strip_style=True, translate=html_translate) @@ -15,6 +15,22 @@ class MembershipGroup(models.Model): icon = fields.Image() page_id = fields.Many2one(comodel_name="website.page") website_url = fields.Char(compute="_compute_website_url", store=True) + website_snippet_wrap_classes = fields.Char( + string="Wrap Classes", + default="mg_wrap mg_show_image mg_show_top mg_show_bottom mg_show_committee mg_show_team", + help="Classes for the #wrap element (visibility options)", + ) + website_snippet_members_classes = fields.Char( + string="Members Row Classes", + default="mg_layout_grid mg_show_committee_desc mg_show_team_desc", + help="Classes for the #mg_members_row element (layout and description options)", + ) + website_committee_col_classes = fields.Char( + string="Committee Column Classes", default="col-lg-4" + ) + website_team_col_classes = fields.Char( + string="Team Column Classes", default="col-lg-8" + ) @api.depends("page_id", "page_id.is_published") def _compute_website_url(self): diff --git a/website_membership_group/static/src/js/membership_group_frontend.esm.js b/website_membership_group/static/src/js/membership_group_frontend.esm.js new file mode 100644 index 00000000..64a8086f --- /dev/null +++ b/website_membership_group/static/src/js/membership_group_frontend.esm.js @@ -0,0 +1,38 @@ +import publicWidget from "@web/legacy/js/public/public_widget"; + +publicWidget.registry.MembershipGroupCollapsible = publicWidget.Widget.extend({ + selector: '#mg_members_row', + + start: function () { + this.setupCollapsibleButtons(); + return this._super.apply(this, arguments); + }, + + setupCollapsibleButtons: function () { + const buttons = this.el.querySelectorAll('.mg_collapse_btn'); + buttons.forEach(button => { + button.onclick = (e) => { + e.preventDefault(); + this.toggleDescription(button); + }; + }); + }, + + toggleDescription: function (button) { + const descriptionId = button.getAttribute('aria-controls'); + // eslint-disable-next-line no-undef + const description = document.getElementById(descriptionId); + + if (!description) return; + + const isExpanded = button.getAttribute('aria-expanded') === 'true'; + + if (isExpanded) { + description.classList.add('d-none'); + button.setAttribute('aria-expanded', 'false'); + } else { + description.classList.remove('d-none'); + button.setAttribute('aria-expanded', 'true'); + } + }, +}); diff --git a/website_membership_group/static/src/js/membership_group_options.esm.js b/website_membership_group/static/src/js/membership_group_options.esm.js new file mode 100644 index 00000000..096a7bd8 --- /dev/null +++ b/website_membership_group/static/src/js/membership_group_options.esm.js @@ -0,0 +1,127 @@ +import options from "@web_editor/js/editor/snippets.options"; +import { rpc } from "@web/core/network/rpc"; +/** + * Helper to filter for module-specific option classes. + * Excludes base structural classes to prevent duplication in templates. + */ +const filterMgClasses = (classList, excludeClass) => { + return classList.split(/\s+/) + .filter(cls => cls.startsWith('mg_') && cls !== excludeClass) + .join(' '); +}; +options.registry.MembershipGroupPage = options.Class.extend({ + /** + * @override + */ + // eslint-disable-next-line no-unused-vars + selectClass: function (previewMode, widgetValue, params) { + this._super(...arguments); + // Only save to database on actual click (not during hover preview) + if (!previewMode) { + this._saveToDatabase(); + } + }, + + /** + * Save the current class list to the database + */ + _saveToDatabase: function () { + const id = parseInt(this.$target[0].dataset.oeId,10); + // Filter to keep only option classes, excluding 'mg_wrap' + const classes = filterMgClasses(this.$target[0].className, 'mg_wrap'); + if (this.lastSavedClasses !== classes) { + this.lastSavedClasses = classes; + if (id && classes) { + return rpc('/web/dataset/call_kw', { + model: "membership.group", + method: 'write', + args: [[id], {'website_snippet_wrap_classes': classes}], + kwargs: {}, + }); + } + } + }, +}); + +options.registry.MemberLayoutOpts = options.Class.extend({ + /** + * @override + * Triggered for Layout and Description toggles. + */ + // eslint-disable-next-line no-unused-vars + selectClass: function (previewMode, value, $li) { + this._super(...arguments); + if (!previewMode) { + this._saveToDatabase(); + } + }, + + _saveToDatabase: function () { + const id = parseInt(this.$target[0].dataset.oeId,10); + // Filter to keep only option classes, excluding 'mg_members_row' + const classes = filterMgClasses(this.$target[0].className, 'mg_members_row'); + + if (this.lastSavedClasses !== classes) { + this.lastSavedClasses = classes; + if (id && classes) { + return rpc('/web/dataset/call_kw', { + model: "membership.group", + method: 'write', + args: [[id], {'website_snippet_members_classes': classes}], + kwargs: {}, + }); + } + } + }, +}); + +options.registry.MemberColOpts = options.Class.extend({ + start: function () { + const self = this; + this._super(...arguments); + + this.observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === "class") { + self._saveToDatabase(); + } + }); + }); + + this.observer.observe(this.$target[0], { + attributes: true, + attributeFilter: ["class"], + }); + }, + + destroy: function () { + if (this.observer) { + this.observer.disconnect(); + } + this._super(...arguments); + }, + + _saveToDatabase: function () { + const $row = this.$target.closest('#mg_members_row'); + const id = parseInt($row[0]?.dataset.oeId, 10); + const classes = this.$target[0].className.split(/\s+/) + .filter(cls => cls.startsWith('mg_') || cls.startsWith('col-')) + .join(' '); + + const field = this.$target.hasClass('mg_committee_col') + ? 'website_committee_col_classes' + : 'website_team_col_classes'; + + if (id && field && classes) { + if (this.lastSavedColClasses !== classes) { + this.lastSavedColClasses = classes; + return rpc('/web/dataset/call_kw', { + model: "membership.group", + method: 'write', + args: [[id], {[field]: classes}], + kwargs: {}, + }); + } + } + }, +}); diff --git a/website_membership_group/static/src/scss/membership_group.scss b/website_membership_group/static/src/scss/membership_group.scss new file mode 100644 index 00000000..6722477c --- /dev/null +++ b/website_membership_group/static/src/scss/membership_group.scss @@ -0,0 +1,183 @@ +/* ======================================================================= + VISIBILITY TOGGLES (LAYOUT) + Controlled via classes applied to the #wrap element + ======================================================================= */ +.mg_wrap { + // Default: Hide all optional sections + .mg_image_col, + .mg_top_block, + .mg_bottom_block, + .mg_committee_col, + .mg_team_col { + display: none; + } + + // Toggle display based on snippet option classes + &.mg_show_image .mg_image_col { display: block; } + &.mg_show_top .mg_top_block { display: block; } + &.mg_show_bottom .mg_bottom_block { display: block; } + &.mg_show_committee .mg_committee_col { display: block; } + &.mg_show_team .mg_team_col { display: block; } +} + +/* ======================================================================= + MEMBER DESCRIPTION VISIBILITY & COLLAPSIBLE SYSTEM + Tied to Committee Desc / Team Desc snippet options + ======================================================================= */ + +/* Default: Hide descriptions and buttons for both sections */ +.mg_member_description, +.mg_collapse_btn { + display: none !important; +} + +// Show descriptions if the respective section toggle is ON +.mg_show_team_desc .mg_team_col .mg_member_description, +.mg_show_committee_desc .mg_committee_col .mg_member_description { + display: block !important; +} + +/* Hide descriptions if collapsible is active and closed */ +#mg_members_row.mg_collapse_desc .mg_member_description.d-none { + display: none !important; +} + +/* Animation for expanding descriptions */ +#mg_members_row.mg_collapse_desc:not(.mg_layout_grid) .mg_member_description:not(.d-none) { + display: block !important; + animation: slideDown 0.3s ease-in-out; +} + +/* Show buttons ONLY if BOTH Collapsible AND the respective Section Toggle are ON */ +#mg_members_row:not(.mg_layout_grid).mg_collapse_desc.mg_show_committee_desc .mg_collapse_btn, +#mg_members_row:not(.mg_layout_grid).mg_collapse_desc.mg_show_team_desc .mg_collapse_btn { + display: inline-flex !important; + padding: 0.25rem 0.5rem; + color: #6c757d; + font-size: 0.875rem; + transition: transform 0.3s ease-in-out, color 0.2s ease-in-out; +} + +#mg_members_row.mg_collapse_desc .mg_collapse_btn:hover { + color: $primary; +} + +#mg_members_row.mg_collapse_desc .mg_collapse_btn[aria-expanded="true"] { + transform: rotate(180deg); +} + +#mg_members_row.mg_collapse_desc .mg_collapse_btn[aria-expanded="true"] i { + color: $primary; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ======================================================================= + MEMBER LAYOUT VARIANTS + ======================================================================= */ + +/* --- GRID LAYOUT --- */ +#mg_members_row.mg_layout_grid { + .mg_member_list { + gap: 0; + } + + /* 1 Column for Committee Section in Grid */ + .mg_committee_col .mg_member_item { + flex: 0 0 100%; + } + + /* 2 Columns for Team Section in Grid */ + .mg_team_col .mg_member_item { + flex: 0 0 50%; + } + + /* Force hide description and collapse buttons in Grid Layout */ + .mg_member_description, + .mg_collapse_btn { + display: none !important; + } +} + +/* --- CARDS LAYOUT --- */ +#mg_members_row.mg_layout_cards { + .mg_member_list { + gap: 0.75rem; + padding: 0.75rem; + } + + .mg_member_item { + flex: 1 1 100% !important; + width: 100%; + min-width: 0; + border: 1px solid $border-color !important; + border-radius: $border-radius !important; + background: #fff; + box-shadow: $box-shadow-sm; + padding: 1rem !important; + transition: box-shadow 0.15s ease-in-out; + + &:hover { + box-shadow: $box-shadow; + } + } + + .mg_member_name { + text-align: left; + } +} + +/* --- AVATARS LAYOUT --- */ +#mg_members_row.mg_layout_avatars { + .mg_member_list { + gap: 1rem; + padding: 1rem; + } + + .mg_member_item { + flex: 1 1 100% !important; + width: 100%; + min-width: 0; + border: none !important; + border-radius: 0 !important; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 0.5rem 0.25rem !important; + background: transparent; + } + + .mg_member_name { + font-size: 0.8125rem; + text-align: center; + } + + .mg_collapse_btn { + margin: 0 auto; + display: block; + text-align: center; + } +} + +/* ======================================================================= + STYLING & THEMING + ======================================================================= */ +.mg_divider { + border-color: $primary; +} + +/* ======================================================================= + RESPONSIVE BREAKPOINTS + ======================================================================= */ +@include media-breakpoint-down(sm) { + #mg_members_row.mg_layout_avatars .mg_member_item { flex: 1 1 calc(50% - 0.5rem); } +} + +@include media-breakpoint-down(md) { + #mg_members_row.mg_layout_cards .mg_member_item { flex: 1 1 100%; } + #mg_members_row.mg_layout_avatars .mg_member_item { flex: 1 1 calc(50% - 1rem); } +} diff --git a/website_membership_group/templates/website.xml b/website_membership_group/templates/website.xml index 022301af..d90dc334 100644 --- a/website_membership_group/templates/website.xml +++ b/website_membership_group/templates/website.xml @@ -1,99 +1,123 @@ - - -