1818import org .eclipse .dirigible .components .intent .generator .IntentGenerationContext ;
1919import org .eclipse .dirigible .components .intent .generator .IntentNaming ;
2020import 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 ;
2123import org .eclipse .dirigible .components .intent .model .EntityIntent ;
2224import org .eclipse .dirigible .components .intent .model .FieldIntent ;
2325import org .eclipse .dirigible .components .intent .model .IntentModel ;
2426import org .eclipse .dirigible .components .intent .model .RelationIntent ;
2527import org .eclipse .dirigible .components .intent .model .SeedIntent ;
28+ import org .eclipse .dirigible .components .intent .model .UsesIntent ;
2629import org .eclipse .dirigible .repository .api .IRepository ;
2730import org .eclipse .dirigible .repository .api .IResource ;
2831import 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