diff --git a/HARMONIA_RUNTIME_PLAN.md b/HARMONIA_RUNTIME_PLAN.md index b5466dd877c..f661864d60b 100644 --- a/HARMONIA_RUNTIME_PLAN.md +++ b/HARMONIA_RUNTIME_PLAN.md @@ -371,7 +371,7 @@ Process Inbox (`/inbox`) and Documents (`/documents`). ### Embedding (Phase 1 — no CDN) -Alpine `3.17.4`, Harmonia `3.6.1`, Lucide `1.48.0` and Pinecone Router `7.6.0` are **webjars** (served +Alpine `3.17.4`, Harmonia `3.6.2`, Lucide `1.48.0` and Pinecone Router `7.6.0` are **webjars** (served version-less via webjars-locator at `/webjars/...`; the versions are the `*.version` properties in the root pom). The chart.js webjar was dropped with the AngularJS dashboard shell (reports use Harmonia's native `x-h-chart-*`), and the once-vendored Pinecone Router copy under `application-core/.../vendor/` diff --git a/components/template/template-application-ui-harmonia-java/README.md b/components/template/template-application-ui-harmonia-java/README.md index d7a0519d442..f86acbbb9a0 100644 --- a/components/template/template-application-ui-harmonia-java/README.md +++ b/components/template/template-application-ui-harmonia-java/README.md @@ -188,7 +188,7 @@ Three things about it are deliberate and easy to get wrong: | process tasks | gated on `hasProcess` / `ProcessId` | ✅ processTasks Alpine store (inbox fetch + claim + bucket by processInstanceId) + inline popover in list/manage/master rows + app-wide task-form dialog | Asset embedding (Phase 1 — DONE, verified end-to-end against a live app): -- Alpine `3.17.4` + Harmonia `3.6.1` + Lucide `1.48.0` are **webjars** bundled via +- Alpine `3.17.4` + Harmonia `3.6.2` + Lucide `1.48.0` are **webjars** bundled via `components/resources/application-core` (`alpinejs.version` / `harmonia.version` / `lucide.version` in the root pom), served version-less through webjars-locator at `/webjars/...` (public). The Harmonia rules live in the upstream skill - see `.claude/docs/harmonia-ui.md`. diff --git a/components/template/template-form-builder-harmonia/src/main/resources/META-INF/dirigible/template-form-builder-harmonia/ui/index.html.template b/components/template/template-form-builder-harmonia/src/main/resources/META-INF/dirigible/template-form-builder-harmonia/ui/index.html.template index f4c84f03208..60055a434a5 100644 --- a/components/template/template-form-builder-harmonia/src/main/resources/META-INF/dirigible/template-form-builder-harmonia/ui/index.html.template +++ b/components/template/template-form-builder-harmonia/src/main/resources/META-INF/dirigible/template-form-builder-harmonia/ui/index.html.template @@ -43,7 +43,7 @@ #elseif($el.controlId == "paragraph")

$!el.label

#elseif($el.controlId == "line") -
+
#elseif($el.controlId == "spacer")
#elseif($el.controlId == "link") diff --git a/pom.xml b/pom.xml index 34a63c92720..eca807d070f 100644 --- a/pom.xml +++ b/pom.xml @@ -253,7 +253,7 @@ 0.47.0 26.3.6 3.17.4 - 3.6.1 + 3.6.2 5.3.0 1.48.0 7.6.0 diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/NumberInputHarmoniaIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/NumberInputHarmoniaIT.java new file mode 100644 index 00000000000..5c46f8f6422 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/NumberInputHarmoniaIT.java @@ -0,0 +1,186 @@ +/* + * 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 io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.tests.base.UserInterfaceIntegrationTest; +import org.eclipse.dirigible.tests.framework.browser.HtmlElementType; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Keys; +import org.springframework.beans.factory.annotation.Autowired; + +import com.codeborne.selenide.Selenide; + +/** + * #8115: a generated number field keeps a zero typed after the decimal point. + * + *

