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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -137,7 +146,7 @@ public void generate(IntentGenerationContext context) {
continue;
}
EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity());
Map<String, Object> document = buildForm(form, boundEntity, entitiesByName, taskFormNames.contains(form.getName()));
Map<String, Object> document = buildForm(form, boundEntity, entitiesByName, taskFormNames.contains(form.getName()), model);
context.writeModelFile(fileName, JsonHelper.toJson(document));
}
}
Expand Down Expand Up @@ -169,10 +178,29 @@ private static Map<String, EntityIntent> 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<String, Map<String, Object>> buildFormsForTest(IntentModel model) {
Map<String, EntityIntent> entitiesByName = indexEntities(model);
Set<String> taskFormNames = taskFormNames(model);
Map<String, Map<String, Object>> 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<String, Object> buildForm(FormIntent form, EntityIntent entity, Map<String, EntityIntent> entitiesByName,
boolean isTaskForm) {
boolean isTaskForm, IntentModel model) {
Map<String, Object> 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));
Expand All @@ -186,18 +214,81 @@ private static Map<String, Object> 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<String, Object> buildMetadata(FormIntent form, boolean isTaskForm) {
private static Map<String, Object> buildMetadata(FormIntent form, boolean isTaskForm, EntityIntent entity, IntentModel model) {
Map<String, Object> metadata = new LinkedHashMap<>();
if (isTaskForm) {
metadata.put("taskForm", true);
if (form.getForEntity() != null) {
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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Map<String, Object>> forms = FormIntentGenerator.buildFormsForTest(model);

Map<String, Object> meta = (Map<String, Object>) 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<String, Map<String, Object>> forms = FormIntentGenerator.buildFormsForTest(model);

Map<String, Object> meta = (Map<String, Object>) forms.get("PlainOrder")
.get("metadata");
assertFalse(meta.containsKey("taskForm"));
assertFalse(meta.containsKey("steps"));
assertTrue(meta.get("statusVar") == null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
<!-- New folder dialog -->
<div x-h-dialog-overlay :data-open="newFolderOpen">
<div x-h-dialog>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.newFolder', 'New folder')"></h2><button x-h-dialog-close @click="newFolderOpen = false" aria-label="Close"></button></div>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.newFolder', 'New folder')"></h2><button x-h-dialog-close @click="newFolderOpen = false" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button></div>
<div x-h-dialog-content>
<div x-h-field>
<label x-h-label for="nf" x-text="T('application-core:shell.documents.folderName', 'Folder name')"></label>
Expand All @@ -173,7 +173,7 @@
<!-- Rename dialog -->
<div x-h-dialog-overlay :data-open="renameOpen">
<div x-h-dialog>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.rename', 'Rename')"></h2><button x-h-dialog-close @click="renameOpen = false" aria-label="Close"></button></div>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.rename', 'Rename')"></h2><button x-h-dialog-close @click="renameOpen = false" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button></div>
<div x-h-dialog-content>
<div x-h-field>
<label x-h-label for="rn" x-text="T('application-core:shell.documents.newName', 'New name')"></label>
Expand All @@ -191,7 +191,7 @@
<!-- Delete dialog -->
<div x-h-dialog-overlay :data-open="deleteOpen">
<div x-h-dialog>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.delete', 'Delete')"></h2><button x-h-dialog-close @click="deleteOpen = false" aria-label="Close"></button></div>
<div x-h-dialog-header><h2 x-h-dialog-title x-text="T('application-core:shell.documents.delete', 'Delete')"></h2><button x-h-dialog-close @click="deleteOpen = false" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button></div>
<div x-h-dialog-content>
<p x-show="deleteTargets.length === 1">Delete <strong x-text="deleteTargets[0] && deleteTargets[0].name"></strong>? This cannot be undone.</p>
<p x-show="deleteTargets.length > 1">Delete <strong x-text="deleteTargets.length"></strong> items? This cannot be undone.</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ <h1 x-h-notification-title x-text="n.title"></h1>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.processTasks.formOpen" :src="$store.processTasks.formUrl"
Expand All @@ -278,7 +278,7 @@ <h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.customActions.dialogTitle"></h2>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.customActions.dialogOpen" :src="$store.customActions.dialogUrl"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ <h1 x-h-notification-title x-text="n.title"></h1>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.processTasks.formOpen" :src="$store.processTasks.formUrl"
Expand All @@ -253,7 +253,7 @@ <h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.customActions.dialogTitle"></h2>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.customActions.dialogOpen" :src="$store.customActions.dialogUrl"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ <h1 x-h-notification-title x-text="n.title"></h1>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.processTasks.closeForm()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.processTasks.formOpen" :src="$store.processTasks.formUrl"
Expand All @@ -253,7 +253,7 @@ <h2 x-h-dialog-title x-text="$store.processTasks.formTitle"></h2>
<div x-h-dialog style="width: 92vw; max-width: 1100px;">
<div x-h-dialog-header>
<h2 x-h-dialog-title x-text="$store.customActions.dialogTitle"></h2>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"></button>
<button x-h-dialog-close @click="$store.customActions.closeDialog()" aria-label="Close"><i role="img" x-h-lucide data-lucide="x"></i></button>
</div>
<div x-h-dialog-content>
<iframe x-show="$store.customActions.dialogOpen" :src="$store.customActions.dialogUrl"
Expand Down
Loading
Loading