1
votes

I have a TabPane where users enter/edit data on each tab and can freely switch between tabs without having to save changes before switching to a new tab. One tab has a TableView, and I'd like to prevent users from leaving that tab if they enter invalid data. My original approach was along the same lines as this question, which does not quite work - the tab is not reliably changed back. I liked James_D's answer and tried to implement something similar. However, most of the time the data being entered into a table is optional, so disabling other tabs until a user enters data is not an option.

What I ultimately did was extend TableColumn to add a BooleanProperty 'invalid' which I then bind to Tab's disableProperty. In that column's commit event, I validate the new value and, if it doesn't pass, set invalid = true, which disables the appropriate tab. This also does not quite work. I have custom table cells that commit edits on loss of focus. If focus is lost to clicking a different tab, the commit event is too late - the tab is first selected, then disabled. I've been wracking my brain for a workaround, but am out of ideas. If anyone has any suggestions, I would really appreciate it!

Short example (clear out any last name and click Tab 2):

import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.CellEditEvent;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.stage.Stage;
import javafx.util.Callback;

public class TabPaneTableTest extends Application {

  @Override
  public void start(Stage primaryStage) {
    TableView<Person> table = new TableView<>();
    ObservableList<Person> data = FXCollections.observableArrayList();

    table.setEditable(true);

    MyTableColumn<Person, String> firstNameCol = new MyTableColumn<>("First Name");
    firstNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("firstName"));
    MyTableColumn<Person, String> lastNameCol = new MyTableColumn<>("Last Name");
    lastNameCol.setCellValueFactory(new PropertyValueFactory<Person, String>("lastName"));

    Callback<TableColumn<Person, String>, TableCell<Person, String>> cellFactory = (TableColumn<Person, String> p) -> new MyEditingCell<Person>();

    firstNameCol.setCellFactory(cellFactory);
    lastNameCol.setCellFactory(cellFactory);

    firstNameCol.setOnEditCommit((CellEditEvent<Person, String> event) -> {
      event.getRowValue().setFirstName(event.getNewValue());
    });
    lastNameCol.setOnEditCommit((CellEditEvent<Person, String> event) -> {
      if(event.getNewValue().trim().isEmpty()) {
        new Alert(AlertType.ERROR, "Last name must be filled out!", ButtonType.OK).showAndWait();
        lastNameCol.setInvalid(true);
      }
      else {
        event.getRowValue().setLastName(event.getNewValue());
        lastNameCol.setInvalid(false);
      }
    });

    table.getColumns().addAll(firstNameCol, lastNameCol);
    table.setItems(data);
    data.add(new Person("Luke", "Skywalker"));
    data.add(new Person("Han", "Solo"));
    data.add(new Person("R2", "D2"));

    TabPane tabPane = new TabPane();
    Tab tab1 = new Tab("Tab 1");
    tab1.setClosable(false);
    tab1.setContent(table);

    Tab tab2 = new Tab("Tab 2");
    tab2.setClosable(false);
    tab2.disableProperty().bind(lastNameCol.invalidProperty());
    tabPane.getTabs().addAll(tab1, tab2);

    Scene scene = new Scene(tabPane, 400, 200);
    primaryStage.setTitle("Tab Pane Table Validation Test");
    primaryStage.setScene(scene);
    primaryStage.show();
  }

  public class MyEditingCell<S> extends TableCell<S, String> {
    private TextField editingField;

    private void createEditingField() {
      editingField = new TextField(getString());
      editingField.focusedProperty().addListener((ov, oldValue, newValue) -> {
        if(!newValue) {
          commitEdit(editingField.getText());
        }
      });
    }

    @Override
    public void startEdit() {
      super.startEdit();
      createEditingField();
      setText(null);
      setGraphic(editingField);
      Platform.runLater(() -> {
        editingField.requestFocus();
        editingField.selectAll();
      });
    }

    @Override
    public void cancelEdit() {
      super.cancelEdit();
      setText((String)getItem());
      setGraphic(null);
    }

    @Override
    public void updateItem(String item, boolean empty) {
      super.updateItem(item, empty);

      if(empty) {
        setText(null);
        setGraphic(null);
      }
      else {
        if(isEditing()) {
          if(editingField != null) {
            editingField.setText(getString());
          }
          setText(null);
          setGraphic(editingField);
        }
        else {
          setText(getString());
          setGraphic(null);
        }
      }
    }

    private String getString() {
      return getItem() == null ? "" : getItem().toString();
    }
  }

  public class MyTableColumn<S, T> extends TableColumn<S, T> {
    private BooleanProperty invalid = new SimpleBooleanProperty(false);

    public MyTableColumn(String header) {
      super(header);
      setEditable(true);
    }

    public BooleanProperty invalidProperty() {
      return invalid;
    }

    public boolean getInvalid() {
      return invalid.get();
    }

    public void setInvalid(boolean value) {
      invalid.set(value);
    }
  }

  public class Person {

    private StringProperty firstName;
    private StringProperty lastName;

    public Person(String first, String last) {
      firstName = new SimpleStringProperty(this, "firstName", first);
      lastName = new SimpleStringProperty(this, "lastName", last);
    }

    public void setFirstName(String value) {
      firstNameProperty().set(value);
    }

    public String getFirstName() {
      return firstNameProperty().get();
    }

    public StringProperty firstNameProperty() {
      if(firstName == null)
        firstName = new SimpleStringProperty(this, "firstName", "First");
      return firstName;
    }

    public void setLastName(String value) {
      lastNameProperty().set(value);
    }

    public String getLastName() {
      return lastNameProperty().get();
    }

    public StringProperty lastNameProperty() {
      if(lastName == null)
        lastName = new SimpleStringProperty(this, "lastName", "Last");
      return lastName;
    }
  }

  public static void main(String[] args) {
    launch(args);
  }
}
1

1 Answers

0
votes

If you create your observable list with an extractor, for example:

ObservableList<Person> data = FXCollections.observableArrayList(person -> 
    new Observable[] { person.lastNameProperty() });

then the list will fire update notifications any time any of the specified properties change in any of the elements (in this case, any time the lastName changes on anything in the list).

So now you can create a binding for invalid:

BooleanBinding invalid = Bindings.createBooleanBinding(
    () -> data.stream().anyMatch(person -> person.getLastName().isEmpty()),
    data);

And then you can just observe that binding:

invalid.addListener((obs, wasInvalid, isNowInvalid) -> {
    if (isNowInvalid) {
        // show alert, etc...
    }
});

or disable a node by binding to it:

someNode.disableProperty().bind(invalid);

You could similarly bind this invalid property in your TableColumn subclass (if you still need that) to this binding.