+ * The generated form binds a number input with {@code x-model.number} inside + * {@code x-h-input-number}, as the Harmonia reference prescribes. Alpine writes the model back into + * the input whenever the model changes, and up to Harmonia 3.6.1 that write landed on the focused + * field too: a field reading {@code 1.0} with a model of {@code 1} was rewritten to {@code 1}, the + * next digit made {@code 15}, and the record was saved with that number (codbex/harmonia#149, fixed + * in 3.6.2). The generated source is byte-correct either way - the defect lives in the running + * directive - so this drives a real form and reads the saved record back. + * + *

+ * The keystrokes matter. Typed straight, {@code 1.05} never changes the model on Linux Chrome (it + * is {@code 1} from the first digit on), so nothing is written back and 3.6.1 passes as well. + * Typing {@code 10} and inserting the point before the {@code 0} moves the model from {@code 10} to + * {@code 1} while the field reads {@code 1.0}: the write-back fires, and only 3.6.2 keeps the zero. + */ +class NumberInputHarmoniaIT extends UserInterfaceIntegrationTest { + + private static final long POLL_TIMEOUT_MILLIS = 60_000; + + private static final long POLL_INTERVAL_MILLIS = 250; + + private static final String PROJECT = "decimalit"; + private static final String WORKSPACE = "workspace"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + PROJECT; + private static final String GENERATE_URL = + "/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + PROJECT + "&path=app.intent"; + private static final String APP = "/services/web/" + PROJECT + "/gen/decimalit/index.html#"; + private static final String API = "/services/java/" + PROJECT + "/gen/decimalit/api/product/ProductController"; + + private static final String TYPED = "1.05"; + + /** The column's own scale is explicit, so only the input can lose the typed zero. */ + private static final String INTENT_YAML = """ + name: decimalit + entities: + - name: Product + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, length: 60 } + - { name: price, type: decimal, precision: 18, scale: 2 } + """; + + /** What the Price input shows and what the form holds for it, as {@code |}. */ + private static final String PRICE = """ + var input = document.getElementById('f_Price'); + if (!input) return 'not-rendered'; + var form = Alpine.$data(input).form; + return input.value + '|' + (form ? JSON.stringify(form.Price) : 'no-form'); + """; + + @Autowired + private IRepository repository; + + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Test + void a_typed_decimal_keeps_its_zero_and_is_saved_as_typed() { + generateAndPublish(); + + // Authenticate the browser session first - a bare openPath lands on the sign-in form. + ide.openHomePage(); + browser.openPath(APP + "/Product/create"); + assertNotEquals("not-rendered", poll(state -> !"not-rendered".equals(state)), "the Product form must render"); + + // 10, then the point inserted before the 0: the model moves from 10 to 1 under a field that + // reads 1.0 - the write-back that used to rewrite the field. + Selenide.$("#f_Price") + .sendKeys("10", Keys.ARROW_LEFT, "."); + assertEquals("1.0|1", poll("1.0|1"::equals), "the field must keep the typed 1.0 while the model is 1"); + Selenide.$("#f_Price") + .sendKeys(Keys.END, "5"); + String expected = TYPED + "|" + TYPED; + assertEquals(expected, poll(expected::equals), "the field must show the typed decimal and the model must hold it"); + + browser.clickOnElementContainingText(HtmlElementType.BUTTON, "Create"); + + restAssuredExecutor.execute(() -> { + List> rows = given().when() + .get(API) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("$"); + assertEquals(1, rows.size(), "the created Product must be listed, found: " + rows); + Object saved = rows.get(0) + .get("Price"); + assertEquals(0, new BigDecimal(TYPED).compareTo(new BigDecimal(String.valueOf(saved))), "saved Price: " + saved); + }, 30); + } + + private String poll(Predicate settled) { + long deadline = System.currentTimeMillis() + POLL_TIMEOUT_MILLIS; + String value = ""; + do { + value = Selenide.executeJavaScript(PRICE); + if (value != null && settled.test(value)) { + return value; + } + Selenide.sleep(POLL_INTERVAL_MILLIS); + } while (System.currentTimeMillis() < deadline); + return value == null ? "" : value; + } + + private void generateAndPublish() { + String path = PROJECT_PATH + "/app.intent"; + if (repository.hasResource(path)) { + repository.getResource(path) + .setContent(INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + } + AtomicReference>> plan = new AtomicReference<>(); + restAssuredExecutor.execute(() -> plan.set(given().when() + .post(GENERATE_URL) + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("codeGenerations"))); + for (Map codeGeneration : plan.get()) { + assertEquals(Boolean.TRUE, codeGeneration.get("generated"), + "generating code from " + codeGeneration.get("path") + " failed: " + codeGeneration.get("error")); + } + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT + "/") + .then() + .statusCode(200)); + synchronizationProcessor.forceProcessSynchronizers(); + } + + @AfterEach + void cleanup() { + restAssuredExecutor.execute(() -> given().when() + .delete("/services/ide/publisher/" + WORKSPACE + "/" + PROJECT) + .then() + .statusCode(greaterThanOrEqualTo(200))); + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); + } + synchronizationProcessor.forceProcessSynchronizers(); + } +}