diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java index f396d453664..bcd8887ff17 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGenerator.java @@ -18,11 +18,14 @@ import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; import org.eclipse.dirigible.components.intent.generator.IntentNaming; import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport; +import org.eclipse.dirigible.components.intent.model.CheckIntent; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.eclipse.dirigible.components.intent.model.SeedIntent; +import org.eclipse.dirigible.components.intent.model.UsesIntent; import org.eclipse.dirigible.repository.api.IRepository; import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; @@ -83,7 +86,7 @@ public void generate(IntentGenerationContext context) { return; } - Map manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities); + Map manifest = buildManifest(baseName, context.getProjectName(), model, edmEntities, context); context.writeModelFile(baseName + ".test", GSON.toJson(manifest) + "\n"); LOGGER.debug("Generated app-test manifest [{}.test]", baseName); } @@ -102,6 +105,15 @@ public void generate(IntentGenerationContext context) { */ public static Map buildManifest(String baseName, String project, IntentModel model, Map> edmEntities) { + return buildManifest(baseName, project, model, edmEntities, null); + } + + /** + * The full variant carrying the generation context, which cross-model relation resolution needs (a + * {@code null} context falls back to the naming-convention target coordinates - unit tests). + */ + public static Map buildManifest(String baseName, String project, IntentModel model, + Map> edmEntities, IntentGenerationContext context) { Map manifest = new LinkedHashMap<>(); manifest.put("module", baseName); manifest.put("standaloneShell", "/services/web/" + project + "/gen/" + baseName + "/index.html"); @@ -120,13 +132,14 @@ public static Map buildManifest(String baseName, String project, if ("MANAGE_DETAILS".equals(string(edm.get("layoutType"))) || "PROJECTION".equals(string(edm.get("type")))) { continue; } - entities.add(entityManifest(entity, edm, model)); + entities.add(entityManifest(entity, edm, model, context, edmEntities)); } manifest.put("entities", entities); return manifest; } - private static Map entityManifest(EntityIntent entity, Map edm, IntentModel model) { + private static Map entityManifest(EntityIntent entity, Map edm, IntentModel model, + IntentGenerationContext context, Map> edmEntities) { Map out = new LinkedHashMap<>(); String name = entity.getName(); out.put("name", name); @@ -138,6 +151,12 @@ private static Map entityManifest(EntityIntent entity, Map entityManifest(EntityIntent entity, Map> exactlyOne = new ArrayList<>(); + for (CheckIntent check : entity.getChecks() == null ? List.of() : entity.getChecks()) { + if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields() + .isEmpty()) { + exactlyOne.add(check.getFields() + .stream() + .map(IntentNaming::pascalCase) + .toList()); + } + } + if (!exactlyOne.isEmpty()) { + out.put("exactlyOne", exactlyOne); + } out.put("fields", fields(entity)); - List> relations = relations(entity, model); + List> relations = relations(entity, model, context, edmEntities); if (!relations.isEmpty()) { out.put("relations", relations); } @@ -176,7 +210,13 @@ private static List> fields(EntityIntent entity) { if (field.getLength() != null) { out.put("length", field.getLength()); } - if (field.isReadOnly()) { + // Read-only must mirror the generated form exactly, or the runner waits forever on an + // input that is not there: an author-marked field and a uuid render in the read-only + // details block (no #f_ input), a calculated field renders as a non-editable + // input, an aggregate renders in the document totals footer, and a dependsOn field is + // auto-populated by its trigger relation's watcher (the runner must not fill it). + if (field.isReadOnly() || "uuid".equalsIgnoreCase(field.getType()) || field.isCalculated() || field.isAggregate() + || field.getDependsOn() != null) { out.put("readOnly", true); } out.put("major", field.isMajor()); @@ -186,15 +226,26 @@ private static List> fields(EntityIntent entity) { } /** - * The user-pickable to-one relations rendered as dropdowns. Cross-model relations are omitted — - * their target lives in another module's manifest, so a single-module runner cannot resolve a - * sample option for them (a phase-2 concern). + * The user-pickable to-one relations rendered as dropdowns. A cross-model relation's target lives + * in another module — its option rows are resolved through an {@code apiAbsolute} controller URL + * (the same owner-project coordinates the generated dropdown uses), so the runner can fill the + * required FK without the target being in this manifest. A {@code function: EntityStatus} relation + * is marked {@code entityStatus} — it renders as a status pill / is excluded from the editable + * inputs by the form templates, and its value comes from the {@code init:} DB default, so the + * runner must neither pick nor post it. */ - private static List> relations(EntityIntent entity, IntentModel model) { + private static List> relations(EntityIntent entity, IntentModel model, IntentGenerationContext context, + Map> edmEntities) { + Map usesByAlias = new LinkedHashMap<>(); + for (UsesIntent uses : model.getUses()) { + if (uses.getModel() != null) { + usesByAlias.put(uses.getModel(), uses); + } + } List> relations = new ArrayList<>(); for (RelationIntent relation : entity.getRelations()) { boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); - if (!toOne || relation.isCrossModel() || relation.getTo() == null) { + if (!toOne || relation.getTo() == null) { continue; } Map out = new LinkedHashMap<>(); @@ -205,7 +256,79 @@ private static List> relations(EntityIntent entity, IntentMo out.put("required", true); } out.put("widget", "dropdown"); - out.put("labelFrom", labelFieldOf(relation.getTo(), model)); + if (relation.isEntityStatus()) { + out.put("entityStatus", true); + } + // dependsOn cascade: the option list narrows to target rows whose filterBy equals the + // trigger sibling's value - the runner must pick MATCHING samples (the dependent row + // first, then its FK as the trigger's sample), not independent first rows. + if (relation.getDependsOn() != null) { + Map dependsOn = new LinkedHashMap<>(); + dependsOn.put("relation", IntentNaming.pascalCase(relation.getDependsOn() + .getRelation())); + if (relation.getDependsOn() + .getFilterBy() != null) { + dependsOn.put("filterBy", IntentNaming.pascalCase(relation.getDependsOn() + .getFilterBy())); + } + out.put("dependsOn", dependsOn); + } + // where: static option filter - only matching target rows are offered as options + if (relation.getWhere() != null && relation.getWhere() + .size() == 1) { + Map.Entry condition = relation.getWhere() + .entrySet() + .iterator() + .next(); + Map where = new LinkedHashMap<>(); + where.put("by", IntentNaming.pascalCase(condition.getKey())); + where.put("value", condition.getValue()); + out.put("where", where); + } + if (relation.isCrossModel()) { + UsesIntent uses = usesByAlias.get(relation.getModel()); + if (uses == null) { + continue; + } + CrossModelSupport.TargetInfo info; + try { + info = CrossModelSupport.resolve(context, uses, relation.getTo()); + } catch (RuntimeException ex) { + // the EDM generator (order 200) fails loudly for a truly unresolvable target; + // reaching here means a degraded context - omit the relation rather than emit a + // guessed URL + LOGGER.warn("Omitting cross-model relation [{}] of [{}] from the app-test manifest - target unresolved", + relation.getName(), entity.getName(), ex); + continue; + } + out.put("crossModel", true); + out.put("apiAbsolute", "/services/java/" + uses.resolveProject() + "/gen/" + sanitizeJavaIdentifier(uses.getModel()) + + "/api/" + sanitizeJavaIdentifier(info.perspectiveName()) + "/" + relation.getTo() + "Controller"); + out.put("labelFrom", info.labelField()); + // leafOnly: the generated validation rejects a non-leaf target - the runner must + // pick a row no other row references via the target's hierarchy edge + if (relation.isLeafOnly() && info.hierarchyProperty() != null) { + out.put("leafOnly", Map.of("hierarchyProperty", info.hierarchyProperty())); + } + } else { + // relative controller path of the same-model target - resolvable even when the + // target is a composition detail (excluded from this manifest's entities list) + Map targetEdm = edmEntities.get(relation.getTo()); + if (targetEdm != null) { + out.put("api", + "/" + sanitizeJavaIdentifier(string(targetEdm.get("perspectiveName"))) + "/" + relation.getTo() + "Controller"); + } + out.put("labelFrom", labelFieldOf(relation.getTo(), model)); + if (relation.isLeafOnly()) { + for (EntityIntent target : model.getEntities()) { + if (relation.getTo() + .equals(target.getName()) + && target.getHierarchy() != null) { + out.put("leafOnly", Map.of("hierarchyProperty", IntentNaming.pascalCase(target.getHierarchy()))); + } + } + } + } relations.add(out); } return relations; @@ -333,6 +456,9 @@ private static String idProperty(Map> edmEntities) { private static String layout(String layoutType) { return switch (layoutType == null ? "" : layoutType) { case "MANAGE_DOCUMENT" -> "document"; + // the view family replaces the table page - the runner must not expect columns/rows + case "MANAGE_CALENDAR" -> "calendar"; + case "MANAGE_SLOTS" -> "slots"; default -> "manage-list"; }; } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java index 2951226777e..ac60dabb2eb 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/apptest/AppTestIntentGeneratorTest.java @@ -35,6 +35,8 @@ class AppTestIntentGeneratorTest { private static final String INTENT = """ name: kf-mod-countries languages: [en, bg] + uses: + - { model: kf-mod-currencies } entities: - name: Country kind: setting @@ -49,8 +51,23 @@ class AppTestIntentGeneratorTest { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 200 } + - { name: uuid, type: uuid } + - { name: slug, type: string, calculatedOnCreate: "1" } + - { name: total, type: decimal, aggregate: true } relations: - { name: Country, kind: manyToOne, to: Country, required: true } + - { name: Currency, kind: manyToOne, to: Currency, model: kf-mod-currencies, required: true } + - { name: Twin, kind: manyToOne, to: City, dependsOn: { relation: Country, filterBy: Country }, where: { name: Plovdiv } } + checks: + - { kind: exactlyOne, fields: [uuid, slug], message: "one of uuid/slug" } + - name: Account + group: master-data + hierarchy: Parent + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + relations: + - { name: Parent, kind: manyToOne, to: Account } seeds: - name: countries entity: Country @@ -76,7 +93,37 @@ void buildsTheModuleLevelCoordinates() { assertEquals("/services/java/kf-mod-countries/gen/kf_mod_countries/api", manifest.get("restBase")); assertEquals("Id", manifest.get("idProperty")); assertEquals(List.of("en", "bg"), manifest.get("languages")); - assertEquals(2, ((List) manifest.get("entities")).size()); + assertEquals(3, ((List) manifest.get("entities")).size()); + } + + @SuppressWarnings("unchecked") + @Test + void marksAutoReadOnlyFieldsAndHierarchyEntities() { + Map manifest = AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()); + + // a uuid and a calculated field render without an editable input - the runner must not fill them + Map city = entity(manifest, "City"); + List> fields = (List>) city.get("fields"); + Map uuid = fields.stream() + .filter(f -> "Uuid".equals(f.get("name"))) + .findFirst() + .orElseThrow(); + assertEquals(Boolean.TRUE, uuid.get("readOnly")); + Map slug = fields.stream() + .filter(f -> "Slug".equals(f.get("name"))) + .findFirst() + .orElseThrow(); + assertEquals(Boolean.TRUE, slug.get("readOnly")); + Map total = fields.stream() + .filter(f -> "Total".equals(f.get("name"))) + .findFirst() + .orElseThrow(); + assertEquals(Boolean.TRUE, total.get("readOnly"), "an aggregate renders in the totals footer, not as an input"); + + // a hierarchy entity lists as a tree - the runner branches on the flag + Map account = entity(manifest, "Account"); + assertEquals(Boolean.TRUE, account.get("hierarchy")); + assertNull(city.get("hierarchy")); } @SuppressWarnings("unchecked") @@ -127,7 +174,7 @@ void emitsToOneRelationsAsDropdowns() { entity(AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()), "City"); List> relations = (List>) city.get("relations"); assertNotNull(relations); - assertEquals(1, relations.size()); + assertEquals(3, relations.size()); Map country = relations.get(0); assertEquals("Country", country.get("name")); assertEquals("manyToOne", country.get("kind")); @@ -135,16 +182,40 @@ void emitsToOneRelationsAsDropdowns() { assertEquals(Boolean.TRUE, country.get("required")); assertEquals("dropdown", country.get("widget")); assertEquals("Name", country.get("labelFrom")); + // same-model targets carry their relative controller path (resolvable even for a + // composition detail excluded from the manifest's entities list) + assertEquals("/settings/CountryController", country.get("api")); + assertNull(country.get("crossModel")); + + // dependsOn + where ride into the manifest so the runner picks consistent, offered samples + Map twin = relations.get(2); + assertEquals("Twin", twin.get("name")); + assertEquals("Country", ((Map) twin.get("dependsOn")).get("relation")); + assertEquals("Country", ((Map) twin.get("dependsOn")).get("filterBy")); + assertEquals("Name", ((Map) twin.get("where")).get("by")); + assertEquals("Plovdiv", ((Map) twin.get("where")).get("value")); + + Map cityEntity = + entity(AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm()), "City"); + assertEquals(List.of(List.of("Uuid", "Slug")), cityEntity.get("exactlyOne")); + + // the cross-model relation resolves an absolute controller URL in the OWNER module (naming + // convention here - no generation context; the real pass resolves against the owner's .model) + Map currency = relations.get(1); + assertEquals("Currency", currency.get("name")); + assertEquals(Boolean.TRUE, currency.get("crossModel")); + assertEquals("/services/java/kf-mod-currencies/gen/kf_mod_currencies/api/currency/CurrencyController", currency.get("apiAbsolute")); + assertEquals("Name", currency.get("labelFrom")); } @Test void skipsProjectionAndDetailEntities() { Map> edm = edm(); edm.put("Extra", edmEntity("Extra", "Extra", "Extras", "MANAGE_DETAILS", "Extras", "master-data", "KF_MOD_COUNTRIES_EXTRA", false)); - // still only Country + City — the detail child is excluded + // still only Country + City + Account — the detail child is excluded Map manifest = AppTestIntentGenerator.buildManifest("kf-mod-countries", "kf-mod-countries", model, edm); List entities = (List) manifest.get("entities"); - assertEquals(2, entities.size()); + assertEquals(3, entities.size()); assertNull(entityOrNull(manifest, "Extra")); } @@ -155,6 +226,8 @@ private static Map> edm() { byName.put("Country", edmEntity("Country", "Country", "Countries", "MANAGE_MASTER", "Settings", "master-data", "KF_MOD_COUNTRIES_COUNTRY", true)); byName.put("City", edmEntity("City", "City", "Cities", "MANAGE_MASTER", "Settings", "master-data", "KF_MOD_COUNTRIES_CITY", false)); + byName.put("Account", + edmEntity("Account", "Account", "Accounts", "MANAGE_LIST", "Accounts", "master-data", "KF_MOD_COUNTRIES_ACCOUNT", false)); return byName; } diff --git a/npm/test/src/api.js b/npm/test/src/api.js index 23264460305..d6544c70fa8 100644 --- a/npm/test/src/api.js +++ b/npm/test/src/api.js @@ -4,7 +4,8 @@ export function makeApi(request, manifest) { async function asJson(response) { if (!response.ok()) { - throw new Error(`${response.request().method()} ${response.url()} -> ${response.status()} ${await response.text()}`); + // APIResponse has no request(); report url + status + body + throw new Error(`${response.url()} -> ${response.status()} ${await response.text()}`); } const text = await response.text(); return text ? JSON.parse(text) : undefined; @@ -12,6 +13,9 @@ export function makeApi(request, manifest) { return { list: (entity, limit = 20) => request.get(url(entity, `?$limit=${limit}`)).then(asJson), + // absolute controller path (a cross-model relation target owned by another module) + listPath: (path, limit = 20) => request.get(`${path}?$limit=${limit}`).then(asJson), + getPath: (path) => request.get(path).then(asJson), count: (entity) => request.get(url(entity, '/count')).then(asJson).then((body) => (typeof body === 'number' ? body : body.count)), get: (entity, id) => request.get(url(entity, '/' + id)).then(asJson), getResponse: (entity, id) => request.get(url(entity, '/' + id)), diff --git a/npm/test/src/flows/crud.js b/npm/test/src/flows/crud.js index 1741713f5eb..3ab6ff3e6c4 100644 --- a/npm/test/src/flows/crud.js +++ b/npm/test/src/flows/crud.js @@ -2,11 +2,12 @@ import { expect, test } from '../fixtures.js'; import { fillField, fillForm, resolveRelationSamples } from '../form.js'; import { handleField, sampleRecord } from '../sample-values.js'; -// Server-side per-column filter (the documented POST /search path). The handle field is -// the first major column, so its filter input is the first one in the filter row. +// Server-side row lookup via the toolbar "Search ..." box - present on every list +// layout (manage-list, master-detail, document) and searching the string columns server-side. +// The per-column filter row is NOT used: its first input can belong to an FK or date column +// (hidden or non-text), which made a blind fill hang. async function filterBy(page, value) { - const filter = page.getByPlaceholder('Filter…').first(); - await filter.fill(value); + await page.getByPlaceholder(/^Search /).first().fill(value); } function dataRow(page, text) { @@ -17,6 +18,12 @@ export function crudFlow(manifest, entity, opts = {}) { const cfg = opts.extend?.entities?.[entity.name] ?? {}; const skip = new Set(cfg.skip ?? []); if (skip.has('crud')) return; + // A hierarchy entity lists as a tree, and a calendar/slots entity replaces the table page + // entirely - no filter row / data rows to drive the walk below; create/read/update/delete + // stays covered by the REST flow. Same for an entity without a string handle field (nothing + // searchable identifies the created row in the table). + if (entity.hierarchy || entity.layout === 'calendar' || entity.layout === 'slots') return; + if (!handleField(entity)) return; test(`${entity.name}: create, edit and delete through the UI`, async ({ page, api }) => { const record = sampleRecord(entity); @@ -25,12 +32,20 @@ export function crudFlow(manifest, entity, opts = {}) { // create await page.goto(manifest.standaloneShell + entity.route); - await page.getByRole('button', { name: 'New' }).click(); + // exact: the empty state adds a second "New " button that a substring match also hits + await page.getByRole('button', { name: 'New', exact: true }).click(); await expect(page).toHaveURL(/\/create$/); await cfg.beforeCreate?.(page, record); await fillForm(page, manifest, entity, record, relationSamples, opts); - await page.getByRole('button', { name: 'Create' }).click(); - await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$')); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + if (entity.layout === 'document') { + // a document create lands on the NEW record's page (line-item editing continues there); + // saving an edit is what returns to the list + await expect(page).toHaveURL(/\/edit$/); + await page.goto(manifest.standaloneShell + entity.route); + } else { + await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$')); + } await filterBy(page, record[handle.name]); await expect(dataRow(page, record[handle.name])).toHaveCount(1); await cfg.afterCreate?.(page, record); @@ -39,10 +54,14 @@ export function crudFlow(manifest, entity, opts = {}) { if (!skip.has('edit')) { const updated = record[handle.name] + '-UPD'; await dataRow(page, record[handle.name]).click(); - await page.getByRole('button', { name: 'Edit' }).first().click(); + // exact: substring name matching would also hit e.g. a "Credit Notes" sidebar item + await page.getByRole('button', { name: 'Edit', exact: true }).first().click(); await expect(page).toHaveURL(/\/edit$/); + // the record loads async after the form renders - filling before the fetch completes + // gets overwritten by the load and Save persists the OLD value + await expect(page.locator('#f_' + handle.name)).toHaveValue(record[handle.name]); await fillField(page, handle, updated, opts); - await page.getByRole('button', { name: 'Save' }).click(); + await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page).toHaveURL(new RegExp(entity.route.replace(/[#/]/g, '\\$&') + '$')); await filterBy(page, updated); await expect(dataRow(page, updated)).toHaveCount(1); @@ -54,7 +73,7 @@ export function crudFlow(manifest, entity, opts = {}) { await dataRow(page, record[handle.name]).click(); await page.getByRole('button', { name: 'Delete', exact: true }).first().click(); const dialog = page.locator('[x-h-dialog-overlay][data-open]'); - await dialog.getByRole('button', { name: 'Delete' }).click(); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); await expect(dialog).toHaveCount(0); await filterBy(page, record[handle.name]); await expect(dataRow(page, record[handle.name])).toHaveCount(0); diff --git a/npm/test/src/flows/list.js b/npm/test/src/flows/list.js index 58ed70ab6b8..aac46e5a897 100644 --- a/npm/test/src/flows/list.js +++ b/npm/test/src/flows/list.js @@ -1,16 +1,34 @@ import { expect, test } from '../fixtures.js'; import { labelOf } from '../sample-values.js'; -// The list page renders: plural title in the toolbar, one column header per major -// field, and (when seed data is expected) at least one data row. +// The list page renders: plural title in the toolbar, then one of three bodies - +// a tree (hierarchy entities render role=treeitem nodes, no table), the table with one +// column header per major field, or (when the entity has no rows yet) the empty state. +// When seed data is expected, rows/nodes must actually be there. export function listFlow(manifest, entity) { test(`${entity.name}: list page renders the declared columns`, async ({ page }) => { await page.goto(manifest.standaloneShell + entity.route); await expect(page.locator('[x-h-toolbar-title]', { hasText: entity.labelPlural }).first()).toBeVisible(); - for (const field of (entity.fields ?? []).filter((f) => f.major !== false && !f.primaryKey)) { - await expect(page.getByRole('columnheader').filter({ hasText: labelOf(field) }).first()).toBeVisible(); + if (entity.hierarchy) { + if (entity.expectSeedData) { + await expect(page.getByRole('treeitem').first()).toBeVisible(); + } + return; } - if (entity.expectSeedData) { + if (entity.layout === 'calendar' || entity.layout === 'slots') { + // the view family renders a calendar / slot picker instead of the table + await expect(page.locator('[x-h-calendar], [x-h-slot-picker]').first()).toBeVisible(); + return; + } + // filter({ visible: true }): the empty-state markup stays in the DOM (x-show) above the + // table, so an unfiltered union's .first() would pick the hidden element and always fail + const firstHeader = page.getByRole('columnheader').filter({ visible: true }).first(); + const emptyState = page.getByText('Get started by creating the first record').filter({ visible: true }).first(); + await expect(firstHeader.or(emptyState).first()).toBeVisible(); + if (entity.expectSeedData || (await firstHeader.isVisible())) { + for (const field of (entity.fields ?? []).filter((f) => f.major !== false && !f.primaryKey)) { + await expect(page.getByRole('columnheader').filter({ hasText: labelOf(field) }).first()).toBeVisible(); + } await expect(page.locator('tbody tr:visible').first()).toBeVisible(); } }); diff --git a/npm/test/src/flows/rest.js b/npm/test/src/flows/rest.js index ed15f387123..0decb9e6d34 100644 --- a/npm/test/src/flows/rest.js +++ b/npm/test/src/flows/rest.js @@ -1,5 +1,6 @@ import { makeApi } from '../api.js'; import { expect, test } from '../fixtures.js'; +import { resolveRelationSamples } from '../form.js'; import { handleField, sampleRecord } from '../sample-values.js'; // The same contract over the generated REST controllers, no browser: isolates backend @@ -13,11 +14,10 @@ export function restFlow(manifest, entity, opts = {}) { const client = makeApi(api, manifest); const payload = sampleRecord(entity); const handle = handleField(entity); - for (const relation of entity.relations ?? []) { - const target = manifest.entities.find((e) => e.name === relation.to); - const rows = await client.list(target, 1); - expect(rows.length, `${relation.to} must have at least one row`).toBeGreaterThan(0); - payload[relation.name] = rows[0][idProperty]; + // same resolution the UI flow uses: cross-model targets via apiAbsolute, entityStatus + // relations left to their init: DB default + for (const sample of await resolveRelationSamples(api, manifest, entity)) { + payload[sample.relation.name] = sample.id; } const created = await client.create(entity, payload); @@ -25,12 +25,16 @@ export function restFlow(manifest, entity, opts = {}) { expect(id, 'create response carries the generated id').toBeTruthy(); try { const read = await client.get(entity, id); - expect(read[handle.name]).toBe(payload[handle.name]); + expect(read[idProperty]).toBe(id); + // the update round-trip needs a writable string field to flip; skip it when there is none + if (handle) { + expect(read[handle.name]).toBe(payload[handle.name]); - const updatedValue = payload[handle.name] + '-UPD'; - await client.update(entity, id, { ...read, [handle.name]: updatedValue }); - const reread = await client.get(entity, id); - expect(reread[handle.name]).toBe(updatedValue); + const updatedValue = payload[handle.name] + '-UPD'; + await client.update(entity, id, { ...read, [handle.name]: updatedValue }); + const reread = await client.get(entity, id); + expect(reread[handle.name]).toBe(updatedValue); + } } finally { await client.remove(entity, id); } diff --git a/npm/test/src/form.js b/npm/test/src/form.js index 97f1a0624c9..19b3c1eafe0 100644 --- a/npm/test/src/form.js +++ b/npm/test/src/form.js @@ -8,37 +8,113 @@ export async function fillField(page, field, value, opts = {}) { if (custom) return custom(page, field, value); const input = page.locator('#f_' + field.name); if (field.type === 'boolean') return input.setChecked(!!value); + if (field.type === 'timestamp' || field.type === 'datetime') { + // the sample is a full ISO instant (what the REST layer binds); a datetime-local input + // takes the zone-less YYYY-MM-DDTHH:mm prefix + return input.fill(String(value).slice(0, 16)); + } await input.fill(String(value)); } // The x-h-select directive hides its input and builds a span[role=combobox] trigger // labelled by the field label; options carry role=option. export async function pickDropdown(page, relation, optionText) { - await page.getByRole('combobox', { name: relation.label ?? relation.name }).click(); - await page.getByRole('option', { name: optionText }).first().click(); + // Anchored prefix match: the combobox accessible name is the label plus the placeholder or + // selected value ("Country Select a Country..."), so exact matching finds nothing - while a + // bare substring match collides with longer sibling labels ("Type" also hits "Chart Type"). + const label = relation.label ?? relation.name; + const anchored = new RegExp('^' + label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'); + await page.getByRole('combobox', { name: anchored }).first().click(); + const option = page.getByRole('option', { name: optionText }).first(); + try { + await option.click({ timeout: 10_000 }); + } catch { + // the option list re-rendered mid-click (async option load reflow) - reopen and retry + await page.getByRole('combobox', { name: anchored }).first().click(); + await option.click({ force: true }); + } } -// Resolve a live option for each to-one relation: take the first existing row of the +// Resolve a live option for each to-one relation: take the first suitable row of the // target entity and use its label field's value as the visible option text. +// - an entityStatus relation is skipped: it renders as a status pill (not an editable +// input in any form) and its value comes from the init: DB default; +// - a cross-model relation's rows come from its apiAbsolute controller URL (the target +// lives in another module and is not in this manifest); +// - a where: option filter narrows the candidate rows to matching ones; +// - a dependsOn cascade forces CONSISTENT samples: the dependent row is chosen first and +// its filterBy FK becomes the trigger sibling's sample (independent first rows would +// pick e.g. Country=Afghanistan + City=Sofia, and the cascade then offers no options). export async function resolveRelationSamples(request, manifest, entity) { const api = makeApi(request, manifest); - const samples = []; - for (const relation of entity.relations ?? []) { + const idProperty = manifest.idProperty ?? 'Id'; + + async function fetchRows(relation, limit) { + if (relation.apiAbsolute) return { rows: await api.listPath(relation.apiAbsolute, limit), labelFrom: relation.labelFrom ?? 'Name' }; + if (relation.api) return { rows: await api.listPath(manifest.restBase + relation.api, limit), labelFrom: relation.labelFrom ?? 'Name' }; const target = manifest.entities.find((e) => e.name === relation.to); if (!target) throw new Error(`Relation ${entity.name}.${relation.name}: target ${relation.to} not in manifest`); - const rows = await api.list(target, 1); - if (!rows?.length) throw new Error(`Relation ${entity.name}.${relation.name}: no ${relation.to} rows to pick from`); - const labelFrom = relation.labelFrom ?? handleField(target).name; + return { rows: await api.list(target, limit), labelFrom: relation.labelFrom ?? handleField(target)?.name ?? 'Name' }; + } + + const samples = []; + for (const relation of entity.relations ?? []) { + if (relation.entityStatus) continue; + // a filtered picker needs a matching candidate, so fetch a page and filter client-side + const wide = relation.where || relation.leafOnly; + const { rows: fetched, labelFrom } = await fetchRows(relation, wide ? 200 : 1); + let rows = relation.where ? fetched?.filter((r) => String(r[relation.where.by]) === String(relation.where.value)) : fetched; + if (relation.leafOnly && rows?.length) { + // the generated validation rejects a non-leaf target - pick a row no other row parents + const prop = relation.leafOnly.hierarchyProperty; + const idProp = manifest.idProperty ?? 'Id'; + rows = rows.filter((row) => !fetched.some((other) => other[prop] === row[idProp])); + } + if (!rows?.length) { + // a required FK cannot be satisfied - fail loudly; an optional one is simply left unset + if (relation.required) throw new Error(`Relation ${entity.name}.${relation.name}: no ${relation.to} rows to pick from`); + continue; + } + const label = rows[0][labelFrom]; + if (label == null && !relation.required) { + // no display label to pick by (the target has no name-like field) - leave the optional + // relation unset rather than clicking blind + continue; + } samples.push({ relation, - id: rows[0][manifest.idProperty ?? 'Id'], - label: rows[0][labelFrom], + row: rows[0], + id: rows[0][idProperty], + label: label ?? String(rows[0][idProperty]), }); } + + // cascade consistency: re-point each dependsOn trigger at the row the dependent's choice implies + for (const sample of samples) { + const dependsOn = sample.relation.dependsOn; + if (!dependsOn?.filterBy) continue; + const trigger = samples.find((s) => s.relation.name === dependsOn.relation); + const impliedId = sample.row[dependsOn.filterBy]; + if (!trigger || impliedId == null || trigger.id === impliedId) continue; + const path = trigger.relation.apiAbsolute ?? (trigger.relation.api ? manifest.restBase + trigger.relation.api : null); + if (!path) continue; + const row = await api.getPath(`${path}/${impliedId}`); + if (!row) continue; + trigger.row = row; + trigger.id = impliedId; + trigger.label = row[trigger.relation.labelFrom ?? 'Name']; + } return samples; } export async function fillForm(page, manifest, entity, record, relationSamples, opts = {}) { - for (const field of editableFields(entity)) await fillField(page, field, record[field.name], opts); - for (const sample of relationSamples) await pickDropdown(page, sample.relation, sample.label); + for (const field of editableFields(entity)) { + if (record[field.name] === undefined) continue; + await fillField(page, field, record[field.name], opts); + } + // cascade order: a dependsOn trigger must be picked BEFORE its dependent, so the narrowed + // option list is the one the dependent's sample was chosen from + const triggers = relationSamples.filter((s) => !s.relation.dependsOn); + const dependents = relationSamples.filter((s) => s.relation.dependsOn); + for (const sample of [...triggers, ...dependents]) await pickDropdown(page, sample.relation, sample.label); } diff --git a/npm/test/src/sample-values.js b/npm/test/src/sample-values.js index 1c341ba3178..96e0421b52f 100644 --- a/npm/test/src/sample-values.js +++ b/npm/test/src/sample-values.js @@ -29,7 +29,9 @@ export function sampleValue(field) { return '2026-07-08'; case 'timestamp': case 'datetime': - return '2026-07-08T10:00'; + // full ISO instant: the generated Java entities bind java.time.Instant, which rejects a + // zone-less value; the UI fill slices this to the datetime-local shape + return '2026-07-08T10:00:00Z'; default: return 'APPTEST-' + rand(ALPHA + DIGITS, 6); } @@ -44,15 +46,20 @@ export function editableFields(entity) { export function sampleRecord(entity) { const record = {}; for (const field of editableFields(entity)) record[field.name] = sampleValue(field); + // an exactlyOne check rejects a record where more than one of the named fields is set - + // keep only the first of each declared set + for (const set of entity.exactlyOne ?? []) { + for (const name of set.slice(1)) delete record[name]; + } return record; } // The searchable "handle" field: the first long string field shown in the list. Its // value identifies the record in the table across the create/edit/delete flow. +// Null when the entity has no such field (all-numeric/date entities) - flows degrade: +// the UI walk is skipped and the REST flow drops its update-value assertion. export function handleField(entity) { - const field = editableFields(entity).find((f) => f.type === 'string' && (f.length ?? 64) >= 16 && f.major !== false); - if (!field) throw new Error(`Entity ${entity.name} has no string handle field for UI flows`); - return field; + return editableFields(entity).find((f) => f.type === 'string' && (f.length ?? 64) >= 16 && f.major !== false) ?? null; } export function labelOf(field) {