diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/main/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridPage.java b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/main/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridPage.java index b72d960a94f..7087ef275e6 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/main/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridPage.java +++ b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/main/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridPage.java @@ -27,6 +27,7 @@ public class DynamicContextMenuGridPage extends Div { public DynamicContextMenuGridPage() { gridWithDynamicContextMenu(); + gridWithDynamicContentProvider(); } private void gridWithDynamicContextMenu() { @@ -53,4 +54,30 @@ private void gridWithDynamicContextMenu() { grid.setId("grid-with-dynamic-context-menu"); add(grid); } + + private void gridWithDynamicContentProvider() { + final Grid grid = new Grid<>(); + Grid.Column nameColumn = grid.addColumn(Person::getFirstName) + .setHeader("Name"); + grid.addColumn(Person::getAge).setHeader("Age"); + + grid.setItems(IntStream.range(0, 50) + .mapToObj(i -> new Person("Person " + i, i)).toList()); + + GridContextMenu contextMenu = grid.addContextMenu(); + + contextMenu.setDynamicContentProvider(context -> { + Person person = context.item(); + contextMenu.removeAll(); + if (person != null) { + contextMenu.addItem(context.column() == nameColumn + ? "Call " + person.getFirstName() + : "Age " + person.getAge()); + } + return person != null; + }); + + grid.setId("grid-with-dynamic-content-provider"); + add(grid); + } } diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/test/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridIT.java b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/test/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridIT.java index 0993f311798..6c95c65b9a1 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/test/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridIT.java +++ b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/src/test/java/com/vaadin/flow/component/grid/contextmenu/DynamicContextMenuGridIT.java @@ -36,7 +36,7 @@ public class DynamicContextMenuGridIT extends AbstractComponentIT { @Before public void init() { open(); - grid = $(GridElement.class).first(); + grid = $(GridElement.class).id("grid-with-dynamic-context-menu"); verifyClosed(); } @@ -76,6 +76,25 @@ public void shouldOpenContextMenuWhenClickingOnTheEdgeOfCell() { getContextMenu().getDomProperty("innerText")); } + @Test + public void dynamicContentProvider_menuContentDependsOnClickedColumn() { + GridElement columnGrid = $(GridElement.class) + .id("grid-with-dynamic-content-provider"); + + columnGrid.getCell(5, 1).contextClick(); + verifyOpened(); + Assert.assertEquals("Age 5", + getContextMenu().getDomProperty("innerText")); + + $("body").first().click(); + verifyClosed(); + + columnGrid.getCell(5, 0).contextClick(); + verifyOpened(); + Assert.assertEquals("Call Person 5", + getContextMenu().getDomProperty("innerText")); + } + private TestBenchElement getContextMenu() { return $("vaadin-context-menu").first(); } diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/test/grid-connector-context-menu.test.ts b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/test/grid-connector-context-menu.test.ts index 34bcdfebb3a..18202172fbe 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/test/grid-connector-context-menu.test.ts +++ b/vaadin-grid-flow-parent/vaadin-grid-flow-integration-tests/test/grid-connector-context-menu.test.ts @@ -23,6 +23,7 @@ describe('grid connector - context menu', () => { column = grid.querySelector('vaadin-grid-column')!; column.id = 'name-column'; + column._flowId = 'col0'; setRootItems(grid.$connector, [ { key: '0', name: 'foo' }, @@ -41,14 +42,14 @@ describe('grid connector - context menu', () => { }); it('should return item key and column id in before-open detail', () => { - let detail: { key: string, columnId: string }; + let detail: { key: string; columnId: string; internalColumnId: string }; // The detail is resolved while the source event is still being dispatched grid.addEventListener('contextmenu', (e) => { detail = grid.getContextMenuBeforeOpenDetail(new CustomEvent('contextmenu', { detail: { sourceEvent: e } })); }); getBodyCellContent(grid, 0, 1)!.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, composed: true })); - expect(detail!).to.deep.equal({ key: '0', columnId: 'name-column' }); + expect(detail!).to.deep.equal({ key: '0', columnId: 'name-column', internalColumnId: 'col0' }); }); it('should prevent context menu on selection column left click', () => { diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenu.java b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenu.java index 00acfa044e4..2d1ca82e105 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenu.java +++ b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenu.java @@ -15,6 +15,7 @@ */ package com.vaadin.flow.component.grid.contextmenu; +import java.io.Serializable; import java.util.List; import java.util.Optional; @@ -44,6 +45,7 @@ public class GridContextMenu extends implements HasGridMenuItems { private SerializablePredicate dynamicContentHandler; + private SerializablePredicate> dynamicContentProvider; /** * Event that is fired when a {@link GridMenuItem} is clicked inside a @@ -135,6 +137,27 @@ public Optional getColumnId() { } + /** + * The context of a context-click on a {@link Grid}, passed to the + * {@link #setDynamicContentProvider(SerializablePredicate) dynamic content + * provider}. + * + * @param item + * the item in the Grid that was the target of the context-click, + * or {@code null} if the context-click didn't target any item + * (eg. if targeting a header) + * @param column + * the column in the Grid that was the target of the + * context-click, or {@code null} if the context-click didn't + * target any application column (eg. selection column) + * @param + * the grid bean type + * @since 25.4 + */ + public record DynamicContentContext(T item, + Grid.Column column) implements Serializable { + } + /** * Creates an empty context menu to be used with a Grid. */ @@ -247,9 +270,15 @@ public Registration addGridContextMenuOpenedListener( *

* * @return the callback function that is executed before opening the context - * menu, or {@code null} if not specified. + * menu, or {@code null} if not specified or if a dynamic content + * provider was set with + * {@link #setDynamicContentProvider(SerializablePredicate)}. * @since 4.1 + * @deprecated Use {@link #getDynamicContentProvider()} together with + * {@link #setDynamicContentProvider(SerializablePredicate)}, + * which also receives the clicked column. */ + @Deprecated(since = "25.4", forRemoval = true) public SerializablePredicate getDynamicContentHandler() { return dynamicContentHandler; } @@ -267,15 +296,80 @@ public SerializablePredicate getDynamicContentHandler() { * The boolean return value of this callback specifies if the context menu * will be opened. *

+ *

+ * Replaces any provider set with + * {@link #setDynamicContentProvider(SerializablePredicate)}. + *

* * @param dynamicContentHandler * the callback function that will be executed before opening the * context menu. * @since 4.1 + * @deprecated Use {@link #setDynamicContentProvider(SerializablePredicate)} + * instead, which also receives the clicked column. */ + @Deprecated(since = "25.4", forRemoval = true) public void setDynamicContentHandler( SerializablePredicate dynamicContentHandler) { this.dynamicContentHandler = dynamicContentHandler; + this.dynamicContentProvider = null; + } + + /** + * Gets the callback function that is executed before the context menu is + * opened to dynamically provide its contents. + * + * @return the callback function that is executed before opening the context + * menu, or {@code null} if not specified or if a handler was set + * with {@link #setDynamicContentHandler(SerializablePredicate)}. + * @see #setDynamicContentProvider(SerializablePredicate) + * @since 25.4 + */ + public SerializablePredicate> getDynamicContentProvider() { + return dynamicContentProvider; + } + + /** + * Sets a callback that is executed before the context menu is opened to + * dynamically provide its contents. + * + *

+ * The callback receives a {@link DynamicContentContext} with the clicked + * item and column. This is useful when the context menu items depend on + * what was clicked, for example: + *

+ * + *
+     * contextMenu.setDynamicContentProvider(context -> {
+     *     contextMenu.removeAll();
+     *     Person person = context.item();
+     *     if (context.column() == nameColumn) {
+     *         contextMenu.addItem("Call", e -> call(person));
+     *     } else if (context.column() == addressColumn) {
+     *         contextMenu.addItem("Show on map", e -> showOnMap(person));
+     *     }
+     *     return true;
+     * });
+     * 
+ * + *

+ * The boolean return value of this callback specifies if the context menu + * will be opened. + *

+ *

+ * Replaces any handler set with + * {@link #setDynamicContentHandler(SerializablePredicate)}. + *

+ * + * @param dynamicContentProvider + * the callback function that will be executed before opening the + * context menu, or {@code null} to remove it + * @since 25.4 + */ + public void setDynamicContentProvider( + SerializablePredicate> dynamicContentProvider) { + this.dynamicContentProvider = dynamicContentProvider; + this.dynamicContentHandler = null; } /** @@ -283,10 +377,26 @@ public void setDynamicContentHandler( * * @since 25.0 */ + @SuppressWarnings("removal") @Override protected boolean onBeforeOpenMenu(ObjectNode eventDetail) { Grid grid = (Grid) getTarget(); String key = eventDetail.get("key").asString(); + String columnId = eventDetail.get("columnId").asString(); + + // The grid connector also reports the target with a separate server + // call, which is processed after this event. Update the target here so + // that it is up to date in the dynamic content handlers. + grid.getElement().setProperty("_contextMenuTargetItemKey", key); + grid.getElement().setProperty("_contextMenuTargetColumnId", columnId); + + if (dynamicContentProvider != null) { + final T item = grid.getDataCommunicator().getKeyMapper().get(key); + final Grid.Column column = getColumnByInternalId(grid, + eventDetail.get("internalColumnId").asString()); + return dynamicContentProvider + .test(new DynamicContentContext<>(item, column)); + } if (getDynamicContentHandler() != null) { final T item = grid.getDataCommunicator().getKeyMapper().get(key); @@ -295,4 +405,12 @@ protected boolean onBeforeOpenMenu(ObjectNode eventDetail) { return super.onBeforeOpenMenu(eventDetail); } + + private static Grid.Column getColumnByInternalId(Grid grid, + String internalId) { + return grid.getColumns().stream() + .filter(column -> internalId + .equals(column.getElement().getProperty("_flowId"))) + .findFirst().orElse(null); + } } diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/gridConnector.ts b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/gridConnector.ts index c647c2d0b30..08c57989726 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/gridConnector.ts +++ b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/gridConnector.ts @@ -512,7 +512,8 @@ export class GridConnector { const eventContext = grid.getEventContext(sourceEvent); const key = eventContext.item?.key || ''; const columnId = eventContext.column?.id || ''; - return { key, columnId }; + const internalColumnId = eventContext.column?._flowId ?? ''; + return { key, columnId, internalColumnId }; }; grid.preventContextMenu = (event) => { diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/vaadin-grid-types.d.ts b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/vaadin-grid-types.d.ts index 429a2e93039..971842023b8 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/vaadin-grid-types.d.ts +++ b/vaadin-grid-flow-parent/vaadin-grid-flow/src/main/resources/META-INF/frontend/vaadin-grid/vaadin-grid-types.d.ts @@ -93,7 +93,11 @@ export interface FlowGridInternals { _isDetailsOpened(item: Item | undefined): boolean; isItemSelectable(item: Item | null | undefined): boolean; _mapSorters(): GridSorterDefinition[]; - getContextMenuBeforeOpenDetail(event: CustomEvent<{ sourceEvent?: Event }>): { key: string; columnId: string }; + getContextMenuBeforeOpenDetail(event: CustomEvent<{ sourceEvent?: Event }>): { + key: string; + columnId: string; + internalColumnId: string; + }; preventContextMenu(event: MouseEvent): boolean; } diff --git a/vaadin-grid-flow-parent/vaadin-grid-flow/src/test/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenuTest.java b/vaadin-grid-flow-parent/vaadin-grid-flow/src/test/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenuTest.java index af4894fe9b2..3c04c752759 100644 --- a/vaadin-grid-flow-parent/vaadin-grid-flow/src/test/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenuTest.java +++ b/vaadin-grid-flow-parent/vaadin-grid-flow/src/test/java/com/vaadin/flow/component/grid/contextmenu/GridContextMenuTest.java @@ -16,6 +16,7 @@ package com.vaadin.flow.component.grid.contextmenu; import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -25,13 +26,18 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.ComponentEventListener; import com.vaadin.flow.component.contextmenu.ContextMenu; +import com.vaadin.flow.component.contextmenu.ContextMenuBase; import com.vaadin.flow.component.contextmenu.MenuItem; import com.vaadin.flow.component.contextmenu.MenuManager; import com.vaadin.flow.component.contextmenu.SubMenu; import com.vaadin.flow.component.grid.Grid; import com.vaadin.flow.component.grid.contextmenu.GridContextMenu.GridContextMenuItemClickEvent; import com.vaadin.flow.component.html.NativeButton; +import com.vaadin.flow.dom.DomEvent; +import com.vaadin.flow.function.SerializablePredicate; import com.vaadin.flow.function.SerializableRunnable; +import com.vaadin.flow.internal.JacksonUtils; +import com.vaadin.flow.internal.nodefeature.ElementListenerMap; class GridContextMenuTest { @@ -136,4 +142,101 @@ void multipleContextMenus_clearTargetOfOne_getContextMenusKeepsOthers() { Assertions.assertEquals(List.of(first, third), grid.getContextMenus()); } + + @Test + void dynamicContentHandler_targetColumnIdIsUpdatedBeforeHandlerRuns() { + Grid grid = new Grid<>(); + grid.addColumn(item -> item).setId("first"); + grid.addColumn(item -> item).setId("second"); + GridContextMenu contextMenu = grid.addContextMenu(); + + AtomicReference columnIdInHandler = new AtomicReference<>(); + contextMenu.setDynamicContentHandler(item -> { + columnIdInHandler.set(grid.getElement() + .getProperty("_contextMenuTargetColumnId")); + return false; + }); + + fireBeforeOpenEvent(grid, "second"); + Assertions.assertEquals("second", columnIdInHandler.get()); + + fireBeforeOpenEvent(grid, "first"); + Assertions.assertEquals("first", columnIdInHandler.get()); + } + + @Test + void dynamicContentProvider_receivesClickedItemAndColumn() { + Grid grid = new Grid<>(); + grid.setItems("foo", "bar"); + Grid.Column first = grid.addColumn(item -> item); + Grid.Column second = grid.addColumn(item -> item); + GridContextMenu contextMenu = grid.addContextMenu(); + + AtomicReference> contextInProvider = new AtomicReference<>(); + contextMenu.setDynamicContentProvider(context -> { + contextInProvider.set(context); + return false; + }); + + String barKey = grid.getDataCommunicator().getKeyMapper().key("bar"); + fireBeforeOpenEvent(grid, barKey, "", getInternalId(second)); + Assertions.assertEquals("bar", contextInProvider.get().item()); + Assertions.assertSame(second, contextInProvider.get().column()); + + fireBeforeOpenEvent(grid, "", "", getInternalId(first)); + Assertions.assertNull(contextInProvider.get().item()); + Assertions.assertSame(first, contextInProvider.get().column()); + + // Not an application column, e.g. the selection column + fireBeforeOpenEvent(grid, barKey, "", ""); + Assertions.assertEquals("bar", contextInProvider.get().item()); + Assertions.assertNull(contextInProvider.get().column()); + } + + @Test + void dynamicContentProviderAndHandler_replaceEachOther() { + GridContextMenu contextMenu = new Grid() + .addContextMenu(); + SerializablePredicate handler = item -> true; + SerializablePredicate> provider = context -> true; + + contextMenu.setDynamicContentHandler(handler); + Assertions.assertSame(handler, contextMenu.getDynamicContentHandler()); + + contextMenu.setDynamicContentProvider(provider); + Assertions.assertSame(provider, + contextMenu.getDynamicContentProvider()); + Assertions.assertNull(contextMenu.getDynamicContentHandler()); + + AtomicReference called = new AtomicReference<>(false); + contextMenu.setDynamicContentProvider(context -> { + called.set(true); + return false; + }); + contextMenu.setDynamicContentHandler(item -> false); + Assertions.assertNull(contextMenu.getDynamicContentProvider()); + fireBeforeOpenEvent((Grid) contextMenu.getTarget(), "", "", ""); + Assertions.assertFalse(called.get()); + } + + private static String getInternalId(Grid.Column column) { + return column.getElement().getProperty("_flowId"); + } + + private static void fireBeforeOpenEvent(Grid grid, String columnId) { + fireBeforeOpenEvent(grid, "", columnId, ""); + } + + private static void fireBeforeOpenEvent(Grid grid, String key, + String columnId, String internalColumnId) { + var detail = JacksonUtils.createObjectNode(); + detail.put("key", key); + detail.put("columnId", columnId); + detail.put("internalColumnId", internalColumnId); + var eventData = JacksonUtils.createObjectNode(); + eventData.set(ContextMenuBase.EVENT_DETAIL, detail); + grid.getElement().getNode().getFeature(ElementListenerMap.class) + .fireEvent(new DomEvent(grid.getElement(), + "vaadin-context-menu-before-open", eventData)); + } }