diff --git a/FEATURE_ROW_ACTIONS.md b/FEATURE_ROW_ACTIONS.md index b59de92..e582e01 100644 --- a/FEATURE_ROW_ACTIONS.md +++ b/FEATURE_ROW_ACTIONS.md @@ -1,75 +1,221 @@ -# Feature: Row Actions (Roadmap) - -> **Note:** This feature is planned for a future iteration and is not yet implemented. - # Feature: Row Actions -Row actions are buttons or menu items displayed in a dedicated actions column, created and managed by `EasyGrid` on the wrapped grid. +Row actions are buttons or menu items presented by `EasyGrid` on the wrapped grid — as inline buttons, an overflow menu, or the grid's right-click context menu. ## API ### `EasyGrid` methods +#### Adding row actions + +All `addRowAction` overloads register a new action and return an `EasyRowAction` that can be further configured via its fluent API (visibility, enablement, tooltip, confirmation). How the action is presented depends on the current `RowActionsStyle`. + +```java +EasyRowAction addRowAction(String label, SerializableConsumer handler); +``` +Adds a text-only action button. When the user clicks it, `handler` is invoked with the corresponding row item. + +--- + +```java +EasyRowAction addRowAction(String label, Icon icon, SerializableConsumer handler); +EasyRowAction addRowAction(String label, IconFactory iconFactory, SerializableConsumer handler); +``` +Adds an action button with both a label and a static icon rendered alongside it. `VaadinIcon` implements `IconFactory` and can be passed directly. + +--- + +```java +EasyRowAction addRowAction(Icon icon, SerializableConsumer handler); +EasyRowAction addRowAction(IconFactory iconFactory, SerializableConsumer handler); +``` +Adds an icon-only action button with no visible label. Useful when space is limited and the icon alone conveys the action. + +--- + ```java -// Add an action button (label + icon) -EasyRowAction addRowAction(String label, VaadinIcon icon, SerializableConsumer handler); +> +EasyRowAction addRowAction(ValueProvider iconProvider, SerializableConsumer handler); + +> +EasyRowAction addRowAction(String label, ValueProvider iconProvider, SerializableConsumer handler); +``` +Adds an action button whose icon is resolved per row by calling `iconProvider` with the row item, so different rows may show a different icon for the same action. The optional `label` is shown alongside the dynamic icon. -// Add an action button with a theme variant -EasyRowAction addRowAction(String label, VaadinIcon icon, ButtonVariant variant, SerializableConsumer handler); +--- -// Render all actions as a context menu (overflow menu) instead of inline buttons -void setRowActionsAsMenu(boolean asMenu); +#### Rendering mode -// Access the underlying Grid.Column for header, width, freezing, etc. +```java +void setRowActionsStyle(RowActionsStyle style); +``` +Controls how row actions are presented. `RowActionsStyle` has three values: + +- `INLINE_BUTTONS` (the default) — each action is rendered as a separate inline button in the actions column. +- `DROPDOWN` — all actions are presented as items in an overflow menu, opened by a single trigger button per row that is hosted in the actions column. +- `CONTEXT_MENU` — all actions are presented as items in the grid's right-click context menu; no actions column is created. + +--- + +#### Accessing the actions column + +```java Grid.Column getActionsColumn(); ``` +Returns the `Grid.Column` backing the actions column, allowing the caller to configure its header text, width, freeze position, or any other `Grid.Column` property. For the column-based styles the column is created automatically when the actions are first rendered. + +Returns a non-`null` column for the `INLINE_BUTTONS` and `DROPDOWN` styles (the latter hosts its overflow menu trigger button in the column). For the `CONTEXT_MENU` style the actions are not hosted in a column, so this method returns `null`. + +--- + +#### Default theme variants + +```java +void setDefaultRowActionVariants(ButtonVariant... variants); +``` +Sets the Vaadin `ButtonVariant`s applied by default to every action button created *after* this call; actions added earlier are unaffected. The built-in default is `LUMO_TERTIARY_INLINE`. Pass no arguments (or `null`) to clear the defaults. Individual actions can still add their own variants via `EasyRowAction.addThemeVariants(...)`. + +--- + +#### Custom renderer + +```java +void setRowActionsRenderer(RowActionsRenderer renderer); +``` +Replaces the strategy that turns the registered actions into UI. The built-in renderers (selected via `setRowActionsStyle`) cover the common cases; supply a custom `RowActionsRenderer` to present actions another way. The previous renderer is cleaned up and a rebuild is scheduled. + +--- + +#### Refreshing after configuration changes + +```java +void refreshRowActions(); +``` +Schedules a rebuild of the row actions on the next server response. The fluent `EasyRowAction` methods (`visibleWhen`, `enabledWhen`, `tooltip`, `withConfirmation`) already trigger this automatically, so an explicit call is only needed after changing an action's attribute or property, which are not applied automatically. + +--- ### `EasyRowAction` +All mutator methods return `this` to support method chaining. + +#### Visibility + +```java +EasyRowAction visibleWhen(SerializablePredicate predicate); +``` +Makes the action conditionally visible on a per-row basis. The predicate is evaluated against each row's item when the row is rendered; the action button is hidden for rows where the predicate returns `false`. + +--- + +#### Enablement + +```java +EasyRowAction enabledWhen(SerializablePredicate predicate); +``` +Makes the action conditionally enabled on a per-row basis. The action button is shown in a disabled state (visible but not clickable) for rows where the predicate returns `false`. + +--- + +#### Tooltip + +```java +EasyRowAction tooltip(String tooltip); +``` +Sets a fixed tooltip displayed when the user hovers over the action button. + +```java +EasyRowAction tooltip(ValueProvider tooltipProvider); +``` +Sets a per-row tooltip resolved by calling `tooltipProvider` with the row item, so different rows may show different tooltip text for the same action. + +--- + +#### Confirmation + ```java -public class EasyRowAction { - // Conditional visibility - EasyRowAction withVisibleWhen(SerializablePredicate predicate); +EasyRowAction withConfirmation(String message); +EasyRowAction withConfirmation(String title, String message); +``` +Intercepts button clicks and presents a confirmation dialog before invoking the action handler. The handler is only called if the user confirms. `message` is the confirmation prompt shown to the user; the optional `title` sets the dialog heading. - // Conditional enablement - EasyRowAction withEnabledWhen(SerializablePredicate predicate); +--- - // Tooltip - EasyRowAction withTooltip(String tooltip); - EasyRowAction withTooltip(SerializableFunction tooltipProvider); +#### Styling and theme variants - // Confirmation dialog before executing the action - EasyRowAction withConfirmation(String message); - EasyRowAction withConfirmation(String title, String message); -} +`EasyRowAction` implements `HasStyle` and `HasThemeVariant`, so the action's button can be styled and themed directly: + +```java +action.addClassName("danger"); +action.getStyle().set("font-weight", "bold"); +action.addThemeVariants(ButtonVariant.LUMO_ERROR); ``` +These are forwarded onto the rendered button. Unlike the fluent mutators above, style and theme-variant changes made after the grid has already rendered are **not** applied automatically — call `easyGrid.refreshRowActions()` afterwards to make them visible. + +--- + +#### Removal + +```java +void remove(); +``` +Removes this action. The row actions are re-rendered on the next server response, refreshing the grid's data view so the change becomes visible without an explicit data refresh. If the action has already been removed, this call is a no-op. After removal the `EasyRowAction` reference is considered dead and cannot be re-added; call `addRowAction` again to create a new action. + +--- ## Usage ```java -// Inline action buttons +// Label + VaadinIcon (VaadinIcon implements IconFactory) easyGrid.addRowAction("Edit", VaadinIcon.EDIT, person -> { editPerson(person); }); -easyGrid.addRowAction("Delete", VaadinIcon.TRASH, ButtonVariant.LUMO_ERROR, person -> { +easyGrid.addRowAction("Delete", VaadinIcon.TRASH, person -> { personService.delete(person); easyGrid.getDataProvider().refreshAll(); }).withConfirmation("Are you sure you want to delete this person?"); -// Actions as a context menu (overflow menu) instead of inline buttons -easyGrid.setRowActionsAsMenu(true); +// Label only +easyGrid.addRowAction("Details", person -> showDetails(person)); + +// Per-row dynamic icon +easyGrid.addRowAction( + person -> person.isActive() ? VaadinIcon.CHECK.create() : VaadinIcon.CLOSE.create(), + person -> toggleActive(person) +); + +// Actions as an overflow menu instead of inline buttons +easyGrid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + +// ...or as the grid's right-click context menu (no actions column) +easyGrid.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); // Conditional visibility easyGrid.addRowAction("Activate", VaadinIcon.CHECK, person -> { personService.activate(person); -}).withVisibleWhen(person -> !person.isActive()); +}).visibleWhen(person -> !person.isActive()); easyGrid.addRowAction("Deactivate", VaadinIcon.CLOSE, person -> { personService.deactivate(person); -}).withVisibleWhen(person -> person.isActive()); +}).visibleWhen(person -> person.isActive()); + +// Removing an action +EasyRowAction adminAction = easyGrid.addRowAction("Purge", VaadinIcon.TRASH, item -> purge(item)); +// later: +adminAction.remove(); + +// Default theme variants applied to every action added afterwards +easyGrid.setDefaultRowActionVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_TERTIARY); + +// Style or theme an individual action's button +easyGrid.addRowAction("Reset", person -> reset(person)) + .addClassName("warning"); -// Configure the actions column via the underlying Grid.Column +// Configure the actions column via the underlying Grid.Column. +// Available for the INLINE_BUTTONS and DROPDOWN styles. With the CONTEXT_MENU +// style there is no actions column and getActionsColumn() returns null, so the +// chain below would throw a NullPointerException. easyGrid.getActionsColumn() .setHeader("Actions") .setWidth("150px") diff --git a/pom.xml b/pom.xml index 2a1656e..fce2086 100644 --- a/pom.xml +++ b/pom.xml @@ -12,8 +12,8 @@ https://www.flowingcode.com/en/open-source/ - 24.9.1 - 4.10.0 + 24.10.7 + 4.44.0 17 17 UTF-8 @@ -70,6 +70,17 @@ vaadin-core true + + com.flowingcode.vaadin + json-migration-helper + 0.9.4 + + + com.flowingcode.vaadin.test + testbench-rpc + 1.5.0 + test + org.projectlombok lombok @@ -603,12 +614,61 @@ + + dance + + + + org.apache.maven.plugins + maven-clean-plugin + + + + ${project.basedir} + + package.json + package-lock.json + tsconfig.json + tsconfig.json.* + types.d.ts + types.d.ts.* + vite.config.ts + vite.generated.ts + webpack.config.js + webpack.generated.js + + + + ${project.basedir}/frontend + + index.html + + + + ${project.basedir}/frontend/generated + + + ${project.basedir}/node_modules + + + ${project.basedir}/src/main/bundles + + + ${project.basedir}/src/main/dev-bundle + + + + + + + + v25 21 21 - 25.1.3 + 25.1.7 @@ -616,6 +676,12 @@ vaadin-dev true + + org.ow2.asm + asm + 9.8 + true + jakarta.servlet jakarta.servlet-api diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/BeanPropertyDefinition.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/BeanPropertyDefinition.java index 61afaca..9fa8586 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/BeanPropertyDefinition.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/BeanPropertyDefinition.java @@ -39,6 +39,7 @@ * * @param the bean type * @param the property value type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") @RequiredArgsConstructor(access = AccessLevel.PRIVATE) diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyColumn.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyColumn.java index 53a21f6..0693a05 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyColumn.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyColumn.java @@ -38,6 +38,7 @@ * * @param the grid bean type * @param the column value type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") public final class EasyColumn implements IEasyGridColumn, Serializable { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGrid.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGrid.java index 30df8ff..8e4f44d 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGrid.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGrid.java @@ -28,6 +28,7 @@ * internally. This is the standard entry point for using the Easy Grid Add-on. * * @param the grid bean type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") public class EasyGrid extends EasyGridWrapper> { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridComposite.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridComposite.java index db2db49..e15556a 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridComposite.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridComposite.java @@ -43,6 +43,7 @@ * * @param the grid bean type * @param the concrete {@code Grid} subtype being wrapped + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") class EasyGridComposite> extends Composite diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridWrapper.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridWrapper.java index 164887b..d919eb8 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridWrapper.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/EasyGridWrapper.java @@ -20,6 +20,8 @@ package com.flowingcode.vaadin.addons.easygrid; +import com.flowingcode.vaadin.addons.easygrid.actions.HasRowActions; +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsManager; import com.flowingcode.vaadin.addons.easygrid.config.ColumnConfiguration; import com.flowingcode.vaadin.addons.easygrid.config.InstanceEasyGridConfiguration; import com.vaadin.flow.component.grid.Grid; @@ -48,9 +50,11 @@ * * @param the grid bean type * @param the concrete {@code Grid} subtype being wrapped + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") -public class EasyGridWrapper> extends EasyGridComposite { +public class EasyGridWrapper> extends EasyGridComposite + implements HasRowActions { @Getter private final Class beanType; @@ -270,4 +274,7 @@ public ColumnConfiguration typeConfiguration(Class type) { return configuration.forType(type); } + @Getter(lazy = true) + private final RowActionsManager rowActionsManager = new RowActionsManager<>(getWrappedGrid()); + } diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridColumn.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridColumn.java index fd429fb..34d5c4e 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridColumn.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridColumn.java @@ -39,6 +39,7 @@ * * @param the grid bean type * @param the column value type + * @author Javier Godoy / Flowing Code */ sealed interface IEasyGridColumn permits EasyColumn { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridComposite.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridComposite.java index f3810c1..e0c51bc 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridComposite.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/IEasyGridComposite.java @@ -50,6 +50,7 @@ * type argument for Lombok {@code @Delegate} on the {@link EasyGridComposite.IEasyGridDelegate} inner class. * * @param the grid bean type + * @author Javier Godoy / Flowing Code */ public sealed interface IEasyGridComposite permits EasyGridComposite.IEasyGridDelegate { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java index e1fabd2..0b87303 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/RuntimeReflectiveOperationException.java @@ -22,6 +22,8 @@ /** * Unchecked wrapper for {@link ReflectiveOperationException}, used to propagate reflective errors * without requiring callers to declare or catch checked exceptions. + * + * @author Javier Godoy / Flowing Code */ public class RuntimeReflectiveOperationException extends RuntimeException { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java new file mode 100644 index 0000000..f0cdb02 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/AbstractContextMenuRowActionsRenderer.java @@ -0,0 +1,113 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.contextmenu.GridContextMenu; +import java.util.ArrayList; +import java.util.List; +import lombok.NonNull; + +/** + * Base class for {@link RowActionsRenderer} implementations that present row actions through a + * {@link GridContextMenu}. Items are rebuilt dynamically for each row via + * {@link GridContextMenu#setDynamicContentHandler}, so visibility, enabled state, and labels are + * evaluated per-item at open time. + * + *

