diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java index 0791207c898..2b4d0c656d6 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java @@ -17,6 +17,7 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; import org.eclipse.dirigible.components.base.helpers.JsonHelper; import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; @@ -28,6 +29,7 @@ import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.ProcessIntent; import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.components.intent.model.SeedIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -101,6 +103,13 @@ public class FormIntentGenerator implements IntentTargetGenerator { private static final Logger LOGGER = LoggerFactory.getLogger(FormIntentGenerator.class); + /** + * Terminal / negative status names excluded from the step indicator - a cancel/reject/void is an + * off-path outcome (it stays the status pill), not a forward step. Same heuristic the badge + * colouring uses so the two stay consistent. + */ + private static final Pattern TERMINAL_STATUS = Pattern.compile("(cancel|reject|declin|void|fail|overdue|insufficient|exhaust)"); + @Override public String name() { return "form"; @@ -137,7 +146,7 @@ public void generate(IntentGenerationContext context) { continue; } EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity()); - Map document = buildForm(form, boundEntity, entitiesByName, taskFormNames.contains(form.getName())); + Map document = buildForm(form, boundEntity, entitiesByName, taskFormNames.contains(form.getName()), model); context.writeModelFile(fileName, JsonHelper.toJson(document)); } } @@ -169,10 +178,29 @@ private static Map indexEntities(IntentModel model) { return index; } + /** + * Test seam: build the form documents ({@code form name -> document}) for a parsed model without + * writing any files - so emission (e.g. the status-flow step-indicator metadata) can be asserted. + */ + static Map> buildFormsForTest(IntentModel model) { + Map entitiesByName = indexEntities(model); + Set taskFormNames = taskFormNames(model); + Map> documents = new LinkedHashMap<>(); + for (FormIntent form : model.getForms()) { + if (form.getName() == null || form.getName() + .isBlank()) { + continue; + } + EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity()); + documents.put(form.getName(), buildForm(form, boundEntity, entitiesByName, taskFormNames.contains(form.getName()), model)); + } + return documents; + } + private static Map buildForm(FormIntent form, EntityIntent entity, Map entitiesByName, - boolean isTaskForm) { + boolean isTaskForm, IntentModel model) { Map document = new LinkedHashMap<>(); - document.put("metadata", buildMetadata(form, isTaskForm)); + document.put("metadata", buildMetadata(form, isTaskForm, entity, model)); document.put("feeds", new ArrayList<>()); document.put("scripts", new ArrayList<>()); document.put("code", buildCode(form, isTaskForm)); @@ -186,7 +214,7 @@ private static Map buildForm(FormIntent form, EntityIntent entit * bound entity's logical name and the editable-field set. Only logical model names are emitted - * never a template-engine output path, which the intent layer must stay agnostic about. */ - private static Map buildMetadata(FormIntent form, boolean isTaskForm) { + private static Map buildMetadata(FormIntent form, boolean isTaskForm, EntityIntent entity, IntentModel model) { Map metadata = new LinkedHashMap<>(); if (isTaskForm) { metadata.put("taskForm", true); @@ -194,10 +222,73 @@ private static Map buildMetadata(FormIntent form, boolean isTask metadata.put("entity", form.getForEntity()); } metadata.put("editable", new ArrayList<>(form.getEditable())); + putStatusSteps(metadata, entity, model); } return metadata; } + /** + * The document status flow for the read-only step indicator. When the bound entity has a + * {@code function: EntityStatus} relation to a LOCAL status entity, emit that entity's non-terminal + * status names (its base seed rows, in seed order, dropping cancel/reject/void-style terminal + * statuses via {@link #TERMINAL_STATUS}) as {@code steps}, plus {@code statusVar} - the model + * variable holding the current status name (the relation name). The runtime renders these as a + * horizontal step indicator with the current status active. Skipped when the status entity is + * cross-model (its seeds are not on this model) or the flow has fewer than two steps. + */ + private static void putStatusSteps(Map metadata, EntityIntent entity, IntentModel model) { + if (entity == null || entity.getRelations() == null || model == null) { + return; + } + RelationIntent statusRel = null; + for (RelationIntent relation : entity.getRelations()) { + if (relation.isEntityStatus()) { + statusRel = relation; + break; + } + } + if (statusRel == null || statusRel.getTo() == null) { + return; + } + if (statusRel.getModel() != null && !statusRel.getModel() + .isBlank()) { + return; // cross-model status entity: its seeds live in the owner module, not resolvable here + } + List> steps = new ArrayList<>(); + for (SeedIntent seed : model.getSeeds()) { + if (seed.getLanguage() != null || !statusRel.getTo() + .equals(seed.getEntity()) + || seed.getRows() == null) { + continue; // base rows of the status entity only (skip translation seeds) + } + for (Map row : seed.getRows()) { + Object name = row.get("name"); + if (name == null) { + continue; + } + String label = name.toString(); + if (TERMINAL_STATUS.matcher(label.toLowerCase(Locale.ROOT)) + .find()) { + continue; // terminal / off-path status - stays the pill, not a step + } + Map step = new LinkedHashMap<>(); + step.put("label", label); + Object description = row.get("description"); + if (description != null && !description.toString() + .isBlank()) { + // Optional per-status help text, authored as a `description` seed column on the + // status entity - shown under the step title. Absent -> title only. + step.put("description", description.toString()); + } + steps.add(step); + } + } + if (steps.size() >= 2) { + metadata.put("steps", steps); + metadata.put("statusVar", statusRel.getName()); + } + } + /** * The form's actions, with a {@code close} action appended for a BPM task form when the author did * not declare one. {@code close} is a non-completing action - it just closes the form/dialog (the diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/form/FormStepIndicatorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/form/FormStepIndicatorTest.java new file mode 100644 index 00000000000..ca93bd83bbe --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/form/FormStepIndicatorTest.java @@ -0,0 +1,100 @@ +/* + * 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.components.intent.generator.form; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies the document-status-flow step-indicator metadata the {@link FormIntentGenerator} emits + * on a BPM task form: the ordered non-terminal status names ({@code steps}) taken from the bound + * entity's {@code function: EntityStatus} relation seeds, plus the current-status variable + * ({@code statusVar}). A terminal (cancel/void/…) status is excluded, translation seeds are + * ignored, and a non-task form gets no steps. + */ +class FormStepIndicatorTest { + + private static final String YAML = """ + name: sales + entities: + - name: OrderStatus + function: Setting + multilingual: true + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: description, type: string } + - name: SalesOrder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: OrderStatus, function: EntityStatus, init: 1 } + processes: + - name: OrderApproval + trigger: { onCreate: SalesOrder } + steps: + - { name: confirm, kind: userTask, args: { assignee: manager, form: ConfirmOrder } } + - { name: end, kind: end } + forms: + - { name: ConfirmOrder, forEntity: SalesOrder, fields: [Status], actions: [confirm] } + - { name: PlainOrder, forEntity: SalesOrder, fields: [Status] } + seeds: + - name: order-statuses + entity: OrderStatus + rows: + - { id: 1, name: DRAFT, description: Being prepared } + - { id: 2, name: APPROVED, description: Ready to ship } + - { id: 3, name: SHIPPED } + - { id: 4, name: CANCELLED, description: Called off } + - name: order-statuses-bg + entity: OrderStatus + language: bg + rows: + - { id: 1, name: "Чернова" } + """; + + @SuppressWarnings("unchecked") + @Test + void emitsTheStatusFlowStepsOnTheTaskForm() { + IntentModel model = IntentParser.parse(YAML); + Map> forms = FormIntentGenerator.buildFormsForTest(model); + + Map meta = (Map) forms.get("ConfirmOrder") + .get("metadata"); + assertEquals(Boolean.TRUE, meta.get("taskForm")); + // CANCELLED is terminal (excluded); the bg translation seed is ignored; order is seed order. + // Each step carries its label plus the optional `description` seed column (absent -> no key). + assertEquals(List.of(Map.of("label", "DRAFT", "description", "Being prepared"), + Map.of("label", "APPROVED", "description", "Ready to ship"), Map.of("label", "SHIPPED")), meta.get("steps")); + // The current-status model variable is the EntityStatus relation name. + assertEquals("Status", meta.get("statusVar")); + } + + @SuppressWarnings("unchecked") + @Test + void noStepsOnANonTaskForm() { + IntentModel model = IntentParser.parse(YAML); + Map> forms = FormIntentGenerator.buildFormsForTest(model); + + Map meta = (Map) forms.get("PlainOrder") + .get("metadata"); + assertFalse(meta.containsKey("taskForm")); + assertFalse(meta.containsKey("steps")); + assertTrue(meta.get("statusVar") == null); + } +} diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html index ec74a220b0a..d4497026c7c 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/views/_documents.html @@ -155,7 +155,7 @@
-

+

@@ -173,7 +173,7 @@
-

+

@@ -191,7 +191,7 @@
-

+

Delete ? This cannot be undone.

Delete items? This cannot be undone.

diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html index 3635fe388ea..1ec9fba8c87 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/index.html @@ -262,7 +262,7 @@

- +
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 e854b255207..487b6d19168 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 @@ -128,26 +128,74 @@ applied by harmonia.min.js (below), which reads the shared `codbex.harmonia.colorMode` the shell set — so this iframe'd form matches the shell (light/dark) automatically. */ html { background-color: var(--background); color: var(--foreground); } - body { min-height: 100vh; } + /* Fill the host dialog/viewport: the body is a flex column so the form (and, on a task form, + its card) grow to the full height instead of floating at content height in the top-left. */ + body { min-height: 100vh; display: flex; flex-direction: column; } [x-cloak] { display: none !important; } -
+
#if($metadata.taskForm) - -
-
+ +
+
+#if($metadata.steps && $metadata.steps.size() > 0) +
+ + +
+#end +#foreach($element in $form) +#if($element.controlId == "container-hbox") +## action row: rendered as the bottom-pinned footer below (outside the detail grid) +#elseif($element.controlId == "container-vbox") +
+#foreach($child in $element.children) +#leaf($child) +#end +
#else -
+#leaf($element) +#end +#end +
+#foreach($element in $form) +#if($element.controlId == "container-hbox") +
+#foreach($child in $element.children) +#leaf($child) +#end +
+#end #end +
+
+#else +
#foreach($element in $form) #if($element.controlId == "container-hbox")
@@ -165,11 +213,6 @@ #leaf($element) #end #end -#if($metadata.taskForm) -
-
-
-#else
#end