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/
+ * 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+