diff --git a/src/main/java/com/andrewlalis/perfin/PerfinApp.java b/src/main/java/com/andrewlalis/perfin/PerfinApp.java index 298f128..de3384b 100644 --- a/src/main/java/com/andrewlalis/perfin/PerfinApp.java +++ b/src/main/java/com/andrewlalis/perfin/PerfinApp.java @@ -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")); diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java index a9233ff..583aff0 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateBalanceRecordController.java @@ -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(input -> { + var timestampValid = new ValidationApplier<>((ValidationFunction) input -> { try { DateUtil.DEFAULT_DATETIME_FORMAT.parse(input); return ValidationResult.valid(); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index 76957d3..a8e2d83 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -60,6 +60,7 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public AccountSelectionBox creditAccountSelector; @FXML public ComboBox vendorComboBox; + @FXML public Hyperlink vendorsHyperlink; @FXML public ComboBox categoryComboBox; @FXML public ComboBox 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()); diff --git a/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java new file mode 100644 index 0000000..3c18cb4 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/EditVendorController.java @@ -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() + .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() + .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(); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java new file mode 100644 index 0000000..acf4916 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/control/VendorsViewController.java @@ -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 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 vendors = repo.findAll(); + Platform.runLater(() -> { + this.vendors.clear(); + this.vendors.addAll(vendors); + }); + }); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java index 36eab89..93dd5cd 100644 --- a/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/TransactionVendorRepository.java @@ -11,5 +11,6 @@ public interface TransactionVendorRepository extends Repository, AutoCloseable { List findAll(); long insert(String name, String description); long insert(String name); + void update(long id, String name, String description); void deleteById(long id); } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java index 25ef9ca..4b9c388 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcTransactionVendorRepository.java @@ -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)); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java new file mode 100644 index 0000000..e618f41 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/AsyncValidationFunction.java @@ -0,0 +1,7 @@ +package com.andrewlalis.perfin.view.component.validation; + +import java.util.concurrent.CompletableFuture; + +public interface AsyncValidationFunction { + CompletableFuture validate(T input); +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java index 663affe..2ad5e78 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java @@ -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 The value type. */ public class ValidationApplier { - private final ValidationFunction validator; + private final AsyncValidationFunction validator; private ValidationDecorator decorator = new FieldSubtextDecorator(); private boolean validateInitially = false; public ValidationApplier(ValidationFunction validator) { + this.validator = input -> CompletableFuture.completedFuture(validator.validate(input)); + } + + public ValidationApplier(AsyncValidationFunction validator) { this.validator = validator; } + public static ValidationApplier of(ValidationFunction validator) { + return new ValidationApplier<>(validator); + } + + public static ValidationApplier ofAsync(AsyncValidationFunction validator) { + return new ValidationApplier<>(validator); + } + public ValidationApplier decoratedWith(ValidationDecorator decorator) { this.decorator = decorator; return this; @@ -29,24 +45,47 @@ public class ValidationApplier { 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 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; } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java index 51c73a5..6ac94d0 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java @@ -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 The value type. */ -public class PredicateValidator implements ValidationFunction { - private record ValidationStep(Function predicate, String message, boolean terminal) {} +public class PredicateValidator implements AsyncValidationFunction { + private static final Logger logger = LoggerFactory.getLogger(PredicateValidator.class); + + private record ValidationStep(Function> predicate, String message, boolean terminal) {} private final List> steps = new ArrayList<>(); - public PredicateValidator addPredicate(Function predicate, String errorMessage) { - steps.add(new ValidationStep<>(predicate, errorMessage, false)); + private PredicateValidator addPredicate(Function predicate, String errorMessage, boolean terminal) { + steps.add(new ValidationStep<>( + v -> CompletableFuture.completedFuture(predicate.apply(v)), + errorMessage, + terminal + )); return this; } - public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) { - steps.add(new ValidationStep<>(predicate, errorMessage, true)); + private PredicateValidator addAsyncPredicate(Function> asyncPredicate, String errorMessage, boolean terminal) { + steps.add(new ValidationStep<>(asyncPredicate, errorMessage, terminal)); return this; } + public PredicateValidator addPredicate(Function predicate, String errorMessage) { + return addPredicate(predicate, errorMessage, false); + } + + public PredicateValidator addAsyncPredicate(Function> 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 addTerminalPredicate(Function predicate, String errorMessage) { + return addPredicate(predicate, errorMessage, true); + } + + public PredicateValidator addTerminalAsyncPredicate(Function> asyncPredicate, String errorMessage) { + return addAsyncPredicate(asyncPredicate, errorMessage); + } + @Override - public ValidationResult validate(T input) { - List 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 validate(T input) { + CompletableFuture cf = new CompletableFuture<>(); + Thread.ofVirtual().start(() -> { + List 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; } } diff --git a/src/main/resources/edit-transaction.fxml b/src/main/resources/edit-transaction.fxml index b6fc840..874977f 100644 --- a/src/main/resources/edit-transaction.fxml +++ b/src/main/resources/edit-transaction.fxml @@ -62,7 +62,7 @@ diff --git a/src/main/resources/edit-vendor.fxml b/src/main/resources/edit-vendor.fxml new file mode 100644 index 0000000..9dfd6e2 --- /dev/null +++ b/src/main/resources/edit-vendor.fxml @@ -0,0 +1,33 @@ + + + + + + + + +
+ + + + + + + +