Subclasses control how the menu is created and triggered by overriding + * {@link #createContextMenu()} and may host the actions in a {@link Grid.Column} by overriding + * {@link #getColumn()}. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +abstract class AbstractContextMenuRowActionsRenderer implements RowActionsRenderer { + + /** The grid this renderer decorates. */ + protected final Grid grid; + + private GridContextMenu contextMenu; + + private List> currentActions = List.of(); + + AbstractContextMenuRowActionsRenderer(@NonNull Grid grid) { + this.grid = grid; + } + + @Override + public void update(List> actions) { + // action snapshot read by the dynamic content handler on each open + currentActions = new ArrayList<>(actions); + + if (contextMenu == null) { + var menu = contextMenu = createContextMenu(); + menu.setDynamicContentHandler(item -> { + if (item == null) { + return false; + } + menu.removeAll(); + for (EasyRowAction action : currentActions) { + if (action.isVisible(item)) { + String label = action.getLabel(item); + var icon = action.getIcon(item); + var menuItem = (label != null) + ? menu.addItem(label, e -> action.execute(item)) + : menu.addItem(icon, e -> action.execute(item)); + menuItem.setEnabled(action.isEnabled(item)); + if (label != null && icon != null) { + menuItem.addComponentAsFirst(icon); + } + } + } + return !menu.getItems().isEmpty(); + }); + } + } + + /** + * Creates the {@link GridContextMenu} that backs this renderer. Invoked once, on the first + * {@link #update(List)} call, before the dynamic content handler is installed. Subclasses may + * override it to customize how the menu is triggered (for example, to suppress the default + * right-click behavior). The default implementation registers a new context menu on the grid via + * {@link Grid#addContextMenu()}. + * + * @return the context menu that backs this renderer + */ + protected GridContextMenu createContextMenu() { + return grid.addContextMenu(); + } + + @Override + public Grid.Column getColumn() { + return null; + } + + @Override + public void remove() { + if (contextMenu != null) { + contextMenu.setTarget(null); + contextMenu.removeFromParent(); + contextMenu = null; + } + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java new file mode 100644 index 0000000..32138ba --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/Constant.java @@ -0,0 +1,59 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.function.ValueProvider; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * A constant-valued provider that always returns the same value. + * + * @param the source type + * @param the value type + * @author Javier Godoy / Flowing Code + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@SuppressWarnings("serial") +final class Constant implements ValueProvider { + + @Getter + private final V value; + + public static Constant of(V value) { + return new Constant<>(value); + } + + public static Constant ofNullable(V value) { + return value == null ? null : of(value); + } + + @Override + public V apply(T source) { + return value; + } + + @Override + public String toString() { + return "CONSTANT["+value+"]"; + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java new file mode 100644 index 0000000..f132385 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/ContextMenuRowActionsRenderer.java @@ -0,0 +1,42 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.contextmenu.GridContextMenu; +import lombok.NonNull; + +/** + * A {@link RowActionsRenderer} that presents row actions as a right-click context menu using + * {@link GridContextMenu}. The menu opens on the grid's default right-click gesture. This renderer + * does not create a {@link Grid.Column}; {@link #getColumn()} always returns {@code null}. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +final class ContextMenuRowActionsRenderer extends AbstractContextMenuRowActionsRenderer { + + ContextMenuRowActionsRenderer(@NonNull Grid grid) { + super(grid); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java new file mode 100644 index 0000000..c5883b3 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/DropdownMenuRowActionsRenderer.java @@ -0,0 +1,97 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.grid.contextmenu.GridContextMenu; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.function.ValueProvider; +import java.util.List; +import lombok.NonNull; + +/** + * A {@link RowActionsRenderer} that presents row actions through an overflow ("⋮") menu. A + * dedicated {@link Grid.Column} hosts a trigger button in each row; clicking it opens a + * {@link GridContextMenu} whose items are rebuilt dynamically per row. The default right-click + * gesture is suppressed so the menu opens only from the trigger button. {@link #getColumn()} + * returns the trigger column. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +final class DropdownMenuRowActionsRenderer extends AbstractContextMenuRowActionsRenderer { + + private Grid.Column column; + + DropdownMenuRowActionsRenderer(@NonNull Grid grid) { + super(grid); + } + + @Override + public void update(List> actions) { + if (column == null) { + var builder = LitRendererBuilder.staticOnly(); + ValueProvider dots = Constant.of(VaadinIcon.ELLIPSIS_DOTS_V.create()); + String handler = """ + ev=>{const grid = ev.composedPath() + .find(el => el.matches?.('vaadin-grid-cell-content')).parentElement + grid.preventContextMenu=false; + grid.$contextMenuTargetConnector.openOnHandler.call(grid,ev); + } + """; + var button = new EasyRowAction(null, null, dots, handler); + button.updateRenderer(builder); + column = grid.addColumn(builder.build()); + column.setAutoWidth(true); + column.setFlexGrow(0); + } + + super.update(actions); + } + + @Override + protected GridContextMenu createContextMenu() { + var menu = super.createContextMenu(); + grid.getElement().executeJs(""" + setTimeout(()=>this.$contextMenuTargetConnector.removeListener()); + """); + return menu; + } + + @Override + public Grid.Column getColumn() { + return column; + } + + @Override + public void remove() { + // Tear down the trigger column in addition to the backing context menu, so switching away from + // this renderer (or removing it) does not leave an orphaned column behind. + super.remove(); + if (column != null) { + grid.removeColumn(column); + column = null; + } + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java new file mode 100644 index 0000000..abece6f --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowAction.java @@ -0,0 +1,361 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.flowingcode.vaadin.addons.easygrid.RuntimeReflectiveOperationException; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.HasStyle; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.icon.AbstractIcon; +import com.vaadin.flow.component.shared.HasThemeVariant; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.SerializablePredicate; +import com.vaadin.flow.function.SerializableSupplier; +import com.vaadin.flow.function.ValueProvider; +import java.io.Serializable; +import java.lang.reflect.Method; +import lombok.Getter; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +/** + * Represents a row action registered on an {@code EasyGrid}. Row actions are rendered either as + * inline buttons in a dedicated actions column or as items in a menu, depending on the grid's + * {@link RowActionsStyle}. + * + *

Use the fluent methods to configure conditional visibility, conditional enablement, tooltips, + * and optional confirmation dialogs before the action is executed. The rendered button can also be + * styled and themed through the inherited {@link HasStyle} and {@link HasThemeVariant} methods. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +public final class EasyRowAction + implements Serializable, HasStyle, HasThemeVariant { + + // ConfirmDialog.addOpenedChangeListener was introduced in Vaadin 25; it does not exist in Vaadin 24. + // Resolved once at class load: non-null means Vaadin 25 (use the Java listener); null means + // Vaadin 24 (fall back to the DOM opened-changed event). + private static final Method ADD_OPENED_CHANGE_LISTENER; + static { + Method addListener = null; + try { + addListener = ConfirmDialog.class.getMethod("addOpenedChangeListener", ComponentEventListener.class); + } catch (NoSuchMethodException ignored) {} + ADD_OPENED_CHANGE_LISTENER = addListener; + } + + private RowActionsManager manager; + + private final ValueProvider labelProvider; + private final ValueProvider> iconProvider; + private final SerializableConsumer actionHandler; + + // Icon fields copied explicitly (with their known attribute/property dispatch) so they take + // precedence over, and are excluded from, the generic attribute/property copy. + private static final String[] PRECEDENCE_ICON_NAMES = + {"icon", "src", "size", ".symbol", ".ligature", ".char", ".fontFamily", ".iconClass"}; + + /** + * Backing element for this action. It is never attached to the DOM; it carries the attributes, + * CSS classes/styles ({@link HasStyle}), and theme variants ({@link HasThemeVariant}) that are + * forwarded onto the rendered {@code } when the actions column is built. + */ + @Getter + private final Element element = new Element("easy-row-action"); + + EasyRowAction(RowActionsManager manager, + ValueProvider labelProvider, + ValueProvider> iconProvider, + SerializableConsumer actionHandler) { + if (labelProvider == null && iconProvider == null) { + throw new IllegalArgumentException("At least one of label or icon must be non-null"); + } + this.manager = manager; + this.labelProvider = labelProvider; + this.iconProvider = iconProvider; + this.actionHandler = actionHandler; + } + + EasyRowAction(RowActionsManager manager, + ValueProvider labelProvider, + ValueProvider> iconProvider, + String eventHandler) { + this(manager, labelProvider, iconProvider, new ClientSideEventHandler(eventHandler)); + } + + @RequiredArgsConstructor + private final static class ClientSideEventHandler implements SerializableConsumer { + + @NonNull + final String eventHandler; + + @Override + public void accept(T t) { + throw new UnsupportedOperationException(); + } + + } + + private SerializablePredicate visibleWhen; + private SerializablePredicate enabledWhen; + private ValueProvider tooltipProvider; + private SerializableSupplier confirmDialogSupplier; + private transient boolean confirmPending; + + private void refresh() { + if (manager != null) { + manager.refresh(); + } + } + + /** + * Sets a predicate that controls whether this action is visible for a given row item. + * + * @param predicate a predicate evaluated for each row item; the action is visible when it returns + * {@code true}. + * @return this action, for method chaining + */ + public EasyRowAction visibleWhen(SerializablePredicate predicate) { + this.visibleWhen = predicate; + refresh(); + return this; + } + + /** + * Sets a predicate that controls whether this action is enabled for a given row item. + * + * @param predicate a predicate evaluated for each row item; the action is enabled when it returns + * {@code true}. + * @return this action, for method chaining + */ + public EasyRowAction enabledWhen(SerializablePredicate predicate) { + this.enabledWhen = predicate; + refresh(); + return this; + } + + /** + * Sets a static tooltip for this action. + * + * @param tooltip the tooltip text + * @return this action, for method chaining + */ + public EasyRowAction tooltip(String tooltip) { + this.tooltipProvider = Constant.of(tooltip); + refresh(); + return this; + } + + /** + * Sets a dynamic tooltip for this action, computed from the row item. + * + * @param tooltipProvider a function that returns the tooltip text for a given row item + * @return this action, for method chaining + */ + public EasyRowAction tooltip(ValueProvider tooltipProvider) { + this.tooltipProvider = tooltipProvider; + refresh(); + return this; + } + + /** + * Configures a confirmation dialog with a message (no title) before the action is executed. + * + * @param message the confirmation message + * @return this action, for method chaining + */ + public EasyRowAction withConfirmation(String message) { + return withConfirmation(null, message); + } + + /** + * Configures a confirmation dialog with both a title and a message before the action is executed. + * + * @param title the dialog title + * @param message the confirmation message + * @return this action, for method chaining + */ + public EasyRowAction withConfirmation(String title, String message) { + return withConfirmation(title, message, "Ok", "Cancel"); + } + + private EasyRowAction withConfirmation(String title, String message, String confirmText, + String cancelText) { + confirmDialogSupplier = () -> { + var dialog = new ConfirmDialog(); + dialog.setHeader(title); + dialog.setText(message); + dialog.setConfirmText(confirmText); + dialog.setCancelable(true); + dialog.setCancelText(cancelText); + return dialog; + }; + // Render-neutral (the dialog is built at click time), but refreshed so every fluent setter + // behaves uniformly. The scheduled rebuild is coalesced with any other pending update. + refresh(); + return this; + } + + /** + * Removes this action from the grid's actions column. The column is re-rendered on the next + * {@code beforeClientResponse} cycle; if no actions remain the column is hidden. Calling + * {@code remove()} on an action that was never registered, or that has already been removed, + * is a no-op. + */ + public void remove() { + if (manager != null) { + var manager = this.manager; + this.manager = null; + manager.removeRowAction(this); + } + } + + boolean isVisible(T item) { + return visibleWhen == null || visibleWhen.test(item); + } + + boolean isEnabled(T item) { + return enabledWhen == null || enabledWhen.test(item); + } + + String getLabel(T item) { + return labelProvider != null ? labelProvider.apply(item) : null; + } + + AbstractIcon getIcon(T item) { + return iconProvider != null ? iconProvider.apply(item) : null; + } + + void execute(T item) { + // Server-side guard: reject the click if the item no longer satisfies visibleWhen/enabledWhen. + // The client-side conditional rendering and ?disabled binding prevent most clicks, but this + // closes the gap for race conditions and devtools manipulation: the per-row server function is + // registered for every item, so an invisible/disabled action remains reachable via a crafted RPC. + if (!isVisible(item) || !isEnabled(item)) { + return; + } + if (confirmDialogSupplier != null) { + // Prevent multiple dialogs from stacking on rapid clicks. + if (confirmPending) { + return; + } + confirmPending = true; + ConfirmDialog dialog = confirmDialogSupplier.get(); + dialog.addConfirmListener(e -> { + if (isVisible(item) && isEnabled(item)) { + actionHandler.accept(item); + } + }); + // Reset on any close path: confirm, cancel, or programmatic dialog.close() + if (ADD_OPENED_CHANGE_LISTENER != null) { + @SuppressWarnings({"rawtypes"}) + ComponentEventListener l = e -> { + if (!dialog.isOpened()) { + confirmPending = false; + } + }; + try { + ADD_OPENED_CHANGE_LISTENER.invoke(dialog, l); + } catch (ReflectiveOperationException ex) { + throw new RuntimeReflectiveOperationException(ex); + } + } else { + dialog.getElement().addEventListener("opened-changed", e -> confirmPending = false) + .setFilter("event.detail.value === false"); + } + dialog.open(); + } else { + actionHandler.accept(item); + } + } + + void updateRenderer(LitRendererBuilder renderer) { + + // Wrap the entire button in a visibility guard; renders nothing when visibleWhen returns false + renderer.withCondition(visibleWhen, () -> { + + // Open the element that represents this action in the row + renderer.tag("vaadin-button", () -> { + if (enabledWhen != null) { + // Bind disabled to the inverse of enabledWhen so the button grays out when the predicate is false + renderer.bindBoolean("disabled", t -> !enabledWhen.test(t)); + } + + // Forward all attributes/properties set on this action's element (e.g. CSS classes) except the two handled below. + // Only exclude "title" if there's a tooltip provider; otherwise preserve manually-set title attributes. + String[] excluded = tooltipProvider != null ? new String[]{"theme", "title"} : new String[]{"theme"}; + renderer.copyAllAttributesAndPropertiesExcept(this, excluded); + // Bind the title attribute per-item so tooltip text can vary by row (only if a provider is set) + if (tooltipProvider != null) { + renderer.bind("title", tooltipProvider); + } + // Set the theme attribute statically; getTheme() appends "icon" when the button is icon-only + renderer.set("theme", getTheme()); + + if (actionHandler instanceof ClientSideEventHandler c) { + renderer.event("click", c.eventHandler); + } else if (actionHandler != null) { + // Wire the DOM click event to the server-side function + int fn = renderer.withFunction((item, args) -> execute(item)); + renderer.event("click", fn); + } + + if (iconProvider instanceof Constant) { + var icon = iconProvider.apply(null); + if (icon != null) { + renderer.tag("vaadin-icon", () -> { + renderer.copyAttributes(icon, PRECEDENCE_ICON_NAMES); + renderer.copyAllAttributesAndPropertiesExcept(icon, PRECEDENCE_ICON_NAMES); + }); + } + } else if (iconProvider != null) { + // Open an child to render the icon; the icon element is evaluated per item + renderer.tag("fc-icon", () -> { + // Spread the icon's relevant attributes/properties (src, ligature, etc.) onto the element + renderer.spreadAllAttributesAndProperties(iconProvider); + }); + } + + // Render the label text as button content; no-op when labelProvider is null (icon-only button) + renderer.addContent(labelProvider); + }); + }); + } + + /** + * Returns the {@code theme} attribute value for this action's {@code }, combining + * any user-set theme variants (e.g. {@code "primary"}) with {@code "icon"} when the button is + * icon-only (an icon is configured and no label provider was set). Returns {@code null} when no + * theme variant applies. + */ + String getTheme() { + String theme = getElement().getAttribute("theme"); + if (iconProvider != null && labelProvider == null) { + theme = theme == null || theme.isEmpty() ? "icon" : theme + " icon"; + } + return theme == null || theme.isEmpty() ? null : theme; + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java new file mode 100644 index 0000000..c76df2c --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/HasRowActions.java @@ -0,0 +1,223 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.confirmdialog.ConfirmDialog; +import com.vaadin.flow.component.dependency.CssImport; +import com.vaadin.flow.component.dependency.JsModule; +import com.vaadin.flow.component.dependency.Uses; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.AbstractIcon; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.IconFactory; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.ValueProvider; +import lombok.NonNull; + +/** + * Mixin interface that adds a configurable row-actions column to a {@link Grid}. Implemented by + * {@code EasyGrid}, it provides fluent {@code default} methods to register per-row actions (the + * {@code addRowAction} family), choose how they are presented + * ({@link #setRowActionsStyle(RowActionsStyle)}), and set default theme variants + * ({@link #setDefaultRowActionVariants(ButtonVariant...)}). Every method delegates to the backing + * {@link RowActionsManager} returned by {@link #getRowActionsManager()}. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@CssImport(value = "./fc-dynamic-buttons.css") +@JsModule("./fc-icon.ts") +@Uses(Button.class) +@Uses(ConfirmDialog.class) +public interface HasRowActions { + + /** + * Returns the {@code RowActionsManager} that backs this grid's actions column. Every other method + * of this interface is a {@code default} method that delegates to it. + * + * @return the row actions manager, never {@code null} + * @apiNote This is a Service Provider Interface (SPI) method: it exists so that classes which + * implement {@code HasRowActions} (such as {@code EasyGrid}) can supply the + * backing manager. Application code should not call it directly; configure row actions + * through the grid's own methods (e.g. {@code addRowAction(...)}, + * {@code setRowActionsStyle(...)}). + */ + RowActionsManager getRowActionsManager(); + + /** + * Adds a label-only row action that invokes {@code handler} when clicked. + * + * @param label the button label + * @param handler the action to execute when clicked + * @return the registered action + */ + default EasyRowAction addRowAction(@NonNull String label, + @NonNull SerializableConsumer handler) { + return addRowAction(label, (ValueProvider) null, handler); + } + + /** + * Adds a row action with a static label and icon that invokes {@code handler} when clicked. + * Either {@code label} or {@code icon} may be {@code null}, but not both. + * + * @param label the button label, or {@code null} for icon-only + * @param icon the button icon, or {@code null} for label-only + * @param handler the action to execute when clicked + * @return the registered action + */ + default EasyRowAction addRowAction( + String label, + Icon icon, + @NonNull SerializableConsumer handler) { + return addRowAction(label, Constant.ofNullable(icon), handler); + } + + /** + * Adds an icon-only row action that invokes {@code handler} when clicked. + * + * @param icon the button icon + * @param handler the action to execute when clicked + * @return the registered action + */ + default EasyRowAction addRowAction( + @NonNull Icon icon, + @NonNull SerializableConsumer handler) { + return addRowAction(null, icon, handler); + } + + /** + * Adds a row action with a static label and an icon created from {@code iconFactory} that + * invokes {@code handler} when clicked. {@code label} may be {@code null} for icon-only. + * + * @param label the button label, or {@code null} for icon-only + * @param iconFactory factory used to create the button icon + * @param handler the action to execute when clicked + * @return the registered action + */ + default EasyRowAction addRowAction( + String label, + @NonNull IconFactory iconFactory, + @NonNull SerializableConsumer handler) { + return addRowAction(label, iconFactory.create(), handler); + } + + /** + * Adds an icon-only row action whose icon is created from {@code iconFactory} that invokes + * {@code handler} when clicked. + * + * @param iconFactory factory used to create the button icon + * @param handler the action to execute when clicked + * @return the registered action + */ + default EasyRowAction addRowAction( + @NonNull IconFactory iconFactory, + @NonNull SerializableConsumer handler) { + return addRowAction(null, iconFactory.create(), handler); + } + + /** + * Adds an icon-only row action whose icon is computed per row from {@code iconProvider} that + * invokes {@code handler} when clicked. + * + * @param the icon type + * @param iconProvider per-row provider for the button icon + * @param handler the action to execute when clicked + * @return the registered action + */ + default > EasyRowAction addRowAction( + @NonNull ValueProvider iconProvider, + @NonNull SerializableConsumer handler) { + return addRowAction(null, iconProvider, handler); + } + + /** + * Adds a row action with a static label and a per-row icon that invokes {@code handler} when + * clicked. Either {@code label} or {@code iconProvider} may be {@code null}, but not both. + * + * @param the icon type + * @param label the button label, or {@code null} for icon-only + * @param iconProvider per-row provider for the button icon, or {@code null} for label-only + * @param handler the action to execute when clicked + * @return the registered action + */ + default > EasyRowAction addRowAction( + String label, + ValueProvider iconProvider, + @NonNull SerializableConsumer handler) { + return getRowActionsManager().addRowAction(Constant.ofNullable(label), iconProvider, handler); + } + + /** + * Sets the style used to present row actions: inline buttons, a dropdown overflow menu, or a + * right-click context menu. + * + * @param style the row actions style + */ + default void setRowActionsStyle(@NonNull RowActionsStyle style) { + getRowActionsManager().setRowActionsStyle(style); + } + + /** + * Replaces the active row-actions renderer. The current renderer is cleaned up before the new + * one is installed, and a rebuild is scheduled for the next {@code beforeClientResponse} cycle. + * + * @param renderer the new renderer to use + */ + default void setRowActionsRenderer(RowActionsRenderer renderer) { + getRowActionsManager().setRenderer(renderer); + } + + /** + * Returns the {@code Grid.Column} that hosts the actions, or {@code null} if the active renderer + * does not use a column (e.g. a context-menu renderer). For column-based renderers the column is + * created on demand, hidden when no actions are registered, and made visible automatically when + * the first action is added. + * + * @return the actions column, or {@code null} if the active renderer does not use a column + */ + default Grid.Column getActionsColumn() { + return getRowActionsManager().getActionsColumn(); + } + + /** + * Sets the theme variants that will be applied upon creation to all row actions added after this + * call. Pass no arguments (or {@code null}) to clear the default variants. + * + * @param variants the variants to apply to each new action + */ + default void setDefaultRowActionVariants(ButtonVariant... variants) { + getRowActionsManager().setDefaultRowActionVariants(variants); + } + + /** + * Schedules a renderer rebuild on the next {@code beforeClientResponse} cycle. The fluent + * configuration methods of {@link EasyRowAction} ({@code visibleWhen}, {@code enabledWhen}, + * {@code tooltip}, {@code withConfirmation}) schedule this automatically, so an explicit call is + * only needed after changing an action's styling or theme variants (e.g. {@code addClassName}, + * {@code getStyle()}, {@code addThemeVariants}), which are not applied automatically. Any + * previously scheduled rebuild is cancelled and replaced. + */ + default void refreshRowActions() { + getRowActionsManager().refresh(); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java new file mode 100644 index 0000000..fc13a8e --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilder.java @@ -0,0 +1,534 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.flowingcode.vaadin.jsonmigration.JsonMigration; +import com.flowingcode.vaadin.jsonmigration.JsonSerializer; +import com.flowingcode.vaadin.jsonmigration.LitRendererMigrationExtension; +import com.vaadin.flow.component.HasElement; +import com.vaadin.flow.data.renderer.LitRenderer; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.SerializableBiConsumer; +import com.vaadin.flow.function.SerializablePredicate; +import com.vaadin.flow.function.ValueProvider; +import elemental.json.Json; +import elemental.json.JsonArray; +import elemental.json.JsonObject; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import lombok.NonNull; +import lombok.experimental.ExtensionMethod; + +/** + * Builds the Lit template and backing {@link LitRenderer} for an {@code EasyGrid} row-actions + * column. Callers open elements with {@link #tag(String, Runnable)} and add attribute/property + * bindings, content, and per-row server functions; all per-row values are exposed to the template + * through a single {@code item.} object, addressed by index. + * + *

The builder is single-use: after {@link #build()} (or {@link #getTemplate()}) it is closed and + * further mutation throws {@link IllegalStateException}. When at least one per-row value is + * registered, the whole template is wrapped in a presence guard so it renders nothing until the + * backing property object is populated on the client. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@ExtensionMethod(value = LitRendererMigrationExtension.class, suppressBaseMethods = true) +final class LitRendererBuilder { + + private static final Pattern PROPERTY_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9]*"); + private static final Pattern TAG_NAME_PATTERN = Pattern.compile("[a-zA-Z][a-zA-Z0-9-]*"); + + private final String property; + private final StringBuilder template = new StringBuilder(); + private final List> functionHandlers = new ArrayList<>(); + private final List> properties = new ArrayList<>(); + private boolean tagOpen = false; + private boolean closed = false; + + /** + * Creates a builder for a static-only template, one that emits no per-row values and so + * needs no backing {@code item.} object. Only build-time literals are permitted (e.g. + * {@link #set}, or {@link #bind}/{@link #addContent} with a {@link Constant}); any operation that + * would register a per-row value ({@code bind} with a dynamic provider, {@link #withCondition}, + * {@link #bindBoolean}, {@link #spreadAllAttributesAndProperties}, {@link #withFunction}, or the + * presence guard added by {@link #build()}) throws {@link IllegalStateException}. + * + *

For a template that binds per-row values, use {@link #LitRendererBuilder(String)} instead. + * + * @param the grid bean type + * @return a builder that accepts only build-time literals (no {@code item.} object) + */ + public static LitRendererBuilder staticOnly() { + return new LitRendererBuilder<>(); + } + + private LitRendererBuilder() { + this.property = null; + } + + /** + * Creates a builder for a template that binds per-row values through a backing + * {@code item.} object addressed by index. For a template that emits only build-time + * literals, use {@link #staticOnly()} instead. + * + * @param property the name of the backing per-row property object; must be an alphanumeric + * identifier starting with a letter + * @throws IllegalArgumentException if {@code property} is not a valid identifier + */ + public LitRendererBuilder(@NonNull String property) { + if (!PROPERTY_PATTERN.matcher(property).matches()) { + throw new IllegalArgumentException( + "Property must be an alphanumeric identifier starting with a letter: " + property); + } + this.property = property; + } + + private String getFunctionName(int index) { + // LitRenderer.withFunction requires alphanumeric names with no underscores. + requireProperty(); + return property + "Handler" + index; + } + + private void close() { + if (!closed) { + if (!properties.isEmpty()) { + requireProperty(); + template.insert(0, "${item.%s ? html`".formatted(property)); + template.append("` : undefined}"); + } + closed = true; + } + } + + private void requireProperty() { + if (property == null) { + throw new IllegalStateException( + "Property name is required to register per-row values"); + } + } + + private void requireNotClosed() { + if (closed) { + throw new IllegalStateException("Builder has already been closed"); + } + } + + /** For testing only. */ + String getTemplate() { + close(); + return template.toString(); + } + + /** Finalizes the template and builds the {@code LitRenderer}. */ + public LitRenderer build() { + close(); + + LitRenderer renderer = LitRenderer.of(template.toString()); + if (!properties.isEmpty()) { + requireProperty(); + int n = properties.size(); + String[] keys = new String[n]; + for (int i = 0; i < n; i++) { + keys[i] = Integer.toString(i); + } + @SuppressWarnings("unchecked") + ValueProvider[] providers = properties.toArray(new ValueProvider[n]); + renderer.withProperty(property, t -> { + var obj = Json.createObject(); + for (int i = 0; i < n; i++) { + obj.put(keys[i], JsonSerializer.toJson(providers[i].apply(t))); + } + return JsonMigration.convertToClientCallableResult(obj); + }); + } + + for (int i = 0; i < functionHandlers.size(); i++) { + renderer.withFunction(getFunctionName(i), functionHandlers.get(i)); + } + return renderer; + } + + + + /** + * Opens an element: appends {@code } is emitted later), runs + * {@code body}, then closes with {@code }. The body should add + * attribute bindings first (via {@link #set}, {@link #bind}, {@link #bindBoolean}, + * {@link #copyAttributes}, {@link #spreadAllAttributesAndProperties}) and then content (via + * nested {@link #tag}, + * {@link #withCondition}, or {@link #addContent}). The opening {@code >} is + * emitted automatically the first time the body adds content, or at body-end for an empty tag. + * Tags nest to arbitrary depth. + * + * @param name the tag name; must match {@code [a-zA-Z][a-zA-Z0-9-]*} + * @param body adds the tag's attributes and content + * @throws IllegalArgumentException if {@code name} is not a valid tag name + */ + public void tag(String name, Runnable body) { + requireNotClosed(); + if (name == null || !TAG_NAME_PATTERN.matcher(name).matches()) { + throw new IllegalArgumentException("Invalid tag name: " + name); + } + finishOpeningTag(); + template.append('<').append(name); + tagOpen = true; + body.run(); + finishOpeningTag(); + template.append("'); + } + + /** + * Wraps {@code body} in a Lit conditional that renders only when {@code predicate} is + * {@code true} for the current row. A {@code null} predicate is treated as always-true and + * {@code body} is invoked directly with no surrounding conditional. + * + * @param predicate evaluated for each row item; the body renders only when it returns + * {@code true}, or always when {@code null} + * @param body adds the conditionally-rendered content + */ + public void withCondition(SerializablePredicate predicate, Runnable body) { + requireNotClosed(); + if (predicate == null) { + body.run(); + return; + } + requireProperty(); + finishOpeningTag(); + template.append("${item.%s[%s] ? html`".formatted(property, register(predicate::test))); + body.run(); + finishOpeningTag(); + template.append("` : undefined}"); + } + + /** + * Emits an attribute or property with a literal value. {@code null} is a no-op; otherwise the + * value is inlined at build time with prefix-aware dispatch. + * + * @param name the attribute or property name, optionally with a {@code .} or {@code ?} binding + * prefix + * @param value the literal value, or {@code null} to emit nothing + */ + public void set(String name, String value) { + requireNotClosed(); + bind(name, Constant.ofNullable(value)); + } + + /** + * Binds an attribute or property to a per-row value. A {@code Constant} value is inlined at + * build time; {@code null} is a no-op. + * + * @param name the attribute or property name, optionally with a {@code .} or {@code ?} binding + * prefix + * @param value per-row provider for the value, or {@code null} to emit nothing + */ + public void bind(String name, ValueProvider value) { + requireNotClosed(); + if (value == null) { + return; + } + if (value instanceof Constant) { + emitLiteral(name, value.apply(null)); + } else { + requireTagOpen(); + requireProperty(); + template.append(" %s=${item.%s[%s]}".formatted(name, property, register(value))); + } + } + + /** + * Binds the current tag's content to a per-row value. A {@code Constant} value is inlined at + * build time; {@code null} provider is a no-op. The opening tag's {@code >} is closed first if + * needed. + * + * @param value per-row provider for the content, or {@code null} to emit nothing + */ + public void addContent(ValueProvider value) { + requireNotClosed(); + if (value == null) { + return; + } + if (value instanceof Constant) { + String str = value.apply(null); + if (str == null) { + return; + } + finishOpeningTag(); + template.append("${`").append(escapeTemplateLiteral(str)).append("`}"); + } else { + requireProperty(); + finishOpeningTag(); + template.append("${item.%s[%s]}".formatted(property, register(value))); + } + } + + /** + * Emits a literal attribute, dispatching on the Lit binding prefix in {@code name}: + *

    + *
  • No prefix: emits an HTML attribute {@code name="value"}.
  • + *
  • {@code .} prefix (property binding): emits .name=${`value`}.
  • + *
  • {@code ?} prefix (boolean attribute binding): emits ?name=${true} unless + * {@code value} is {@code "false"}; in that case the attribute is omitted.
  • + *
+ * {@code null} values are no-ops. + */ + private void emitLiteral(String name, String value) { + if (value == null) { + return; + } + if (name.startsWith("?")) { + if ("false".equals(value)) { + return; + } + requireTagOpen(); + template.append(" %s=${true}".formatted(name)); + } else if (name.startsWith(".")) { + requireTagOpen(); + template.append(" %s=${`%s`}".formatted(name, escapeTemplateLiteral(value))); + } else { + requireTagOpen(); + template.append(" %s=%s".formatted(name, wrapAndEscapeTemplateCharacters(value))); + } + } + + /** + * Emits a Lit event listener binding @eventName=${functionName}. {@code functionName} + * must reference a function previously registered via {@link #withFunction}. + * + * @param eventName the DOM event name (without the {@code @} prefix) + * @param functionIndex the index of a function previously registered via {@link #withFunction} + */ + public void event(String eventName, int functionIndex) { + requireNotClosed(); + requireTagOpen(); + template.append(" @%s=${%s}".formatted(eventName, getFunctionName(functionIndex))); + } + + /** + * Emits a Lit event listener binding @eventName=${handler} where {@code handler} is + * an inline client-side expression rather than a registered function reference. + * + * @param eventName the DOM event name (without the {@code @} prefix) + * @param handler the client-side event handler expression + */ + public void event(String eventName, String handler) { + requireNotClosed(); + requireTagOpen(); + template.append(" @%s=${%s}".formatted(eventName, handler)); + } + + /** + * Binds a Lit boolean attribute ?name=${...} to a per-row predicate. {@code null} is a + * no-op. + */ + public void bindBoolean(String name, SerializablePredicate predicate) { + requireNotClosed(); + if (predicate != null) { + requireTagOpen(); + requireProperty(); + template.append(" ?%s=${item.%s[%s]}".formatted(name, property, register(predicate::test))); + } + } + + /** + * Snapshots the named attributes from {@code component}'s element and delegates each non-empty + * value to {@link #set(String, String)}. Names with {@code .} or {@code ?} prefixes are + * read via {@link Element#getProperty(String)} on the stripped name; names with no prefix are + * read via {@link Element#getAttribute(String)}. Emission (HTML attribute vs. property vs. + * boolean binding) follows {@code set(String, String)}'s dispatch rules. + */ + public void copyAttributes(HasElement component, String... names) { + requireNotClosed(); + requireTagOpen(); + + Element element = component.getElement(); + for (String name : names) { + String value = switch (BindingType.of(name)) { + case ATTRIBUTE -> element.getAttribute(name); + default -> element.getProperty(name.substring(1)); + }; + set(name, value); + } + } + + /** + * Snapshots all attributes and properties from {@code component}'s element (excluding names + * listed in {@code names}) and delegates each to {@link #set(String, String)}. Properties are + * emitted with a {@code .} prefix; attributes are emitted as-is. An empty {@code names} array + * copies everything. + * + *

Exclusions are matched against the emitted binding name, prefix included: a plain + * name (e.g. {@code "theme"}) excludes only the attribute of that name, while a {@code .}-prefixed + * name (e.g. {@code ".theme"}) excludes only the property. To exclude both the attribute and the + * property of the same name, list both forms. + */ + public void copyAllAttributesAndPropertiesExcept(HasElement component, String... names) { + requireNotClosed(); + requireTagOpen(); + + Element element = component.getElement(); + + element.getAttributeNames().filter(excludingAttribute(names)).forEach(name->{ + set(name, element.getAttribute(name)); + }); + + element.getPropertyNames().filter(excludingProperty(names)).forEach(name->{ + set("."+name, element.getProperty(name)); + }); + } + + private Predicate excludingAttribute(String[] names) { + return name -> { + for (String n : names) { + if (n.equals(name)) { + return false; + } + } + return true; + }; + } + + private Predicate excludingProperty(String[] names) { + return name -> excludingAttribute(names).test("."+name); + } + + /** + * Binds per-row {@code .attr} and {@code .prop} object properties on the current open tag, + * populated from all attributes and properties of the component returned by + * {@code componentProvider}. Attributes are collected into a map bound to {@code .attr}; + * properties are collected into a map bound to {@code .prop}. Empty maps are passed as + * {@code null}; a {@code null} component maps to {@code null} for both. + * + * @param the component type + * @param componentProvider provides the source component for each row item + */ + public void spreadAllAttributesAndProperties( + ValueProvider componentProvider) { + requireNotClosed(); + requireTagOpen(); + requireProperty(); + + // Evaluate componentProvider once per item across all lambdas. + // Cache intentionally shadows the enclosing method's T and C type parameters: + // local records are implicitly static and cannot capture method type parameters directly. + record Cache(T item, C component) {} + Ref> cacheRef = new Ref<>(); + + ValueProvider once = item -> { + if (cacheRef.value == null || cacheRef.value.item != item) { + cacheRef.value = new Cache<>(item, componentProvider.apply(item)); + } + return cacheRef.value.component; + }; + + int attrIdx = register(item -> { + C component = once.apply(item); + if (component == null) { + return null; + } + Element el = component.getElement(); + JsonObject obj = Json.createObject(); + el.getAttributeNames().forEach(name -> obj.put(name, el.getAttribute(name))); + return obj.keys().length == 0 ? null : obj; + }); + + int propIdx = register(item -> { + C component = once.apply(item); + if (component == null) { + return null; + } + Element el = component.getElement(); + JsonObject obj = Json.createObject(); + el.getPropertyNames().forEach(name -> obj.put(name, el.getProperty(name))); + return obj.keys().length == 0 ? null : obj; + }); + + template.append(" .attr=${item.%s[%d]} .prop=${item.%s[%d]}".formatted(property, attrIdx, property, propIdx)); + } + + /** + * Registers a server-side function handler and returns its index for use with + * {@link #event(String, int)}. + * + * @param handler the function handler invoked when the client fires the event + * @return the index of the registered function + */ + public int withFunction(SerializableBiConsumer handler) { + requireNotClosed(); + requireProperty(); + functionHandlers.add(handler); + return functionHandlers.size() - 1; + } + + private int register(ValueProvider provider) { + int index = properties.size(); + properties.add(provider); + return index; + } + + private void finishOpeningTag() { + if (tagOpen) { + template.append('>'); + tagOpen = false; + } + } + + private void requireTagOpen() { + if (!tagOpen) { + throw new IllegalStateException( + "Attribute can only be added inside a start tag, before any content"); + } + } + + private static String wrapAndEscapeTemplateCharacters(String value) { + if (value.indexOf('"') < 0 && value.indexOf('`') < 0 && value.indexOf('\\') < 0 + && !value.contains("${")) { + return '"' + value + '"'; + } + return "${`" + escapeTemplateLiteral(value) + "`}"; + } + + private static String escapeTemplateLiteral(String value) { + return value.replace("\\", "\\\\").replace("`", "\\`").replace("$", "\\$"); + } + + @SuppressWarnings("serial") + private static final class Ref implements Serializable { + transient V value; + } + + enum BindingType { + ATTRIBUTE, PROPERTY, BOOLEAN; + + static BindingType of(String name) { + if (name.startsWith("?")) { + return BOOLEAN; + } + if (name.startsWith(".")) { + return PROPERTY; + } + return ATTRIBUTE; + } + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java new file mode 100644 index 0000000..d3946c7 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRowActionsRenderer.java @@ -0,0 +1,92 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; +import lombok.NonNull; + +/** + * A {@link RowActionsRenderer} that renders row actions as inline buttons inside a dedicated + * {@link Grid.Column}, using a Lit template backed by {@link LitRendererBuilder}. The column is + * created the first time {@link #update} is called, and its renderer is replaced on every + * subsequent call. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +final class LitRowActionsRenderer implements RowActionsRenderer { + + private static final String GRID_BUTTONS_COUNT = "--grid-buttons-count"; + + private final Grid grid; + private Grid.Column column; + + LitRowActionsRenderer(@NonNull Grid grid) { + this.grid = grid; + } + + private static final int TAIL = Character.MAX_RADIX * Character.MAX_RADIX * Character.MAX_RADIX; + + private static String getProperty() { + // Random property name ensures no collisions between consecutive renderer updates + int n = ThreadLocalRandom.current().nextInt(26 * 10 * TAIL); + char letter = (char) ('a' + n / (10 * TAIL)); + char digit = (char) ('0' + (n / TAIL) % 10); + String tail = Integer.toString(TAIL + n % TAIL, Character.MAX_RADIX).substring(1); + return String.valueOf(letter) + digit + tail; + } + + @Override + public void update(List> actions) { + var builder = new LitRendererBuilder(getProperty()); + builder.tag("fc-dynamic-buttons", () -> { + actions.forEach(action -> action.updateRenderer(builder)); + }); + var litRenderer = builder.build(); + if (column == null) { + column = grid.addColumn(litRenderer); + column.setAutoWidth(true); + column.setFlexGrow(0); + } else { + column.setRenderer(litRenderer); + grid.getGenericDataView().refreshAll(); + } + grid.getElement().getStyle().set(GRID_BUTTONS_COUNT, Integer.toString(actions.size())); + } + + @Override + public Grid.Column getColumn() { + return column; + } + + @Override + public void remove() { + if (column != null) { + grid.removeColumn(column); + column = null; + } + grid.getElement().getStyle().remove(GRID_BUTTONS_COUNT); + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java new file mode 100644 index 0000000..adade9c --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsManager.java @@ -0,0 +1,211 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.AbstractIcon; +import com.vaadin.flow.function.SerializableConsumer; +import com.vaadin.flow.function.ValueProvider; +import com.vaadin.flow.shared.Registration; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import lombok.NonNull; + +/** + * Manages the row actions column for an {@code EasyGrid}. Maintains the list of registered + * {@link EasyRowAction} instances, the render style (inline buttons, an overflow menu, or a + * context menu; see {@link RowActionsStyle}), and delegates all visual concerns to a + * {@link RowActionsRenderer}. + * + *

A manager instance is created eagerly together with its grid. The instance itself is + * lightweight; the actions {@link Grid.Column} it manages is created lazily (on the first renderer + * update or {@link #getActionsColumn()} call) and stays hidden until the first action is added. + * + * @param the grid bean type + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +public class RowActionsManager implements Serializable { + + private final Grid grid; + private final List> actions = new ArrayList<>(); + private RowActionsRenderer renderer; + private ButtonVariant[] defaultVariants = {ButtonVariant.LUMO_TERTIARY_INLINE}; + private boolean rendererInitialized = false; + private Registration rendererRegistration; + + private void setRendererRegistration(Registration registration) { + if (rendererRegistration != null) { + rendererRegistration.remove(); + } + rendererRegistration = registration; + } + + /** + * Creates a new {@code RowActionsManager} for the given grid. + * + * @param grid the grid to manage row actions for + */ + public RowActionsManager(@NonNull Grid grid) { + this.grid = grid; + this.renderer = new LitRowActionsRenderer<>(grid); + } + + /** + * Creates and registers a new row action, applying the current default theme variants and + * scheduling a renderer rebuild. At least one of {@code labelProvider} or {@code iconProvider} + * must be non-{@code null}. + * + * @param the icon type + * @param labelProvider per-row label provider, or {@code null} for icon-only + * @param iconProvider per-row icon provider, or {@code null} for label-only + * @param handler the action to execute when the action is clicked + * @return the registered action + */ + > EasyRowAction addRowAction( + ValueProvider labelProvider, + ValueProvider iconProvider, + @NonNull SerializableConsumer handler) { + EasyRowAction action = new EasyRowAction(this, labelProvider, iconProvider, handler); + if (defaultVariants != null) { + action.addThemeVariants(defaultVariants); + } + actions.add(action); + updateColumnVisibility(); + scheduleRendererUpdate(); + return action; + } + + /** + * Removes the specified action. If the action is not currently registered, this call is a no-op. + * The renderer is rebuilt on the next {@code beforeClientResponse} cycle; if no actions remain + * and the active renderer uses a column, that column is hidden. + * + * @param action the action to remove + */ + void removeRowAction(EasyRowAction action) { + if (!actions.remove(action)) { + return; + } + updateColumnVisibility(); + scheduleRendererUpdate(); + action.remove(); + } + + private void scheduleRendererUpdate() { + grid.getUI().ifPresentOrElse( + ui -> setRendererRegistration(ui.beforeClientResponse(grid, ctx -> updateRenderer())), + () -> setRendererRegistration(grid.addAttachListener(e -> setRendererRegistration( + e.getUI().beforeClientResponse(grid, ctx -> updateRenderer()))))); + } + + /** + * Sets the theme variants that are applied to every action upon creation. + * + * @param variants the variants to apply + */ + void setDefaultRowActionVariants(ButtonVariant... variants) { + this.defaultVariants = variants != null && variants.length > 0 ? variants : null; + } + + /** + * Sets the style used to present row actions, replacing the active renderer when the style + * changes. Has no effect when the given style is already active. + * + * @param style the row actions style + */ + void setRowActionsStyle(@NonNull RowActionsStyle style) { + if (!style.isInstance(renderer)) { + setRenderer(style.createRenderer(grid)); + } + } + + /** + * Replaces the active renderer. The current renderer is cleaned up via + * {@link RowActionsRenderer#remove()} before the new one is installed. A renderer rebuild is + * scheduled for the next {@code beforeClientResponse} cycle. + * + * @param renderer the new renderer to use + */ + public void setRenderer(@NonNull RowActionsRenderer renderer) { + this.renderer.remove(); + rendererInitialized = false; + this.renderer = renderer; + scheduleRendererUpdate(); + } + + /** + * Schedules a renderer rebuild on the next {@code beforeClientResponse} cycle. The fluent + * configuration methods of {@link EasyRowAction} schedule this automatically; an explicit call is + * only needed after changing an action's styling or theme variants, which are not applied + * automatically. Any previously scheduled rebuild is cancelled and replaced. + */ + void refresh() { + scheduleRendererUpdate(); + } + + /** + * Returns an unmodifiable view of the registered action entries. + * + * @return the list of action entries + */ + List> getRowActions() { + return Collections.unmodifiableList(actions); + } + + private void updateRenderer() { + setRendererRegistration(null); + renderer.update(Collections.unmodifiableList(actions)); + rendererInitialized = true; + updateColumnVisibility(); + } + + /** + * Returns the {@code Grid.Column} that hosts the actions, or {@code null} if the active renderer + * does not use a column (e.g. a context-menu renderer). + * + *

For column-based renderers: the column is created on demand if it does not exist yet, hidden + * when no actions are registered, and made visible automatically when the first action is added. + * If a deferred renderer update is pending it is cancelled and applied immediately so the column + * reflects the current action list. + */ + Grid.Column getActionsColumn() { + if (!rendererInitialized) { + updateRenderer(); + } + return renderer.getColumn(); + } + + /** + * Shows the actions column when at least one action is registered and hides it otherwise. A no-op + * for renderers that do not use a column. + */ + private void updateColumnVisibility() { + var column = renderer.getColumn(); + if (column != null) { + column.setVisible(!actions.isEmpty()); + } + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java new file mode 100644 index 0000000..6059171 --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsRenderer.java @@ -0,0 +1,68 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import java.io.Serializable; +import java.util.List; + +/** + * Strategy interface for rendering the row actions of an {@code EasyGrid}. Implementations decide + * how the actions are presented: as inline buttons in a dedicated column, as an overflow/context + * menu, or any other mechanism. + * + *

An instance is held by {@link RowActionsManager} and is called whenever the action list + * changes and a visual refresh is needed. + * + * @param the grid bean type + * @see HasRowActions#setRowActionsRenderer(RowActionsRenderer) + * @see LitRowActionsRenderer + * @author Javier Godoy / Flowing Code + */ +public interface RowActionsRenderer extends Serializable { + + /** + * Rebuilds the visual representation to reflect the given action list. Called on every scheduled + * renderer update. Implementations that use a {@link Grid.Column} should create it on the first + * call and update its renderer on subsequent calls. Renderers are responsible for any + * data-view refresh their presentation requires. + * + * @param actions the current list of registered actions (unmodifiable) + */ + void update(List> actions); + + /** + * Returns the {@code Grid.Column} used to host the actions, if this renderer uses a dedicated + * column. Renderers that present actions through a context menu or another mechanism not tied to a + * column should return {@code null}. + * + * @return the actions column, or {@code null} if not applicable + */ + Grid.Column getColumn(); + + /** + * Cleans up all UI elements created by this renderer (columns, context menus, etc.). Called by + * {@link RowActionsManager} when the renderer is being replaced. The default implementation is a + * no-op. + */ + default void remove() {} + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java new file mode 100644 index 0000000..2eea55e --- /dev/null +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/actions/RowActionsStyle.java @@ -0,0 +1,79 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +package com.flowingcode.vaadin.addons.easygrid.actions; + +import com.vaadin.flow.component.grid.Grid; +import lombok.NonNull; + +/** + * Identifies how an {@code EasyGrid}'s row actions are presented. + * + * @see RowActionsRenderer + * @author Javier Godoy / Flowing Code + */ +public enum RowActionsStyle { + + /** Actions are presented as inline buttons in a dedicated column. This is the default style. */ + INLINE_BUTTONS, + + /** + * Actions are presented through an overflow ("⋮") button hosted in a dedicated column; clicking + * the button opens the menu for that row. + */ + DROPDOWN, + + /** + * Actions are presented through the grid's right-click context menu; no dedicated column is + * created. + */ + CONTEXT_MENU; + + /** + * Creates a {@code RowActionsRenderer} that presents row actions in this style. + * + * @param the grid bean type + * @param grid the grid the renderer will decorate + * @return a new renderer for this style + */ + RowActionsRenderer createRenderer(@NonNull Grid grid) { + return switch (this) { + case INLINE_BUTTONS -> new LitRowActionsRenderer<>(grid); + case DROPDOWN -> new DropdownMenuRowActionsRenderer<>(grid); + case CONTEXT_MENU -> new ContextMenuRowActionsRenderer<>(grid); + }; + } + + /** + * Returns whether the given renderer is the kind produced by {@link #createRenderer(Grid)} for + * this style. + * + * @param renderer the renderer to test + * @return {@code true} if {@code renderer} presents actions in this style + */ + boolean isInstance(RowActionsRenderer renderer) { + return switch (this) { + case INLINE_BUTTONS -> renderer instanceof LitRowActionsRenderer; + case DROPDOWN -> renderer instanceof DropdownMenuRowActionsRenderer; + case CONTEXT_MENU -> renderer instanceof ContextMenuRowActionsRenderer; + }; + } + +} diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfiguration.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfiguration.java index 53a3d33..e2857c4 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfiguration.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfiguration.java @@ -30,6 +30,7 @@ * that non-{@code null} values at a more specific level take precedence over less specific ones. * * @param the column value type + * @author Javier Godoy / Flowing Code */ public sealed interface ColumnConfiguration extends Serializable permits ColumnConfigurationImpl, ColumnConfigurationLink { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationImpl.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationImpl.java index 708c023..c1df479 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationImpl.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationImpl.java @@ -40,6 +40,7 @@ * non-{@code null}, otherwise delegates to the parent. * * @param the column value type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") @Setter diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationLink.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationLink.java index 7fc52e2..8c33eb8 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationLink.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationLink.java @@ -31,6 +31,9 @@ * A {@code ColumnConfiguration} that delegates reads to a primary configuration and, when the * primary returns {@code null} for a given property, falls back to a secondary configuration. * Writes (setters) are always forwarded to the primary and return {@code this} for fluent chaining. + * + * @param the column value type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") @RequiredArgsConstructor(access = AccessLevel.PACKAGE) diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationTextRenderer.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationTextRenderer.java index 1e9ffc3..51ee21b 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationTextRenderer.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/ColumnConfigurationTextRenderer.java @@ -31,6 +31,7 @@ * * @param the grid bean type * @param the column value type + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") public final class ColumnConfigurationTextRenderer extends TextRenderer { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/EasyGridConfigurationClassMap.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/EasyGridConfigurationClassMap.java index 069d02d..52e8fe1 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/EasyGridConfigurationClassMap.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/EasyGridConfigurationClassMap.java @@ -28,6 +28,8 @@ * A map from value type to {@link ColumnConfiguration}, building a parent chain by following the * class hierarchy so that a configuration for a subtype inherits from its supertype's configuration. * Primitive types are mapped to their wrapper counterparts before hierarchy traversal. + * + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") final class EasyGridConfigurationClassMap implements Serializable { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/GlobalEasyGridConfiguration.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/GlobalEasyGridConfiguration.java index 97b3031..e94ebc0 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/GlobalEasyGridConfiguration.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/GlobalEasyGridConfiguration.java @@ -49,6 +49,8 @@ * post-startup modifications. Omitting {@link #freeze()} is a stability risk: any code that runs * after startup — including request-handling code — could inadvertently alter rendering for every * active session. + * + * @author Javier Godoy / Flowing Code */ @UtilityClass public class GlobalEasyGridConfiguration { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/InstanceEasyGridConfiguration.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/InstanceEasyGridConfiguration.java index a1e81c2..96fdc14 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/InstanceEasyGridConfiguration.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/config/InstanceEasyGridConfiguration.java @@ -28,6 +28,8 @@ * {@link GlobalEasyGridConfiguration}. * *

See {@link #resolve(Class)} for the full resolution order. + * + * @author Javier Godoy / Flowing Code */ @SuppressWarnings("serial") public final class InstanceEasyGridConfiguration implements Serializable { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateRenderers.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateRenderers.java index 2a8ceeb..961cc3b 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateRenderers.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateRenderers.java @@ -29,6 +29,8 @@ /** * Factory methods for creating {@link RendererFactory} instances that render {@link LocalDate} * values using {@link com.vaadin.flow.data.renderer.LocalDateRenderer}. + * + * @author Javier Godoy / Flowing Code */ @UtilityClass public class LocalDateRenderers { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateTimeRenderers.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateTimeRenderers.java index 36e1a72..d7580a9 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateTimeRenderers.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalDateTimeRenderers.java @@ -29,6 +29,8 @@ /** * Factory methods for creating {@link RendererFactory} instances that render {@link LocalDateTime} * values using {@link com.vaadin.flow.data.renderer.LocalDateTimeRenderer}. + * + * @author Javier Godoy / Flowing Code */ @UtilityClass public class LocalDateTimeRenderers { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalTimeRenderers.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalTimeRenderers.java index c34a3e6..d57f12c 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalTimeRenderers.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/LocalTimeRenderers.java @@ -29,6 +29,8 @@ /** * Factory methods for creating {@link RendererFactory} instances that render {@link LocalTime} * values using {@link TextRenderer} and {@link DateTimeFormatter}. + * + * @author Javier Godoy / Flowing Code */ @UtilityClass public class LocalTimeRenderers { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/NumberRenderers.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/NumberRenderers.java index b089830..8161169 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/NumberRenderers.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/NumberRenderers.java @@ -28,6 +28,8 @@ /** * Factory methods for creating {@link RendererFactory} instances that render {@link Number} values * using {@link com.vaadin.flow.data.renderer.NumberRenderer}. + * + * @author Javier Godoy / Flowing Code */ @UtilityClass public class NumberRenderers { diff --git a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/RendererFactory.java b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/RendererFactory.java index dac9cfa..0880308 100644 --- a/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/RendererFactory.java +++ b/src/main/java/com/flowingcode/vaadin/addons/easygrid/renderers/RendererFactory.java @@ -29,6 +29,7 @@ * * @param the grid bean type * @param the column value type + * @author Javier Godoy / Flowing Code */ @FunctionalInterface public interface RendererFactory extends SerializableFunction, Renderer> { diff --git a/src/main/resources/META-INF/frontend/fc-dynamic-buttons.css b/src/main/resources/META-INF/frontend/fc-dynamic-buttons.css new file mode 100644 index 0000000..979e642 --- /dev/null +++ b/src/main/resources/META-INF/frontend/fc-dynamic-buttons.css @@ -0,0 +1,25 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +fc-dynamic-buttons { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: var(--fc-dynamic-buttons-gap, 0.25rem); +} diff --git a/src/main/resources/META-INF/frontend/fc-icon.ts b/src/main/resources/META-INF/frontend/fc-icon.ts new file mode 100644 index 0000000..8005afb --- /dev/null +++ b/src/main/resources/META-INF/frontend/fc-icon.ts @@ -0,0 +1,139 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +import { LitElement, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { directive, Directive, ElementPart, PartInfo, PartType } from 'lit/directive.js'; +import '@vaadin/icon'; + +/** + * A directive that spreads an object's entries as attributes onto an element. + * Usage: + */ +export const spreadAttrs = directive(class extends Directive { + // Attribute names set on the previous render, so stale ones can be removed when the element is + // reused for another row whose map omits them (Lit reuses one directive instance per binding). + private prev = new Set(); + + constructor(partInfo: PartInfo) { + super(partInfo); + if (partInfo.type !== PartType.ELEMENT) { + throw new Error('The `spreadAttrs` directive must be used on an element tag.'); + } + } + + render(attrs: Record) { + return ''; // Nothing to render directly in the template + } + + update(part: ElementPart, [attrs]: [Record]) { + const element = part.element; + const next = new Set(); + + for (const [key, value] of Object.entries(attrs)) { + if (value === undefined || value === null) { + element.removeAttribute(key); + } else { + element.setAttribute(key, String(value)); + next.add(key); + } + } + // Remove attributes this directive set on a previous render but no longer sets. + for (const key of this.prev) { + if (!next.has(key)) { + element.removeAttribute(key); + } + } + this.prev = next; + } +}); + +/** + * A directive that spreads an object's entries as properties onto an element. + * Usage: + */ +export const spreadProps = directive(class extends Directive { + // Property names set on the previous render, so stale ones can be reset when the element is + // reused for another row whose map omits them (Lit reuses one directive instance per binding). + private prev = new Set(); + + constructor(partInfo: PartInfo) { + super(partInfo); + if (partInfo.type !== PartType.ELEMENT) { + throw new Error('The `spreadProps` directive must be used on an element tag.'); + } + } + + render(props: Record) { + return ''; // Nothing to render directly in the template + } + + update(part: ElementPart, [props]: [Record]) { + const element = part.element as any; + const next = new Set(); + + for (const [key, value] of Object.entries(props)) { + element[key] = value; + next.add(key); + } + // Reset properties this directive set on a previous render but no longer sets. Setting them + // to undefined clears the leaked value (it does not restore a component-specific default). + for (const key of this.prev) { + if (!next.has(key)) { + element[key] = undefined; + } + } + this.prev = next; + } +}); + +/** + * Wraps ``. The Java row-actions renderer passes the source icon's attributes and + * properties as two catch-all maps — `attr` and `prop` — which are forwarded onto the inner + * ``: `attr` is spread as attributes, `prop` as properties. The component renders + * nothing unless one of the well-known icon fields is present: `icon`/`src` among the attributes, + * or `symbol`/`ligature`/`char`/`fontFamily`/`iconClass` among the properties. + */ +@customElement('fc-icon') +export class FcIcon extends LitElement { + @property({ attribute: false }) attr?: Record | null; + @property({ attribute: false }) prop?: Record | null; + + protected createRenderRoot() { + return this; + } + + render() { + const attr: Record = this.attr ?? {}; + const prop: Record = this.prop ?? {}; + + return attr.icon || attr.src || + prop.symbol || prop.ligature || prop.char || prop.fontFamily || prop.iconClass ? + html`` : nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'fc-icon': FcIcon; + } +} diff --git a/src/main/resources/META-INF/frontend/styles/static_addon_styles b/src/main/resources/META-INF/frontend/styles/static_addon_styles deleted file mode 100644 index c2a6ed1..0000000 --- a/src/main/resources/META-INF/frontend/styles/static_addon_styles +++ /dev/null @@ -1 +0,0 @@ -Place add-on shareable styles in this folder \ No newline at end of file diff --git a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java index f00a909..908ecb1 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java +++ b/src/test/java/com/flowingcode/vaadin/addons/DemoLayout.java @@ -22,6 +22,11 @@ import com.vaadin.flow.component.html.Div; import com.vaadin.flow.router.RouterLayout; +/** + * Layout component for demo views. + * + * @author Javier Godoy / Flowing Code + */ @SuppressWarnings("serial") public class DemoLayout extends Div implements RouterLayout { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/AutoColumnsDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/AutoColumnsDemo.java index cd1ceca..60425e9 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/AutoColumnsDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/AutoColumnsDemo.java @@ -27,6 +27,11 @@ import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; +/** + * Demo view showing automatic column discovery. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Auto Column Discovery") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/ColumnConfigurationDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/ColumnConfigurationDemo.java index 5b431b1..f86bb2a 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/ColumnConfigurationDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/ColumnConfigurationDemo.java @@ -33,6 +33,11 @@ import java.time.LocalDateTime; import lombok.Getter; +/** + * Demo view showing column configuration options. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Column Configuration") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/ConfigurationHierarchyDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/ConfigurationHierarchyDemo.java index c389ab3..ca92cd7 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/ConfigurationHierarchyDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/ConfigurationHierarchyDemo.java @@ -30,6 +30,11 @@ import com.vaadin.flow.router.Route; import lombok.Getter; +/** + * Demo view showing configuration hierarchy and precedence. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Configuration Hierarchy") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/DemoView.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/DemoView.java index 0c98166..1085c8c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/DemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/DemoView.java @@ -25,6 +25,11 @@ import com.vaadin.flow.router.BeforeEnterObserver; import com.vaadin.flow.router.Route; +/** + * Entry point demo view that forwards to the EasyGridDemoView. + * + * @author Javier Godoy / Flowing Code + */ @SuppressWarnings("serial") @Route("") public class DemoView extends VerticalLayout implements BeforeEnterObserver { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/EasyGridDemoView.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/EasyGridDemoView.java index a8f232d..14ab057 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/EasyGridDemoView.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/EasyGridDemoView.java @@ -26,6 +26,11 @@ import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; +/** + * Main demo view for EasyGrid add-on. + * + * @author Javier Godoy / Flowing Code + */ @SuppressWarnings("serial") @ParentLayout(DemoLayout.class) @Route("easy-grid") @@ -41,6 +46,9 @@ public EasyGridDemoView() { addDemo(ColumnConfigurationDemo.class); addDemo(ConfigurationHierarchyDemo.class); addDemo(TypedColumnDemo.class); + addDemo(RowActionsDemo.class); + addDemo(RowActionsMenuDemo.class); + addDemo(RowActionsDynamicDemo.class); setSizeFull(); } } diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/GridColumnMethodLister.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/GridColumnMethodLister.java index 82e6b66..3e1905c 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/GridColumnMethodLister.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/GridColumnMethodLister.java @@ -37,6 +37,8 @@ * *

Run via {@code mvn exec:java -Dexec.mainClass=...GridColumnMethodLister} or directly from an * IDE. + * + * @author Javier Godoy / Flowing Code */ public class GridColumnMethodLister { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/NumberRenderingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/NumberRenderingDemo.java index 3e77ff6..ad3ebcc 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/NumberRenderingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/NumberRenderingDemo.java @@ -28,6 +28,11 @@ import java.math.BigDecimal; import java.util.List; +/** + * Demo view showing number rendering for various numeric types. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @DemoSource(clazz = NumberSample.class) @PageTitle("Number Rendering") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDemo.java new file mode 100644 index 0000000..4e1eb71 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDemo.java @@ -0,0 +1,87 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.easygrid.model.Person; +import com.flowingcode.vaadin.addons.easygrid.service.PersonService; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; + +/** + * Demo view showing conditional row actions. + * + * @author Javier Godoy / Flowing Code + */ +@DemoSource +@PageTitle("Conditional Actions") +@SuppressWarnings("serial") +@Route(value = "easy-grid/row-actions", layout = EasyGridDemoView.class) +public class RowActionsDemo extends Div { + + private final PersonService service = new PersonService(); + + public RowActionsDemo() { + var grid = new EasyGrid<>(Person.class); + grid.hideColumns("phoneNumber", "appointmentDateTime", "appointmentTime", "subscriber"); + grid.setItems(service.fetchAll()); + + // Always visible; static tooltip + grid.addRowAction("Edit", VaadinIcon.EDIT.create(), person -> + Notification.show("Edit: " + person.getFirstName() + " " + person.getLastName())) + .tooltip("Edit person details"); + + // Always visible; enabled only when a phone number is set; + // tooltip changes dynamically to show the number or explain the disabled state + grid.addRowAction(VaadinIcon.PHONE, person -> + Notification.show("Calling " + person.getPhoneNumber())) + .enabledWhen(person -> person.getPhoneNumber() != null) + .tooltip(person -> person.getPhoneNumber() != null + ? "Call " + person.getPhoneNumber() + : "No phone number on record"); + + // Visible only for active persons; requires confirmation before executing + grid.addRowAction("Deactivate", VaadinIcon.CLOSE_CIRCLE.create(), person -> + Notification.show("Deactivated: " + person.getFirstName() + " " + person.getLastName())) + .visibleWhen(Person::isActive) + .withConfirmation("Deactivate person", + "Are you sure you want to deactivate this person?") + .addThemeVariants(ButtonVariant.LUMO_ERROR); + + // Visible only for inactive persons + grid.addRowAction("Activate", VaadinIcon.CHECK_CIRCLE.create(), person -> + Notification.show("Activated: " + person.getFirstName() + " " + person.getLastName())) + .visibleWhen(person -> !person.isActive()); + + // Per-row dynamic icon: filled star for subscribers, outline star for non-subscribers + grid.addRowAction( + person -> person.isSubscriber() ? VaadinIcon.STAR.create() : VaadinIcon.STAR_O.create(), + person -> Notification.show( + (person.isSubscriber() ? "Unsubscribe" : "Subscribe") + ": " + person.getFirstName())); + + grid.getActionsColumn().setHeader("Actions"); + add(grid); + setSizeFull(); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDynamicDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDynamicDemo.java new file mode 100644 index 0000000..bc9c6f9 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsDynamicDemo.java @@ -0,0 +1,75 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.easygrid.model.Person; +import com.flowingcode.vaadin.addons.easygrid.service.PersonService; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.checkbox.Checkbox; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.component.orderedlayout.HorizontalLayout; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; + +/** + * Demo view showing runtime modification of row actions. + * + * @author Javier Godoy / Flowing Code + */ +@DemoSource +@PageTitle("Runtime Action Changes") +@SuppressWarnings("serial") +@Route(value = "easy-grid/row-actions-dynamic", layout = EasyGridDemoView.class) +public class RowActionsDynamicDemo extends Div { + + private final PersonService service = new PersonService(); + + public RowActionsDynamicDemo() { + var grid = new EasyGrid<>(Person.class); + grid.hideColumns("phoneNumber", "appointmentDateTime", "appointmentTime", "subscriber"); + grid.setItems(service.fetchAll()); + + var editAction = grid.addRowAction("Edit", VaadinIcon.EDIT.create(), person -> + Notification.show("Edit: " + person.getFirstName() + " " + person.getLastName())); + var deleteAction = grid.addRowAction(VaadinIcon.TRASH, person -> + Notification.show("Delete: " + person.getFirstName() + " " + person.getLastName())); + deleteAction.addThemeVariants(ButtonVariant.LUMO_ERROR); + + // Fluent mutators like visibleWhen automatically refresh the grid. + var restrictCheckbox = new Checkbox("Show edit only for active persons"); + restrictCheckbox.addValueChangeListener(e -> { + editAction.visibleWhen(e.getValue() ? Person::isActive : null); + }); + + // Removes the delete action from the grid entirely at runtime. + var removeButton = new Button("Remove delete action"); + removeButton.addClickListener(e -> { + deleteAction.remove(); + removeButton.setEnabled(false); + }); + + add(new HorizontalLayout(restrictCheckbox, removeButton), grid); + setSizeFull(); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsMenuDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsMenuDemo.java new file mode 100644 index 0000000..cf27449 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/RowActionsMenuDemo.java @@ -0,0 +1,74 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid; + +import com.flowingcode.vaadin.addons.demo.DemoSource; +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsStyle; +import com.flowingcode.vaadin.addons.easygrid.model.Person; +import com.flowingcode.vaadin.addons.easygrid.service.PersonService; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.component.notification.Notification; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; + +/** + * Demo view showing row actions as a dropdown menu. + * + * @author Javier Godoy / Flowing Code + */ +@DemoSource +@PageTitle("Actions as Menu") +@SuppressWarnings("serial") +@Route(value = "easy-grid/row-actions-menu", layout = EasyGridDemoView.class) +public class RowActionsMenuDemo extends Div { + + private final PersonService service = new PersonService(); + + public RowActionsMenuDemo() { + var grid = new EasyGrid<>(Person.class); + grid.hideColumns("phoneNumber", "appointmentDateTime", "appointmentTime", "subscriber"); + grid.setItems(service.fetchAll()); + + grid.addRowAction("Edit", VaadinIcon.EDIT.create(), person -> + Notification.show("Edit: " + person.getFirstName() + " " + person.getLastName())); + + // Menu item enabled only when a phone number is available + grid.addRowAction("Call", VaadinIcon.PHONE.create(), person -> + Notification.show("Calling " + person.getPhoneNumber())) + .enabledWhen(person -> person.getPhoneNumber() != null); + + // Shown only for active persons + grid.addRowAction("Deactivate", VaadinIcon.CLOSE_CIRCLE.create(), person -> + Notification.show("Deactivated: " + person.getFirstName())) + .visibleWhen(Person::isActive); + + // Shown only for inactive persons + grid.addRowAction("Activate", VaadinIcon.CHECK_CIRCLE.create(), person -> + Notification.show("Activated: " + person.getFirstName())) + .visibleWhen(person -> !person.isActive()); + + // Present the row actions as an overflow (⋮) menu + grid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + + add(grid); + setSizeFull(); + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/SelectiveColumnsDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/SelectiveColumnsDemo.java index 6987fb8..c6caf07 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/SelectiveColumnsDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/SelectiveColumnsDemo.java @@ -28,6 +28,11 @@ import com.vaadin.flow.router.Route; import lombok.Getter; +/** + * Demo view showing selective column creation and reordering. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Selective Columns") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypeRenderingDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypeRenderingDemo.java index d699403..9cef5f1 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypeRenderingDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypeRenderingDemo.java @@ -28,6 +28,11 @@ import com.vaadin.flow.router.Route; import lombok.Getter; +/** + * Demo view showing default type-driven rendering. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Type Rendering") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypedColumnDemo.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypedColumnDemo.java index 2431ffb..acb3f37 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypedColumnDemo.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/TypedColumnDemo.java @@ -31,6 +31,11 @@ import java.time.temporal.ChronoUnit; import lombok.Getter; +/** + * Demo view showing typed column support for computed values. + * + * @author Javier Godoy / Flowing Code + */ @DemoSource @PageTitle("Typed Column") @SuppressWarnings("serial") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowActionTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowActionTest.java new file mode 100644 index 0000000..8e6ea45 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/EasyRowActionTest.java @@ -0,0 +1,402 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.actions; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.function.SerializableConsumer; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +/** + * Tests for EasyRowAction rendering and behavior. + * + * @author Javier Godoy / Flowing Code + */ +public class EasyRowActionTest { + + private static final SerializableConsumer NOP = item -> {}; + + private static final String OUTER_PREFIX = "${item.actions ? html`"; + private static final String OUTER_SUFFIX = "` : undefined}"; + + private static String templateFor(EasyRowAction action) { + var builder = new LitRendererBuilder("actions"); + action.updateRenderer(builder); + String template = builder.getTemplate(); + if (template.contains("item.actions")) { + assertThat(template, startsWith(OUTER_PREFIX)); + assertThat(template, endsWith(OUTER_SUFFIX)); + return template.substring(OUTER_PREFIX.length(), template.length() - OUTER_SUFFIX.length()); + } else { + assertThat(template, not(startsWith(OUTER_PREFIX))); + assertThat(template, not(endsWith(OUTER_SUFFIX))); + return template; + } + } + + // --- label / icon presence --- + + @Test + public void labelOnly_rendersButtonWithContent() { + var action = new EasyRowAction<>(null, item -> "Save", null, NOP); + assertEquals( + "${item.actions[0]}", + templateFor(action)); + } + + @Test + public void iconOnly_rendersIconChild() { + var action = new EasyRowAction<>(null, null, Constant.of(new Icon("vaadin", "check")), NOP); + assertEquals( + "" + + "" + + "", + templateFor(action)); + } + + @Test + public void labelAndIcon_rendersIconChildAndContent() { + var action = + new EasyRowAction<>(null, item -> "Save", Constant.of(new Icon("vaadin", "check")), NOP); + assertEquals( + "" + + "" + + "${item.actions[0]}" + + "", + templateFor(action)); + } + + // --- per-row (dynamic) icon: branch --- + // A non-Constant icon provider is evaluated per row, so the icon is rendered as an + // whose attributes/properties are spread per item, rather than the static emitted + // for a Constant icon. + + @Test + public void iconProviderOnly_rendersFcIconChild() { + var action = new EasyRowAction<>(null, null, item -> new Icon("vaadin", "check"), NOP); + assertEquals( + "" + + "" + + "", + templateFor(action)); + } + + @Test + public void labelAndIconProvider_rendersFcIconChildAndContent() { + var action = new EasyRowAction<>(null, item -> "Save", item -> new Icon("vaadin", "check"), NOP); + assertEquals( + "" + + "" + + "${item.actions[2]}" + + "", + templateFor(action)); + } + + // --- getTheme --- + + @Test + public void getTheme_iconOnly_addsIconVariant() { + var action = new EasyRowAction<>(null, null, Constant.of(new Icon("vaadin", "check")), NOP); + assertEquals("icon", action.getTheme()); + } + + @Test + public void getTheme_labelOnly_returnsNull() { + var action = new EasyRowAction<>(null, Constant.of("Save"), null, NOP); + assertNull(action.getTheme()); + } + + @Test + public void getTheme_iconOnly_combinesWithUserVariant() { + var action = new EasyRowAction<>(null, null, Constant.of(new Icon("vaadin", "check")), NOP); + action.addThemeVariants(ButtonVariant.LUMO_ERROR); + assertEquals("error icon", action.getTheme()); + } + + @Test + public void getTheme_labelAndIcon_noIconVariantAdded() { + var action = new EasyRowAction<>(null, Constant.of("Save"), + Constant.of(new Icon("vaadin", "check")), NOP); + assertNull(action.getTheme()); + } + + // --- baseline --- + + @Test + public void unconfiguredAction_rendersPlainButton() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + assertEquals( + "${`X`}", + templateFor(action)); + } + + // --- visibleWhen --- + + @Test + public void visibleWhen_wrapsInLitConditional() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.visibleWhen(item -> true); + assertEquals( + "${item.actions[0] ? html`${`X`}` : undefined}", + templateFor(action)); + } + + // --- enabledWhen --- + + @Test + public void enabledWhen_addsDisabledBinding() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.enabledWhen(item -> true); + assertEquals( + "${`X`}", + templateFor(action)); + } + + // --- tooltip --- + + @Test + public void tooltipStatic_emitsLiteralTitleAttribute() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.tooltip("Save item"); + assertEquals( + "${`X`}", + templateFor(action)); + } + + @Test + public void tooltipDynamic_emitsPerRowTitleBinding() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.tooltip(item -> "Delete " + item); + assertEquals( + "${`X`}", + templateFor(action)); + } + + @Test + public void manualTitle_preservedWhenNoTooltipProvider() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.getElement().setAttribute("title", "Manual tooltip"); + assertEquals( + "${`X`}", + templateFor(action)); + } + + @Test + public void tooltipOverridesManualTitle() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.getElement().setAttribute("title", "Manual tooltip"); + action.tooltip("Provider tooltip"); + assertEquals( + "${`X`}", + templateFor(action)); + } + + // --- execute: server-side enabledWhen guard --- + + @Test + public void execute_invokesHandler_whenNoEnabledWhen() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.execute(7); + assertEquals(Integer.valueOf(7), clicked.get()); + } + + @Test + public void execute_invokesHandler_whenEnabledWhenTrue() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.enabledWhen(item -> true); + action.execute(7); + assertEquals(Integer.valueOf(7), clicked.get()); + } + + @Test + public void execute_skipsHandler_whenEnabledWhenFalse() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.enabledWhen(item -> false); + // Server-side guard: the click is rejected without relying on the client ?disabled binding, + // closing the gap for devtools manipulation and enabledWhen/render races. + action.execute(7); + assertNull(clicked.get()); + } + + @Test + public void execute_appliesEnabledWhenPerItem() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.enabledWhen(item -> item % 2 == 0); // enabled only for even items + action.execute(3); // odd → rejected + assertNull(clicked.get()); + action.execute(4); // even → accepted + assertEquals(Integer.valueOf(4), clicked.get()); + } + + // --- execute: server-side visibleWhen guard --- + + @Test + public void execute_invokesHandler_whenVisibleWhenTrue() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.visibleWhen(item -> true); + action.execute(7); + assertEquals(Integer.valueOf(7), clicked.get()); + } + + @Test + public void execute_skipsHandler_whenVisibleWhenFalse() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.visibleWhen(item -> false); + // Server-side guard: visibleWhen omits the button on the client, but the per-row click function + // is registered for every item, leaving a hidden action reachable via a crafted RPC. execute() + // must reject it server-side, just as it does for enabledWhen. + action.execute(7); + assertNull(clicked.get()); + } + + @Test + public void execute_appliesVisibleWhenPerItem() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.visibleWhen(item -> item % 2 == 0); // visible only for even items + action.execute(3); // odd → rejected + assertNull(clicked.get()); + action.execute(4); // even → accepted + assertEquals(Integer.valueOf(4), clicked.get()); + } + + @Test + public void execute_skipsHandler_whenInvisibleButEnabled() { + var clicked = new AtomicReference(); + var action = new EasyRowAction(null, Constant.of("X"), null, item -> clicked.set(item)); + action.visibleWhen(item -> false); + action.enabledWhen(item -> true); + // Either guard failing must reject the click; an invisible action is never executable even + // when enabledWhen would allow it. + action.execute(7); + assertNull(clicked.get()); + } + + // --- HasStyle forwarding --- + + @Test + public void className_isForwardedToButton() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.addClassName("danger"); + assertEquals( + "${`X`}", + templateFor(action)); + } + + @Test + public void style_isForwardedToButton() { + var action = new EasyRowAction<>(null, Constant.of("X"), null, NOP); + action.getStyle().set("color", "red"); + assertEquals( + "${`X`}", + templateFor(action)); + } + + // --- remove: lifecycle / idempotency --- + + @Test + public void remove_onUnregisteredAction_isNoOp() { + var action = new EasyRowAction(null, Constant.of("X"), null, item -> {}); + // manager == null (never registered): must be a no-op, not an NPE. + action.remove(); + action.remove(); + } + + @Test + public void remove_unregistersFromManager_andIsIdempotent() { + var manager = new RowActionsManager(new Grid<>()); + var action = manager.addRowAction(Constant.of("X"), null, item -> {}); + assertEquals(1, manager.getRowActions().size()); + + action.remove(); + assertTrue(manager.getRowActions().isEmpty()); + + // a second remove() is a no-op (the manager reference was already cleared) and must not throw + action.remove(); + assertTrue(manager.getRowActions().isEmpty()); + } + + // --- custom renderer SPI (HasRowActions.setRowActionsRenderer -> manager.setRenderer) --- + + @Test + public void setRenderer_installsCustomRenderer_delegatesColumn_andCleansUpPrevious() { + var grid = new Grid(); + grid.setItems(1, 2, 3); + var actionsColumn = grid.addColumn(item -> item); + var manager = new RowActionsManager(grid); + + var custom = new RecordingRenderer(actionsColumn); + manager.setRenderer(custom); + + // resolving the actions column initializes the active renderer (update()) and must delegate + // to the custom renderer's getColumn() + assertSame(actionsColumn, manager.getActionsColumn()); + assertTrue("custom renderer should receive update()", custom.updated); + assertEquals(0, custom.removed); + + // installing another renderer cleans up the custom one exactly once + manager.setRenderer(new RecordingRenderer<>(null)); + assertEquals(1, custom.removed); + } + + /** Minimal {@link RowActionsRenderer} that records the SPI calls made by the manager. */ + private static final class RecordingRenderer implements RowActionsRenderer { + private final Grid.Column column; + private boolean updated; + private int removed; + + RecordingRenderer(Grid.Column column) { + this.column = column; + } + + @Override + public void update(List> actions) { + updated = true; + } + + @Override + public Grid.Column getColumn() { + return column; + } + + @Override + public void remove() { + removed++; + } + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilderTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilderTest.java new file mode 100644 index 0000000..c63f307 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/actions/LitRendererBuilderTest.java @@ -0,0 +1,505 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.actions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import com.vaadin.flow.component.HasElement; +import com.vaadin.flow.data.renderer.LitRenderer; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.function.ValueProvider; +import elemental.json.Json; +import elemental.json.JsonObject; +import elemental.json.JsonValue; +import lombok.Builder; +import lombok.Value; +import org.junit.Test; + +/** + * Tests for LitRendererBuilder. + * + * @author Javier Godoy / Flowing Code + */ +public class LitRendererBuilderTest { + + private LitRendererBuilder newBuilder() { + return new LitRendererBuilder<>("actions"); + } + + private LitRendererBuilder newPojoBuilder() { + return new LitRendererBuilder<>("actions"); + } + + private static JsonObject actionsFor(LitRenderer renderer, Pojo row) { + ValueProvider vp = renderer.getValueProviders().get("actions"); + assertNotNull("expected an 'actions' value provider", vp); + // The provider's value is meant to be serialized to the client, not read back through the + // elemental API. Under V25 it is a Jackson-backed node whose elemental JsonObject accessors + // (getString/getBoolean/getObject) are unimplemented and throw AbstractMethodError. Round-trip + // through toJson()/Json.parse() to obtain a real JsonObject whose accessors behave identically + // on V24 and V25. + JsonValue value = (JsonValue) vp.apply(row); + return Json.parse(value.toJson()); + } + + /** + * Wraps {@code inner} in the {@code item.actions} presence guard the builder emits whenever at + * least one per-row property is registered. + */ + private static String guarded(String inner) { + return "${item.actions ? html`" + inner + "` : undefined}"; + } + + // --- structural emission --- + + @Test + public void emptyTagOpensAndCloses() { + LitRendererBuilder b = newBuilder(); + b.tag("vaadin-button", () -> {}); + assertEquals("", b.getTemplate()); + } + + @Test + public void nestedTags() { + LitRendererBuilder b = newBuilder(); + b.tag("outer", () -> { + b.set("a", "1"); + b.tag("inner", () -> b.set("b", "2")); + }); + assertEquals("", b.getTemplate()); + } + + // --- set: literal attribute / property / boolean dispatch --- + + @Test + public void setEmitsHtmlAttribute() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", "bar")); + assertEquals("", b.getTemplate()); + } + + @Test + public void setDotPrefixEmitsPropertyBinding() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set(".prop", "hello")); + assertEquals("", b.getTemplate()); + } + + @Test + public void setQuestionPrefixTrueEmitsBooleanAttribute() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("?disabled", "true")); + assertEquals("", b.getTemplate()); + } + + @Test + public void setQuestionPrefixFalseSkips() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("?disabled", "false")); + assertEquals("", b.getTemplate()); + } + + @Test + public void setNullValueIsNoOp() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", null)); + assertEquals("", b.getTemplate()); + } + + // --- bind: dynamic vs Constant --- + + @Test + public void bindRegistersProviderAndEmitsReference() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.bind("foo", item -> "value")); + assertEquals(guarded(""), b.getTemplate()); + } + + @Test + public void bindWithConstantDelegatesToLiteralPath() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.bind("foo", Constant.of("bar"))); + assertEquals("", b.getTemplate()); + } + + @Test + public void bindNullProviderIsNoOp() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.bind("foo", null)); + assertEquals("", b.getTemplate()); + } + + @Test + public void bindBooleanRegistersAndEmits() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.bindBoolean("disabled", item -> true)); + assertEquals(guarded(""), b.getTemplate()); + } + + // --- withCondition --- + + @Test + public void withConditionWrapsBody() { + LitRendererBuilder b = newBuilder(); + b.withCondition(item -> true, () -> b.tag("x", () -> {})); + assertEquals(guarded("${item.actions[0] ? html`` : undefined}"), b.getTemplate()); + } + + @Test + public void withConditionNullPredicateIsPassThrough() { + LitRendererBuilder b = newBuilder(); + b.withCondition(null, () -> b.tag("x", () -> {})); + assertEquals("", b.getTemplate()); + } + + // --- content --- + + @Test + public void addContentLiteral() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.addContent(Constant.of("Hello"))); + assertEquals("${`Hello`}", b.getTemplate()); + } + + @Test + public void addContentDynamic() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.addContent(item -> "label")); + assertEquals(guarded("${item.actions[0]}"), b.getTemplate()); + } + + @Test + public void addContentNullProviderIsNoOp() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.addContent(null)); + assertEquals("", b.getTemplate()); + } + + @Test + public void addContentConstantOfNullIsNoOp() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.addContent(Constant.of(null))); + assertEquals("", b.getTemplate()); + } + + // --- tagOpen guard --- + + @Test + public void attributeOutsideTagThrows() { + LitRendererBuilder b = newBuilder(); + assertThrows(IllegalStateException.class, () -> b.set("foo", "bar")); + } + + @Test + public void attributeAfterNestedContentThrows() { + LitRendererBuilder b = newBuilder(); + assertThrows(IllegalStateException.class, () -> { + b.tag("outer", () -> { + b.tag("inner", () -> {}); + b.set("foo", "bar"); + }); + }); + } + + @Test + public void requireTagOpenExceptionMessageMentionsAttribute() { + LitRendererBuilder b = newBuilder(); + IllegalStateException ex = + assertThrows(IllegalStateException.class, () -> b.set("foo", "bar")); + assertEquals("Attribute can only be added inside a start tag, before any content", + ex.getMessage()); + } + + // --- copyAttributes BOOLEAN branch --- + + @Test + public void copyAttributesQuestionPrefixWithTrue() { + Element el = new Element("test"); + el.setProperty("disabled", true); + HasElement holder = () -> el; + + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.copyAttributes(holder, "?disabled")); + assertEquals("", b.getTemplate()); + } + + @Test + public void copyAttributesQuestionPrefixWithFalseSkips() { + Element el = new Element("test"); + el.setProperty("disabled", false); + HasElement holder = () -> el; + + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.copyAttributes(holder, "?disabled")); + assertEquals("", b.getTemplate()); + } + + @Test + public void copyAttributesQuestionPrefixMissingPropertySkips() { + Element el = new Element("test"); + HasElement holder = () -> el; + + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.copyAttributes(holder, "?disabled")); + assertEquals("", b.getTemplate()); + } + + // --- withFunction / event --- + + @Test + public void withFunctionDoesNotAlterTemplate() { + LitRendererBuilder b = newBuilder(); + b.withFunction((item, args) -> {}); + b.tag("x", () -> {}); + assertEquals("", b.getTemplate()); + } + + @Test + public void withFunctionReturnsSequentialIndices() { + LitRendererBuilder b = newBuilder(); + assertEquals(0, b.withFunction((item, args) -> {})); + assertEquals(1, b.withFunction((item, args) -> {})); + assertEquals(2, b.withFunction((item, args) -> {})); + } + + @Test + public void withFunctionBuildSucceeds() { + LitRendererBuilder b = newBuilder(); + b.withFunction((item, args) -> {}); + b.tag("x", () -> {}); + // build() must not throw — the registered handlers are bound to the LitRenderer. + assertNotNull(b.build()); + } + + @Test + public void eventEmitsListenerBinding() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> { + int idx = b.withFunction((item, args) -> {}); + b.event("click", idx); + }); + assertEquals("", b.getTemplate()); + } + + @Test + public void eventUsesPropertyAsFunctionPrefix() { + LitRendererBuilder b = new LitRendererBuilder<>("widgets"); + b.tag("x", () -> { + int idx = b.withFunction((item, args) -> {}); + b.event("click", idx); + }); + assertEquals("", b.getTemplate()); + } + + @Test + public void eventMultipleFunctionsKeepDistinctIndices() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> { + int a = b.withFunction((item, args) -> {}); + int c = b.withFunction((item, args) -> {}); + b.event("click", a); + b.event("dblclick", c); + }); + assertEquals( + "", b.getTemplate()); + } + + @Test + public void eventRequiresOpenTag() { + LitRendererBuilder b = newBuilder(); + int idx = b.withFunction((item, args) -> {}); + assertThrows(IllegalStateException.class, () -> b.event("click", idx)); + } + + // --- escape behavior --- + + @Test + public void quoteValueSwitchesToBacktickForm() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", "a\"b")); + assertEquals("", b.getTemplate()); + } + + @Test + public void backtickValueSwitchesToBacktickForm() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", "a`b")); + // backtick is escaped to \` inside the template literal + assertEquals("", b.getTemplate()); + } + + @Test + public void dollarBraceValueSwitchesToBacktickForm() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", "a${x}")); + // $ is escaped to \$ so it isn't interpreted as JS interpolation + assertEquals("", b.getTemplate()); + } + + @Test + public void backslashValueSwitchesToBacktickForm() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.set("foo", "a\\b")); + // backslash is escaped to \\ + assertEquals("", b.getTemplate()); + } + + // --- copyAttributes --- + + @Test + public void copyAttributesReadsAttributeAndProperty() { + Element el = new Element("test"); + el.setAttribute("foo", "fooValue"); + el.setProperty("bar", "barValue"); + + HasElement holder = () -> el; + + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.copyAttributes(holder, "foo", ".bar")); + assertEquals("", b.getTemplate()); + } + + @Test + public void copyAttributesSkipsMissingValues() { + Element el = new Element("test"); + HasElement holder = () -> el; + + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.copyAttributes(holder, "foo", ".bar")); + assertEquals("", b.getTemplate()); + } + + // --- BindingType --- + + @Test + public void bindingTypeDispatchesOnPrefix() { + assertEquals(LitRendererBuilder.BindingType.ATTRIBUTE, + LitRendererBuilder.BindingType.of("foo")); + assertEquals(LitRendererBuilder.BindingType.PROPERTY, + LitRendererBuilder.BindingType.of(".foo")); + assertEquals(LitRendererBuilder.BindingType.BOOLEAN, + LitRendererBuilder.BindingType.of("?foo")); + } + + // --- build() per case --- + + @Test + public void buildLiteralOnlyHasNoActionsProvider() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("vaadin-button", () -> b.set("label", "OK")); + LitRenderer r = b.build(); + assertNotNull(r); + assertFalse("no registered providers expected for a literal-only template", + r.getValueProviders().containsKey("actions")); + } + + @Test + public void buildBindEmitsStringPerRow() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("vaadin-button", () -> b.bind("label", Pojo::getName)); + + JsonObject actions = actionsFor(b.build(), Pojo.builder().name("hello").build()); + assertEquals("hello", actions.getString("0")); + } + + @Test + public void buildBindBooleanEmitsBooleanPerRow() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("vaadin-button", () -> b.bindBoolean("disabled", Pojo::getActive)); + + JsonObject actions = actionsFor(b.build(), Pojo.builder().active(true).build()); + assertTrue(actions.getBoolean("0")); + } + + @Test + public void buildAddContentDynamicEmitsPerRowString() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("vaadin-button", () -> b.addContent(Pojo::getName)); + + JsonObject actions = actionsFor(b.build(), Pojo.builder().name("Click").build()); + assertEquals("Click", actions.getString("0")); + } + + @Test + public void buildWithConditionRegistersPredicateAsFirstProperty() { + LitRendererBuilder b = newPojoBuilder(); + b.withCondition(Pojo::getActive, () -> b.tag("vaadin-button", () -> {})); + + JsonObject actions = actionsFor(b.build(), Pojo.builder().active(true).build()); + assertTrue(actions.getBoolean("0")); + } + + @Test + public void buildMixesLiteralAndDynamicProperties() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("vaadin-button", () -> { + b.set("theme", "primary"); + b.bind("label", Pojo::getName); + b.bindBoolean("disabled", Pojo::getActive); + }); + JsonObject actions = actionsFor(b.build(), + Pojo.builder().name("Save").active(false).build()); + // literal "theme" doesn't register a provider — only label (idx 0) and disabled (idx 1) + assertEquals("Save", actions.getString("0")); + assertFalse(actions.getBoolean("1")); + } + + // --- spreadAllAttributesAndProperties --- + + @Test + public void spread_emitsAttrAndPropMaps() { + LitRendererBuilder b = newBuilder(); + b.tag("x", () -> b.spreadAllAttributesAndProperties(item -> (HasElement) () -> new Element("test"))); + assertEquals( + guarded(""), + b.getTemplate()); + } + + @Test + public void spread_build_attrMapHoldsAttributes_propMapHoldsProperties() { + LitRendererBuilder b = newPojoBuilder(); + b.tag("fc-icon", () -> b.spreadAllAttributesAndProperties(row -> { + Element el = new Element("vaadin-icon"); + el.setAttribute("icon", row.getIconName()); + el.setAttribute("extra", "val"); + el.setProperty("symbol", row.getName()); + return (HasElement) () -> el; + })); + + JsonObject actions = actionsFor(b.build(), + Pojo.builder().iconName("vaadin:check").name("done").build()); + JsonObject attrMap = actions.getObject("0"); + assertEquals("vaadin:check", attrMap.getString("icon")); + assertEquals("val", attrMap.getString("extra")); + JsonObject propMap = actions.getObject("1"); + assertEquals("done", propMap.getString("symbol")); + } + + @Value + @Builder + static class Pojo { + String name; + Boolean active; + String iconName; + } +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/data/PersonData.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/data/PersonData.java index 5f77089..d2c01e6 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/data/PersonData.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/data/PersonData.java @@ -29,6 +29,11 @@ import java.util.ArrayList; import java.util.List; +/** + * Generates test data for Person entities. + * + * @author Javier Godoy / Flowing Code + */ public class PersonData { private final List people = new ArrayList<>(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/AbstractViewTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/AbstractViewTest.java index 51a1289..e88f486 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/AbstractViewTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/AbstractViewTest.java @@ -24,6 +24,8 @@ import com.vaadin.testbench.TestBench; import com.vaadin.testbench.parallel.ParallelTest; import io.github.bonigarcia.wdm.WebDriverManager; +import java.util.concurrent.Semaphore; +import org.junit.After; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Rule; @@ -46,6 +48,7 @@ public abstract class AbstractViewTest extends ParallelTest { private static final int SERVER_PORT = 8080; private final String route; + private boolean semaphoreAcquired = false; @Rule public ScreenshotOnFailureRule rule = new ScreenshotOnFailureRule(this, true); @@ -62,15 +65,27 @@ public static void setupClass() { WebDriverManager.chromedriver().setup(); } - @Override + private final static Semaphore semaphore = new Semaphore(4); + @Before public void setup() throws Exception { + semaphore.acquire(); + semaphoreAcquired = true; if (isUsingHub()) { super.setup(); } else { setDriver(TestBench.createDriver(new ChromeDriver())); } getDriver().get(getURL(route)); + getCommandExecutor().waitForVaadin(); + } + + @After + public void tearDown() throws Exception { + if (semaphoreAcquired) { + semaphore.release(); + semaphoreAcquired = false; + } } /** diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionIT.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionIT.java new file mode 100644 index 0000000..12673d2 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionIT.java @@ -0,0 +1,511 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.it; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsStyle; +import com.flowingcode.vaadin.testbench.rpc.HasRpcSupport; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.confirmdialog.testbench.ConfirmDialogElement; +import com.vaadin.flow.component.contextmenu.testbench.ContextMenuElement; +import com.vaadin.flow.component.contextmenu.testbench.ContextMenuItemElement; +import com.vaadin.flow.component.grid.testbench.GridElement; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.testbench.ElementQuery; +import com.vaadin.testbench.TestBenchElement; +import java.time.Duration; +import java.util.List; +import lombok.experimental.ExtensionMethod; +import org.junit.Before; +import org.junit.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.StaleElementReferenceException; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +class ElementQueryExtension { + public static T waitForSingle(ElementQuery q) { + // https://github.com/vaadin/testbench/issues/2189 + q.waitForFirst(); + return q.single(); + } +} + +/** + * Integration tests for EasyRowAction. + * + * @author Javier Godoy / Flowing Code + */ +@ExtensionMethod(ElementQueryExtension.class) +public class EasyRowActionIT extends AbstractViewTest implements HasRpcSupport { + + private EasyRowActionITCallables $server = createCallableProxy(EasyRowActionITCallables.class); + + private GridElement grid; + + public EasyRowActionIT() { + super(EasyRowActionITView.ROUTE); + } + + @Before + public void before() { + grid = $(GridElement.class).waitForSingle(); + } + + // In V24 the menu items are read from the via + // ContextMenuOverlayElement; in V25 that overlay element is gone (ContextMenuOverlayElement is + // deprecated and now maps to ) and items are read from ContextMenuElement. + // Resolve the version-appropriate, non-deprecated element class by name and invoke its + // getMenuItems() reflectively so this compiles and runs against either Vaadin version. + private List getContextMenuItems() { + int major = $server.getVersion().getMajorVersion(); + TestBenchElement element = major >= 25 + ? $("body").waitForSingle().findElement(By.tagName("vaadin-context-menu")) + : $("vaadin-context-menu-overlay").waitForSingle(); + + return element.$(ContextMenuItemElement.class).all(); + } + + // Whether a context menu overlay is currently open. In V24 the open overlay is a separate + // element, while in V25 it is a child of ; + // rather than probe for either DOM shape, rely on the "opened" attribute exposed through + // ContextMenuElement.isOpen(), which maps to in both versions. + // In V24, a stale element reference may occur during menu closure as the overlay is detached; + // treat such an exception as "not open" since the element is being disposed. + private boolean isContextMenuOpen() { + try { + return $(ContextMenuElement.class).all().stream() + .anyMatch(e -> e.isOpen() || e.getPropertyBoolean("opened") == Boolean.TRUE); + } catch (StaleElementReferenceException e) { + return false; + } + } + + // Opens the DROPDOWN overflow menu for the given row by clicking its trigger button and + // waits until the menu overlay is open. The dropdown renderer hosts the trigger in a dedicated + // column (index 1, after the value column at index 0); unlike CONTEXT_MENU, the menu opens only + // from this button, never from a row right-click. The backing GridContextMenu is the same kind + // used by CONTEXT_MENU, so items are read through getContextMenuItems(). + private void openDropdownMenu(int rowIndex) { + grid.getCell(rowIndex, 1).$("vaadin-button").single().click(); + waitUntil(d -> isContextMenuOpen()); + } + + // Closes any open menu overlay via ESCAPE and waits until it is gone, so the next open starts + // from a known-closed state. + private void closeMenu() { + new org.openqa.selenium.interactions.Actions(getDriver()).sendKeys(Keys.ESCAPE).perform(); + waitUntil(d -> !isContextMenuOpen()); + } + + // Grace period during which an overlay that must never appear would have appeared. Asserting + // absence has no positive signal to wait on, so a short fixed sleep is the pragmatic choice. + private void sleepGrace() { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + @Test + public void testActionInvocation() { + $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + + assertNull($server.getClickedValue()); + + // Items are 1-10; row index 2 = item 3 (value intentionally differs from row index) + grid.getCell(2, 1).$("vaadin-button").single().click(); + + assertEquals(Integer.valueOf(3), $server.getClickedValue()); + } + + @Test + public void testVisibleWhen() { + var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + action.visibleWhen(x -> x % 2 == 0); // visible only for even items + + // button absent in an odd-item row (row 0 = item 1) + assertTrue(grid.getCell(0, 1).$("vaadin-button").all().isEmpty()); + + // button present in an even-item row of the same grid (row 1 = item 2) + assertFalse(grid.getCell(1, 1).$("vaadin-button").all().isEmpty()); + + // clearing visibleWhen makes the button visible in every row + action.visibleWhen(null); + assertFalse(grid.getCell(0, 1).$("vaadin-button").all().isEmpty()); + } + + @Test + public void testMultipleActionsInvocation() { + $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(2)); + + // first action in row 2 (item 3) + grid.getCell(2, 1).$("vaadin-button").get(0).click(); + assertEquals(Integer.valueOf(3), $server.getClickedValue()); + assertEquals(Integer.valueOf(1), $server.getClickedAction()); + + // second action in row 4 (item 5) + grid.getCell(4, 1).$("vaadin-button").get(1).click(); + assertEquals(Integer.valueOf(5), $server.getClickedValue()); + assertEquals(Integer.valueOf(2), $server.getClickedAction()); + } + + @Test + public void testEnabledWhen() { + var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + action.enabledWhen(x -> x % 2 == 0); // enabled only for even items + $server.refreshRowActions(); + + // disabled attribute present in odd row (row 0 = item 1) + assertNotNull(grid.getCell(0, 1).$("vaadin-button").single().getAttribute("disabled")); + + // clicking enabled button fires the handler (row 1 = item 2) + grid.getCell(1, 1).$("vaadin-button").single().click(); + assertEquals(Integer.valueOf(2), $server.getClickedValue()); + + // clicking disabled button does not fire the handler + grid.getCell(0, 1).$("vaadin-button").single().click(); + assertEquals(Integer.valueOf(2), $server.getClickedValue()); // unchanged + } + + @Test + public void testConfirmation() { + var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + action.withConfirmation("Confirm", "Proceed?"); + + // cancel does not fire the handler + grid.getCell(0, 1).$("vaadin-button").single().click(); + $(ConfirmDialogElement.class).waitForSingle().getCancelButton().click(); + assertNull($server.getClickedValue()); + + // click opens the dialog; confirming fires the handler + grid.getCell(0, 1).$("vaadin-button").single().click(); + $(ConfirmDialogElement.class).waitForSingle(); + assertNull($server.getClickedValue()); + $(ConfirmDialogElement.class).waitForSingle().getConfirmButton().click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + + // second click while dialog is open does not open a second dialog + grid.getCell(0, 1).$("vaadin-button").single().click(); + $(ConfirmDialogElement.class).waitForSingle(); // ensure dialog is open + grid.getCell(0, 1).$("vaadin-button").single().click(); + assertEquals(1, $(ConfirmDialogElement.class).all().size()); + } + + @Test + public void testContextMenu() { + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + $server.addRowAction("Edit", $server.action(1)); + + // no inline buttons (no actions column) + assertTrue(grid.getCell(0, 0).$("vaadin-button").all().isEmpty()); + + // right-click opens the context menu + grid.getCell(0, 0).contextClick(); + var items = getContextMenuItems(); + + // one menu item for the registered action + assertEquals(1, items.size()); + + // clicking the item fires the handler with the correct item (row 0 = item 1) + items.get(0).click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + } + + @Test + public void testContextMenuVisibleWhen() { + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + $server.addRowAction("Edit", $server.action(1)); + $server.addRowAction("Delete", $server.action(2)) + .visibleWhen(x -> x % 2 == 0); // visible only for even items + + // Delete absent for odd row (row 0 = item 1); only Edit shown + grid.getCell(0, 0).contextClick(); + assertEquals(1, getContextMenuItems().size()); + closeMenu(); + + // even row (row 1 = item 2): both items shown + grid.getCell(1, 0).contextClick(); + assertEquals(2, getContextMenuItems().size()); + } + + @Test + public void testContextMenuEnabledWhen() { + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + $server.addRowAction("Edit", $server.action(1)) + .enabledWhen(x -> x % 2 == 0); // enabled only for even items + + // menu item disabled for odd row (row 0 = item 1) + grid.getCell(0, 0).contextClick(); + var item = getContextMenuItems().get(0); + assertNotNull(item.getAttribute("disabled")); + } + + @Test + public void testContextMenuConfirmation() throws InterruptedException { + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + $server.addRowAction("Edit", $server.action(1)).withConfirmation("Confirm", "Proceed?"); + + // selecting the menu item opens the confirmation dialog; cancelling does not fire the handler + grid.getCell(0, 0).contextClick(); + getContextMenuItems().get(0).click(); + var dialog = $(ConfirmDialogElement.class).waitForSingle(); + dialog.getCancelButton().click(); + assertNull($server.getClickedValue()); + + new WebDriverWait(getDriver(), + Duration.ofSeconds(1)) + .until(ExpectedConditions + .numberOfElementsToBe(By.tagName("vaadin-confirm-dialog-overlay"), 0)); + + // selecting it again and confirming fires the handler (row 0 = item 1) + grid.getCell(0, 0).contextClick(); + getContextMenuItems().get(0).click(); + $(ConfirmDialogElement.class).waitForSingle().getConfirmButton().click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + } + + @Test + public void testDropdown() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)); + + // a single overflow trigger button is rendered in the actions column — the actions + // themselves surface through the menu, not as inline buttons + assertEquals(1, grid.getCell(2, 1).$("vaadin-button").all().size()); + + // clicking the trigger opens the menu bound to that row (row 2 = item 3) + openDropdownMenu(2); + var items = getContextMenuItems(); + + // one menu item for the registered action + assertEquals(1, items.size()); + + // clicking the item fires the handler with the trigger row's item + items.get(0).click(); + assertEquals(Integer.valueOf(3), $server.getClickedValue()); + } + + @Test + public void testDropdownVisibleWhen() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)); + $server.addRowAction("Delete", $server.action(2)) + .visibleWhen(x -> x % 2 == 0); // visible only for even items + + // regardless of how many actions are registered, the column shows exactly one trigger button + // (unlike inline mode, which renders one button per action) + assertEquals(1, grid.getCell(0, 1).$("vaadin-button").all().size()); + + // Delete absent for odd row (row 0 = item 1); only Edit shown + openDropdownMenu(0); + assertEquals(1, getContextMenuItems().size()); + closeMenu(); + + // even row (row 1 = item 2): both items shown + openDropdownMenu(1); + assertEquals(2, getContextMenuItems().size()); + } + + @Test + public void testDropdownEnabledWhen() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)) + .enabledWhen(x -> x % 2 == 0); // enabled only for even items + + // menu item disabled for odd row (row 0 = item 1) + openDropdownMenu(0); + var item = getContextMenuItems().get(0); + assertNotNull(item.getAttribute("disabled")); + } + + @Test + public void testDropdownConfirmation() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)).withConfirmation("Confirm", "Proceed?"); + + // selecting the overflow menu item opens the confirmation dialog; cancelling does not fire + openDropdownMenu(0); + getContextMenuItems().get(0).click(); + $(ConfirmDialogElement.class).waitForSingle().getCancelButton().click(); + assertNull($server.getClickedValue()); + + // selecting it again and confirming fires the handler (row 0 = item 1) + openDropdownMenu(0); + getContextMenuItems().get(0).click(); + $(ConfirmDialogElement.class).waitForSingle().getConfirmButton().click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + } + + @Test + public void testDropdownSuppressesContextMenu() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)); + + // the default right-click gesture stays suppressed: right-clicking a data cell opens nothing + grid.getCell(0, 0).contextClick(); + sleepGrace(); + assertFalse(isContextMenuOpen()); + + // nor does a left click anywhere else in the row open the menu + grid.getCell(0, 0).click(); + sleepGrace(); + assertFalse(isContextMenuOpen()); + + // positive control: the overflow trigger button is the only thing that opens the menu + openDropdownMenu(0); + assertEquals(1, getContextMenuItems().size()); + } + + @Test + public void testDropdownRemovedOnRendererSwitch() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)); + + // baseline: value column + the dropdown trigger column + assertEquals(2, $server.getColumnCount()); + + // positive control: the trigger opens the menu while in dropdown mode + openDropdownMenu(0); + closeMenu(); + + // switch to inline buttons and wait for the actions column to render + $server.setRowActionsStyle(RowActionsStyle.INLINE_BUTTONS); + waitUntil(d -> { + try { + return !grid.getCell(0, 1).$("vaadin-button").all().isEmpty(); + } catch (RuntimeException e) { + return false; + } + }); + + // the trigger column was replaced, not left behind: still value + a single actions column + assertEquals(2, $server.getColumnCount()); + + // and that column now hosts a working inline action button (row 0 = item 1), not the old trigger + grid.getCell(0, 1).$("vaadin-button").single().click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + } + + @Test + public void testDropdownToContextMenuSwitch() { + $server.setRowActionsStyle(RowActionsStyle.DROPDOWN); + $server.addRowAction("Edit", $server.action(1)); + + // baseline: value column + the dropdown trigger column + assertEquals(2, $server.getColumnCount()); + + // switch to the context-menu style + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + + // the trigger column is removed and nothing replaces it: only the value column remains + assertEquals(1, $server.getColumnCount()); + + // the actions now open the context-menu way, on right-click + grid.getCell(0, 0).contextClick(); + var items = getContextMenuItems(); + assertEquals(1, items.size()); + items.get(0).click(); + assertEquals(Integer.valueOf(1), $server.getClickedValue()); + } + + @Test + public void testRefreshRowActions() { + var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + + // baseline: the button carries only the default variant, not "error" + assertFalse( + grid.getCell(0, 1).$("vaadin-button").single().getAttribute("theme").contains("error")); + + // An element-level change (a theme variant) is NOT applied automatically — unlike the fluent + // mutators (visibleWhen/enabledWhen/tooltip), which self-refresh. + action.addThemeVariants(ButtonVariant.LUMO_ERROR); + assertFalse( + grid.getCell(0, 1).$("vaadin-button").single().getAttribute("theme").contains("error")); + + // It takes effect only after an explicit refreshRowActions(). + $server.refreshRowActions(); + assertTrue( + grid.getCell(0, 1).$("vaadin-button").single().getAttribute("theme").contains("error")); + } + + @Test + public void testThemeVariants() { + $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1)); + + // default tertiary-inline variant applied + var theme = grid.getCell(0, 1).$("vaadin-button").single().getAttribute("theme"); + assertNotNull(theme); + assertTrue(theme.contains("tertiary-inline")); + + // extra variant combined with the default + var action2 = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(2)); + action2.addThemeVariants(ButtonVariant.LUMO_ERROR); + $server.refreshRowActions(); + var theme2 = grid.getCell(0, 1).$("vaadin-button").get(1).getAttribute("theme"); + assertNotNull(theme2); + assertTrue(theme2.contains("tertiary-inline")); + assertTrue(theme2.contains("error")); + + // setDefaultRowActionVariants overrides the default for subsequently added actions + $server.setDefaultRowActionVariants(ButtonVariant.LUMO_ERROR); + $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(3)); + var theme3 = grid.getCell(0, 1).$("vaadin-button").get(2).getAttribute("theme"); + assertNotNull(theme3); + assertTrue(theme3.contains("error")); + assertFalse(theme3.contains("tertiary-inline")); + } + + @Test + public void testContextMenuRemovedOnRendererSwitch() { + $server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + $server.addRowAction("Edit", $server.action(1)); + + // positive control: the context menu opens while in menu mode + grid.getCell(0, 0).contextClick(); + waitUntil(d -> isContextMenuOpen()); + new org.openqa.selenium.interactions.Actions(getDriver()).sendKeys(Keys.ESCAPE).perform(); + waitUntil(d -> !isContextMenuOpen()); + + // switch back to inline buttons and wait for the actions column to render + $server.setRowActionsStyle(RowActionsStyle.INLINE_BUTTONS); + waitUntil(d -> { + try { + return !grid.getCell(0, 1).$("vaadin-button").all().isEmpty(); + } catch (RuntimeException e) { + return false; + } + }); + + // the replaced renderer must have unwired the context menu from the grid + grid.getCell(0, 0).contextClick(); + sleepGrace(); + assertFalse(isContextMenuOpen()); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITCallables.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITCallables.java new file mode 100644 index 0000000..1da8eac --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITCallables.java @@ -0,0 +1,58 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.it; + +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsStyle; +import com.flowingcode.vaadin.testbench.rpc.RmiCallable; +import com.flowingcode.vaadin.testbench.rpc.Version; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.icon.IconFactory; +import com.vaadin.flow.function.SerializableConsumer; + +/** + * RMI interface for EasyRowAction integration tests. + * + * @author Javier Godoy / Flowing Code + */ +public interface EasyRowActionITCallables extends RmiCallable { + + SerializableConsumer action(int action); + + Integer getClickedValue(); + + Integer getClickedAction(); + + void setRowActionsStyle(RowActionsStyle style); + + void setDefaultRowActionVariants(ButtonVariant variant); + + void refreshRowActions(); + + int getColumnCount(); + + RmiEasyRowAction addRowAction(String label, + SerializableConsumer handler); + + RmiEasyRowAction addRowAction( + IconFactory iconFactory, + SerializableConsumer handler); + + Version getVersion(); +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITView.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITView.java new file mode 100644 index 0000000..618defb --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/EasyRowActionITView.java @@ -0,0 +1,131 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.it; + +import com.flowingcode.vaadin.addons.easygrid.EasyGrid; +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsStyle; +import com.flowingcode.vaadin.jsonmigration.InstrumentedRoute; +import com.flowingcode.vaadin.jsonmigration.LegacyClientCallable; +import com.flowingcode.vaadin.testbench.rpc.RmiRemote; +import com.flowingcode.vaadin.testbench.rpc.Version; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.icon.IconFactory; +import com.vaadin.flow.component.internal.AllowInert; +import com.vaadin.flow.function.SerializableConsumer; +import elemental.json.JsonObject; +import elemental.json.JsonValue; +import java.util.stream.IntStream; +import lombok.Getter; +import lombok.experimental.Delegate; + +/** + * Integration test view for EasyRowAction. + * + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +@InstrumentedRoute(EasyRowActionITView.ROUTE) +public class EasyRowActionITView extends Div implements EasyRowActionITCallables { + + public static final String ROUTE = "it/EasyRowAction"; + private EasyGrid grid; + + public static interface IState extends RmiRemote { + Integer getClickedValue(); + + Integer getClickedAction(); + + default SerializableConsumer action(int action) { + return value -> { + ((State) this).clickedValue = value; + ((State) this).clickedAction = action; + }; + } + } + + @Getter + private static class State implements IState { + private Integer clickedValue; + private Integer clickedAction; + }; + + @Delegate + private State state = new State(); + + public EasyRowActionITView() { + grid = new EasyGrid<>(Integer.class, false); + grid.setItems(IntStream.rangeClosed(1, 10).boxed().toList()); + grid.getWrappedGrid().addColumn(x -> x); + add(grid); + } + + @Override + public SerializableConsumer action(int action) { + return state.action(action); + } + + @Override + @AllowInert + @LegacyClientCallable + public JsonValue $call(JsonObject invocation) { + return EasyRowActionITCallables.super.$call(invocation); + } + + @Override + public Version getVersion() { + return new Version(); + } + + @Override + public RmiEasyRowAction addRowAction(String label, + SerializableConsumer handler) { + return RmiEasyRowAction.of(grid.addRowAction(label, handler)); + } + + @Override + public void setRowActionsStyle(RowActionsStyle style) { + grid.setRowActionsStyle(style); + } + + @Override + public void setDefaultRowActionVariants(ButtonVariant variant) { + grid.setDefaultRowActionVariants(variant); + } + + @Override + public void refreshRowActions() { + grid.refreshRowActions(); + } + + @Override + public int getColumnCount() { + return grid.getWrappedGrid().getColumns().size(); + } + + @Override + public RmiEasyRowAction addRowAction( + IconFactory iconFactory, + SerializableConsumer handler) { + var action = grid.addRowAction(iconFactory, handler); + return RmiEasyRowAction.of(action); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/RmiEasyRowAction.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/RmiEasyRowAction.java new file mode 100644 index 0000000..c9df9c9 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/RmiEasyRowAction.java @@ -0,0 +1,78 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.it; + +import com.flowingcode.vaadin.addons.easygrid.actions.EasyRowAction; +import com.flowingcode.vaadin.testbench.rpc.RmiRemote; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.function.SerializablePredicate; + +/** + * RMI interface for EasyRowAction proxying in integration tests. + * + * @param the row item type + * @author Javier Godoy / Flowing Code + */ +public interface RmiEasyRowAction extends RmiRemote { + + RmiEasyRowAction visibleWhen(SerializablePredicate predicate); + + RmiEasyRowAction enabledWhen(SerializablePredicate predicate); + + RmiEasyRowAction withConfirmation(String title, String message); + + RmiEasyRowAction addThemeVariants(ButtonVariant variant); + + void remove(); + + static RmiEasyRowAction of(EasyRowAction action) { + return new RmiEasyRowAction() { + @Override + public RmiEasyRowAction visibleWhen(SerializablePredicate predicate) { + action.visibleWhen(predicate); + return this; + } + + @Override + public RmiEasyRowAction enabledWhen(SerializablePredicate predicate) { + action.enabledWhen(predicate); + return this; + } + + @Override + public RmiEasyRowAction withConfirmation(String title, String message) { + action.withConfirmation(title, message); + return this; + } + + @Override + public RmiEasyRowAction addThemeVariants(ButtonVariant variant) { + action.addThemeVariants(variant); + return this; + } + + @Override + public void remove() { + action.remove(); + } + }; + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/ViewInitializerImpl.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/ViewInitializerImpl.java new file mode 100644 index 0000000..a7bb69a --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/it/ViewInitializerImpl.java @@ -0,0 +1,37 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.it; +import com.flowingcode.vaadin.jsonmigration.InstrumentationViewInitializer; +import com.vaadin.flow.server.ServiceInitEvent; + +/** + * Service initializer for instrumented test views. + * + * @author Javier Godoy / Flowing Code + */ +@SuppressWarnings("serial") +public class ViewInitializerImpl extends InstrumentationViewInitializer { + + @Override + public void serviceInit(ServiceInitEvent event) { + registerInstrumentedRoute(EasyRowActionITView.class); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Address.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Address.java index d0a6c71..577cc4d 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Address.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Address.java @@ -25,6 +25,11 @@ import lombok.Getter; import lombok.Setter; +/** + * Address entity model. + * + * @author Javier Godoy / Flowing Code + */ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/NumberSample.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/NumberSample.java index eedea9a..28986ad 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/NumberSample.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/NumberSample.java @@ -23,6 +23,11 @@ import java.math.BigInteger; import lombok.Getter; +/** + * Sample entity with various numeric types for testing. + * + * @author Javier Godoy / Flowing Code + */ @Getter public class NumberSample { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Person.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Person.java index 95bb106..73711fa 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Person.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/model/Person.java @@ -29,6 +29,11 @@ import lombok.Getter; import lombok.Setter; +/** + * Person entity model. + * + * @author Javier Godoy / Flowing Code + */ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @Getter diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/service/PersonService.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/service/PersonService.java index 6aa8f59..46c65a2 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/service/PersonService.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/service/PersonService.java @@ -23,6 +23,11 @@ import com.flowingcode.vaadin.addons.easygrid.model.Person; import java.util.List; +/** + * Service for managing Person data. + * + * @author Javier Godoy / Flowing Code + */ public class PersonService { private final PersonData personData = new PersonData(); diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationImplParentDelegationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationImplParentDelegationTest.java index b7bf152..9559300 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationImplParentDelegationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationImplParentDelegationTest.java @@ -36,6 +36,8 @@ *

Methods are discovered reflectively from {@link ColumnConfiguration}. Getters (names starting * with {@code "get"}) are verified to call through to the parent; all other public methods are * verified to call only getter methods on the parent (never setters). + * + * @author Javier Godoy / Flowing Code */ public class ColumnConfigurationImplParentDelegationTest extends DelegationTest { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationLinkDelegationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationLinkDelegationTest.java index ff78f61..5b917a7 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationLinkDelegationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/ColumnConfigurationLinkDelegationTest.java @@ -39,6 +39,8 @@ * link must fall back to the fallback config; and once with the primary stubbed to return a * non-{@code null} value, in which case the fallback must not be called. For non-getter methods, * no interaction with the fallback is expected. + * + * @author Javier Godoy / Flowing Code */ public class ColumnConfigurationLinkDelegationTest extends DelegationTest { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/DelegationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/DelegationTest.java index 30e6870..1669fb8 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/DelegationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/DelegationTest.java @@ -53,6 +53,8 @@ * {@link #createDelegate()}, {@link #createTarget(Object)}, * {@link #buildArg(Class, int)}, and {@link #assertDelegated(Object, String, Object[])}. * + * + * @author Javier Godoy / Flowing Code */ @RunWith(Parameterized.class) public abstract class DelegationTest { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnTestHelper.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnTestHelper.java index 4b00cbf..832b340 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnTestHelper.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnTestHelper.java @@ -27,6 +27,11 @@ import java.lang.reflect.Constructor; import org.mockito.Mockito; +/** + * Helper class for EasyColumn tests. + * + * @author Javier Godoy / Flowing Code + */ final class EasyColumnTestHelper { private EasyColumnTestHelper() {} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToConfigurationDelegationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToConfigurationDelegationTest.java index df4cb64..039fc6f 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToConfigurationDelegationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToConfigurationDelegationTest.java @@ -45,6 +45,7 @@ * the setter was called on the mock with the expected arguments. * * @see DelegationTest + * @author Javier Godoy / Flowing Code */ public class EasyColumnToConfigurationDelegationTest extends DelegationTest { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToGridColumnDelegationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToGridColumnDelegationTest.java index 4d24243..0d0d247 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToGridColumnDelegationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyColumnToGridColumnDelegationTest.java @@ -43,6 +43,7 @@ * asserts that the setter was called on the mock with the expected arguments. * * @see DelegationTest + * @author Javier Godoy / Flowing Code */ public class EasyColumnToGridColumnDelegationTest extends DelegationTest { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyGridConstructionTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyGridConstructionTest.java index ac68759..fc8fbcf 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyGridConstructionTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyGridConstructionTest.java @@ -38,6 +38,11 @@ import org.junit.Test; import org.mockito.Mockito; +/** + * Tests for EasyGrid construction and column management. + * + * @author Javier Godoy / Flowing Code + */ public class EasyGridConstructionTest { @After diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyRowActionUnitTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyRowActionUnitTest.java new file mode 100644 index 0000000..4c2a7c3 --- /dev/null +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/EasyRowActionUnitTest.java @@ -0,0 +1,177 @@ +/*- + * #%L + * Easy Grid Add-on + * %% + * Copyright (C) 2020 - 2026 Flowing Code + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ +package com.flowingcode.vaadin.addons.easygrid.test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import com.flowingcode.vaadin.addons.easygrid.EasyGrid; +import com.flowingcode.vaadin.addons.easygrid.actions.EasyRowAction; +import com.flowingcode.vaadin.addons.easygrid.actions.RowActionsStyle; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.grid.Grid; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.function.SerializableConsumer; +import java.util.Locale; +import java.util.stream.IntStream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Browser-free unit tests for the row-actions column lifecycle: when the actions column is created, + * shown, hidden, and replaced. {@link EasyGrid#getActionsColumn()} forces the deferred renderer + * rebuild to run synchronously, so the assertions can read server-side {@link Grid.Column} state + * without a browser. + * + *

Assertions that depend on rendered DOM, menu overlays, or pointer/keyboard gestures live in + * the integration test instead, as they cannot be reproduced without a browser. + * + * @author Javier Godoy / Flowing Code + */ +public class EasyRowActionUnitTest { + + private static final SerializableConsumer NOP = item -> {}; + + private EasyGrid grid; + + @Before + public void setUp() { + // EasyGrid construction/formatting may consult UI.getCurrent(); mock one as in + // EasyGridConstructionTest. The grid is never attached, so deferred renderer updates are forced + // explicitly through getActionsColumn() rather than a beforeClientResponse round trip. + UI ui = Mockito.mock(UI.class); + Mockito.when(ui.getLocale()).thenReturn(Locale.ENGLISH); + UI.setCurrent(ui); + + grid = new EasyGrid<>(Integer.class, false); + grid.setItems(IntStream.rangeClosed(1, 10).boxed().toList()); + grid.getWrappedGrid().addColumn(x -> x); + } + + @After + public void tearDown() { + UI.setCurrent(null); + } + + // The actions column exists (non-null) and is shown. getActionsColumn() also forces any pending + // renderer rebuild to run synchronously, standing in for the beforeClientResponse round trip a + // live UI would otherwise perform. + private boolean isActionsColumnVisible() { + Grid.Column column = grid.getActionsColumn(); + return column != null && column.isVisible(); + } + + // Total grid column count, forcing a pending renderer (re)build first so the actions/trigger + // column is materialized before counting. + private int columnCount() { + grid.getActionsColumn(); + return grid.getWrappedGrid().getColumns().size(); + } + + @Test + public void testActionsColumnVisibility() { + // column not visible before any action is added + assertFalse(isActionsColumnVisible()); + + // column becomes visible after the first action + grid.addRowAction(VaadinIcon.VAADIN_H, NOP); + assertTrue(isActionsColumnVisible()); + } + + @Test + public void testActionRemove() { + EasyRowAction action = grid.addRowAction(VaadinIcon.VAADIN_H, NOP); + assertTrue(isActionsColumnVisible()); + + // removing the last action hides the column + action.remove(); + assertFalse(isActionsColumnVisible()); + } + + @Test + public void testDropdownActionRemove() { + grid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + EasyRowAction action = grid.addRowAction("Edit", NOP); + + // the trigger column is visible while an action is registered + assertTrue(isActionsColumnVisible()); + + // removing the last action auto-hides the trigger column (as in inline mode) + action.remove(); + assertFalse(isActionsColumnVisible()); + } + + @Test + public void testDropdownWithoutActionsDoesNotShowColumn() { + // selecting the dropdown style with no registered actions must not surface the trigger column + grid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + assertFalse(isActionsColumnVisible()); + + // the trigger column appears once the first action is registered + grid.addRowAction("Edit", NOP); + assertTrue(isActionsColumnVisible()); + } + + @Test + public void testRefreshWithoutActionsDoesNotShowColumn() { + // a renderer rebuild with no registered actions must not surface an empty column + grid.refreshRowActions(); + assertFalse(isActionsColumnVisible()); + } + + @Test + public void testRendererSwitchWithoutActionsDoesNotShowColumn() { + // a menu-mode round trip with no registered actions must not surface an empty column + grid.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + grid.setRowActionsStyle(RowActionsStyle.INLINE_BUTTONS); + assertFalse(isActionsColumnVisible()); + } + + @Test + public void testColumnReplacedOnRendererSwitch() { + grid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + grid.addRowAction("Edit", NOP); + + // baseline: value column + the dropdown trigger column + assertEquals(2, columnCount()); + + // switch to inline buttons + grid.setRowActionsStyle(RowActionsStyle.INLINE_BUTTONS); + + // the trigger column was replaced, not left behind: still value + a single actions column + assertEquals(2, columnCount()); + } + + @Test + public void testColumnRemovedSwitchingToContextMenu() { + grid.setRowActionsStyle(RowActionsStyle.DROPDOWN); + grid.addRowAction("Edit", NOP); + + // baseline: value column + the dropdown trigger column + assertEquals(2, columnCount()); + + // switching to the context-menu style removes the trigger column and adds none in its place + grid.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU); + assertEquals(1, columnCount()); + } + +} diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationFreezeTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationFreezeTest.java index 2869486..772c2a4 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationFreezeTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationFreezeTest.java @@ -31,7 +31,11 @@ import org.junit.Before; import org.junit.Test; -/** Verifies {@link GlobalEasyGridConfiguration#freeze()} behaviour. */ +/** + * Verifies {@link GlobalEasyGridConfiguration#freeze()} behaviour. + * + * @author Javier Godoy / Flowing Code + */ public class GlobalEasyGridConfigurationFreezeTest { private static void setFrozen(boolean value) throws ReflectiveOperationException { diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationTest.java index 519ccde..00f1552 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalEasyGridConfigurationTest.java @@ -27,6 +27,11 @@ import java.lang.reflect.Method; import org.junit.Test; +/** + * Tests for GlobalEasyGridConfiguration. + * + * @author Javier Godoy / Flowing Code + */ public class GlobalEasyGridConfigurationTest { @SuppressWarnings("unchecked") diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalRendererFactoryTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalRendererFactoryTest.java index d8025f9..b207b99 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalRendererFactoryTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/GlobalRendererFactoryTest.java @@ -30,6 +30,11 @@ import java.time.LocalTime; import org.junit.Test; +/** + * Tests for global renderer factory configuration. + * + * @author Javier Godoy / Flowing Code + */ public class GlobalRendererFactoryTest { @Test diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/InstanceFormatterNullRepresentationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/InstanceFormatterNullRepresentationTest.java index 65cc77e..64f2dda 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/InstanceFormatterNullRepresentationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/InstanceFormatterNullRepresentationTest.java @@ -26,6 +26,11 @@ import com.vaadin.flow.component.Component; import org.junit.Test; +/** + * Tests for instance-level formatter null representation handling. + * + * @author Javier Godoy / Flowing Code + */ public class InstanceFormatterNullRepresentationTest { /** diff --git a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/SerializationTest.java b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/SerializationTest.java index 421d023..ecd9697 100644 --- a/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/SerializationTest.java +++ b/src/test/java/com/flowingcode/vaadin/addons/easygrid/test/SerializationTest.java @@ -31,6 +31,11 @@ import org.junit.Test; import org.mockito.Mockito; +/** + * Tests for EasyGrid serialization. + * + * @author Javier Godoy / Flowing Code + */ public class SerializationTest { private void testSerializationOf(Object obj) throws IOException, ClassNotFoundException { diff --git a/src/test/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener b/src/test/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener new file mode 100644 index 0000000..ba1b13b --- /dev/null +++ b/src/test/resources/META-INF/services/com.vaadin.flow.server.VaadinServiceInitListener @@ -0,0 +1 @@ +com.flowingcode.vaadin.addons.easygrid.it.ViewInitializerImpl \ No newline at end of file