diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js index 49a1a883356..86ff42a8788 100644 --- a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/intent-diagrams.js @@ -40,7 +40,7 @@ window.IntentDiagrams = (() => { terminal: '#708090', // slate - start / end events edge: '#7a8896', // mid-gray - relations and sequence flows, visible on both themes output: '#9141ac', // purple - authoring outputs (forms, reports) - glue: '#c64600', // rust - declarative glue (notifications, schedules, integrations, inbound, outbound, rollups) + glue: '#c64600', // rust - declarative glue (notifications, schedules, integrations, inbound, outbound, rollups, register lookups, create-froms) label: '#ffffff' // white - on-shape text }; @@ -57,7 +57,9 @@ window.IntentDiagrams = (() => { integration: 'sap-icon--chain-link', inbound: 'sap-icon--inbox', outbound: 'sap-icon--outbox', - rollup: 'sap-icon--sum' + rollup: 'sap-icon--sum', + resolve: 'sap-icon--search', + generate: 'sap-icon--create-form' }; const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); @@ -409,10 +411,46 @@ window.IntentDiagrams = (() => { return relation ? relation.to : null; }; - // Card label: an icon-badged name over a one-line binding detail (escaped; the detail is optional). + // What a register lookup does, in the record's own vocabulary: the moment it runs on and the to-one + // it fills. The register it reads is not repeated here - it is the dashed edge coming into the card. + const resolveDetail = (resolve) => eventVerb(resolve.event) + ' \u2022 sets ' + (resolve.set || '?'); + + // What a create-from turns into what. The source is normally the entity the card is edged to, so it + // is named only when it is FOREIGN - then there is no local node to edge to and the detail line is + // all the reader has (the same shape a cross-model schedule or roll-up card uses). + const generatesFlow = (generates) => (generates.fromUses ? generates.fromUses + '.' + (generates.from || '?') + ' ' : '') + + '\u2192 ' + (generates.uses ? generates.uses + '.' : '') + (generates.to || '?'); + + // The moment a create-from fires, as it reads on the card. `onTransition` is this construct's own + // axis - no other glue binds it - so it is spelled out here rather than in the shared `eventVerb`, + // and it carries its `when` guard: two rules bound to the same transition are told apart by their + // guards, which is precisely what the picture has to make visible. + const generatesTrigger = (event) => { + const guard = event.when ? ' ' + event.when : ''; + if (event.onTransition) return 'on transition' + guard; + if (event.onCreate) return 'on create' + guard; + if (event.onPhase) return 'on phase ' + (event.phase || ''); + return eventVerb(event); + }; + + // A create-from's trigger over its CARDINALITY. `once` is the DSL's default - the target's + // back-reference is checked first, so a redelivery is a no-op - while `append` drops that guard and + // mints a row per delivery. The badge is the point of drawing these at all: two appending rules on + // one transition, side by side, is a defect that is obvious in a picture and invisible in YAML. + // With no event at all the create-from is a button, which has no cardinality to state. + const generatesDetail = (generates) => { + const event = generates.event || {}; + const trigger = generatesTrigger(event); + return [generatesFlow(generates), trigger ? trigger + ' \u2022 ' + (event.mode || 'once') : 'button']; + }; + + // Card label: an icon-badged name over its binding detail (escaped). The detail is optional and may + // be an array, which renders one line per entry - a card whose binding needs two. const cardLabel = (icon, name, detail) => `
${escapeHtml(name)}
` - + (detail ? `
${escapeHtml(detail)}
` : ''); + + (Array.isArray(detail) ? detail : [detail]).filter(Boolean) + .map((line) => `
${escapeHtml(line)}
`) + .join(''); // One section diagramming the artifacts that hang off the entities - authoring outputs (forms, // reports) and the declarative glue (notifications, schedules, integrations, inbound arrivals, @@ -429,7 +467,14 @@ window.IntentDiagrams = (() => { { list: model.outbound, icon: ICON.outbound, color: COLOR.glue, entity: o => eventEntity(model, o.event), detail: outboundDetail }, // A cross-model child has no node here, so the card anchors to the LOCAL parent it feeds and // names the foreign child in its detail line (the same shape a cross-model schedule uses). - { list: model.rollups, icon: ICON.rollup, color: COLOR.glue, entity: r => r.model ? r.parent : r.entity, detail: r => (r.model ? r.model + '.' + (r.entity || '') + ' ' : '') + '→ ' + (rollupParent(model, r) || '?') + '.' + (r.field || '') } + { list: model.rollups, icon: ICON.rollup, color: COLOR.glue, entity: r => r.model ? r.parent : r.entity, detail: r => (r.model ? r.model + '.' + (r.entity || '') + ' ' : '') + '→ ' + (rollupParent(model, r) || '?') + '.' + (r.field || '') }, + // A register lookup reads one entity to fill a relation on another, so it is the one card with + // two edges: the solid binding edge from the record it runs on, and a dashed `reads` edge from + // the register - the read direction on the picture rather than only in the detail line. + { list: model.resolves, icon: ICON.resolve, color: COLOR.glue, entity: r => eventEntity(model, r.event), reads: r => r.from, detail: resolveDetail }, + // A create-from with a FOREIGN source has no local node to hang off, so it anchors to the local + // target it mints instead (its detail line names the foreign source). + { list: model.generates, icon: ICON.generate, color: COLOR.glue, entity: g => g.fromUses ? (g.uses ? null : g.to) : g.from, detail: generatesDetail } ]; const items = []; @@ -458,6 +503,8 @@ window.IntentDiagrams = (() => { const card = graph.insertVertex(parent, null, cardLabel(category.icon, item.name, category.detail(item)), 0, 0, 190, 48, nodeStyle(category.color)); const entity = anchor(category.entity(item)); if (entity) graph.insertEdge(parent, null, '', entity, card, edgeStyle(false)); + const read = category.reads ? anchor(category.reads(item)) : null; + if (read) graph.insertEdge(parent, null, 'reads', read, card, edgeStyle(true)); } const layout = new mxHierarchicalLayout(graph, mxConstants.DIRECTION_WEST); layout.intraCellSpacing = 30; @@ -476,7 +523,7 @@ window.IntentDiagrams = (() => { const normalize = (model) => { model = model || {}; for (const key of ['entities', 'processes', 'forms', 'reports', 'permissions', 'seeds', - 'notifications', 'schedules', 'integrations', 'inbound', 'outbound', 'rollups']) { + 'notifications', 'schedules', 'integrations', 'inbound', 'outbound', 'rollups', 'resolves', 'generates']) { model[key] = model[key] || []; } return model; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentDiagramGlueIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentDiagramGlueIT.java new file mode 100644 index 00000000000..8a560a22ed7 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentDiagramGlueIT.java @@ -0,0 +1,197 @@ +/* + * 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 java.nio.charset.StandardCharsets; +import java.time.Duration; + +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.ide.Workbench; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.springframework.beans.factory.annotation.Autowired; + +import com.codeborne.selenide.Condition; +import com.codeborne.selenide.Selenide; + +/** + * Browser test for the Intent Editor's "Glue & Outputs" diagram, over the two constructs that + * used to be drawn by nothing at all: {@code resolves} (the effective-dated register lookup) and + * {@code generates} (the create-from). A model whose whole automation lives in those two rendered + * an ER diagram and nothing else, so the picture said the model did almost nothing - and the defect + * class the picture exists to expose (two appending rules on one transition) stayed invisible. + * + *

+ * The fixture is written straight into the workspace rather than cloned from a sample repository: + * the assertions are about the renderer, so the intent that feeds it belongs next to them, and the + * test needs no external repository to be merged first. + * + *

+ * Only a browser can catch this. {@code intent-diagrams.js} is framework-free JavaScript rendering + * through mxGraph, so every service behind it stays green whether or not a card is drawn. + */ +public class IntentDiagramGlueIT extends UserInterfaceIntegrationTest { + + private static final String PROJECT = "intent-diagram-glue-test"; + private static final String INTENT_FILE = "app.intent"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/workspace/" + PROJECT; + + /** + * The diagram container of the "Glue & Outputs" section, so the ER section cannot satisfy an + * assertion. + */ + private static final String GLUE_DIAGRAM = + "//h4[contains(@class, 'intent-section-title') and contains(text(), 'Glue')]/following-sibling::div[1]"; + + /** + * A fine identifies its driver from the vehicle-assignment register valid on the violation date, + * then mints a declaration per identification and offers a notice on demand - one register lookup + * and both create-from triggers (event-driven appending, and button-only) in one small model. + */ + private static final String INTENT = """ + name: fines + description: Traffic fines with driver identification + entities: + - name: FineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Vehicle + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: plate, type: string } + - name: Driver + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: VehicleAssignment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: validFrom, type: date } + - { name: validTo, type: date } + relations: + - { name: vehicle, kind: manyToOne, to: Vehicle } + - { name: driver, kind: manyToOne, to: Driver } + - name: Fine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + - { name: violationAt, type: timestamp } + - { name: resolution, type: string, readOnly: true } + relations: + - { name: vehicle, kind: manyToOne, to: Vehicle } + - { name: driver, kind: manyToOne, to: Driver } + - { name: Status, kind: manyToOne, to: FineStatus, function: EntityStatus, init: 1 } + - name: Declaration + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Fine, kind: manyToOne, to: Fine } + - name: Notice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string } + relations: + - { name: Fine, kind: manyToOne, to: Fine } + seeds: + - name: fineStatuses + entity: FineStatus + rows: + - { id: 1, name: NEW } + - { id: 2, name: IDENTIFIED } + - { id: 3, name: UNRESOLVED } + resolves: + - name: identifyDriver + event: { onCreate: Fine } + set: driver + from: VehicleAssignment + match: { vehicle: vehicle } + between: { start: validFrom, end: validTo, value: violationAt } + outcome: resolution + found: { setStatus: IDENTIFIED } + notFound: { setStatus: UNRESOLVED } + ambiguous: { setStatus: UNRESOLVED } + generates: + - name: declarationFromFine + from: Fine + to: Declaration + event: { onTransition: Fine, when: "Status == IDENTIFIED", mode: append } + map: { Fine: id, Note: note } + - name: noticeFromFine + from: Fine + to: Notice + label: "Create Notice" + map: { Fine: id, Note: note } + """; + + @Autowired + private IRepository repository; + + @Test + void theGlueDiagramDrawsRegisterLookupsAndCreateFroms() { + repository.createResource(PROJECT_PATH + "/" + INTENT_FILE, INTENT.getBytes(StandardCharsets.UTF_8)); + + ide.openHomePage(); + Workbench workbench = ide.openWorkbench(); + workbench.openFile(PROJECT, INTENT_FILE); + + // The frame sweep enters the editor's iframe; the Monaco source pane visible = editor loaded. It + // is deliberately the sweep's target rather than the diagram: the sweep is a single pass, while + // the diagram only appears after the debounced /parse round-trip, which Selenide polls for below. + browser.findElementInAllFrames(By.cssSelector(".intent-monaco .monaco-editor"), Condition.visible); + + // The editor parsed the buffer and drew the section this test is about - its container exists only + // once renderGlue has run, so this is the wait for the debounced /parse round-trip. (Deliberately + // not an `//svg` step: XPath is namespace-aware, so it never matches an SVG element in an HTML + // document - the CSS descendant selector the sibling tests use does.) + Selenide.$(By.xpath(GLUE_DIAGRAM)) + .shouldBe(Condition.visible, Duration.ofSeconds(30)); + + // The register lookup: its own card, the relation it fills, and the register it reads - which is + // drawn as a node of its own plus the dashed `reads` edge, so the read direction is on the + // picture. Nothing else in this section mentions the register, so both can only come from here. + glueDiagramDrew("identifyDriver"); + glueDiagramDrew("sets driver"); + glueDiagramDrew("VehicleAssignment"); + glueDiagramDrew("reads"); + + // The event-driven create-from: its target, its trigger WITH the status guard, and its + // cardinality. The guard and the `append` badge are what make two rules on one transition + // distinguishable at a glance - the whole reason for drawing these. + glueDiagramDrew("declarationFromFine"); + glueDiagramDrew("\u2192 Declaration"); + glueDiagramDrew("on transition Status == 2"); + glueDiagramDrew("append"); + + // ...and the create-from nobody triggers automatically says so, rather than claiming a + // cardinality it does not have. + glueDiagramDrew("noticeFromFine"); + glueDiagramDrew("button"); + } + + /** + * Assert the "Glue & Outputs" diagram drew a label carrying the given text. + * + *

+ * Deliberately {@code exist} rather than {@code visible}: the diagram pane scrolls, so whether a + * given card is inside the viewport is a fact about the pane's scroll position and the window size, + * not about the renderer this test is exercising. The section container itself is asserted visible. + * + * @param text the label text, matched as a substring of one text node + */ + private void glueDiagramDrew(String text) { + Selenide.$(By.xpath(GLUE_DIAGRAM + "//*[contains(text(), '" + text + "')]")) + .should(Condition.exist); + } +}