diff --git a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java index 3f78824..34d252b 100644 --- a/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/CreateTransactionController.java @@ -8,9 +8,15 @@ import com.andrewlalis.perfin.model.CreditAndDebitAccounts; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.view.AccountComboBoxCellFactory; import com.andrewlalis.perfin.view.component.FileSelectionArea; +import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; +import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; import javafx.application.Platform; +import javafx.beans.property.Property; +import javafx.beans.property.SimpleObjectProperty; import javafx.fxml.FXML; import javafx.scene.control.*; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import java.math.BigDecimal; @@ -22,50 +28,58 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.Currency; import java.util.List; -import java.util.stream.Collectors; import static com.andrewlalis.perfin.PerfinApp.router; public class CreateTransactionController implements RouteSelectionListener { @FXML public TextField timestampField; - @FXML public Label timestampInvalidLabel; - @FXML public Label timestampFutureLabel; - @FXML public TextField amountField; @FXML public ChoiceBox currencyChoiceBox; @FXML public TextArea descriptionField; - @FXML public Label descriptionErrorLabel; + @FXML public HBox linkedAccountsContainer; @FXML public ComboBox linkDebitAccountComboBox; @FXML public ComboBox linkCreditAccountComboBox; - @FXML public Label linkedAccountsErrorLabel; @FXML public VBox attachmentsVBox; private FileSelectionArea attachmentsSelectionArea; + @FXML public Button saveButton; + + public CreateTransactionController() { + } + @FXML public void initialize() { // Setup error field validation. - timestampInvalidLabel.managedProperty().bind(timestampInvalidLabel.visibleProperty()); - timestampFutureLabel.managedProperty().bind(timestampFutureLabel.visibleProperty()); - timestampField.textProperty().addListener((observable, oldValue, newValue) -> { - LocalDateTime parsedTimestamp = parseTimestamp(); - timestampInvalidLabel.setVisible(parsedTimestamp == null); - timestampFutureLabel.setVisible(parsedTimestamp != null && parsedTimestamp.isAfter(LocalDateTime.now())); - }); - descriptionErrorLabel.managedProperty().bind(descriptionErrorLabel.visibleProperty()); - descriptionErrorLabel.visibleProperty().bind(descriptionErrorLabel.textProperty().isNotEmpty()); - descriptionField.textProperty().addListener((observable, oldValue, newValue) -> { - if (newValue != null && newValue.length() > 255) { - descriptionErrorLabel.setText("Description is too long."); - } else { - descriptionErrorLabel.setText(null); - } - }); - linkedAccountsErrorLabel.managedProperty().bind(linkedAccountsErrorLabel.visibleProperty()); - linkedAccountsErrorLabel.visibleProperty().bind(linkedAccountsErrorLabel.textProperty().isNotEmpty()); - linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated()); - linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> onLinkedAccountsUpdated()); + var timestampValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> parseTimestamp() != null, "Invalid timestamp.") + .addPredicate(s -> { + LocalDateTime ts = parseTimestamp(); + return ts != null && ts.isBefore(LocalDateTime.now()); + }, "Timestamp cannot be in the future.") + ).validatedInitially().attachToTextField(timestampField); + var amountValid = new ValidationApplier<>( + new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) + ).validatedInitially().attachToTextField(amountField, currencyChoiceBox.valueProperty()); + + var descriptionValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.") + ).validatedInitially().attach(descriptionField, descriptionField.textProperty()); + + Property linkedAccountsProperty = new SimpleObjectProperty<>(getSelectedAccounts()); + linkDebitAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); + linkCreditAccountComboBox.valueProperty().addListener((observable, oldValue, newValue) -> linkedAccountsProperty.setValue(getSelectedAccounts())); + var linkedAccountsValid = new ValidationApplier<>(new PredicateValidator() + .addPredicate(accounts -> accounts.hasCredit() || accounts.hasDebit(), "At least one account must be linked.") + .addPredicate( + accounts -> (!accounts.hasCredit() || !accounts.hasDebit()) || !accounts.creditAccount().equals(accounts.debitAccount()), + "The credit and debit accounts cannot be the same." + ) + ).validatedInitially().attach(linkedAccountsContainer, linkedAccountsProperty); + + var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); + saveButton.disableProperty().bind(formValid.not()); // Update the lists of accounts available for linking based on the selected currency. var cellFactory = new AccountComboBoxCellFactory(); @@ -87,35 +101,23 @@ public class CreateTransactionController implements RouteSelectionListener { } @FXML public void save() { - var validationMessages = validateFormData(); - if (!validationMessages.isEmpty()) { - Alert alert = new Alert( - Alert.AlertType.WARNING, - "There are some issues with your data:\n\n" + - validationMessages.stream() - .map(s -> "- " + s) - .collect(Collectors.joining("\n\n")) + LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp()); + BigDecimal amount = new BigDecimal(amountField.getText()); + Currency currency = currencyChoiceBox.getValue(); + String description = descriptionField.getText() == null ? null : descriptionField.getText().strip(); + CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); + List attachments = attachmentsSelectionArea.getSelectedFiles(); + Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { + repo.insert( + utcTimestamp, + amount, + currency, + description, + linkedAccounts, + attachments ); - alert.show(); - } else { - LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp()); - BigDecimal amount = new BigDecimal(amountField.getText()); - Currency currency = currencyChoiceBox.getValue(); - String description = descriptionField.getText() == null ? null : descriptionField.getText().strip(); - CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); - List attachments = attachmentsSelectionArea.getSelectedFiles(); - Profile.getCurrent().getDataSource().useTransactionRepository(repo -> { - repo.insert( - utcTimestamp, - amount, - currency, - description, - linkedAccounts, - attachments - ); - }); - router.navigateBackAndClear(); - } + }); + router.navigateBackAndClear(); } @FXML public void cancel() { @@ -129,7 +131,7 @@ public class CreateTransactionController implements RouteSelectionListener { private void resetForm() { timestampField.setText(LocalDateTime.now().format(DateUtil.DEFAULT_DATETIME_FORMAT)); - amountField.setText("0"); + amountField.setText(null); descriptionField.setText(null); attachmentsSelectionArea.clear(); Thread.ofVirtual().start(() -> { @@ -191,41 +193,4 @@ public class CreateTransactionController implements RouteSelectionListener { }); }); } - - private void onLinkedAccountsUpdated() { - Account debitAccount = linkDebitAccountComboBox.getValue(); - Account creditAccount = linkCreditAccountComboBox.getValue(); - if (debitAccount == null && creditAccount == null) { - linkedAccountsErrorLabel.setText("At least one credit or debit account must be linked to the transaction for it to have any effect."); - } else if (debitAccount != null && debitAccount.equals(creditAccount)) { - linkedAccountsErrorLabel.setText("Cannot link the same account to both credit and debit."); - } else { - linkedAccountsErrorLabel.setText(null); - } - } - - private List validateFormData() { - List errorMessages = new ArrayList<>(); - if (parseTimestamp() == null) errorMessages.add("Invalid or missing timestamp."); - if (descriptionField.getText() != null && descriptionField.getText().strip().length() > 255) { - errorMessages.add("Description is too long."); - } - try { - BigDecimal value = new BigDecimal(amountField.getText()); - if (value.compareTo(BigDecimal.ZERO) <= 0) { - errorMessages.add("Amount should be a positive number."); - } - } catch (NumberFormatException e) { - errorMessages.add("Invalid or missing amount."); - } - Account debitAccount = linkDebitAccountComboBox.getValue(); - Account creditAccount = linkCreditAccountComboBox.getValue(); - if (debitAccount == null && creditAccount == null) { - errorMessages.add("At least one account must be linked to this transaction."); - } - if (debitAccount != null && debitAccount.equals(creditAccount)) { - errorMessages.add("Credit and debit accounts cannot be the same."); - } - return errorMessages; - } } diff --git a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java index f6c572d..89c915d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditAccountController.java @@ -4,14 +4,15 @@ import com.andrewlalis.javafx_scene_router.RouteSelectionListener; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.AccountType; import com.andrewlalis.perfin.model.Profile; +import com.andrewlalis.perfin.view.component.PropertiesPane; +import com.andrewlalis.perfin.view.component.validation.ValidationApplier; +import com.andrewlalis.perfin.view.component.validation.validators.CurrencyAmountValidator; +import com.andrewlalis.perfin.view.component.validation.validators.PredicateValidator; +import javafx.beans.binding.BooleanExpression; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.fxml.FXML; -import javafx.scene.control.ChoiceBox; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.TextField; -import javafx.scene.layout.VBox; +import javafx.scene.control.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,12 +42,33 @@ public class EditAccountController implements RouteSelectionListener { @FXML public ChoiceBox accountTypeChoiceBox; @FXML - public VBox initialBalanceContent; + public PropertiesPane initialBalanceContent; @FXML public TextField initialBalanceField; + @FXML public Button saveButton; + @FXML public void initialize() { + var nameValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "Name should not be empty.") + .addPredicate(s -> s.length() <= 63, "Name is too long.") + ).attachToTextField(accountNameField); + + var numberValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "Account number should not be empty.") + .addPredicate(s -> s.length() <= 255, "Account number is too long.") + .addPredicate(s -> s.matches("\\d+"), "Account number should contain only numeric digits.") + ).attachToTextField(accountNumberField); + + var balanceValid = new ValidationApplier<>( + new CurrencyAmountValidator(() -> accountCurrencyComboBox.getValue(), false, false) + ).attachToTextField(initialBalanceField, accountCurrencyComboBox.valueProperty()); + + // Combine validity of all fields for an expression that determines if the whole form is valid. + BooleanExpression formValid = nameValid.and(numberValid).and(balanceValid); + saveButton.disableProperty().bind(formValid.not()); + List priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD") .map(Currency::getInstance) .toList(); 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 new file mode 100644 index 0000000..663affe --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationApplier.java @@ -0,0 +1,58 @@ +package com.andrewlalis.perfin.view.component.validation; + +import com.andrewlalis.perfin.view.component.validation.decorators.FieldSubtextDecorator; +import javafx.beans.binding.BooleanExpression; +import javafx.beans.property.Property; +import javafx.scene.Node; +import javafx.scene.control.TextField; + +/** + * Fluent interface for applying a validator to one or more controls. + * @param The value type. + */ +public class ValidationApplier { + private final ValidationFunction validator; + private ValidationDecorator decorator = new FieldSubtextDecorator(); + private boolean validateInitially = false; + + public ValidationApplier(ValidationFunction validator) { + this.validator = validator; + } + + public ValidationApplier decoratedWith(ValidationDecorator decorator) { + this.decorator = decorator; + return this; + } + + public ValidationApplier validatedInitially() { + this.validateInitially = true; + return this; + } + + public BooleanExpression attach(Node node, Property valueProperty, Property... triggerProperties) { + BooleanExpression validProperty = BooleanExpression.booleanExpression( + valueProperty.map(value -> validator.validate(value).isValid()) + ); + valueProperty.addListener((observable, oldValue, newValue) -> { + ValidationResult result = validator.validate(newValue); + decorator.decorate(node, result); + }); + for (Property influencingProperty : triggerProperties) { + influencingProperty.addListener((observable, oldValue, newValue) -> { + ValidationResult result = validator.validate(valueProperty.getValue()); + decorator.decorate(node, result); + }); + } + + if (validateInitially) { + // Call the decorator once to perform validation right away. + decorator.decorate(node, validator.validate(valueProperty.getValue())); + } + return validProperty; + } + + @SuppressWarnings("unchecked") + public BooleanExpression attachToTextField(TextField field, Property... triggerProperties) { + return attach(field, (Property) field.textProperty(), triggerProperties); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationDecorator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationDecorator.java new file mode 100644 index 0000000..a6853ac --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationDecorator.java @@ -0,0 +1,12 @@ +package com.andrewlalis.perfin.view.component.validation; + +import javafx.scene.Node; + +/** + * A controller style component that, when validation of a field updates, + * will represent the result in some way in the UI, usually by modifying the + * given control and/or its parents in the scene graph. + */ +public interface ValidationDecorator { + void decorate(Node node, ValidationResult result); +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationFunction.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationFunction.java new file mode 100644 index 0000000..8fe98dc --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationFunction.java @@ -0,0 +1,5 @@ +package com.andrewlalis.perfin.view.component.validation; + +public interface ValidationFunction { + ValidationResult validate(T input); +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationResult.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationResult.java new file mode 100644 index 0000000..aee78a3 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/ValidationResult.java @@ -0,0 +1,23 @@ +package com.andrewlalis.perfin.view.component.validation; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public record ValidationResult(List messages) { + public boolean isValid() { + return messages.isEmpty(); + } + + public String asLines() { + return messages.stream().map(String::strip).collect(Collectors.joining("\n")); + } + + public static ValidationResult valid() { + return new ValidationResult(Collections.emptyList()); + } + + public static ValidationResult of(String... messages) { + return new ValidationResult(List.of(messages)); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java new file mode 100644 index 0000000..14f41ef --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/decorators/FieldSubtextDecorator.java @@ -0,0 +1,70 @@ +package com.andrewlalis.perfin.view.component.validation.decorators; + +import com.andrewlalis.perfin.view.component.validation.ValidationDecorator; +import com.andrewlalis.perfin.view.component.validation.ValidationResult; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.layout.Pane; +import javafx.scene.layout.VBox; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A validation decorator that can be applied to most controls, such that when + * the field's value is invalid, messages are displayed in a label below the + * field. This is accomplished by wrapping the control in a VBox only while + * the value is invalid. + */ +public class FieldSubtextDecorator implements ValidationDecorator { + private static final Logger log = LoggerFactory.getLogger(FieldSubtextDecorator.class); + private static final String WRAP_KEY = FieldSubtextDecorator.class.getName(); + + @Override + public void decorate(Node node, ValidationResult result) { + if (!result.isValid()) { + if (!node.getStyleClass().contains("validation-field-invalid")) { + node.getStyleClass().add("validation-field-invalid"); + } + // Wrap the control in a VBox and put error messages under it. + Label errorLabel; + if (isNodeWrapped(node)) { + VBox validationContainer = (VBox) node.getParent(); + errorLabel = (Label) validationContainer.getChildren().get(1); + } else { + errorLabel = wrapNode(node); + } + errorLabel.setText(result.asLines()); + } else { + node.getStyleClass().remove("validation-field-invalid"); + // Unwrap the control, if it's wrapped. + if (isNodeWrapped(node)) { + unwrapNode(node); + } + } + } + + private boolean isNodeWrapped(Node node) { + return WRAP_KEY.equals(node.getParent().getUserData()); + } + + private Label wrapNode(Node node) { + Pane trueParent = (Pane) node.getParent(); + int idx = trueParent.getChildren().indexOf(node); + + Label errorLabel = new Label(); + errorLabel.getStyleClass().addAll("small-font", "negative-color-text-fill"); + errorLabel.setWrapText(true); + VBox validationContainer = new VBox(node, errorLabel); + validationContainer.setUserData(WRAP_KEY); + trueParent.getChildren().add(idx, validationContainer); + return errorLabel; + } + + private void unwrapNode(Node node) { + VBox validationContainer = (VBox) node.getParent(); + Pane trueParent = (Pane) node.getParent().getParent(); + int idx = trueParent.getChildren().indexOf(validationContainer); + trueParent.getChildren().remove(idx); + trueParent.getChildren().add(idx, node); + } +} diff --git a/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/CurrencyAmountValidator.java b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/CurrencyAmountValidator.java new file mode 100644 index 0000000..811331b --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/CurrencyAmountValidator.java @@ -0,0 +1,48 @@ +package com.andrewlalis.perfin.view.component.validation.validators; + +import com.andrewlalis.perfin.view.component.validation.ValidationFunction; +import com.andrewlalis.perfin.view.component.validation.ValidationResult; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Currency; +import java.util.List; +import java.util.function.Supplier; + +public class CurrencyAmountValidator implements ValidationFunction { + private final Supplier currencySupplier; + private final boolean allowNegative; + private final boolean allowEmpty; + + public CurrencyAmountValidator(Supplier currencySupplier, boolean allowNegative, boolean allowEmpty) { + this.currencySupplier = currencySupplier; + this.allowNegative = allowNegative; + this.allowEmpty = allowEmpty; + } + + @Override + public ValidationResult validate(String input) { + if (input == null || input.isBlank()) { + if (!allowEmpty) { + return ValidationResult.of("Amount should not be empty."); + } + return ValidationResult.valid(); + } + + try { + BigDecimal amount = new BigDecimal(input); + int scale = amount.scale(); + List messages = new ArrayList<>(); + Currency currency = currencySupplier.get(); + if (currency != null && scale > currency.getDefaultFractionDigits()) { + messages.add("The selected currency doesn't support that many digits."); + } + if (!allowNegative && amount.compareTo(BigDecimal.ZERO) < 0) { + messages.add("Negative amounts are not allowed."); + } + return new ValidationResult(messages); + } catch (NumberFormatException e) { + return ValidationResult.of("Invalid amount. Should be a decimal value."); + } + } +} 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 new file mode 100644 index 0000000..51c73a5 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/validation/validators/PredicateValidator.java @@ -0,0 +1,43 @@ +package com.andrewlalis.perfin.view.component.validation.validators; + +import com.andrewlalis.perfin.view.component.validation.ValidationFunction; +import com.andrewlalis.perfin.view.component.validation.ValidationResult; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +/** + * A common validator pattern, where a series of checks are done on a value to + * 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) {} + + private final List> steps = new ArrayList<>(); + + public PredicateValidator addPredicate(Function predicate, String errorMessage) { + steps.add(new ValidationStep<>(predicate, errorMessage, false)); + return this; + } + + public PredicateValidator addTerminalPredicate(Function predicate, String errorMessage) { + steps.add(new ValidationStep<>(predicate, errorMessage, true)); + return this; + } + + @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); + } + } + } + return new ValidationResult(messages); + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 21d0a50..006df3f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -18,4 +18,5 @@ module com.andrewlalis.perfin { opens com.andrewlalis.perfin.control to javafx.fxml; opens com.andrewlalis.perfin.view to javafx.fxml; opens com.andrewlalis.perfin.view.component to javafx.fxml; + opens com.andrewlalis.perfin.view.component.validation to javafx.fxml; } \ No newline at end of file diff --git a/src/main/resources/create-transaction.fxml b/src/main/resources/create-transaction.fxml index 3f36257..bc7edc8 100644 --- a/src/main/resources/create-transaction.fxml +++ b/src/main/resources/create-transaction.fxml @@ -1,5 +1,6 @@ +
- + - + + + + + + - + - + - +