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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions FEATURE_ROW_ACTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ EasyRowAction<T> 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.

```java
EasyRowAction<T> withConfirmation(String title, ValueProvider<T, String> messageProvider);
```
The same, with the message resolved per row: `messageProvider` is called with the clicked row item while the dialog is being built, so the prompt can name the affected item. Pass a `null` `title` to show the dialog without a heading.

---

#### Styling and theme variants
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.flowingcode.vaadin.addons</groupId>
<artifactId>easy-grid-addon</artifactId>
<version>1.0.1-SNAPSHOT</version>
<version>1.1.0-SNAPSHOT</version>
<name>Easy Grid Add-on</name>
<description>Easy Grid Add-on for Vaadin Flow</description>
<url>https://www.flowingcode.com/en/open-source/</url>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
import com.vaadin.flow.component.shared.HasThemeVariant;
import com.vaadin.flow.dom.Element;
import com.vaadin.flow.function.SerializableConsumer;
import com.vaadin.flow.function.SerializableFunction;
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;
Expand Down Expand Up @@ -126,7 +126,7 @@ public void accept(T t) {
private SerializablePredicate<T> visibleWhen;
private SerializablePredicate<T> enabledWhen;
private ValueProvider<T, String> tooltipProvider;
private SerializableSupplier<ConfirmDialog> confirmDialogSupplier;
private SerializableFunction<T, ConfirmDialog> confirmDialogFactory;
private transient boolean confirmPending;

private void refresh() {
Expand Down Expand Up @@ -203,15 +203,28 @@ public EasyRowAction<T> withConfirmation(String message) {
* @return this action, for method chaining
*/
public EasyRowAction<T> withConfirmation(String title, String message) {
return withConfirmation(title, message, "Ok", "Cancel");
return withConfirmation(title, Constant.of(message), "Ok", "Cancel");
}

private EasyRowAction<T> withConfirmation(String title, String message, String confirmText,
String cancelText) {
confirmDialogSupplier = () -> {
/**
* Configures a confirmation dialog with a static title, whose message is computed from the row
* item when the action is clicked.
*
* @param title the dialog title, or {@code null} for a dialog without a heading
* @param messageProvider a function that returns the confirmation message for a given row item
* @return this action, for method chaining
*/
public EasyRowAction<T> withConfirmation(String title,
@NonNull ValueProvider<T, String> messageProvider) {
return withConfirmation(title, messageProvider, "Ok", "Cancel");
}
Comment thread
paodb marked this conversation as resolved.

private EasyRowAction<T> withConfirmation(String title,
ValueProvider<T, String> messageProvider, String confirmText, String cancelText) {
confirmDialogFactory = item -> {
var dialog = new ConfirmDialog();
dialog.setHeader(title);
dialog.setText(message);
dialog.setText(messageProvider.apply(item));
dialog.setConfirmText(confirmText);
dialog.setCancelable(true);
dialog.setCancelText(cancelText);
Expand Down Expand Up @@ -253,6 +266,15 @@ AbstractIcon<?> getIcon(T item) {
return iconProvider != null ? iconProvider.apply(item) : null;
}

/**
* Builds the confirmation dialog for the given row item, or returns {@code null} when no
* confirmation is configured. The dialog is created on every call, since its message may be
* derived from the item.
*/
ConfirmDialog getConfirmDialog(T item) {
return confirmDialogFactory != null ? confirmDialogFactory.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
Expand All @@ -261,36 +283,43 @@ void execute(T item) {
if (!isVisible(item) || !isEnabled(item)) {
return;
}
if (confirmDialogSupplier != null) {
if (confirmDialogFactory != 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;
// Reset the flag if building, wiring, or opening the dialog fails; otherwise the action
// would stay blocked for the rest of the session.
try {
ConfirmDialog dialog = getConfirmDialog(item);
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);
}
};
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");
}
} else {
dialog.getElement().addEventListener("opened-changed", e -> confirmPending = false)
.setFilter("event.detail.value === false");
dialog.open();
} catch (RuntimeException ex) {
confirmPending = false;
throw ex;
}
dialog.open();
} else {
actionHandler.accept(item);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public RowActionsDynamicDemo() {
Notification.show("Delete: " + person.getFirstName() + " " + person.getLastName()));
deleteAction.addThemeVariants(ButtonVariant.LUMO_ERROR);

// The confirmation message is computed from the row item when the button is clicked.
deleteAction.withConfirmation("Delete person", person -> "Are you sure you want to delete "
+ person.getFirstName() + " " + person.getLastName() + "?");

// Fluent mutators like visibleWhen automatically refresh the grid.
var restrictCheckbox = new Checkbox("Show edit only for active persons");
restrictCheckbox.addValueChangeListener(e -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.confirmdialog.ConfirmDialog;
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.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;

Expand Down Expand Up @@ -219,6 +221,72 @@ public void tooltipOverridesManualTitle() {
templateFor(action));
}

// --- confirmation dialog contents ---
// The dialog is built at click time, so its title and message are read back from the
// ConfirmDialog element properties that setHeader/setText write.

private static String headerOf(ConfirmDialog dialog) {
return dialog.getElement().getProperty("header");
}

private static String messageOf(ConfirmDialog dialog) {
return dialog.getElement().getProperty("message");
}

@Test
public void withConfirmation_noConfirmation_hasNoDialog() {
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
assertNull(action.getConfirmDialog(7));
}

@Test
public void withConfirmation_message_setsMessageAndNoHeader() {
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
action.withConfirmation("Proceed?");
ConfirmDialog dialog = action.getConfirmDialog(7);
assertNull(headerOf(dialog));
assertEquals("Proceed?", messageOf(dialog));
}

@Test
public void withConfirmation_titleAndMessage_setsHeaderAndMessage() {
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
action.withConfirmation("Confirm", "Proceed?");
ConfirmDialog dialog = action.getConfirmDialog(7);
assertEquals("Confirm", headerOf(dialog));
assertEquals("Proceed?", messageOf(dialog));
}

@Test
public void withConfirmation_nullTitleAndMessageProvider_derivesMessageAndHasNoHeader() {
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
action.withConfirmation(null, item -> "Delete item " + item + "?");
assertNull(headerOf(action.getConfirmDialog(7)));
assertEquals("Delete item 7?", messageOf(action.getConfirmDialog(7)));
assertEquals("Delete item 8?", messageOf(action.getConfirmDialog(8)));
}

@Test
public void withConfirmation_titleAndMessageProvider_derivesMessageFromItem() {
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
action.withConfirmation("Confirm", item -> "Delete item " + item + "?");
assertEquals("Confirm", headerOf(action.getConfirmDialog(7)));
assertEquals("Delete item 7?", messageOf(action.getConfirmDialog(7)));
assertEquals("Confirm", headerOf(action.getConfirmDialog(8)));
assertEquals("Delete item 8?", messageOf(action.getConfirmDialog(8)));
}

@Test
public void withConfirmation_providerIsEvaluatedPerDialog() {
var count = new AtomicInteger();
var action = new EasyRowAction<Integer>(null, Constant.of("X"), null, item -> {});
action.withConfirmation("Confirm", item -> "Message " + count.incrementAndGet());
// The provider is not consulted while configuring the action, only when a dialog is built.
assertEquals(0, count.get());
assertEquals("Message 1", messageOf(action.getConfirmDialog(7)));
assertEquals("Message 2", messageOf(action.getConfirmDialog(7)));
}

// --- execute: server-side enabledWhen guard ---

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,13 @@
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 extends TestBenchElement> T waitForSingle(ElementQuery<T> q) {
Expand Down Expand Up @@ -193,6 +190,12 @@ public void testEnabledWhen() {
assertEquals(Integer.valueOf(2), $server.getClickedValue()); // unchanged
}

// Waits until the confirmation dialog is gone, so that a subsequent click does not observe the
// dialog that is still closing.
private void waitForNoConfirmDialog() {
waitUntil(d -> $(ConfirmDialogElement.class).all().isEmpty());
}

@Test
public void testConfirmation() {
var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1));
Expand All @@ -217,6 +220,29 @@ public void testConfirmation() {
assertEquals(1, $(ConfirmDialogElement.class).all().size());
}

@Test
public void testDynamicConfirmation() {
var action = $server.addRowAction(VaadinIcon.VAADIN_H, $server.action(1));
action.withConfirmation("Confirm", x -> "Delete item " + x + "?");

// the dialog message is computed from the clicked row (row 0 = item 1)
grid.getCell(0, 1).$("vaadin-button").single().click();
var dialog = $(ConfirmDialogElement.class).waitForSingle();
assertEquals("Confirm", dialog.getHeaderText());
assertEquals("Delete item 1?", dialog.getMessageText());
dialog.getCancelButton().click();

waitForNoConfirmDialog();

// a different row yields a different message (row 1 = item 2)
grid.getCell(1, 1).$("vaadin-button").single().click();
dialog = $(ConfirmDialogElement.class).waitForSingle();
assertEquals("Confirm", dialog.getHeaderText());
assertEquals("Delete item 2?", dialog.getMessageText());
dialog.getConfirmButton().click();
assertEquals(Integer.valueOf(2), $server.getClickedValue());
}

@Test
public void testContextMenu() {
$server.setRowActionsStyle(RowActionsStyle.CONTEXT_MENU);
Expand Down Expand Up @@ -278,10 +304,7 @@ public void testContextMenuConfirmation() throws InterruptedException {
dialog.getCancelButton().click();
assertNull($server.getClickedValue());

new WebDriverWait(getDriver(),
Duration.ofSeconds(1))
.until(ExpectedConditions
.numberOfElementsToBe(By.tagName("vaadin-confirm-dialog-overlay"), 0));
waitForNoConfirmDialog();

// selecting it again and confirming fires the handler (row 0 = item 1)
grid.getCell(0, 0).contextClick();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.flowingcode.vaadin.testbench.rpc.RmiRemote;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.function.SerializablePredicate;
import com.vaadin.flow.function.ValueProvider;

/**
* RMI interface for EasyRowAction proxying in integration tests.
Expand All @@ -38,6 +39,8 @@ public interface RmiEasyRowAction<T> extends RmiRemote {

RmiEasyRowAction<T> withConfirmation(String title, String message);

RmiEasyRowAction<T> withConfirmation(String title, ValueProvider<T, String> messageProvider);

RmiEasyRowAction<T> addThemeVariants(ButtonVariant variant);

void remove();
Expand All @@ -62,6 +65,13 @@ public RmiEasyRowAction<T> withConfirmation(String title, String message) {
return this;
}

@Override
public RmiEasyRowAction<T> withConfirmation(String title,
ValueProvider<T, String> messageProvider) {
action.withConfirmation(title, messageProvider);
return this;
}

@Override
public RmiEasyRowAction<T> addThemeVariants(ButtonVariant variant) {
action.addThemeVariants(variant);
Expand Down
Loading