Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/docs/harmonia-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* The shell runtime ({@code application-core/shell/js/components/layout/appShell.js}) is served
* once, by absolute URL, to every generated {@code gen/<model>/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.
* <p>
* 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();
}
}
}
Loading
Loading