Add Transaction Properties #15
			
				
			
		
		
		
	| 
						 | 
				
			
			@ -91,6 +91,8 @@ public class PerfinApp extends Application {
 | 
			
		|||
            router.map("edit-transaction", PerfinApp.class.getResource("/edit-transaction.fxml"));
 | 
			
		||||
            router.map("create-balance-record", PerfinApp.class.getResource("/create-balance-record.fxml"));
 | 
			
		||||
            router.map("balance-record", PerfinApp.class.getResource("/balance-record-view.fxml"));
 | 
			
		||||
            router.map("vendors", PerfinApp.class.getResource("/vendors-view.fxml"));
 | 
			
		||||
            router.map("edit-vendor", PerfinApp.class.getResource("/edit-vendor.fxml"));
 | 
			
		||||
 | 
			
		||||
            // Help pages.
 | 
			
		||||
            helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import com.andrewlalis.perfin.model.Profile;
 | 
			
		|||
import com.andrewlalis.perfin.view.component.FileSelectionArea;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.PropertiesPane;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
| 
						 | 
				
			
			@ -39,7 +40,7 @@ public class CreateBalanceRecordController implements RouteSelectionListener {
 | 
			
		|||
    private Account account;
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        var timestampValid = new ValidationApplier<String>(input -> {
 | 
			
		||||
        var timestampValid = new ValidationApplier<>((ValidationFunction<String>)  input -> {
 | 
			
		||||
            try {
 | 
			
		||||
                DateUtil.DEFAULT_DATETIME_FORMAT.parse(input);
 | 
			
		||||
                return ValidationResult.valid();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -60,6 +60,7 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
    @FXML public AccountSelectionBox creditAccountSelector;
 | 
			
		||||
 | 
			
		||||
    @FXML public ComboBox<String> vendorComboBox;
 | 
			
		||||
    @FXML public Hyperlink vendorsHyperlink;
 | 
			
		||||
    @FXML public ComboBox<String> categoryComboBox;
 | 
			
		||||
    @FXML public ComboBox<String> tagsComboBox;
 | 
			
		||||
    @FXML public Button addTagButton;
 | 
			
		||||
| 
						 | 
				
			
			@ -89,6 +90,8 @@ public class EditTransactionController implements RouteSelectionListener {
 | 
			
		|||
        ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
			
		||||
        var linkedAccountsValid = initializeLinkedAccountsValidationUi();
 | 
			
		||||
        initializeTagSelectionUi();
 | 
			
		||||
        // Setup hyperlinks.
 | 
			
		||||
        vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
 | 
			
		||||
 | 
			
		||||
        var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
 | 
			
		||||
        saveButton.disableProperty().bind(formValid.not());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,93 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.DataSource;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationApplier;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.TextArea;
 | 
			
		||||
import javafx.scene.control.TextField;
 | 
			
		||||
 | 
			
		||||
import java.util.Objects;
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class EditVendorController implements RouteSelectionListener {
 | 
			
		||||
    private TransactionVendor vendor;
 | 
			
		||||
 | 
			
		||||
    @FXML public TextField nameField;
 | 
			
		||||
    @FXML public TextArea descriptionField;
 | 
			
		||||
    @FXML public Button saveButton;
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
 | 
			
		||||
                .addPredicate(s -> s.strip().length() <= TransactionVendor.NAME_MAX_LENGTH, "Name is too long.")
 | 
			
		||||
                // A predicate that prevents duplicate names.
 | 
			
		||||
                .addAsyncPredicate(
 | 
			
		||||
                        s -> {
 | 
			
		||||
                            if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
 | 
			
		||||
                            return Profile.getCurrent().dataSource().mapRepoAsync(
 | 
			
		||||
                                    TransactionVendorRepository.class,
 | 
			
		||||
                                    repo -> {
 | 
			
		||||
                                        var vendorByName = repo.findByName(s).orElse(null);
 | 
			
		||||
                                        if (this.vendor != null) {
 | 
			
		||||
                                            return this.vendor.equals(vendorByName) || vendorByName == null;
 | 
			
		||||
                                        }
 | 
			
		||||
                                        return vendorByName == null;
 | 
			
		||||
                                    }
 | 
			
		||||
                            );
 | 
			
		||||
                        },
 | 
			
		||||
                        "Vendor with this name already exists."
 | 
			
		||||
                )
 | 
			
		||||
            ).validatedInitially().attachToTextField(nameField);
 | 
			
		||||
        var descriptionValid = new ValidationApplier<>(new PredicateValidator<String>()
 | 
			
		||||
                .addPredicate(
 | 
			
		||||
                        s -> s == null || s.strip().length() <= TransactionVendor.DESCRIPTION_MAX_LENGTH,
 | 
			
		||||
                        "Description is too long."
 | 
			
		||||
                )
 | 
			
		||||
            ).validatedInitially().attach(descriptionField, descriptionField.textProperty());
 | 
			
		||||
 | 
			
		||||
        var formValid = nameValid.and(descriptionValid);
 | 
			
		||||
        saveButton.disableProperty().bind(formValid.not());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        if (context instanceof TransactionVendor tv) {
 | 
			
		||||
            this.vendor = tv;
 | 
			
		||||
            nameField.setText(vendor.getName());
 | 
			
		||||
            descriptionField.setText(vendor.getDescription());
 | 
			
		||||
        } else {
 | 
			
		||||
            nameField.setText(null);
 | 
			
		||||
            descriptionField.setText(null);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void save() {
 | 
			
		||||
        String name = nameField.getText().strip();
 | 
			
		||||
        String description = descriptionField.getText() == null ? null : descriptionField.getText().strip();
 | 
			
		||||
        DataSource ds = Profile.getCurrent().dataSource();
 | 
			
		||||
        if (vendor != null) {
 | 
			
		||||
            ds.useRepo(TransactionVendorRepository.class, repo -> repo.update(vendor.id, name, description));
 | 
			
		||||
        } else {
 | 
			
		||||
            ds.useRepo(TransactionVendorRepository.class, repo -> {
 | 
			
		||||
                if (description == null || description.isEmpty()) {
 | 
			
		||||
                    repo.insert(name);
 | 
			
		||||
                } else {
 | 
			
		||||
                    repo.insert(name, description);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        router.replace("vendors");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void cancel() {
 | 
			
		||||
        router.navigateBackAndClear();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,78 @@
 | 
			
		|||
package com.andrewlalis.perfin.control;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
 | 
			
		||||
import com.andrewlalis.perfin.data.TransactionVendorRepository;
 | 
			
		||||
import com.andrewlalis.perfin.model.Profile;
 | 
			
		||||
import com.andrewlalis.perfin.model.TransactionVendor;
 | 
			
		||||
import com.andrewlalis.perfin.view.BindingUtil;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.collections.FXCollections;
 | 
			
		||||
import javafx.collections.ObservableList;
 | 
			
		||||
import javafx.fxml.FXML;
 | 
			
		||||
import javafx.geometry.Pos;
 | 
			
		||||
import javafx.scene.Node;
 | 
			
		||||
import javafx.scene.control.Button;
 | 
			
		||||
import javafx.scene.control.Label;
 | 
			
		||||
import javafx.scene.layout.BorderPane;
 | 
			
		||||
import javafx.scene.layout.VBox;
 | 
			
		||||
 | 
			
		||||
import java.util.List;
 | 
			
		||||
 | 
			
		||||
import static com.andrewlalis.perfin.PerfinApp.router;
 | 
			
		||||
 | 
			
		||||
public class VendorsViewController implements RouteSelectionListener {
 | 
			
		||||
    @FXML public VBox vendorsVBox;
 | 
			
		||||
    private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
 | 
			
		||||
 | 
			
		||||
    @FXML public void initialize() {
 | 
			
		||||
        BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, this::buildVendorTile);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private Node buildVendorTile(TransactionVendor transactionVendor) {
 | 
			
		||||
        BorderPane pane = new BorderPane();
 | 
			
		||||
        pane.getStyleClass().addAll("tile", "std-spacing");
 | 
			
		||||
        pane.setOnMouseClicked(event -> router.navigate("edit-vendor", transactionVendor));
 | 
			
		||||
 | 
			
		||||
        Label nameLabel = new Label(transactionVendor.getName());
 | 
			
		||||
        nameLabel.getStyleClass().addAll("bold-text");
 | 
			
		||||
        Label descriptionLabel = new Label(transactionVendor.getDescription());
 | 
			
		||||
        descriptionLabel.setWrapText(true);
 | 
			
		||||
        VBox contentVBox = new VBox(nameLabel, descriptionLabel);
 | 
			
		||||
        contentVBox.getStyleClass().addAll("std-spacing");
 | 
			
		||||
        pane.setCenter(contentVBox);
 | 
			
		||||
        BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
 | 
			
		||||
 | 
			
		||||
        Button removeButton = new Button("Remove");
 | 
			
		||||
        removeButton.setOnAction(event -> {
 | 
			
		||||
            boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions with assigned to this vendor will have their vendor field cleared. This cannot be undone.");
 | 
			
		||||
            if (confirm) {
 | 
			
		||||
                Profile.getCurrent().dataSource().useRepo(TransactionVendorRepository.class, repo -> {
 | 
			
		||||
                    repo.deleteById(transactionVendor.id);
 | 
			
		||||
                });
 | 
			
		||||
                refreshVendors();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        pane.setRight(removeButton);
 | 
			
		||||
 | 
			
		||||
        return pane;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void onRouteSelected(Object context) {
 | 
			
		||||
        refreshVendors();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @FXML public void addVendor() {
 | 
			
		||||
        router.navigate("edit-vendor");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private void refreshVendors() {
 | 
			
		||||
        Profile.getCurrent().dataSource().useRepoAsync(TransactionVendorRepository.class, repo -> {
 | 
			
		||||
            final List<TransactionVendor> vendors = repo.findAll();
 | 
			
		||||
            Platform.runLater(() -> {
 | 
			
		||||
                this.vendors.clear();
 | 
			
		||||
                this.vendors.addAll(vendors);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,5 +11,6 @@ public interface TransactionVendorRepository extends Repository, AutoCloseable {
 | 
			
		|||
    List<TransactionVendor> findAll();
 | 
			
		||||
    long insert(String name, String description);
 | 
			
		||||
    long insert(String name);
 | 
			
		||||
    void update(long id, String name, String description);
 | 
			
		||||
    void deleteById(long id);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,7 @@ import java.sql.Connection;
 | 
			
		|||
import java.sql.ResultSet;
 | 
			
		||||
import java.sql.SQLException;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.Objects;
 | 
			
		||||
import java.util.Optional;
 | 
			
		||||
 | 
			
		||||
public record JdbcTransactionVendorRepository(Connection conn) implements TransactionVendorRepository {
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +59,29 @@ public record JdbcTransactionVendorRepository(Connection conn) implements Transa
 | 
			
		|||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void update(long id, String name, String description) {
 | 
			
		||||
        DbUtil.doTransaction(conn, () -> {
 | 
			
		||||
            TransactionVendor vendor = findById(id).orElseThrow();
 | 
			
		||||
            if (!vendor.getName().equals(name)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_vendor SET name = ? WHERE id = ?",
 | 
			
		||||
                        name,
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
            if (!Objects.equals(vendor.getDescription(), description)) {
 | 
			
		||||
                DbUtil.updateOne(
 | 
			
		||||
                        conn,
 | 
			
		||||
                        "UPDATE transaction_vendor SET description = ? WHERE id = ?",
 | 
			
		||||
                        description,
 | 
			
		||||
                        id
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public void deleteById(long id) {
 | 
			
		||||
        DbUtil.update(conn, "DELETE FROM transaction_vendor WHERE id = ?", List.of(id));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component.validation;
 | 
			
		||||
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
public interface AsyncValidationFunction<T> {
 | 
			
		||||
    CompletableFuture<ValidationResult> validate(T input);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,24 +1,40 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component.validation;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator;
 | 
			
		||||
import javafx.application.Platform;
 | 
			
		||||
import javafx.beans.binding.BooleanExpression;
 | 
			
		||||
import javafx.beans.property.Property;
 | 
			
		||||
import javafx.beans.property.SimpleBooleanProperty;
 | 
			
		||||
import javafx.scene.Node;
 | 
			
		||||
import javafx.scene.control.TextField;
 | 
			
		||||
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fluent interface for applying a validator to one or more controls.
 | 
			
		||||
 * @param <T> The value type.
 | 
			
		||||
 */
 | 
			
		||||
public class ValidationApplier<T> {
 | 
			
		||||
    private final ValidationFunction<T> validator;
 | 
			
		||||
    private final AsyncValidationFunction<T> validator;
 | 
			
		||||
    private ValidationDecorator decorator = new FieldSubtextDecorator();
 | 
			
		||||
    private boolean validateInitially = false;
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier(ValidationFunction<T> validator) {
 | 
			
		||||
        this.validator = input -> CompletableFuture.completedFuture(validator.validate(input));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier(AsyncValidationFunction<T> validator) {
 | 
			
		||||
        this.validator = validator;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> ValidationApplier<T> of(ValidationFunction<T> validator) {
 | 
			
		||||
        return new ValidationApplier<>(validator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public static <T> ValidationApplier<T> ofAsync(AsyncValidationFunction<T> validator) {
 | 
			
		||||
        return new ValidationApplier<>(validator);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
 | 
			
		||||
        this.decorator = decorator;
 | 
			
		||||
        return this;
 | 
			
		||||
| 
						 | 
				
			
			@ -29,24 +45,47 @@ public class ValidationApplier<T> {
 | 
			
		|||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Attaches the configured validator and decorator to a node, so that when
 | 
			
		||||
     * the node's specified valueProperty changes, the validator will be called
 | 
			
		||||
     * and if the new value is invalid, the decorator will update the UI to
 | 
			
		||||
     * show the message(s) to the user.
 | 
			
		||||
     * @param node The node to attach to.
 | 
			
		||||
     * @param valueProperty The property to listen for changes and validate on.
 | 
			
		||||
     * @param triggerProperties Additional properties that, when changed, can
 | 
			
		||||
     *                          trigger validation.
 | 
			
		||||
     * @return A boolean expression that tells whether the given valueProperty
 | 
			
		||||
     * is valid at any given time.
 | 
			
		||||
     */
 | 
			
		||||
    public BooleanExpression attach(Node node, Property<T> valueProperty, Property<?>... triggerProperties) {
 | 
			
		||||
        BooleanExpression validProperty = BooleanExpression.booleanExpression(
 | 
			
		||||
                valueProperty.map(value -> validator.validate(value).isValid())
 | 
			
		||||
        );
 | 
			
		||||
        final SimpleBooleanProperty validProperty = new SimpleBooleanProperty();
 | 
			
		||||
        valueProperty.addListener((observable, oldValue, newValue) -> {
 | 
			
		||||
            ValidationResult result = validator.validate(newValue);
 | 
			
		||||
            decorator.decorate(node, result);
 | 
			
		||||
            validProperty.set(false); // Always set valid to false before we start validation.
 | 
			
		||||
            validator.validate(newValue)
 | 
			
		||||
                .thenAccept(result -> Platform.runLater(() -> {
 | 
			
		||||
                    validProperty.set(result.isValid());
 | 
			
		||||
                    decorator.decorate(node, result);
 | 
			
		||||
                }));
 | 
			
		||||
        });
 | 
			
		||||
        for (Property<?> influencingProperty : triggerProperties) {
 | 
			
		||||
            influencingProperty.addListener((observable, oldValue, newValue) -> {
 | 
			
		||||
                ValidationResult result = validator.validate(valueProperty.getValue());
 | 
			
		||||
                decorator.decorate(node, result);
 | 
			
		||||
                validProperty.set(false); // Always set valid to false before we start validation.
 | 
			
		||||
                validator.validate(valueProperty.getValue())
 | 
			
		||||
                    .thenAccept(result -> Platform.runLater(() -> {
 | 
			
		||||
                        validProperty.set(result.isValid());
 | 
			
		||||
                        decorator.decorate(node, result);
 | 
			
		||||
                    }));
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (validateInitially) {
 | 
			
		||||
            // Call the decorator once to perform validation right away.
 | 
			
		||||
            decorator.decorate(node, validator.validate(valueProperty.getValue()));
 | 
			
		||||
            validProperty.set(false); // Always set valid to false before we start validation.
 | 
			
		||||
            validator.validate(valueProperty.getValue())
 | 
			
		||||
                .thenAccept(result -> Platform.runLater(() -> {
 | 
			
		||||
                    validProperty.set(result.isValid());
 | 
			
		||||
                    decorator.decorate(node, result);
 | 
			
		||||
                }));
 | 
			
		||||
        }
 | 
			
		||||
        return validProperty;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,14 @@
 | 
			
		|||
package com.andrewlalis.perfin.view.component.validation.validators;
 | 
			
		||||
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationFunction;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.AsyncValidationFunction;
 | 
			
		||||
import com.andrewlalis.perfin.view.component.validation.ValidationResult;
 | 
			
		||||
import org.slf4j.Logger;
 | 
			
		||||
import org.slf4j.LoggerFactory;
 | 
			
		||||
 | 
			
		||||
import java.util.ArrayList;
 | 
			
		||||
import java.util.List;
 | 
			
		||||
import java.util.concurrent.CompletableFuture;
 | 
			
		||||
import java.util.concurrent.ExecutionException;
 | 
			
		||||
import java.util.function.Function;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
| 
						 | 
				
			
			@ -12,32 +16,73 @@ import java.util.function.Function;
 | 
			
		|||
 * determine if it's valid. If invalid, a message is added.
 | 
			
		||||
 * @param <T> The value type.
 | 
			
		||||
 */
 | 
			
		||||
public class PredicateValidator<T> implements ValidationFunction<T> {
 | 
			
		||||
    private record ValidationStep<T>(Function<T, Boolean> predicate, String message, boolean terminal) {}
 | 
			
		||||
public class PredicateValidator<T> implements AsyncValidationFunction<T> {
 | 
			
		||||
    private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class);
 | 
			
		||||
 | 
			
		||||
    private record ValidationStep<T>(Function<T, CompletableFuture<Boolean>> predicate, String message, boolean terminal) {}
 | 
			
		||||
 | 
			
		||||
    private final List<ValidationStep<T>> steps = new ArrayList<>();
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        steps.add(new ValidationStep<>(predicate, errorMessage, false));
 | 
			
		||||
    private PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage, boolean terminal) {
 | 
			
		||||
        steps.add(new ValidationStep<>(
 | 
			
		||||
                v -> CompletableFuture.completedFuture(predicate.apply(v)),
 | 
			
		||||
                errorMessage,
 | 
			
		||||
                terminal
 | 
			
		||||
        ));
 | 
			
		||||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        steps.add(new ValidationStep<>(predicate, errorMessage, true));
 | 
			
		||||
    private PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage, boolean terminal) {
 | 
			
		||||
        steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal));
 | 
			
		||||
        return this;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        return addPredicate(predicate, errorMessage, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
			
		||||
        return addAsyncPredicate(asyncPredicate, errorMessage, false);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Adds a terminal predicate, that is, if the given boolean function
 | 
			
		||||
     * evaluates to false, then no further predicates are evaluated.
 | 
			
		||||
     * @param predicate The predicate function.
 | 
			
		||||
     * @param errorMessage The error message to display if the predicate
 | 
			
		||||
     *                     evaluates to false for a given value.
 | 
			
		||||
     * @return A reference to the validator, for method chaining.
 | 
			
		||||
     */
 | 
			
		||||
    public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
 | 
			
		||||
        return addPredicate(predicate, errorMessage, true);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public PredicateValidator<T> addTerminalAsyncPredicate(Function<T, CompletableFuture<Boolean>> asyncPredicate, String errorMessage) {
 | 
			
		||||
        return addAsyncPredicate(asyncPredicate, errorMessage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @Override
 | 
			
		||||
    public ValidationResult validate(T input) {
 | 
			
		||||
        List<String> messages = new ArrayList<>();
 | 
			
		||||
        for (var step : steps) {
 | 
			
		||||
            if (!step.predicate().apply(input)) {
 | 
			
		||||
                messages.add(step.message());
 | 
			
		||||
                if (step.terminal()) {
 | 
			
		||||
                    return new ValidationResult(messages);
 | 
			
		||||
    public CompletableFuture<ValidationResult> validate(T input) {
 | 
			
		||||
        CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
 | 
			
		||||
        Thread.ofVirtual().start(() -> {
 | 
			
		||||
            List<String> messages = new ArrayList<>();
 | 
			
		||||
            for (var step : steps) {
 | 
			
		||||
                try {
 | 
			
		||||
                    boolean success = step.predicate().apply(input).get();
 | 
			
		||||
                    if (!success) {
 | 
			
		||||
                        messages.add(step.message());
 | 
			
		||||
                        if (step.terminal()) {
 | 
			
		||||
                            cf.complete(new ValidationResult(messages));
 | 
			
		||||
                            return; // Exit if this is a terminal step and it failed.
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                } catch (InterruptedException | ExecutionException e) {
 | 
			
		||||
                    logger.error("Applying a predicate to input failed.", e);
 | 
			
		||||
                    cf.completeExceptionally(e);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return new ValidationResult(messages);
 | 
			
		||||
            cf.complete(new ValidationResult(messages));
 | 
			
		||||
        });
 | 
			
		||||
        return cf;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -62,7 +62,7 @@
 | 
			
		|||
 | 
			
		||||
                    <VBox>
 | 
			
		||||
                        <Label text="Vendor" labelFor="${vendorComboBox}" styleClass="bold-text"/>
 | 
			
		||||
                        <Hyperlink text="Manage vendors" styleClass="small-font"/>
 | 
			
		||||
                        <Hyperlink fx:id="vendorsHyperlink" text="Manage vendors" styleClass="small-font"/>
 | 
			
		||||
                    </VBox>
 | 
			
		||||
                    <ComboBox fx:id="vendorComboBox" editable="true" maxWidth="Infinity"/>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
 | 
			
		||||
<?import javafx.scene.control.*?>
 | 
			
		||||
<?import javafx.scene.layout.*?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.EditVendorController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Edit Vendor" styleClass="bold-text,large-font,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
 | 
			
		||||
                <columnConstraints>
 | 
			
		||||
                    <ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
 | 
			
		||||
                    <ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
 | 
			
		||||
                </columnConstraints>
 | 
			
		||||
 | 
			
		||||
                <Label text="Name" labelFor="${nameField}"/>
 | 
			
		||||
                <TextField fx:id="nameField"/>
 | 
			
		||||
 | 
			
		||||
                <Label text="Description" labelFor="${descriptionField}"/>
 | 
			
		||||
                <TextArea fx:id="descriptionField" wrapText="true"/>
 | 
			
		||||
            </PropertiesPane>
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing">
 | 
			
		||||
                <Button text="Save" fx:id="saveButton" onAction="#save"/>
 | 
			
		||||
                <Button text="Cancel" onAction="#cancel"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8"?>
 | 
			
		||||
 | 
			
		||||
<?import javafx.scene.control.Button?>
 | 
			
		||||
<?import javafx.scene.control.Label?>
 | 
			
		||||
<?import javafx.scene.control.ScrollPane?>
 | 
			
		||||
<?import javafx.scene.layout.BorderPane?>
 | 
			
		||||
<?import javafx.scene.layout.HBox?>
 | 
			
		||||
<?import javafx.scene.layout.VBox?>
 | 
			
		||||
<?import javafx.scene.text.Text?>
 | 
			
		||||
<BorderPane xmlns="http://javafx.com/javafx"
 | 
			
		||||
            xmlns:fx="http://javafx.com/fxml"
 | 
			
		||||
            fx:controller="com.andrewlalis.perfin.control.VendorsViewController"
 | 
			
		||||
>
 | 
			
		||||
    <top>
 | 
			
		||||
        <Label text="Vendors" styleClass="large-font,bold-text,std-padding"/>
 | 
			
		||||
    </top>
 | 
			
		||||
    <center>
 | 
			
		||||
        <VBox>
 | 
			
		||||
            <HBox styleClass="std-padding,std-spacing" VBox.vgrow="NEVER">
 | 
			
		||||
                <Button text="Add Vendor" onAction="#addVendor"/>
 | 
			
		||||
            </HBox>
 | 
			
		||||
            <ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
 | 
			
		||||
                <VBox fx:id="vendorsVBox" styleClass="tile-container"/>
 | 
			
		||||
            </ScrollPane>
 | 
			
		||||
        </VBox>
 | 
			
		||||
    </center>
 | 
			
		||||
</BorderPane>
 | 
			
		||||
		Loading…
	
		Reference in New Issue