Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@
<template x-for="col in def.columns" :key="col.name">
<th x-h-table-head scope="col" :class="col.number ? 'text-right' : ''" x-text="T(col.tkey, col.label || col.name)"></th>
</template>
<th x-h-table-head scope="col" x-show="!isPreview || def.locksWithMaster === false || rows.some(r => $store.processTasks.getTasks(r).length)"></th>
<th x-h-table-head scope="col" x-show="!isPreview || def.locksWithMaster === false || rows.some(r => $store.processTasks.getTasks(r).length || rowActions(r).length)"></th>
</tr>
</thead>
<tbody x-h-table-body>
Expand All @@ -594,7 +594,7 @@
</template>
</td>
</template>
<td x-h-table-cell data-row-actions x-show="!isPreview || def.locksWithMaster === false || rows.some(r => $store.processTasks.getTasks(r).length)">
<td x-h-table-cell data-row-actions x-show="!isPreview || def.locksWithMaster === false || rows.some(r => $store.processTasks.getTasks(r).length || rowActions(r).length)">
<div class="hbox items-center justify-end gap-2">
<!-- The child row's own actionable user tasks (a review its process waits on),
one button each - offered on Preview too, where the row menu is withheld:
Expand All @@ -604,13 +604,24 @@
<svg role="presentation" x-h-lucide data-lucide="inbox"></svg><span x-text="$store.processTasks.taskName(task)"></span>
</button>
</template>
<div class="inline-flex" x-show="!isPreview || def.locksWithMaster === false">
<!-- The row menu. On the master's preview it is shown only for a row whose record has
actions (#8289), and then offers them and Preview: an action is the child's own
declared operation, not an edit, so Edit and Delete stay withheld there. -->
<div class="inline-flex" x-show="!isPreview || def.locksWithMaster === false || rowActions(row).length">
<button type="button" x-h-menu-trigger.dropdown x-h-table-cell-button aria-label="Row actions"><svg role="presentation" x-h-lucide data-lucide="ellipsis"></svg></button>
<ul x-h-menu data-align="bottom-end">
<!-- The row's record's own actions (#8289), first, as in the record page's menu. -->
<template x-for="action in rowActions(row)" :key="action.id">
<li x-h-menu-item @click="runRowAction(action, row)"
x-text="action.translation &amp;&amp; action.translation.key ? T(action.translation.key, action.label, action.translation.options || {}) : action.label"></li>
</template>
<template x-if="rowActions(row).length">
<li x-h-menu-separator></li>
</template>
<li x-h-menu-item @click="previewRow(row)" x-text="T('$projectName:${tprefix}.defaults.preview', 'Preview')"></li>
<li x-h-menu-item x-show="!isForbidden('update')" @click="editRow(row)" x-text="T('$projectName:${tprefix}.defaults.edit', 'Edit')"></li>
<li x-h-menu-separator x-show="!isForbidden('update') && !isForbidden('delete')"></li>
<li x-h-menu-item x-show="!isForbidden('delete')" data-variant="negative" @click="askDelete(row)" x-text="T('$projectName:${tprefix}.defaults.delete', 'Delete')"></li>
<li x-h-menu-item x-show="(!isPreview || def.locksWithMaster === false) && !isForbidden('update')" @click="editRow(row)" x-text="T('$projectName:${tprefix}.defaults.edit', 'Edit')"></li>
<li x-h-menu-separator x-show="(!isPreview || def.locksWithMaster === false) && !isForbidden('update') && !isForbidden('delete')"></li>
<li x-h-menu-item x-show="(!isPreview || def.locksWithMaster === false) && !isForbidden('delete')" data-variant="negative" @click="askDelete(row)" x-text="T('$projectName:${tprefix}.defaults.delete', 'Delete')"></li>
</ul>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 }
""";

/**
Expand Down Expand Up @@ -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<Map<String, Object>> received() {
return received;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -279,6 +317,33 @@ void a_call_action_sends_the_typed_request_to_the_applications_own_endpoint_and_
List<String> 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<Map<String, Object>> 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");
Expand Down Expand Up @@ -400,6 +465,45 @@ private List<String> 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<String> 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<String> 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<String> actionIds(String user) {
AtomicReference<List<String>> ids = new AtomicReference<>();
Expand Down
Loading