From 2e5fd4940c0c4e8ccc81f6e18a204c782e3a6f9b Mon Sep 17 00:00:00 2001 From: delchev Date: Thu, 27 Aug 2026 12:02:02 +0300 Subject: [PATCH] Draw resolves: and generates: in the intent Glue & Outputs diagram (#6954) The renderer knew nothing about either construct - neither word appeared in intent-diagrams.js - so a model whose automation lives in a register lookup and a few event-driven create-froms drew an ER diagram and nothing else. The BPMN correctly showed only the genuinely human steps, so the picture said the model did almost nothing. Two categories added to renderGlue(), on the existing card + edge pattern: - one card per `resolves:` entry, edged to the record it runs on and labelled with the to-one it fills, plus a second DASHED `reads` edge from the register it reads - the read direction on the picture rather than only in prose. It is the one card with two edges, hence the new optional `reads` on a category; - one card per `generates:` entry, edged to its source - or, when the source is foreign and so has no local node, to the local target it mints - labelled with its target, its trigger WITH the status guard, and its CARDINALITY. The cardinality is the point: two appending rules on one transition, side by side, is a defect that is obvious in a picture and nearly invisible in YAML. `normalize()` defaults both collections like the other twelve, and `cardLabel` now accepts an array of detail lines for a card whose binding needs two. A string detail renders exactly as before, so a model declaring neither construct is byte-identical to today. intent-diagrams.js is the shared renderer (`window.IntentDiagrams`), so the Builder shell gains the cards from the same change. IntentDiagramGlueIT is the guard, and only a browser can be: this is framework-free JavaScript over mxGraph, so every service behind it stays green whether or not a card is drawn. Its 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 beside them, and the test needs no external repository merged first. Co-Authored-By: Claude Opus 5 --- .../editor-intent/js/intent-diagrams.js | 59 +++++- .../tests/ui/tests/IntentDiagramGlueIT.java | 197 ++++++++++++++++++ 2 files changed, 250 insertions(+), 6 deletions(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentDiagramGlueIT.java 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); + } +}