From d894d25092815a021c351410061ec3a99c7250f2 Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 17 Sep 2026 15:03:18 +0300 Subject: [PATCH] ui: the shared shell runtime keeps the names every already-generated app shell binds (#7427) #7400 rewrote the generated shell template together with the shared runtime appShell.js and renamed the contract between them: hiddenPanels.left -> isSmallScreen, isOpen -> showSidebarSheet, the sidebarPanel / overlay refs -> sidebarSlot / sidebarSheet. The runtime is served once by absolute URL to every generated gen//index.html, and that page is a file in the project, rewritten only by an explicit Generate - so the upgrade swapped the runtime under every deployed application's page. Against such a page init() threw on an undefined ref before moving the sidebar out of the split panel, and the panel's :data-hidden threw on the missing property, so the ONE thing that hid the sidebar in embedded mode was gone: every entity opened in the application shell rendered the app's own sidebar next to the form. The runtime now keeps the old names alive alongside the new ones - hiddenPanels and isOpen as accessors kept in step with isSmallScreen / embedded and showSidebarSheet (a plain setter too, because the old sheet writes isOpen = false from its overlay click), and the breakpoint handler resolves the refs by their current names else by the old ones. The comment above them states the rule the rename missed: this file's page contract is public across generated vintages. ShellRuntimeVintageIT is the guard the class of failure lacked. Every other Harmonia IT regenerates the page, so old page + new runtime was never exercised. The fixture is the pre-#7400 template rendered by the real pipeline for the DependsOnHarmoniaIT model (not hand-written): the project is generated with the current template, that page is put in the registry over the generated one - the way a deployed project keeps its page through an upgrade - and the browser asserts the sidebar is hidden embedded, visible standalone, and in the drawer the hamburger opens below the breakpoint. Against origin/master's runtime it fails at the embedded assertion; with this change it passes. Verified: ShellRuntimeVintageIT green (and red against the unfixed runtime), DependsOnHarmoniaIT green (current page + current runtime), formatter:validate with the cache wiped. Co-Authored-By: Claude Fable 5.1 --- .claude/docs/harmonia-ui.md | 2 +- .../shell/js/components/layout/appShell.js | 17 +- .../tests/ui/tests/ShellRuntimeVintageIT.java | 104 ++++ .../ShellRuntimeVintageIT/index.html | 537 ++++++++++++++++++ 4 files changed, 657 insertions(+), 3 deletions(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/ShellRuntimeVintageIT.java create mode 100644 tests/tests-integrations/src/main/resources/ShellRuntimeVintageIT/index.html diff --git a/.claude/docs/harmonia-ui.md b/.claude/docs/harmonia-ui.md index 944a019a225..203101f86bc 100644 --- a/.claude/docs/harmonia-ui.md +++ b/.claude/docs/harmonia-ui.md @@ -14,7 +14,7 @@ The runtime UI stack for generated applications: they render as a self-contained ### Dirigible-specific facts the skill cannot know -- **One shared shell runtime, never copied per project.** It lives once at `/services/web/application-core/shell/` (`components/resources/application-core/.../application-core/shell/`: `app.js`, `services/*`, `stores/*`, `components/layout/appShell.js`, base/page components, `css/app.css`, the Inbox/Documents/Reports/notfound views). The generated per-project shell and the platform shells (`resources-application`, `-admin`, `-personal`, `-partner`, `-monitoring`, `-database`, `-builder`, `-home`, the tenant picker in `security-oauth2`) load it by absolute URL; `template-application-ui-harmonia-java` only **generates** the model-specific files (`index.html`, `config.js`, `dashboardPage.js`, `_settings.html`, `_dashboard.html`, per-entity pages). Do NOT re-add per-project copies of the shared files. +- **One shared shell runtime, never copied per project.** It lives once at `/services/web/application-core/shell/` (`components/resources/application-core/.../application-core/shell/`: `app.js`, `services/*`, `stores/*`, `components/layout/appShell.js`, base/page components, `css/app.css`, the Inbox/Documents/Reports/notfound views). The generated per-project shell and the platform shells (`resources-application`, `-admin`, `-personal`, `-partner`, `-monitoring`, `-database`, `-builder`, `-home`, the tenant picker in `security-oauth2`) load it by absolute URL; `template-application-ui-harmonia-java` only **generates** the model-specific files (`index.html`, `config.js`, `dashboardPage.js`, `_settings.html`, `_dashboard.html`, per-entity pages). Do NOT re-add per-project copies of the shared files. **Because it is shared, its page contract is public across generated vintages (#7427):** a project's `index.html` is rewritten only by an explicit Generate, so a platform upgrade swaps this runtime under pages generated months earlier. The properties a page binds and the `x-ref` names the runtime reaches for must stay alive when renamed - `appShell.js` keeps `hiddenPanels.left` / `isOpen` / `sidebarPanel` / `overlay` from before #7400 as aliases - and `ShellRuntimeVintageIT` is the guard: it puts the PREVIOUS template's rendered page (a fixture, not a regeneration) in the registry over the generated one and drives it in the browser, because every other Harmonia IT regenerates the page and so never sees old page + new runtime. - **Theme = Harmonia's colour-scheme API, three modes.** Harmonia persists the selection under `codbex.harmonia.colorMode` (default `auto`, the OS scheme), applies the `.dark` class itself and keeps every same-origin frame and browser tab in sync itself - nothing in this repo seeds the key or listens to `storage` events, and nothing may toggle `.dark` by hand. The shared `$store.theme` (`shell/js/stores/theme.js`) exposes `mode` (`light|dark|auto`, a getter/setter over `Harmonia.get/setColorScheme`) and `icon`, and re-syncs through `Harmonia.addColorSchemeListener((scheme, mode) => ...)`; every toolbar carries the same control - an icon button that is an `x-h-menu-trigger.dropdown` opening three `x-h-menu-radio-item`s bound to `$store.theme.mode`. Pages that load no shell runtime (home, tenant picker) carry the same getter/setter locally. - **Toasts go through the public `$notifications` magic.** A magic lives on an Alpine component scope, so the root component that renders the `x-h-notification-overlay` hands it to the shared store once at init (`Alpine.store('notifications').attachToaster(this.$notifications)`); `announce()` records the bell entry first and raises the toast through `add({ template: 'toast', position, timeout, data })`. Never reach into Harmonia's private `_h_notifications` store. - **`format.js` (`window.HarmoniaFormat`) stays the date/number engine**, not `x-h-date-format`: it preserves Java `DecimalFormat`/`DateTimeFormatter` pattern semantics and is dependency-free for the BPM task-form iframe. `HarmoniaDateFormatIT` asserts the `x-h-date-picker-popup="HarmoniaFormat.pickerConfig()"` contract. `window.HarmoniaFormat`, `window.HarmoniaCalendar` and `window.HarmoniaBranding` are Dirigible globals that only share the word; Harmonia itself sets only `window.Harmonia`. diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js index b9c61fc21f2..8a9045efbe3 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/layout/appShell.js @@ -37,6 +37,17 @@ document.addEventListener('alpine:init', () => { // A route template is being fetched - drives the toolbar's indefinite progress bar. routeLoading: false, + // THE PAGE CONTRACT IS PUBLIC ACROSS GENERATED VINTAGES. This file is served once, by absolute URL, + // to every generated shell (#6094) - and a project's index.html is rewritten only by an explicit + // Generate, so a platform upgrade swaps this runtime under pages generated months earlier. The + // names a page binds (properties in its Alpine expressions, the x-ref names reached for below) + // must therefore stay alive when they are renamed. A page generated before #7400 binds its split + // panel to `hiddenPanels.left`, its sheet to `isOpen` and carries `sidebarPanel` / `overlay` refs; + // dropping them left every deployed app's embedded iframe showing its own sidebar (#7427). + get hiddenPanels() { return { left: this.isSmallScreen || this.embedded }; }, + get isOpen() { return this.showSidebarSheet; }, + set isOpen(open) { this.showSidebarSheet = open; }, + currentPath: '', // Embedded mode: when this SPA is hosted inside the shared application shell (the platform @@ -72,8 +83,10 @@ document.addEventListener('alpine:init', () => { // The sidebar is ONE element, moved between its wide-screen slot and the drawer. Embedded // mode has no navigation of its own (the host shell owns it), so it parks in the drawer - // which is closed, and whose trigger is hidden - at every width. - const home = isNarrow || this.embedded ? this.$refs.sidebarSheet : this.$refs.sidebarSlot; - home.appendChild(this.$refs.sidebar); + // The refs by their current names, else by the names a page generated before #7400 carries. + const sheet = this.$refs.sidebarSheet || this.$refs.overlay; + const slot = this.$refs.sidebarSlot || this.$refs.sidebarPanel; + (isNarrow || this.embedded ? sheet : slot).appendChild(this.$refs.sidebar); }, 1024); }, diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/ShellRuntimeVintageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/ShellRuntimeVintageIT.java new file mode 100644 index 00000000000..06350aab9c3 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/ShellRuntimeVintageIT.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.ui.tests; + +import static com.codeborne.selenide.Condition.visible; + +import com.codeborne.selenide.Selenide; +import com.codeborne.selenide.WebDriverRunner; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.tests.base.ProjectDeployer; +import org.eclipse.dirigible.tests.base.UserInterfaceIntegrationTest; +import org.eclipse.dirigible.tests.framework.browser.HtmlElementType; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Dimension; +import org.springframework.beans.factory.annotation.Autowired; + +import java.io.IOException; +import java.io.InputStream; + +/** + * A generated shell page of an EARLIER vintage keeps working against the CURRENT shared runtime. + *

+ * The shell runtime ({@code application-core/shell/js/components/layout/appShell.js}) is served + * once, by absolute URL, to every generated {@code gen//index.html} - and that page is a + * file in the project, rewritten only by an explicit Generate. So a platform upgrade swaps the + * runtime under every page generated before it, and the names the page binds (its Alpine + * expressions, its {@code x-ref}s) are a contract across vintages. #7400 renamed them together with + * the template and every deployed application's embedded iframe showed its own sidebar (#7427); no + * test caught it because every other Harmonia IT REGENERATES the page and so only ever sees the + * current page with the current runtime. + *

+ * The fixture is the pre-#7400 template rendered by the real pipeline for the + * {@code DependsOnHarmoniaIT} model - not hand-written, so it binds exactly what a deployed page of + * that vintage binds. The project is generated with the current template, then that page is put in + * the registry over the generated one, the way a deployed project keeps its page through an + * upgrade. One method on purpose: the base class discards the application context after each. + */ +class ShellRuntimeVintageIT extends UserInterfaceIntegrationTest { + + private static final String PROJECT = "DependsOnHarmoniaIT"; + private static final String PAGE = "/services/web/" + PROJECT + "/gen/edm/index.html"; + private static final String OLD_PAGE_FIXTURE = "ShellRuntimeVintageIT/index.html"; + private static final By SIDEBAR = By.cssSelector("[data-slot=sidebar]"); + + @Autowired + private ProjectDeployer deployer; + + @Autowired + private IRepository repository; + + @Test + void anOlderPageRendersAgainstTheCurrentRuntime() throws IOException { + deployer.deployGeneratedFromModel(PROJECT, "edm.model"); + repository.createResource(IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT + "/gen/edm/index.html", oldPage()); + + // Log in on the page itself, then navigate with the plain opener: the login redirect keeps the + // query but drops the fragment, and the route is in the fragment. + ide.openPath(PAGE); + + // Hosted in the application shell: the page draws no navigation of its own, the host owns it. + browser.openPath(PAGE + "?embedded=true#/Orders"); + browser.assertElementExistsByTypeAndText(HtmlElementType.BUTTON, "New"); + Selenide.$(SIDEBAR) + .shouldNotBe(visible); + + // Standalone on a wide screen: the sidebar is the navigation. + browser.openPath(PAGE + "#/Orders"); + browser.assertElementExistsByTypeAndText(HtmlElementType.BUTTON, "New"); + Selenide.$(SIDEBAR) + .shouldBe(visible); + + // Standalone below the breakpoint: the sidebar lives in the drawer the hamburger opens. + WebDriverRunner.getWebDriver() + .manage() + .window() + .setSize(new Dimension(800, 900)); + Selenide.$(SIDEBAR) + .shouldNotBe(visible); + Selenide.$(By.cssSelector("button[aria-label=Navigation]")) + .shouldBe(visible) + .click(); + Selenide.$(SIDEBAR) + .shouldBe(visible); + } + + private static byte[] oldPage() throws IOException { + try (InputStream page = ShellRuntimeVintageIT.class.getClassLoader() + .getResourceAsStream(OLD_PAGE_FIXTURE)) { + if (page == null) { + throw new IllegalStateException("Missing fixture [" + OLD_PAGE_FIXTURE + "]"); + } + return page.readAllBytes(); + } + } +} diff --git a/tests/tests-integrations/src/main/resources/ShellRuntimeVintageIT/index.html b/tests/tests-integrations/src/main/resources/ShellRuntimeVintageIT/index.html new file mode 100644 index 00000000000..00fe23d07c6 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/ShellRuntimeVintageIT/index.html @@ -0,0 +1,537 @@ + + + + + + + + + + + DependsOnHarmoniaIT + + + + + + + + + + + + +

+
+
+ + +
+ + +
+
+
+
+ + + DependsOnHarmoniaIT +
+
+
+ +
+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+ +
+
+
+
    +
  • + +
  • +
+
+
+ +
+
+
+
    + +
+
+
+
+ +
+ +
+
+
+ + +
+
+ +
+ + +
+ + + +
    +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ + + +
+
+ +
+ +
+
+
    + +
+
+ + + +
    + +
  • +
    +
    +
    +
    +
    +
    +
  • +
    +
  • + +
  • +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+

+ +
+
+ +
+
+
+ + +
+
+
+

+ +
+
+ +
+
+
+ + +
+
+
+

+ +
+
+

+
+
+ + +
+
+
+ + +
+
+
+
+

+ +
+
+
+
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

+ +
+
+ +
+
+
+ + + + +