Added validation, cleaned up CSS colors with theme definitions.
This commit is contained in:
parent
ebf4880297
commit
c02e5d3fc6
|
@ -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<Currency> currencyChoiceBox;
|
||||
@FXML public TextArea descriptionField;
|
||||
@FXML public Label descriptionErrorLabel;
|
||||
|
||||
@FXML public HBox linkedAccountsContainer;
|
||||
@FXML public ComboBox<Account> linkDebitAccountComboBox;
|
||||
@FXML public ComboBox<Account> 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<String>()
|
||||
.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<String>()
|
||||
.addTerminalPredicate(s -> s == null || s.length() <= 255, "Description is too long.")
|
||||
).validatedInitially().attach(descriptionField, descriptionField.textProperty());
|
||||
|
||||
Property<CreditAndDebitAccounts> 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<CreditAndDebitAccounts>()
|
||||
.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,17 +101,6 @@ 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"))
|
||||
);
|
||||
alert.show();
|
||||
} else {
|
||||
LocalDateTime utcTimestamp = DateUtil.localToUTC(parseTimestamp());
|
||||
BigDecimal amount = new BigDecimal(amountField.getText());
|
||||
Currency currency = currencyChoiceBox.getValue();
|
||||
|
@ -116,7 +119,6 @@ public class CreateTransactionController implements RouteSelectionListener {
|
|||
});
|
||||
router.navigateBackAndClear();
|
||||
}
|
||||
}
|
||||
|
||||
@FXML public void cancel() {
|
||||
router.navigateBackAndClear();
|
||||
|
@ -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<String> validateFormData() {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AccountType> 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<String>()
|
||||
.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<String>()
|
||||
.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<Currency> priorityCurrencies = Stream.of("USD", "EUR", "GBP", "CAD", "AUD")
|
||||
.map(Currency::getInstance)
|
||||
.toList();
|
||||
|
|
|
@ -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 <T> The value type.
|
||||
*/
|
||||
public class ValidationApplier<T> {
|
||||
private final ValidationFunction<T> validator;
|
||||
private ValidationDecorator decorator = new FieldSubtextDecorator();
|
||||
private boolean validateInitially = false;
|
||||
|
||||
public ValidationApplier(ValidationFunction<T> validator) {
|
||||
this.validator = validator;
|
||||
}
|
||||
|
||||
public ValidationApplier<T> decoratedWith(ValidationDecorator decorator) {
|
||||
this.decorator = decorator;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ValidationApplier<T> validatedInitially() {
|
||||
this.validateInitially = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
public BooleanExpression attach(Node node, Property<T> 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<T>) field.textProperty(), triggerProperties);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package com.andrewlalis.perfin.view.component.validation;
|
||||
|
||||
public interface ValidationFunction<T> {
|
||||
ValidationResult validate(T input);
|
||||
}
|
|
@ -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<String> 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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<String> {
|
||||
private final Supplier<Currency> currencySupplier;
|
||||
private final boolean allowNegative;
|
||||
private final boolean allowEmpty;
|
||||
|
||||
public CurrencyAmountValidator(Supplier<Currency> 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<String> 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.");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <T> The value type.
|
||||
*/
|
||||
public class PredicateValidator<T> implements ValidationFunction<T> {
|
||||
private record ValidationStep<T>(Function<T, 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));
|
||||
return this;
|
||||
}
|
||||
|
||||
public PredicateValidator<T> addTerminalPredicate(Function<T, Boolean> predicate, String errorMessage) {
|
||||
steps.add(new ValidationStep<>(predicate, errorMessage, true));
|
||||
return this;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ValidationResult(messages);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
<?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"
|
||||
|
@ -9,36 +10,34 @@
|
|||
>
|
||||
<center>
|
||||
<ScrollPane fitToWidth="true" fitToHeight="true">
|
||||
<VBox styleClass="std-spacing,std-padding" style="-fx-max-width: 500px;">
|
||||
<VBox style="-fx-max-width: 400px;">
|
||||
<!-- Basic properties -->
|
||||
<VBox>
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<Label text="Timestamp" labelFor="${timestampField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="timestampField" styleClass="mono-font"/>
|
||||
<Label fx:id="timestampInvalidLabel" text="Invalid timestamp." styleClass="error-text"/>
|
||||
<Label fx:id="timestampFutureLabel" text="Timestamp cannot be in the future." styleClass="error-text"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
|
||||
<Label text="Amount" labelFor="${amountField}" styleClass="bold-text"/>
|
||||
<TextField fx:id="amountField" styleClass="mono-font"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
|
||||
<Label text="Currency" labelFor="${currencyChoiceBox}" styleClass="bold-text"/>
|
||||
<ChoiceBox fx:id="currencyChoiceBox"/>
|
||||
</VBox>
|
||||
<VBox>
|
||||
|
||||
<Label text="Description" labelFor="${descriptionField}" styleClass="bold-text"/>
|
||||
<Label text="Maximum of 255 characters." styleClass="small-font"/>
|
||||
<TextArea
|
||||
fx:id="descriptionField"
|
||||
styleClass="mono-font"
|
||||
wrapText="true"
|
||||
style="-fx-pref-height: 100px;-fx-min-height: 100px;"
|
||||
/>
|
||||
<Label fx:id="descriptionErrorLabel" styleClass="error-text" wrapText="true"/>
|
||||
</VBox>
|
||||
</PropertiesPane>
|
||||
|
||||
<!-- Container for linked accounts -->
|
||||
<VBox>
|
||||
<HBox styleClass="std-spacing">
|
||||
<HBox styleClass="std-padding,std-spacing" fx:id="linkedAccountsContainer">
|
||||
<VBox>
|
||||
<Label text="Debited Account" labelFor="${linkDebitAccountComboBox}" styleClass="bold-text"/>
|
||||
<ComboBox fx:id="linkDebitAccountComboBox">
|
||||
|
@ -52,21 +51,20 @@
|
|||
</ComboBox>
|
||||
</VBox>
|
||||
</HBox>
|
||||
<Label fx:id="linkedAccountsErrorLabel" styleClass="error-text" wrapText="true"/>
|
||||
</VBox>
|
||||
<!-- Container for attachments -->
|
||||
<VBox fx:id="attachmentsVBox">
|
||||
<VBox fx:id="attachmentsVBox" styleClass="std-padding">
|
||||
<Label text="Attachments" styleClass="bold-text"/>
|
||||
<Label text="Attach receipts, invoices, or other content to this transaction." styleClass="small-font" wrapText="true"/>
|
||||
<!-- FileSelectionArea inserted here! -->
|
||||
</VBox>
|
||||
|
||||
<!-- Buttons -->
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</ScrollPane>
|
||||
</center>
|
||||
<bottom>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
<Button text="Save" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</HBox>
|
||||
</bottom>
|
||||
</BorderPane>
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<?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/17.0.2-ea"
|
||||
xmlns:fx="http://javafx.com/fxml/1"
|
||||
fx:controller="com.andrewlalis.perfin.control.EditAccountController"
|
||||
stylesheets="@style/edit-account.css,@style/base.css"
|
||||
stylesheets="@style/base.css"
|
||||
>
|
||||
<top>
|
||||
<HBox styleClass="std-padding,std-spacing">
|
||||
|
@ -14,34 +15,42 @@
|
|||
</HBox>
|
||||
</top>
|
||||
<center>
|
||||
<GridPane BorderPane.alignment="TOP_LEFT" styleClass="fields-grid,std-padding">
|
||||
<VBox style="-fx-max-width: 400px;" BorderPane.alignment="TOP_LEFT">
|
||||
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" minWidth="10.0" />
|
||||
<ColumnConstraints hgrow="ALWAYS" minWidth="10.0" />
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="0" text="Name"/>
|
||||
<TextField fx:id="accountNameField" GridPane.columnIndex="1" GridPane.rowIndex="0"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="1" text="Account Number"/>
|
||||
<TextField fx:id="accountNumberField" GridPane.columnIndex="1" GridPane.rowIndex="1"/>
|
||||
<Label text="Name" styleClass="bold-text"/>
|
||||
<TextField fx:id="accountNameField"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="2" text="Currency"/>
|
||||
<ComboBox fx:id="accountCurrencyComboBox" GridPane.columnIndex="1" GridPane.rowIndex="2"/>
|
||||
<Label text="Account Number" styleClass="bold-text"/>
|
||||
<TextField fx:id="accountNumberField" styleClass="mono-font"/>
|
||||
|
||||
<Label GridPane.columnIndex="0" GridPane.rowIndex="3" text="Account Type"/>
|
||||
<ChoiceBox fx:id="accountTypeChoiceBox" GridPane.columnIndex="1" GridPane.rowIndex="3"/>
|
||||
<Label text="Currency" styleClass="bold-text"/>
|
||||
<ComboBox fx:id="accountCurrencyComboBox"/>
|
||||
|
||||
<Label text="Account Type" styleClass="bold-text"/>
|
||||
<ChoiceBox fx:id="accountTypeChoiceBox"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<!-- Initial balance content that's only visible when creating a new account. -->
|
||||
<PropertiesPane fx:id="initialBalanceContent" hgap="5" vgap="5" styleClass="std-padding">
|
||||
<columnConstraints>
|
||||
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
|
||||
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
|
||||
</columnConstraints>
|
||||
|
||||
<VBox fx:id="initialBalanceContent" GridPane.columnIndex="0" GridPane.rowIndex="4" GridPane.columnSpan="2">
|
||||
<Separator/>
|
||||
<Label text="Initial Balance" styleClass="bold-text"/>
|
||||
<TextField fx:id="initialBalanceField"/>
|
||||
</VBox>
|
||||
</GridPane>
|
||||
</center>
|
||||
<bottom>
|
||||
<FlowPane BorderPane.alignment="CENTER_RIGHT">
|
||||
<Button text="Save" onAction="#save"/>
|
||||
</PropertiesPane>
|
||||
|
||||
<Separator/>
|
||||
<HBox styleClass="std-padding,std-spacing" alignment="CENTER_RIGHT">
|
||||
<Button text="Save" fx:id="saveButton" onAction="#save"/>
|
||||
<Button text="Cancel" onAction="#cancel"/>
|
||||
</FlowPane>
|
||||
</bottom>
|
||||
</HBox>
|
||||
</VBox>
|
||||
</center>
|
||||
</BorderPane>
|
||||
|
|
|
@ -4,10 +4,21 @@ classes to use for common style variants. Prefer to use the styles defined here
|
|||
rather than with your own CSS.
|
||||
*/
|
||||
|
||||
* {
|
||||
-fx-theme-text: rgb(26, 26, 26);
|
||||
-fx-theme-text-secondary: rgb(99, 99, 99);
|
||||
-fx-theme-background: rgb(237, 237, 237);
|
||||
-fx-theme-background-2: rgb(232, 232, 232);
|
||||
-fx-theme-background-3: rgb(220, 220, 220);
|
||||
-fx-theme-negative: rgb(247, 37, 69);
|
||||
-fx-theme-positive: rgb(43, 196, 77);
|
||||
}
|
||||
|
||||
.root {
|
||||
-fx-font-family: "Roboto", sans-serif;
|
||||
-fx-font-size: 14px;
|
||||
-fx-text-fill: rgb(26, 26, 26);
|
||||
-fx-text-fill: -fx-theme-text;
|
||||
-fx-background-color: -fx-theme-background;
|
||||
}
|
||||
|
||||
.mono-font {
|
||||
|
@ -24,7 +35,7 @@ rather than with your own CSS.
|
|||
|
||||
.error-text {
|
||||
-fx-font-size: small;
|
||||
-fx-text-fill: red;
|
||||
-fx-text-fill: -fx-theme-negative;
|
||||
}
|
||||
|
||||
.bold-text {
|
||||
|
@ -65,38 +76,47 @@ rather than with your own CSS.
|
|||
|
||||
/* Standard "tile" styling. */
|
||||
.tile {
|
||||
-fx-border-color: lightgray;
|
||||
-fx-border-width: 2px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 5px;
|
||||
-fx-padding: 5px;
|
||||
-fx-background-color: -fx-theme-background-2;
|
||||
-fx-padding: 10px;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
-fx-background-color: derive(-fx-theme-background-2, -5%);
|
||||
}
|
||||
|
||||
.tile-border-selected {
|
||||
-fx-border-color: darkgray;
|
||||
-fx-background-color: -fx-theme-background-3;
|
||||
}
|
||||
|
||||
/* Validation styling. */
|
||||
.validation-field-invalid {
|
||||
-fx-border-color: -fx-theme-negative;
|
||||
-fx-border-width: 2px;
|
||||
-fx-border-style: solid;
|
||||
-fx-border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Standard colors */
|
||||
.normal-color-text-fill {
|
||||
-fx-text-fill: rgb(26, 26, 26);
|
||||
-fx-text-fill: -fx-theme-text;
|
||||
}
|
||||
|
||||
.secondary-color-fill {
|
||||
-fx-fill: rgb(99, 99, 99);
|
||||
-fx-fill: -fx-theme-text-secondary;
|
||||
}
|
||||
|
||||
.negative-color-text-fill {
|
||||
-fx-text-fill: rgb(247, 37, 69);
|
||||
-fx-text-fill: -fx-theme-negative;
|
||||
}
|
||||
.negative-color-fill {
|
||||
-fx-fill: rgb(247, 37, 69);
|
||||
-fx-fill: -fx-theme-negative;
|
||||
}
|
||||
|
||||
.positive-color-text-fill {
|
||||
-fx-text-fill: rgb(43, 196, 77);
|
||||
-fx-text-fill: -fx-theme-positive;
|
||||
}
|
||||
.positive-color-fill {
|
||||
-fx-fill: rgb(43, 196, 77);
|
||||
-fx-fill: -fx-theme-positive;
|
||||
}
|
||||
|
||||
/* DEBUG BORDERS */
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
.fields-grid {
|
||||
-fx-hgap: 3px;
|
||||
-fx-vgap: 3px;
|
||||
-fx-max-width: 500px;
|
||||
}
|
Loading…
Reference in New Issue