From 949f27612df79ce2e0c272a4c22c2a72f4320df4 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Mon, 4 Aug 2025 16:22:51 +0200 Subject: [PATCH] Bug 35716: (another alternate) Use rspack to manage shared JS assets between OPAC and Staff This POC introduces a shared assets directory and configures rspack to build (currently) identical JS files from a single source to both OPAC and Staff locations, eliminating code duplication and preventing drift. The POC uses cookieconsent.js which already started to drift into diverging implementations (OPAC: 257 lines, Staff: 199 lines). Here, this is due to different requirements in Staff Interface vs OPAC. One way to handle this is to have aliases configured in rspack that import what's needed for the respective bundle via module imports. If needed I can provide an extended POC for that feature specifically but wanted to show first how this could work. How it works: - Source files live in koha-tmpl/shared/js/ (this is totally arbitrary) - Rspack builds twice: once to intranet-tmpl/prog/js/, once to opac-tmpl/bootstrap/js/ - Templates continue using [% Asset.js() %] unchanged Test plan: 1. Apply patch 2. Run: yarn js:build 3. Verify koha-tmpl/intranet-tmpl/prog/js/cookieconsent.js exists 4. Verify koha-tmpl/opac-tmpl/bootstrap/js/cookieconsent.js exists 5. Confirm both files are identical 6. Test cookie consent functionality works in both OPAC and Staff interface (with the caveats brought up earlier) --- koha-tmpl/shared/js/cookieconsent.js | 199 +++++++++++++++++++++++++++ rspack.config.js | 78 +++++++++++ 2 files changed, 277 insertions(+) create mode 100644 koha-tmpl/shared/js/cookieconsent.js diff --git a/koha-tmpl/shared/js/cookieconsent.js b/koha-tmpl/shared/js/cookieconsent.js new file mode 100644 index 00000000000..834dc1fd626 --- /dev/null +++ b/koha-tmpl/shared/js/cookieconsent.js @@ -0,0 +1,199 @@ +(function () { + // Has the user previously consented, establish our + // initial state + let selected = []; + let existingConsent = ""; + // The presence of a 'cookieConsent' local storage item indicates + // that previous consent has been set. If the value is empty, then + // no consent to non-essential cookies was given + const hasStoredConsent = localStorage.getItem("cookieConsent"); + getExistingConsent(); + + // The consent bar may not be in the DOM if it is not being used + const consentBar = $("#cookieConsentBar"); + if (!consentBar) { + return; + } + + // Initialize the consent modal and prevent the modal + // from being closed with anything but the buttons + const cookieConsentModal = new bootstrap.Modal( + document.getElementById("cookieConsentModal"), + { + backdrop: "static", + keyboard: false, + focus: true, + show: true, + } + ); + + if (hasStoredConsent === null) { + showConsentBar(); + } + addButtonHandlers(); + + // When the modal is opened, populate our state based on currently + // selected values + const cookieConsentModalEl = document.getElementById("cookieConsentModal"); + cookieConsentModalEl.addEventListener("shown.bs.modal", () => { + initialiseSelected(); + }); + + // Initialise existing consent based on local storage + function getExistingConsent() { + existingConsent = localStorage.getItem("cookieConsent") + ? localStorage.getItem("cookieConsent") + : []; + } + + function showConsentBar() { + const consentBar = $("#cookieConsentBar"); + const langmenu = $("#changelanguage"); + if (langmenu) { + const height = langmenu.height(); + consentBar.css("bottom", height); + } + consentBar.attr("aria-hidden", "false"); + consentBar.css("display", "flex"); + } + + function hideConsentBar() { + const consentBar = $("#cookieConsentBar"); + consentBar.attr("aria-hidden", "true"); + consentBar.hide(); + } + + // Hides the appropriate consent container, depending on what + // is currently visible + function hideContainer() { + if ($(cookieConsentModalEl).is(":visible")) { + cookieConsentModal.hide(); + } else { + hideConsentBar(); + } + } + + // Initialise our state of selected item and enable/disable + // the "Accept selected" button appropriately + function initialiseSelected() { + selected = []; + $(".consentCheckbox").each(function () { + const val = $(this).val(); + if (existingConsent.indexOf(val) > -1) { + $(this).prop("checked", true); + selected.push(val); + } else { + $(this).prop("checked", false); + } + }); + enableDisableSelectedButton(); + } + + // Maintain our state of selected items and enable/disable + // the "Accept selected" button appropriately + function maintainSelected() { + selected = []; + $(".consentCheckbox:checked").each(function () { + selected.push($(this).val()); + }); + enableDisableSelectedButton(); + } + + // Set the enabled / disabled state of the + // "Accept selected button based on the checkbox + // states + function enableDisableSelectedButton() { + $("#consentAcceptSelected").prop("disabled", selected.length === 0); + } + + function runConsentedCode() { + getExistingConsent(); + $(".consentCode").each(function () { + // The user has explicitly consented to this code, or the + // code doesn't require consent in the staff view + if ( + existingConsent.indexOf($(this).data("consent-id")) > -1 || + !$(this).data("requires-consent") + ) { + const code = atob($(this).data("consent-code")); + const func = Function(code); + func(); + } else { + // This code doesn't have consent to run, we may need to remove + // any cookies it has previously set + const matchPattern = $(this).data("consent-match-pattern"); + const cookieDomain = $(this).data("consent-cookie-domain"); + const cookiePath = $(this).data("consent-cookie-path"); + if (matchPattern.length > 0) { + const regex = new RegExp(matchPattern); + const allCookies = document.cookie.split("; "); + allCookies.forEach(function (cookie) { + const name = cookie.split("=")[0]; + if (regex.test(name)) { + document.cookie = + name + + "=; expires=Thu, 01 Jan 1970 00:00:01 GMT; domain=" + + cookieDomain + + "; path=" + + cookiePath; + } + }); + } + } + }); + } + + function addButtonHandlers() { + // "Accept all" handler + $(".consentAcceptAll").on("click", function (e) { + e.preventDefault(); + let toSave = []; + $(".consentCheckbox").each(function () { + const val = $(this).val(); + toSave.push(val); + }); + localStorage.setItem("cookieConsent", toSave); + hideContainer(); + runConsentedCode(); + }); + + // "Accept essential" handler + $(".consentAcceptEssential").on("click", function (e) { + e.preventDefault(); + localStorage.setItem("cookieConsent", []); + hideContainer(); + runConsentedCode(); + }); + + // "Accept selected" handler + $("#consentAcceptSelected").on("click", function (e) { + e.preventDefault(); + const toSave = selected.length > 0 ? selected : []; + localStorage.setItem("cookieConsent", toSave); + hideContainer(); + runConsentedCode(); + }); + + // "Cancel" handler + $(".consentCloseModal").on("click", function (e) { + e.preventDefault(); + hideContainer(); + showConsentBar(); + }); + + // "More information" handler + $("#consentMoreInfo, #viewCookieConsents").on("click", function (e) { + e.preventDefault(); + hideConsentBar(); + // Ensure we're up to date with the existing consent + getExistingConsent(); + cookieConsentModal.show(); + }); + $(".consentCheckbox").on("click", function () { + maintainSelected(); + }); + } + + // On page load, run any code that has been given consent + runConsentedCode(); +})(); diff --git a/rspack.config.js b/rspack.config.js index 9b10583d51b..b98e1137346 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -3,7 +3,85 @@ const { VueLoaderPlugin } = require("vue-loader"); const path = require("path"); const rspack = require("@rspack/core"); +const sharedAssetsStaffConfig = { + name: "shared-assets-staff", + entry: { + cookieconsent: "./koha-tmpl/shared/js/cookieconsent.js", + // Future shared modules: + // coce: "./koha-tmpl/shared/js/coce.js", + // "ill-availability": "./koha-tmpl/shared/js/ill-availability.js", + // and so on... + }, + output: { + filename: "[name].js", + path: path.resolve(__dirname, "koha-tmpl/intranet-tmpl/prog/js/"), + }, + module: { + rules: [ + { + test: /\.js$/, + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "ecmascript", + }, + }, + }, + exclude: [/node_modules/], + type: "javascript/auto", + }, + ], + }, + externals: { + jquery: "jQuery", + }, + optimization: { + minimize: process.env.NODE_ENV === "production", + }, +}; + +const sharedAssetsOpacConfig = { + name: "shared-assets-opac", + entry: { + cookieconsent: "./koha-tmpl/shared/js/cookieconsent.js", + // Future shared modules: + // coce: "./koha-tmpl/shared/js/coce.js", + // "ill-availability": "./koha-tmpl/shared/js/ill-availability.js", + // and so on... + }, + output: { + filename: "[name].js", + path: path.resolve(__dirname, "koha-tmpl/opac-tmpl/bootstrap/js/"), + }, + module: { + rules: [ + { + test: /\.js$/, + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "ecmascript", + }, + }, + }, + exclude: [/node_modules/], + type: "javascript/auto", + }, + ], + }, + externals: { + jquery: "jQuery", + }, + optimization: { + minimize: process.env.NODE_ENV === "production", + }, +}; + module.exports = [ + sharedAssetsStaffConfig, + sharedAssetsOpacConfig, { resolve: { alias: { -- 2.50.1