diff --git a/.chachalog/MphU2FML.md b/.chachalog/MphU2FML.md
new file mode 100644
index 00000000..dcf2d862
--- /dev/null
+++ b/.chachalog/MphU2FML.md
@@ -0,0 +1,6 @@
+---
+# Allowed version bumps: patch, minor, major
+javascript-modules: minor
+---
+
+Added an optional `parent` parameter to the `Area` component, allowing areas to be stored under any existing JCR node instead of only under the current page node.
diff --git a/jahia-test-module/src/react/server/views/testAreas/TestAreas.tsx b/jahia-test-module/src/react/server/views/testAreas/TestAreas.tsx
index 926b4e8d..d1011fb6 100644
--- a/jahia-test-module/src/react/server/views/testAreas/TestAreas.tsx
+++ b/jahia-test-module/src/react/server/views/testAreas/TestAreas.tsx
@@ -7,7 +7,7 @@ jahiaComponent(
displayName: "test Areas (react)",
componentType: "view",
},
- () => (
+ (_, { currentNode, renderContext }) => (
<>
React JArea test component
@@ -57,6 +57,21 @@ jahiaComponent(
}}
/>
+
+ Area with parent node
+
+
+ Area with home page as parent
+
+
+ Area with site root as parent
+
>
),
);
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
index 08da5727..1d8d458e 100644
--- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/RenderHelper.java
@@ -62,7 +62,7 @@ public class RenderHelper {
private static final Set ABSOLUTEAREA_ALLOWED_ATTRIBUTES = Set.of("name", "parent", "view",
"allowedNodeTypes", "numberOfItems", "nodeType", "editable", "areaType", "limitedAbsoluteAreaEdit",
"parameters");
- private static final Set AREA_ALLOWED_ATTRIBUTES = Set.of("name", "view", "allowedNodeTypes",
+ private static final Set AREA_ALLOWED_ATTRIBUTES = Set.of("name", "parent", "view", "allowedNodeTypes",
"numberOfItems", "nodeType", "editable", "parameters");
private JCRSessionFactory jcrSessionFactory;
@@ -409,15 +409,22 @@ public String renderAbsoluteArea(Map attr, RenderContext renderC
public String renderArea(Map attr, RenderContext renderContext)
throws IllegalAccessException, InvocationTargetException, JspException, IOException {
checkAttributes(attr, AREA_ALLOWED_ATTRIBUTES);
- // This is actually expected, the path of an area is relative by default, so the
- // name is directly mapped to: path
- // the AreaTag will resolve the area content using template inheritance
- // hierarchy.
- // Even if it's not used in the javascript engine, we need to respect this
- // concept for compatibility with existing system relying on this behavior.
- // (jExperience for example is using this behavior, for page perso/opti, by
- // pushing the page variant node as parent template)
- attr.put("path", readMandatoryAttribute(attr, "name"));
+ String name = readMandatoryAttribute(attr, "name");
+ JCRNodeWrapper parent = (JCRNodeWrapper) attr.remove("parent");
+ if (parent != null) {
+ // When a parent node is provided, compute an absolute JCR path so that AreaTag
+ // resolves the area via a direct node lookup (findNodeForAbsoluteAreaPath) rather
+ // than the default template-inheritance resolution.
+ attr.put("path", parent.getPath() + "/" + name);
+ } else {
+ // the AreaTag will resolve the area content using template inheritance
+ // hierarchy.
+ // Even if it's not used in the javascript engine, we need to respect this
+ // concept for compatibility with existing system relying on this behavior.
+ // (jExperience for example is using this behavior, for page perso/opti, by
+ // pushing the page variant node as parent template)
+ attr.put("path", name);
+ }
return internalRenderArea(attr, "area", renderContext);
}
diff --git a/javascript-modules-library/src/components/Area.tsx b/javascript-modules-library/src/components/Area.tsx
index 4ae0db3e..aaf4c061 100644
--- a/javascript-modules-library/src/components/Area.tsx
+++ b/javascript-modules-library/src/components/Area.tsx
@@ -1,3 +1,4 @@
+import type { JCRNodeWrapper } from "org.jahia.services.content";
import { createElement, type JSX } from "react";
import { useServerContext } from "../hooks/useServerContext.js";
@@ -8,6 +9,7 @@ import { useServerContext } from "../hooks/useServerContext.js";
*/
export function Area({
name,
+ parent,
view,
allowedNodeTypes,
numberOfItems,
@@ -18,6 +20,16 @@ export function Area({
/** The name of the area. */
name: string;
+ /**
+ * Optional parent node where the area is stored in the JCR. When provided, the area is stored
+ * as a child of this node (i.e. at `/`), and the node is resolved directly
+ * by its absolute JCR path. The parent node must already exist.
+ *
+ * When omitted, the area is stored as a child of the current page node and resolved using the
+ * standard template-inheritance hierarchy (default behavior).
+ */
+ parent?: JCRNodeWrapper;
+
/** The view to use for the area. */
view?: string;
/** The allowed types for the area. */
@@ -47,6 +59,7 @@ export function Area({
__html: server.render.renderArea(
{
name,
+ parent,
view,
allowedNodeTypes,
numberOfItems,
diff --git a/tests/cypress/e2e/ui/areaParentTest.cy.ts b/tests/cypress/e2e/ui/areaParentTest.cy.ts
new file mode 100644
index 00000000..92743412
--- /dev/null
+++ b/tests/cypress/e2e/ui/areaParentTest.cy.ts
@@ -0,0 +1,107 @@
+import { addNode, getNodeByPath } from "@jahia/cypress";
+import { addSimplePage } from "../../utils/helpers";
+import { GENERIC_SITE_KEY } from '../../support/constants';
+
+/**
+ * This test verifies that the parent parameter on the Area component works correctly,
+ * ensuring that areas are stored as subnodes of the specified parent node rather than
+ * at the page level, and that two components with the same area name but different
+ * parent nodes produce distinct area nodes.
+ */
+describe("Area with parent parameter test", () => {
+ const pageName = "testAreaParent";
+ const pagePath = `/sites/${GENERIC_SITE_KEY}/home/${pageName}`;
+ const componentPath = `${pagePath}/pagecontent`;
+
+ before("Create test page and multiple components", () => {
+ addSimplePage(`/sites/${GENERIC_SITE_KEY}/home`, pageName, pageName, "en", "simple", [
+ {
+ name: "pagecontent",
+ primaryNodeType: "jnt:contentList",
+ },
+ ]).then(() => {
+ ['testArea', 'testArea2'].forEach((componentName) => {
+ addNode({
+ parentPathOrId: componentPath,
+ name: componentName,
+ primaryNodeType: "javascriptExample:testAreas",
+ });
+ });
+ });
+ });
+
+ beforeEach('Login and visit test page', () => {
+ cy.login();
+ cy.visit(`/jahia/jcontent/${GENERIC_SITE_KEY}/en/pages/home/${pageName}`);
+ });
+
+ afterEach('Logout', () => cy.logout());
+
+ it(`${pageName}: Area with parent (current node) should render`, () => {
+ cy.iframe("#page-builder-frame-1").within(() => {
+ cy.get('div[data-testid="areaWithParent"]')
+ .find('div[type="area"]')
+ .should("be.visible");
+ });
+ });
+
+ it(`${pageName}: Area with home page as parent should render`, () => {
+ cy.iframe("#page-builder-frame-1").within(() => {
+ cy.get('div[data-testid="areaWithHomeParent"]')
+ .find('div[type="area"]')
+ .should("be.visible");
+ });
+ });
+
+ it(`${pageName}: Area with site root as parent should render`, () => {
+ cy.iframe("#page-builder-frame-1").within(() => {
+ cy.get('div[data-testid="areaWithSiteParent"]')
+ .find('div[type="area"]')
+ .should("be.visible");
+ });
+ });
+
+ it(`${pageName}: Area without parent should create area as subnode of the page`, () => {
+ const areaName = 'basicArea';
+ getNodeByPath(`${pagePath}/${areaName}`).then((area) => {
+ const msg = `${areaName} should be created as a subnode of the page`;
+ expect(area?.data?.jcr?.nodeByPath?.name, msg).to.equal(areaName);
+ });
+ });
+
+ it(`${pageName}: Area with parent should create distinct areas as subnodes of each component`, () => {
+ const areaName = 'parentArea';
+ getNodeByPath(`${componentPath}/testArea/${areaName}`).then((area) => {
+ const msg = `${areaName} should be created as a subnode of the first test area component`;
+ expect(area?.data?.jcr?.nodeByPath?.name, msg).to.equal(areaName);
+ });
+ getNodeByPath(`${componentPath}/testArea2/${areaName}`).then((area) => {
+ const msg = `${areaName} should be created as a subnode of the second test area component`;
+ expect(area?.data?.jcr?.nodeByPath?.name, msg).to.equal(areaName);
+ });
+ });
+
+ it(`${pageName}: Area with parent should NOT create area at the page level`, () => {
+ const areaName = 'parentArea';
+ getNodeByPath(`${pagePath}/${areaName}`).then((area) => {
+ const msg = `${areaName} should NOT exist at the page level when parent is specified`;
+ expect(area?.data?.jcr?.nodeByPath, msg).to.not.exist;
+ });
+ });
+
+ it(`${pageName}: Area with home page as parent should store area under home`, () => {
+ const areaName = 'areaAtHomePage';
+ getNodeByPath(`/sites/${GENERIC_SITE_KEY}/home/${areaName}`).then((area) => {
+ const msg = `${areaName} should be created as a subnode of the home page`;
+ expect(area?.data?.jcr?.nodeByPath?.name, msg).to.equal(areaName);
+ });
+ });
+
+ it(`${pageName}: Area with site root as parent should store area under site root`, () => {
+ const areaName = 'areaAtSiteRoot';
+ getNodeByPath(`/sites/${GENERIC_SITE_KEY}/${areaName}`).then((area) => {
+ const msg = `${areaName} should be created as a subnode of the site root`;
+ expect(area?.data?.jcr?.nodeByPath?.name, msg).to.equal(areaName);
+ });
+ });
+});