Skip to content

Commit e7ce620

Browse files
authored
Merge branch 'master' into fix/task-form-layout
2 parents 0c230ac + ac13a40 commit e7ce620

15 files changed

Lines changed: 454 additions & 65 deletions

File tree

.github/workflows/release.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,16 @@ jobs:
246246

247247
#--------------- Publish aerokit/sdk to NPM -----------------#
248248

249+
# The Playwright test runner (npm/test) ships as the @aerokit/sdk `./test` subpath export -
250+
# publishing it as its own @aerokit/test package needs new-package creation rights the npm
251+
# token does not have. The copy is release-runner-local only (the jar was built before this
252+
# step), and the nested package.json marks the subtree ESM (the sdk root has no "type").
253+
- name: (@aerokit/sdk) Bundle the test runner as the ./test subpath
254+
run: |
255+
mkdir -p ./components/api/api-modules-javascript/src/main/resources/META-INF/dirigible/modules/test
256+
cp -R ./npm/test/src/. ./components/api/api-modules-javascript/src/main/resources/META-INF/dirigible/modules/test/
257+
printf '{\n "type": "module"\n}\n' > ./components/api/api-modules-javascript/src/main/resources/META-INF/dirigible/modules/test/package.json
258+
249259
- name: (@aerokit/sdk) Set the new package version
250260
working-directory: ./components/api/api-modules-javascript/src/main/resources/META-INF/dirigible/modules
251261
run: npm version "${{ github.event.inputs.releaseVersion }}" --no-git-tag-version

components/api/api-modules-javascript/src/main/resources/META-INF/dirigible/modules/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
},
3030
"files": [
3131
"dist",
32+
"test",
3233
"LICENSE",
3334
"README.md",
3435
"package.json"
@@ -42,6 +43,8 @@
4243
"require": "./dist/cjs/index.js",
4344
"types": "./dist/dts/index.d.ts"
4445
},
46+
"./test": "./test/index.js",
47+
"./test/fixtures": "./test/fixtures.js",
4548
"./*": {
4649
"import": "./dist/esm/*/index.mjs",
4750
"require": "./dist/cjs/*/index.js",
@@ -54,5 +57,13 @@
5457
"dist/dts/*/index.d.ts"
5558
]
5659
}
60+
},
61+
"peerDependencies": {
62+
"@playwright/test": ">=1.45"
63+
},
64+
"peerDependenciesMeta": {
65+
"@playwright/test": {
66+
"optional": true
67+
}
5768
}
5869
}

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext;
1919
import org.eclipse.dirigible.components.intent.generator.IntentNaming;
2020
import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator;
21+
import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport;
22+
import org.eclipse.dirigible.components.intent.model.CheckIntent;
2123
import org.eclipse.dirigible.components.intent.model.EntityIntent;
2224
import org.eclipse.dirigible.components.intent.model.FieldIntent;
2325
import org.eclipse.dirigible.components.intent.model.IntentModel;
2426
import org.eclipse.dirigible.components.intent.model.RelationIntent;
2527
import org.eclipse.dirigible.components.intent.model.SeedIntent;
28+
import org.eclipse.dirigible.components.intent.model.UsesIntent;
2629
import org.eclipse.dirigible.repository.api.IRepository;
2730
import org.eclipse.dirigible.repository.api.IResource;
2831
import org.slf4j.Logger;
@@ -83,7 +86,7 @@ public void generate(IntentGenerationContext context) {
8386
return;
8487
}
8588

86-
Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities);
89+
Map<String, Object> manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities, context);
8790
context.writeModelFile(baseName + ".test", GSON.toJson(manifest) + "\n");
8891
LOGGER.debug("Generated app-test manifest [{}.test]", baseName);
8992
}
@@ -102,6 +105,15 @@ public void generate(IntentGenerationContext context) {
102105
*/
103106
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
104107
Map<String, Map<String, Object>> edmEntities) {
108+
return buildManifest(baseName, project, model, edmEntities, null);
109+
}
110+
111+
/**
112+
* The full variant carrying the generation context, which cross-model relation resolution needs (a
113+
* {@code null} context falls back to the naming-convention target coordinates - unit tests).
114+
*/
115+
public static Map<String, Object> buildManifest(String baseName, String project, IntentModel model,
116+
Map<String, Map<String, Object>> edmEntities, IntentGenerationContext context) {
105117
Map<String, Object> manifest = new LinkedHashMap<>();
106118
manifest.put("module", baseName);
107119
manifest.put("standaloneShell", "/services/web/" + project + "/gen/" + baseName + "/index.html");
@@ -120,13 +132,14 @@ public static Map<String, Object> buildManifest(String baseName, String project,
120132
if ("MANAGE_DETAILS".equals(string(edm.get("layoutType"))) || "PROJECTION".equals(string(edm.get("type")))) {
121133
continue;
122134
}
123-
entities.add(entityManifest(entity, edm, model));
135+
entities.add(entityManifest(entity, edm, model, context, edmEntities));
124136
}
125137
manifest.put("entities", entities);
126138
return manifest;
127139
}
128140

