diff --git a/package.json b/package.json index b776eaef84..8d1ac49f7d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@11ty/eleventy-upgrade-help": "3.0.1", "@patternslib/pat-code-editor": "4.0.1", "@patternslib/patternslib": "9.10.3", + "@plone/plonetheme-barceloneta-base": "^3.3.0", "@plone/registry": "^2.5.4", "backbone": "1.6.1", "backbone.paginator": "2.0.8", diff --git a/src/pat/base-url/README.md b/src/pat/base-url/README.md new file mode 100644 index 0000000000..b4aaddc8fe --- /dev/null +++ b/src/pat/base-url/README.md @@ -0,0 +1,4 @@ +# pat-base-url + +Update the `data-base-url` attribute on the body tag when a `navigate` event is +thrown and thus the browser's URL has changed. diff --git a/src/pat/base-url/base-url.js b/src/pat/base-url/base-url.js new file mode 100644 index 0000000000..6079a9f916 --- /dev/null +++ b/src/pat/base-url/base-url.js @@ -0,0 +1,49 @@ +// Set the base URL based on the current location, listening on navigation changes. +import { BasePattern } from "@patternslib/patternslib/src/core/basepattern"; +import registry from "@patternslib/patternslib/src/core/registry"; +import events from "@patternslib/patternslib/src/core/events"; + +class Pattern extends BasePattern { + static name = "pat-base-url"; + static trigger = "body"; + + init() { + events.add_event_listener( + window.navigation, + "navigate", + "thet-base-url--set", + this.set_base_url.bind(this) + ); + } + + set_base_url() { + let url = window.location.href; + + // Split the following words from the URL as we want to get the + // contents absolute URL. + const split_words = [ + // NOTE: order matters. + "/@@", // also catches @@folder_contents and @@edit + "/++", // traversal urls. + "/folder_contents", + "/edit", + "/view", + "#", + "?", + ]; + + // Split all split words out of url + url = split_words.reduce((url_, split_) => url_.split(split_)[0], url); + // Remove the trailing slash + if (url[url.length -1] === "/") { + url = url.substring(0, url.length - 1) + } + + // Set the contents absolute URL on `data-base-url`. + document.body.dataset.baseUrl = url; + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/base-url/base-url.test.js b/src/pat/base-url/base-url.test.js new file mode 100644 index 0000000000..51da1be7dc --- /dev/null +++ b/src/pat/base-url/base-url.test.js @@ -0,0 +1,49 @@ +import "@patternslib/patternslib/src/core/polyfills"; +import utils from "@patternslib/patternslib/src/core/utils"; +import Pattern from "./base-url"; + +describe("pat-base-url tests", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("is initialized correctly", async () => { + document.body.dataset.baseUrl = "http://localhost/not/okay" + + new Pattern(document.body); + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.dataset.baseUrl).toBe("http://localhost/not/okay"); + history.pushState(null, "", "/okay/okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay/okay"); + }); + + it("Cleans the URL from views and traversal URLs.", async () => { + new Pattern(document.body); + await utils.timeout(1); // wait a tick for async to settle. + + history.pushState(null, "", "/okay/@@okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/++okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/folder_contents"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/edit"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/view"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/#okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/?okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + + history.pushState(null, "", "/okay/?okay#okay"); + expect(document.body.dataset.baseUrl).toBe("http://localhost/okay"); + }); +}); diff --git a/src/pat/navigationmarker/navigationmarker.js b/src/pat/navigationmarker/navigationmarker.js index effd3be060..8ef3c15c67 100644 --- a/src/pat/navigationmarker/navigationmarker.js +++ b/src/pat/navigationmarker/navigationmarker.js @@ -1,52 +1,114 @@ -import $ from "jquery"; -import Base from "@patternslib/patternslib/src/core/base"; - -export default Base.extend({ - name: "navigationmarker", - trigger: ".pat-navigationmarker", - parser: "mockup", - init: function () { - const portal_url = document.body.dataset.portalUrl; - var href = - document.querySelector('head link[rel="canonical"]').href || +import { BasePattern } from "@patternslib/patternslib/src/core/basepattern"; +import events from "@patternslib/patternslib/src/core/events"; +import Parser from "@patternslib/patternslib/src/core/parser"; +import registry from "@patternslib/patternslib/src/core/registry"; + +export const parser = new Parser("navigationmarker"); +parser.addArgument("portal-url", undefined); +parser.addArgument("parent-selector", undefined); + +class Pattern extends BasePattern { + static name = "navigationmarker"; + static trigger = ".pat-navigationmarker"; + static parser = parser; + + async init() { + this.portal_url = this.options.portalUrl || document.body.dataset.portalUrl; + + events.add_event_listener( + window.navigation, + "navigate", + "pat-navigationmarker--history-changed", + () => { + this.scan_navigation(); + } + ); + + this.scan_navigation(); + } + + scan_navigation() { + const href = + //document.querySelector('head link[rel="canonical"]')?.href || window.location.href; - $("a", this.$el).each(function () { - var navlink = this.href.replace("/view", ""); - if (href.indexOf(navlink) !== -1) { - var parent = $(this).parent(); + const anchors = this.el.querySelectorAll("a"); - // check the input-openers within the path - var check = parent.find("> input"); - if (check.length) { - check[0].checked = true; - } + for (const anchor of anchors) { - // set "inPath" to all nav items which are within the current path - // check if parts of navlink are in canonical url parts - var hrefParts = href.split("/"); - var navParts = navlink.split("/"); - var inPath = false; - for (var i = 0, size = navParts.length; i < size; i++) { - // The last path-part must match. - inPath = false; - if (navParts[i] === hrefParts[i]) { - inPath = true; - } - } - if (navlink === portal_url && href !== portal_url) { - // Avoid marking "Home" with "inPath", when not actually there - inPath = false; - } - if (inPath) { - parent.addClass("inPath"); - } + //const parent = anchor.parentElement; + const parent = this.options.parentSelector ? anchor.closest(this.options.parentSelector) : anchor.parentElement; + const navlink = anchor.href.replace("/view", ""); + + // We can exit early, if the navlink is not part of the current URL. + if (href.indexOf(navlink) === -1) { + this.clear(parent); + this.clear(anchor); + continue; + } - // set "current" to the current selected nav item, if it is in the navigation structure. - if (href === navlink) { - parent.addClass("current"); + //// BBB + //// check the input-openers within the path + //const check = parent.querySelector(":scope > input"); + //if (check) { + // check.checked = true; + //} + + // set "inPath" to all nav items which are within the current path + // check if parts of navlink are in canonical url parts + // + const href_parts = href.split("/"); + const nav_parts = navlink.split("/"); + let inPath = false; + + // The last part of the URL must match. + const nav_compare = nav_parts[nav_parts.length - 1]; + const href_compare = href_parts[nav_parts.length - 1]; + if (nav_compare === href_compare) { + inPath = true; + } + + // Avoid marking "Home" with "inPath", when not actually there + if (navlink === this.portal_url && href !== this.portal_url) { + inPath = false; + } + + // Set the class + if (inPath) { + // inPath is set along with current | TODO: OR NOT, verify. + parent.classList.add("inPath"); + if (parent.tagName === "DETAILS") { + parent.open = true; } + } - }); - }, -}); + + // set "current" to the current selected nav item, if it is in the navigation structure. + if (href === navlink) { + parent.classList.add("current"); + anchor.classList.add("current"); + } + } + + this.el.dispatchEvent(events.generic_event(`${this.name}.scanned`)); + } + + clear(element) { + // Clear all classes + if (element.classList.contains("inPath")) { + element.classList.remove("inPath"); + } + if (element.classList.contains("current")) { + element.classList.remove("current"); + } + if (element.tagName === "DETAILS") { + element.open = false; + } + } +} + +// Register Pattern class in the global pattern registry +registry.register(Pattern); + +// Make it available +export default Pattern; diff --git a/src/pat/structure/js/views/app.js b/src/pat/structure/js/views/app.js index 2844f1e783..78d70886bd 100644 --- a/src/pat/structure/js/views/app.js +++ b/src/pat/structure/js/views/app.js @@ -60,7 +60,11 @@ export default BaseView.extend({ // ignore this, fake event trigger to element that is not visible return; } - if ($el.is("a") || $el.parent().is("a") || $el.hasClass("popover-structure-query-active")) { + if ( + $el.is("a") || + $el.parent().is("a") || + $el.hasClass("popover-structure-query-active") + ) { // elements that should not close // NOTE: "popover-structure-query-active" is set on body when // select2 elements are clicked inside the structure filter @@ -183,8 +187,6 @@ export default BaseView.extend({ // of some kind - use the base object instead for that by not // specifying a path. path = ""; - // TODO figure out whether the following event after this is - // needed at all. } $("body").trigger("structure-url-changed", [path]); diff --git a/src/pat/toolbar/toolbar.js b/src/pat/toolbar/toolbar.js index 7476889e03..2f4ebca4bd 100644 --- a/src/pat/toolbar/toolbar.js +++ b/src/pat/toolbar/toolbar.js @@ -1,48 +1,102 @@ import $ from "jquery"; -import Base from "@patternslib/patternslib/src/core/base"; +import { BasePattern } from "@patternslib/patternslib/src/core/basepattern"; +import events from "@patternslib/patternslib/src/core/events"; +import logging from "@patternslib/patternslib/src/core/logging"; +import Parser from "@patternslib/patternslib/src/core/parser"; import registry from "@patternslib/patternslib/src/core/registry"; -import utils from "../../core/utils"; +import utils from "@patternslib/patternslib/src/core/utils"; import Cookies from "js-cookie"; -export default Base.extend({ - name: "toolbar", - trigger: ".pat-toolbar", - parser: "mockup", - defaults: {}, - - init: function () { - $("body").on("structure-url-changed", (e, path) => { - $.ajax({ - url: $("body").attr("data-portal-url") + path + "/@@render-toolbar", - }).done((data) => { - const wrapper = $(utils.parseBodyTag(data)); - const $main_toolbar = wrapper.find("#edit-zone .plone-toolbar-main"); - const $personal_tools = wrapper.find( - "#edit-zone #collapse-personaltools" - ); - // setup modals - registry.scan($main_toolbar); - $(".plone-toolbar-main", this.$el).replaceWith($main_toolbar); - $("#collapse-personaltools", this.$el).replaceWith($personal_tools); - }); - }); +const log = logging.getLogger("pat-toolbar"); + +export const parser = new Parser("toolbar"); +parser.addArgument("render-view", "@@render-toolbar"); + +class Pattern extends BasePattern { + static name = "toolbar"; + static trigger = ".pat-toolbar"; + static parser = parser; + + parser_group_options = false; + + previous_toolbar_url = null; + + async init() { + if (window.__patternslib_import_styles) { + //import("./toolbar.scss"); + } + + // Reload the toolbar on history change. + events.add_event_listener( + window.navigation, + "navigate", + "pat-toolbar--history-changed", + async () => { + // Wait a tick to let other Patterns set the `data-base-url`. + await utils.timeout(1); + await this.reload_toolbar(); + } + ); + + const $el = $(this.el); // unpin toolbar and save state - this.$el.on("click", ".toolbar-collapse", () => { - $("body").removeClass("plone-toolbar-left-expanded"); + $el.on("click", ".toolbar-collapse", () => { + document.body.classList.remove("plone-toolbar-left-expanded"); Cookies.set("plone-toolbar", JSON.stringify({ expanded: false }), { path: "/", }); }); // pin toolbar and save state - this.$el.on("click", ".toolbar-expand", () => { - $("body").addClass("plone-toolbar-left-expanded"); + $el.on("click", ".toolbar-expand", () => { + document.body.classList.add("plone-toolbar-left-expanded"); Cookies.set("plone-toolbar", JSON.stringify({ expanded: true }), { path: "/", }); }); this.el.classList.add("initialized"); - }, -}); + } + + async reload_toolbar() { + const render_view = this.options["render-view"]; + let url = document.body.dataset.baseUrl; + // Ensure a trailing slash in the URL. + url = url[url.length - 1] === "/" ? url : `${url}/` + url = `${url}${render_view}`; + + if (this.previous_toolbar_url === url) { + // no need to reload same url + log.debug("No URL change, no reload."); + return; + } + + // fetch toolbar + log.debug("Reload toolbar on: ", url); + const response = await fetch(url); + const data = await response.text(); + + this.previous_toolbar_url = url; + + // Find toolbar nodes + const div = document.createElement("div"); + div.innerHTML = data; + const main_toolbar = div.querySelector("#edit-zone .plone-toolbar-main"); + const personal_tools = div.querySelector("#edit-zone #collapse-personaltools"); + + // setup modals + registry.scan(main_toolbar); + registry.scan(personal_tools); + document.querySelector(".plone-toolbar-main")?.replaceWith(main_toolbar); + document.querySelector("#collapse-personaltools")?.replaceWith(personal_tools); + log.debug("Re-scanned."); + + // Notify others that the toolbar has been reloaded. + this.el.dispatchEvent(events.generic_event("pat-toolbar--reloaded")); + log.debug("Event pat-toolbar--reloaded dispatched."); + } +} +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/toolbar/toolbar.scss b/src/pat/toolbar/toolbar.scss new file mode 100644 index 0000000000..3bf5e85cc1 --- /dev/null +++ b/src/pat/toolbar/toolbar.scss @@ -0,0 +1,2 @@ +@import "bootstrap/scss/bootstrap"; +@import "@plone/plonetheme-barceloneta-base/scss/_toolbar.scss"; diff --git a/src/patterns.js b/src/patterns.js index 96800ba2f3..b080dc9c6a 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -19,6 +19,7 @@ import "@patternslib/patternslib/src/pat/depends/depends"; // Import all used patterns for the bundle to be generated import "./pat/autotoc/autotoc"; import "./pat/backdrop/backdrop"; +import "./pat/base-url/base-url"; import "./pat/contentloader/contentloader"; import "./pat/contentbrowser/contentbrowser"; import "./pat/cookietrigger/cookietrigger"; diff --git a/webpack.config.js b/webpack.config.js index 31a9ff4e87..50f489180f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -147,14 +147,13 @@ module.exports = () => { // so for the devServer the public path set to "/". config.devServer.allowedHosts = ['localhost', 'plone.lan']; config.devServer.port = "8000"; - config.devServer.static.directory = path.resolve(__dirname, "./_site/"); + config.devServer.static.directory = path.resolve(__dirname, "_site/"); + config.devServer.watchFiles = ["src/"]; // TODO: path.resolve? relative to static directory? + config.devServer.liveReload = false; } if (process.env.DEPLOYMENT === "docs") { - config.output.path = path.resolve( - __dirname, - "./_site/dist/mockup/" - ); + config.output.path = path.resolve(__dirname, "./_site/dist/mockup/"); } if (process.env.DEPLOYMENT === "plone") { config.output.path = path.resolve( diff --git a/yarn.lock b/yarn.lock index 4d0303796c..b53ec1ed6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2104,6 +2104,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@plone/plonetheme-barceloneta-base@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@plone/plonetheme-barceloneta-base/-/plonetheme-barceloneta-base-3.3.0.tgz#f1bc079ec932cdee1484e52f18b9880b41a92d72" + integrity sha512-cUVNWSt0M+3l0AmqwAK753X5JgU6lQqMDuo2IKECDJn8NKwmT9m1Hci9Y+MjGN+xZ5mVdbPFtWRxs891LkF+Pw== + dependencies: + bootstrap "5.3.7" + "@plone/registry@^2.5.4": version "2.5.4" resolved "https://registry.yarnpkg.com/@plone/registry/-/registry-2.5.4.tgz#6767936d8fca94bd7bf477fa0bc434ef3049081f" @@ -3240,6 +3247,11 @@ bootstrap-icons@1.13.1: resolved "https://registry.yarnpkg.com/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz#0aad3f5b55b67402990e729ce3883416f9cef6c5" integrity sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw== +bootstrap@5.3.7: + version "5.3.7" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.7.tgz#8640065036124d961d885d80b5945745e1154d90" + integrity sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw== + bootstrap@5.3.8: version "5.3.8" resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.8.tgz#6401a10057a22752d21f4e19055508980656aeed"