From 0ff3dccb6f02a94fe65e91314ca5aa4eb98a8f17 Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Wed, 28 Oct 2026 14:00:00 +0200 Subject: [PATCH] ui: a composition detail panel's rows offer their record's actions, also on the master's preview (#8289) detailPanel gains rowActions(row) and runRowAction(action, row), the pair relatedPanel has since #8118: the child entity's actions its status guards and roles allow. The panel's row menu lists them first, before Preview, Edit and Delete; on the master's preview the menu is shown for a row with actions and offers them and Preview only - Edit and Delete stay withheld there. A composition child has no list and its Preview dialog shows no actions, so before this its declared actions were unreachable without typing its route. Co-Authored-By: Claude Opus 5.5 --- .../shell/js/components/detailPanel.js | 16 +++ .../manage/form-view.html.template | 23 +++- .../tests/api/HarmoniaRecordActionsIT.java | 24 ++++ .../ui/tests/IntentCallActionSampleIT.java | 110 +++++++++++++++++- 4 files changed, 164 insertions(+), 9 deletions(-) diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js index 6ad1b571b09..f179841251b 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js @@ -266,6 +266,22 @@ function detailPanel(def, masterId, master) { this.openForm("/" + this.def.entity + "/" + encodeURIComponent(row[this.def.primaryKey]) + "/preview?" + this.masterQuery() + "&embedded=1&dialog=1", window.T ? T(this.def.tkey, this.def.label) : this.def.label); }, + // A row's record's own contributed actions (#8289), for the row menu: the child entity's actions its + // status guards and roles allow - the rule of the list's row menu (#8116). A composition child has no + // list, and the record dialog Preview opens shows no actions, so the row menu is the one place they + // can be offered. A composition child belongs to this application, so every panel asks; relatedPanel + // keeps its own rule (#8118: a cross-project register asks nothing). An action is the child's own + // declared operation, guarded by its own endpoint - not an edit through the master - so the row menu + // offers it on the master's preview too, and the panel re-reads after it (onActionDone in init). + rowActions(row) { + const store = Alpine.store("customActions"); + return row && store ? store.getActions(this.def.entity, "entity", row) : []; + }, + + runRowAction(action, row) { + Alpine.store("customActions").trigger(action, row[this.def.primaryKey], row); + }, + // --- embedded calendar (calendar defs only) ------------------------------------------------- // Row -> event mapping, the same conventions as the standalone calendar page: Jackson java.time // arrays / epoch seconds / ISO strings normalize via toISO; rows with no start are skipped. diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template index 9c756ba1542..7dfdf4d58ea 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template @@ -580,7 +580,7 @@ - + @@ -594,7 +594,7 @@ - +
+
    + + +
  • -
  • -
  • -
  • +
  • +
  • +
diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaRecordActionsIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaRecordActionsIT.java index 8e45fa8fe97..b969dbe6422 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaRecordActionsIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/HarmoniaRecordActionsIT.java @@ -38,6 +38,8 @@ class HarmoniaRecordActionsIT { private static final String RELATED = "/META-INF/dirigible/application-core/shell/js/components/relatedPanel.js"; + private static final String DETAIL = "/META-INF/dirigible/application-core/shell/js/components/detailPanel.js"; + /** * The proforma's three actions: Print-like (unguarded), a create-from guarded by {@code allowed}, * and a transition guarded by {@code from}. @@ -152,6 +154,28 @@ void aRegisterRowOffersItsSourcesActionsByStatus() throws Exception { } } + /** + * A composition child's detail panel row offers its record's actions by the row's status (#8289) - + * every panel, since a composition child belongs to this application - and the panel's row menu + * lists them, on the master's preview too, where Edit and Delete stay withheld. + */ + @Test + void aDetailPanelRowOffersItsChildsActionsByStatus() throws Exception { + try (Context context = load()) { + context.eval("js", "var panel = { def: { entity: 'ProformaInvoice', primaryKey: 'Id' }, " + + method(DETAIL, " rowActions(row) {") + " };"); + assertEquals("notes,invoice,void", ids(context, "panel.rowActions({ Id: 1, Status: 2 })"), "an ISSUED row offers all three"); + assertEquals("notes", ids(context, "panel.rowActions({ Id: 2, Status: 3 })"), "an INVOICED row only the unguarded one"); + } + String view = read(MANAGE + "form-view.html.template"); + assertTrue(view.contains("x-show=\"!isPreview || def.locksWithMaster === false || rowActions(row).length\""), + "the panel's row menu must show on the master's preview for a row with actions"); + assertTrue(view.contains("x-show=\"(!isPreview || def.locksWithMaster === false) && !isForbidden('update')\""), + "Edit must stay withheld on the master's preview"); + assertTrue(view.contains("x-show=\"(!isPreview || def.locksWithMaster === false) && !isForbidden('delete')\""), + "Delete must stay withheld on the master's preview"); + } + /** * An action declared primary is the record page's filled button when it applies (#8119), after a * pending process task, and leaves the overflow menu. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentCallActionSampleIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentCallActionSampleIT.java index 94bf9d5dd30..b5239af96eb 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentCallActionSampleIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentCallActionSampleIT.java @@ -72,12 +72,18 @@ class IntentCallActionSampleIT extends UserInterfaceIntegrationTest { fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string } + - name: WatcherStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } - name: Ticket fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: subject, type: string, required: true } relations: - { name: Status, kind: manyToOne, to: TicketStatus, function: EntityStatus, init: 1 } + - { name: watchers, kind: oneToMany, to: Watcher } related: - { entity: Escalation, show: [id, reason, Status] } - name: Escalation @@ -87,6 +93,13 @@ class IntentCallActionSampleIT extends UserInterfaceIntegrationTest { relations: - { name: Ticket, kind: manyToOne, to: Ticket } - { name: Status, kind: manyToOne, to: EscalationStatus, function: EntityStatus, init: 1 } + - name: Watcher + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: email, type: string } + relations: + - { name: Ticket, kind: manyToOne, to: Ticket, composition: true } + - { name: Status, kind: manyToOne, to: WatcherStatus, function: EntityStatus, init: 1 } actions: - name: EscalateTicket forEntity: Ticket @@ -112,11 +125,19 @@ class IntentCallActionSampleIT extends UserInterfaceIntegrationTest { call: method: POST path: /services/java/helpdesk/custom/api/TicketApi/escalations/{id}/retry + - name: MuteWatcher + forEntity: Watcher + label: Mute + fromStatus: [ACTIVE] + can: Watcher:mute + call: + method: POST + path: /services/java/helpdesk/custom/api/TicketApi/watchers/{id}/mute permissions: - role: SupportLead - can: [Ticket:read, Ticket:escalate, Escalation:read, Escalation:retry] + can: [Ticket:read, Ticket:escalate, Escalation:read, Escalation:retry, Watcher:read, Watcher:mute] - role: Agent - can: [Ticket:read, Escalation:read] + can: [Ticket:read, Escalation:read, Watcher:read] seeds: - name: ticket-statuses entity: TicketStatus @@ -139,6 +160,16 @@ class IntentCallActionSampleIT extends UserInterfaceIntegrationTest { entity: Escalation rows: - { id: 1, reason: Pager down, Ticket: 1, Status: 3 } + - name: watcher-statuses + entity: WatcherStatus + rows: + - { id: 1, name: ACTIVE } + - { id: 2, name: MUTED } + - name: watchers + entity: Watcher + rows: + - { id: 1, email: ann@example.com, Ticket: 1, Status: 1 } + - { id: 2, email: bob@example.com, Ticket: 1, Status: 2 } """; /** @@ -196,6 +227,12 @@ public void retry(@PathParam("id") Integer id) { Response.getNative().setStatus(202); } + @Post("/watchers/{id}/mute") + public void mute(@PathParam("id") Integer id) { + received.add(Map.of("call", "mute " + id)); + Response.getNative().setStatus(202); + } + @Get("/received") public List> received() { return received; @@ -227,7 +264,8 @@ void a_call_action_sends_the_typed_request_to_the_applications_own_endpoint_and_ // whose role lists neither token - receives neither. securityUtil.createUserInDefaultTenant("lead", PASSWORD, "SupportLead"); securityUtil.createUserInDefaultTenant("agent", PASSWORD, "Agent"); - assertEquals(List.of("helpdesk-Escalation-RetryEscalation", "helpdesk-Ticket-EscalateTicket"), actionIds("lead")); + assertEquals(List.of("helpdesk-Escalation-RetryEscalation", "helpdesk-Ticket-EscalateTicket", "helpdesk-Watcher-MuteWatcher"), + actionIds("lead")); assertEquals(List.of(), actionIds("agent")); // The administrator escalates the OPEN ticket through the dialog. @@ -279,6 +317,33 @@ void a_call_action_sends_the_typed_request_to_the_applications_own_endpoint_and_ List register = registerRowMenu(1, "Pager down"); assertTrue(register.contains("Retry"), "the register row of a FAILED escalation offers Retry: " + register); + // A composition child's detail panel offers its rows' actions (#8289). On the ticket's preview the + // ACTIVE watcher's menu offers Mute and Preview - not Edit or Delete, which stay withheld there - + // and + // the MUTED watcher, with no action allowed, has no menu at all. On the ticket's edit the actions + // come + // first, before the panel's own Preview, Edit and Delete. + assertEquals(List.of("Mute", "Preview"), panelRowMenu("/Ticket/1/preview", "ann@example.com"), + "the ACTIVE watcher's row on the ticket's preview"); + assertEquals(List.of(), panelRowMenu("/Ticket/1/preview", "bob@example.com"), "the MUTED watcher's row on the ticket's preview"); + assertEquals(List.of("Mute", "Preview", "Edit", "Delete"), panelRowMenu("/Ticket/1/edit", "ann@example.com"), + "the ACTIVE watcher's row on the ticket's edit"); + // Running it from the panel asks for the confirmation a call action without a prompt always gets, + // then + // posts the call, as from any other menu. + openPanelRowMenu("/Ticket/1/preview", "ann@example.com"); + Selenide.$$("li[x-h-menu-item]") + .filterBy(Condition.visible) + .findBy(Condition.exactText("Mute")) + .click(); + assertEquals("Mute", Selenide.executeJavaScript("var a = Alpine.store('customActions').confirmAction; return a ? a.label : null;"), + "the panel row's Mute asks for its confirmation"); + Selenide.executeJavaScript("Alpine.store('customActions').confirmRun();"); + awaitAnnouncement("Mute completed"); + List> muted = received(); + assertEquals("mute 1", muted.get(muted.size() - 1) + .get("call")); + // Retry is declared primary (#8119): on the FAILED escalation it is the filled button - in the // list's sheet and on its page - and Edit steps down. browser.openPath("/services/web/" + PROJECT + "/gen/helpdesk/index.html#/Escalation"); @@ -400,6 +465,45 @@ private List registerRowMenu(int ticket, String text) { return texts; } + /** + * The items of a detail panel row's menu, on the given ticket route, opened on the row with the + * text - empty when the row offers no menu there. + */ + private List panelRowMenu(String route, String text) { + SelenideElement trigger = openPanelRowMenu(route, text); + if (trigger == null) { + return List.of(); + } + ElementsCollection items = Selenide.$$("ul[aria-labelledby='" + trigger.getAttribute("id") + "'] li[x-h-menu-item]") + .filterBy(Condition.visible); + items.first() + .shouldBe(Condition.visible); + List texts = items.texts(); + Selenide.actions() + .sendKeys(Keys.ESCAPE) + .perform(); + return texts; + } + + /** + * Opens a detail panel row's menu and answers its trigger, or answers null when the row shows none. + */ + private SelenideElement openPanelRowMenu(String route, String text) { + browser.openPath("/services/web/" + PROJECT + "/gen/helpdesk/index.html#" + route); + Selenide.refresh(); + SelenideElement row = Selenide.$$("div[x-data^='detailPanel'] tbody tr") + .findBy(Condition.text(text)) + .shouldBe(Condition.visible); + // The panel's rows render with the store's actions already loaded; give the guards a moment. + Selenide.sleep(500); + SelenideElement trigger = row.$("button[aria-label='Row actions']"); + if (!trigger.isDisplayed()) { + return null; + } + trigger.click(); + return trigger; + } + /** The custom-action descriptors the given user is offered, by id. */ private List actionIds(String user) { AtomicReference> ids = new AtomicReference<>();