129-
private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model) {
141+
private static Map<String, Object> entityManifest(EntityIntent entity, Map<String, Object> edm, IntentModel model,
142+
IntentGenerationContext context, Map<String, Map<String, Object>> edmEntities) {
130143
Map<String, Object> out = new LinkedHashMap<>();
131144
String name = entity.getName();
132145
out.put("name", name);
@@ -138,6 +151,12 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
138151
out.put("api", "/" + sanitizeJavaIdentifier(string(edm.get("perspectiveName"))) + "/" + name + "Controller");
139152
out.put("table", string(edm.get("dataName")));
140153

154+
// A hierarchical entity renders its list as a tree (role=treeitem, no table/columnheaders),
155+
// so the runner must branch on it.
156+
if (entity.getHierarchy() != null && !entity.getHierarchy()
157+
.isBlank()) {
158+
out.put("hierarchy", true);
159+
}
141160
boolean multilingual = "true".equals(string(edm.get("multilingual")));
142161
if (multilingual) {
143162
out.put("multilingual", true);
@@ -149,8 +168,23 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
149168
if (hasSeed(model, name)) {
150169
out.put("expectSeedData", true);
151170
}
171+
// exactlyOne checks: exactly one of the named fields may be non-null - a sample record
172+
// filling all of them is rejected with 400, so the runner keeps only the first
173+
List<List<String>> exactlyOne = new ArrayList<>();
174+
for (CheckIntent check : entity.getChecks() == null ? List.<CheckIntent>of() : entity.getChecks()) {
175+
if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields()
176+
.isEmpty()) {
177+
exactlyOne.add(check.getFields()
178+
.stream()
179+
.map(IntentNaming::pascalCase)
180+
.toList());
181+
}
182+
}
183+
if (!exactlyOne.isEmpty()) {
184+
out.put("exactlyOne", exactlyOne);
185+
}
152186
out.put("fields", fields(entity));
153-
List<Map<String, Object>> relations = relations(entity, model);
187+
List<Map<String, Object>> relations = relations(entity, model, context, edmEntities);
154188
if (!relations.isEmpty()) {
155189
out.put("relations", relations);
156190
}
@@ -176,7 +210,13 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
176210
if (field.getLength() != null) {
177211
out.put("length", field.getLength());
178212
}
179-
if (field.isReadOnly()) {
213+
// Read-only must mirror the generated form exactly, or the runner waits forever on an
214+
// input that is not there: an author-marked field and a uuid render in the read-only
215+
// details block (no #f_<Name> input), a calculated field renders as a non-editable
216+
// input, an aggregate renders in the document totals footer, and a dependsOn field is
217+
// auto-populated by its trigger relation's watcher (the runner must not fill it).
218+
if (field.isReadOnly() || "uuid".equalsIgnoreCase(field.getType()) || field.isCalculated() || field.isAggregate()
219+
|| field.getDependsOn() != null) {
180220
out.put("readOnly", true);
181221
}
182222
out.put("major", field.isMajor());
@@ -186,15 +226,26 @@ private static List<Map<String, Object>> fields(EntityIntent entity) {
186226
}
187227

188228
/**
189-
* The user-pickable to-one relations rendered as dropdowns. Cross-model relations are omitted —
190-
* their target lives in another module's manifest, so a single-module runner cannot resolve a
191-
* sample option for them (a phase-2 concern).
229+
* The user-pickable to-one relations rendered as dropdowns. A cross-model relation's target lives
230+
* in another module — its option rows are resolved through an {@code apiAbsolute} controller URL
231+
* (the same owner-project coordinates the generated dropdown uses), so the runner can fill the
232+
* required FK without the target being in this manifest. A {@code function: EntityStatus} relation
233+
* is marked {@code entityStatus} — it renders as a status pill / is excluded from the editable
234+
* inputs by the form templates, and its value comes from the {@code init:} DB default, so the
235+
* runner must neither pick nor post it.
192236
*/
193-
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model) {
237+
private static List<Map<String, Object>> relations(EntityIntent entity, IntentModel model, IntentGenerationContext context,
238+
Map<String, Map<String, Object>> edmEntities) {
239+
Map<String, UsesIntent> usesByAlias = new LinkedHashMap<>();
240+
for (UsesIntent uses : model.getUses()) {
241+
if (uses.getModel() != null) {
242+
usesByAlias.put(uses.getModel(), uses);
243+
}
244+
}
194245
List<Map<String, Object>> relations = new ArrayList<>();
195246
for (RelationIntent relation : entity.getRelations()) {
196247
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
197-
if (!toOne || relation.isCrossModel() || relation.getTo() == null) {
248+
if (!toOne || relation.getTo() == null) {
198249
continue;
199250
}
200251
Map<String, Object> out = new LinkedHashMap<>();
@@ -205,7 +256,79 @@ private static List<Map<String, Object>> relations(EntityIntent entity, IntentMo
205256
out.put("required", true);
206257
}
207258
out.put("widget", "dropdown");
208-
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
259+
if (relation.isEntityStatus()) {
260+
out.put("entityStatus", true);
261+
}
262+
// dependsOn cascade: the option list narrows to target rows whose filterBy equals the
263+
// trigger sibling's value - the runner must pick MATCHING samples (the dependent row
264+
// first, then its FK as the trigger's sample), not independent first rows.
265+
if (relation.getDependsOn() != null) {
266+
Map<String, Object> dependsOn = new LinkedHashMap<>();
267+
dependsOn.put("relation", IntentNaming.pascalCase(relation.getDependsOn()
268+
.getRelation()));
269+
if (relation.getDependsOn()
270+
.getFilterBy() != null) {
271+
dependsOn.put("filterBy", IntentNaming.pascalCase(relation.getDependsOn()
272+
.getFilterBy()));
273+
}
274+
out.put("dependsOn", dependsOn);
275+
}
276+
// where: static option filter - only matching target rows are offered as options
277+
if (relation.getWhere() != null && relation.getWhere()
278+
.size() == 1) {
279+
Map.Entry<String, Object> condition = relation.getWhere()
280+
.entrySet()
281+
.iterator()
282+
.next();
283+
Map<String, Object> where = new LinkedHashMap<>();
284+
where.put("by", IntentNaming.pascalCase(condition.getKey()));
285+
where.put("value", condition.getValue());
286+
out.put("where", where);
287+
}
288+
if (relation.isCrossModel()) {
289+
UsesIntent uses = usesByAlias.get(relation.getModel());
290+
if (uses == null) {
291+
continue;
292+
}
293+
CrossModelSupport.TargetInfo info;
294+
try {
295+
info = CrossModelSupport.resolve(context, uses, relation.getTo());
296+
} catch (RuntimeException ex) {
297+
// the EDM generator (order 200) fails loudly for a truly unresolvable target;
298+
// reaching here means a degraded context - omit the relation rather than emit a
299+
// guessed URL
300+
LOGGER.warn("Omitting cross-model relation [{}] of [{}] from the app-test manifest - target unresolved",
301+
relation.getName(), entity.getName(), ex);
302+
continue;
303+
}
304+
out.put("crossModel", true);
305+
out.put("apiAbsolute", "/services/java/" + uses.resolveProject() + "/gen/" + sanitizeJavaIdentifier(uses.getModel())
306+
+ "/api/" + sanitizeJavaIdentifier(info.perspectiveName()) + "/" + relation.getTo() + "Controller");
307+
out.put("labelFrom", info.labelField());
308+
// leafOnly: the generated validation rejects a non-leaf target - the runner must
309+
// pick a row no other row references via the target's hierarchy edge
310+
if (relation.isLeafOnly() && info.hierarchyProperty() != null) {
311+
out.put("leafOnly", Map.of("hierarchyProperty", info.hierarchyProperty()));
312+
}
313+
} else {
314+
// relative controller path of the same-model target - resolvable even when the
315+
// target is a composition detail (excluded from this manifest's entities list)
316+
Map<String, Object> targetEdm = edmEntities.get(relation.getTo());
317+
if (targetEdm != null) {
318+
out.put("api",
319+
"/" + sanitizeJavaIdentifier(string(targetEdm.get("perspectiveName"))) + "/" + relation.getTo() + "Controller");
320+
}
321+
out.put("labelFrom", labelFieldOf(relation.getTo(), model));
322+
if (relation.isLeafOnly()) {
323+
for (EntityIntent target : model.getEntities()) {
324+
if (relation.getTo()
325+
.equals(target.getName())
326+
&& target.getHierarchy() != null) {
327+
out.put("leafOnly", Map.of("hierarchyProperty", IntentNaming.pascalCase(target.getHierarchy())));
328+
}
329+
}
330+
}
331+
}
209332
relations.add(out);
210333
}
211334
return relations;
@@ -333,6 +456,9 @@ private static String idProperty(Map<String, Map<String, Object>> edmEntities) {
333456
private static String layout(String layoutType) {
334457
return switch (layoutType == null ? "" : layoutType) {
335458
case "MANAGE_DOCUMENT" -> "document";
459+
// the view family replaces the table page - the runner must not expect columns/rows
460+
case "MANAGE_CALENDAR" -> "calendar";
461+
case "MANAGE_SLOTS" -> "slots";
336462
default -> "manage-list";
337463
};
338464
}

0 commit comments

Comments
 (0)