Added pages for viewing and editing vendors, and refactored validation to support async validators.

This commit is contained in:
Andrew Lalis 2024-01-30 21:54:34 -05:00
parent ae2713dbd0
commit 77291ba724
13 changed files with 380 additions and 27 deletions

View File

@ -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"));

View File

@ -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();

View File

@ -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());

View File

@ -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();
}
}

View File

@ -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);
});
});
}
}

View File

@ -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);
}

View File

@ -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));

View File

@ -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);
}

View File

@ -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);
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());
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;
}

View File

@ -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) {
public CompletableFuture<ValidationResult> validate(T input) {
CompletableFuture<ValidationResult> cf = new CompletableFuture<>();
Thread.ofVirtual().start(() -> {
List<String> messages = new ArrayList<>();
for (var step : steps) {
if (!step.predicate().apply(input)) {
try {
boolean success = step.predicate().apply(input).get();
if (!success) {
messages.add(step.message());
if (step.terminal()) {
return new ValidationResult(messages);
cf.complete(new ValidationResult(messages));
return; // Exit if this is a terminal step and it failed.
}
}
}
return new ValidationResult(messages);
} catch (InterruptedException | ExecutionException e) {
logger.error("Applying a predicate to input failed.", e);
cf.completeExceptionally(e);
}
}
cf.complete(new ValidationResult(messages));
});
return cf;
}
}

View File

@ -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"/>

View File

@ -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>

View File

@ -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>