From 90ec1e9b091f03ded5fa9d03039f37c74b463a95 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 2 Feb 2024 09:22:46 -0500 Subject: [PATCH] Added thing to load default categories from a JSON file. --- .../control/EditTransactionController.java | 55 +++++-- .../andrewlalis/perfin/control/Popups.java | 5 + .../control/TransactionViewController.java | 147 +++++++++++++----- .../control/TransactionsViewController.java | 12 +- .../data/impl/JdbcDataSourceFactory.java | 45 +++++- .../andrewlalis/perfin/data/util/DbUtil.java | 17 +- .../andrewlalis/perfin/view/BindingUtil.java | 7 + .../view/component/AccountSelectionBox.java | 16 +- .../view/component/CategorySelectionBox.java | 81 ++++++++++ src/main/resources/create-balance-record.fxml | 2 +- src/main/resources/edit-category.fxml | 3 +- src/main/resources/edit-transaction.fxml | 34 +++- src/main/resources/edit-vendor.fxml | 3 +- .../sql/data/default-categories.json | 82 ++++++++++ src/main/resources/sql/schema.sql | 8 - src/main/resources/transaction-view.fxml | 34 +++- 16 files changed, 463 insertions(+), 88 deletions(-) create mode 100644 src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java create mode 100644 src/main/resources/sql/data/default-categories.json diff --git a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java index d6dc755..0a7c119 100644 --- a/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java +++ b/src/main/java/com/andrewlalis/perfin/control/EditTransactionController.java @@ -10,13 +10,16 @@ import com.andrewlalis.perfin.data.util.DateUtil; import com.andrewlalis.perfin.model.*; import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; +import com.andrewlalis.perfin.view.component.CategorySelectionBox; 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.binding.BooleanExpression; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.Property; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -61,7 +64,7 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public ComboBox vendorComboBox; @FXML public Hyperlink vendorsHyperlink; - @FXML public ComboBox categoryComboBox; + @FXML public CategorySelectionBox categoryComboBox; @FXML public Hyperlink categoriesHyperlink; @FXML public ComboBox tagsComboBox; @FXML public Hyperlink tagsHyperlink; @@ -69,6 +72,15 @@ public class EditTransactionController implements RouteSelectionListener { @FXML public VBox tagsVBox; private final ObservableList selectedTags = FXCollections.observableArrayList(); + @FXML public Spinner lineItemQuantitySpinner; + @FXML public TextField lineItemValueField; + @FXML public TextField lineItemDescriptionField; + @FXML public Button addLineItemButton; + @FXML public VBox addLineItemForm; + @FXML public Button addLineItemAddButton; + @FXML public Button addLineItemCancelButton; + @FXML public final BooleanProperty addingLineItemProperty = new SimpleBooleanProperty(false); + @FXML public FileSelectionArea attachmentsSelectionArea; @FXML public Button saveButton; @@ -97,6 +109,26 @@ public class EditTransactionController implements RouteSelectionListener { categoriesHyperlink.setOnAction(event -> router.navigate("categories")); tagsHyperlink.setOnAction(event -> router.navigate("tags")); + // Initialize line item stuff. + addLineItemButton.setOnAction(event -> addingLineItemProperty.set(true)); + addLineItemCancelButton.setOnAction(event -> { + lineItemQuantitySpinner.getValueFactory().setValue(1); + lineItemValueField.setText(null); + lineItemDescriptionField.setText(null); + addingLineItemProperty.set(false); + }); + BindingUtil.bindManagedAndVisible(addLineItemButton, addingLineItemProperty.not()); + BindingUtil.bindManagedAndVisible(addLineItemForm, addingLineItemProperty); + lineItemQuantitySpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, Integer.MAX_VALUE, 1, 1)); + var lineItemValueValid = new ValidationApplier<>( + new CurrencyAmountValidator(() -> currencyChoiceBox.getValue(), false, false) + ).attachToTextField(lineItemValueField, currencyChoiceBox.valueProperty()); + var lineItemDescriptionValid = new ValidationApplier<>(new PredicateValidator() + .addTerminalPredicate(s -> s != null && !s.isBlank(), "A description is required.") + ).attachToTextField(lineItemDescriptionField); + var lineItemFormValid = lineItemValueValid.and(lineItemDescriptionValid); + addLineItemAddButton.disableProperty().bind(lineItemFormValid.not()); + var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid); saveButton.disableProperty().bind(formValid.not()); } @@ -108,7 +140,7 @@ public class EditTransactionController implements RouteSelectionListener { String description = getSanitizedDescription(); CreditAndDebitAccounts linkedAccounts = getSelectedAccounts(); String vendor = vendorComboBox.getValue(); - String category = categoryComboBox.getValue(); + String category = categoryComboBox.getValue() == null ? null : categoryComboBox.getValue().getName(); Set tags = new HashSet<>(selectedTags); List newAttachmentPaths = attachmentsSelectionArea.getSelectedPaths(); List existingAttachments = attachmentsSelectionArea.getSelectedAttachments(); @@ -161,7 +193,7 @@ public class EditTransactionController implements RouteSelectionListener { // Clear some initial fields immediately: tagsComboBox.setValue(null); vendorComboBox.setValue(null); - categoryComboBox.setValue(null); + categoryComboBox.select(null); if (transaction == null) { titleLabel.setText("Create New Transaction"); @@ -191,17 +223,18 @@ public class EditTransactionController implements RouteSelectionListener { .toList(); List accounts = accountRepo.findAll(PageRequest.unpaged(Sort.asc("name"))).items(); final List attachments; + final var categoryTreeNodes = categoryRepo.findTree(); final List availableTags = transactionRepo.findAllTags(); final List tags; final CreditAndDebitAccounts linkedAccounts; final String vendorName; - final String categoryName; + final TransactionCategory category; if (transaction == null) { attachments = Collections.emptyList(); tags = Collections.emptyList(); linkedAccounts = new CreditAndDebitAccounts(null, null); vendorName = null; - categoryName = null; + category = null; } else { attachments = transactionRepo.findAttachments(transaction.id); tags = transactionRepo.findTags(transaction.id); @@ -213,14 +246,12 @@ public class EditTransactionController implements RouteSelectionListener { vendorName = null; } if (transaction.getCategoryId() != null) { - categoryName = categoryRepo.findById(transaction.getCategoryId()) - .map(TransactionCategory::getName).orElse(null); + category = categoryRepo.findById(transaction.getCategoryId()).orElse(null); } else { - categoryName = null; + category = null; } } final List availableVendors = vendorRepo.findAll(); - final List availableCategories = categoryRepo.findAll(); // Then make updates to the view. Platform.runLater(() -> { currencyChoiceBox.getItems().setAll(currencies); @@ -228,12 +259,13 @@ public class EditTransactionController implements RouteSelectionListener { debitAccountSelector.setAccounts(accounts); vendorComboBox.getItems().setAll(availableVendors.stream().map(TransactionVendor::getName).toList()); vendorComboBox.setValue(vendorName); - categoryComboBox.getItems().setAll(availableCategories.stream().map(TransactionCategory::getName).toList()); - categoryComboBox.setValue(categoryName); + categoryComboBox.loadCategories(categoryTreeNodes); + categoryComboBox.select(category); tagsComboBox.getItems().setAll(availableTags); attachmentsSelectionArea.clear(); attachmentsSelectionArea.addAttachments(attachments); selectedTags.clear(); + selectedTags.addAll(tags); if (transaction == null) { currencyChoiceBox.getSelectionModel().selectFirst(); creditAccountSelector.select(null); @@ -242,7 +274,6 @@ public class EditTransactionController implements RouteSelectionListener { currencyChoiceBox.getSelectionModel().select(transaction.getCurrency()); creditAccountSelector.select(linkedAccounts.creditAccount()); debitAccountSelector.select(linkedAccounts.debitAccount()); - selectedTags.addAll(tags); } container.setDisable(false); }); diff --git a/src/main/java/com/andrewlalis/perfin/control/Popups.java b/src/main/java/com/andrewlalis/perfin/control/Popups.java index ff2af1f..b0477bb 100644 --- a/src/main/java/com/andrewlalis/perfin/control/Popups.java +++ b/src/main/java/com/andrewlalis/perfin/control/Popups.java @@ -1,5 +1,6 @@ package com.andrewlalis.perfin.control; +import javafx.application.Platform; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Alert; @@ -54,6 +55,10 @@ public class Popups { error(getWindowFromNode(node), e); } + public static void errorLater(Node node, Exception e) { + Platform.runLater(() -> error(node, e)); + } + private static Window getWindowFromNode(Node n) { Window owner = null; Scene scene = n.getScene(); diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java index 8884c8d..02cf37d 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionViewController.java @@ -3,23 +3,37 @@ package com.andrewlalis.perfin.control; import com.andrewlalis.perfin.data.TransactionRepository; import com.andrewlalis.perfin.data.util.CurrencyUtil; import com.andrewlalis.perfin.data.util.DateUtil; -import com.andrewlalis.perfin.model.Attachment; -import com.andrewlalis.perfin.model.CreditAndDebitAccounts; -import com.andrewlalis.perfin.model.Profile; -import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.model.*; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.component.AttachmentsViewPane; +import com.andrewlalis.perfin.view.component.PropertiesPane; import javafx.application.Platform; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; import javafx.fxml.FXML; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; +import javafx.scene.shape.Circle; import javafx.scene.text.TextFlow; - -import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static com.andrewlalis.perfin.PerfinApp.router; public class TransactionViewController { - private Transaction transaction; + private static final Logger log = LoggerFactory.getLogger(TransactionViewController.class); + + private final ObjectProperty transactionProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty linkedAccountsProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty vendorProperty = new SimpleObjectProperty<>(null); + private final ObjectProperty categoryProperty = new SimpleObjectProperty<>(null); + private final ObservableList tagsList = FXCollections.observableArrayList(); + private final ListProperty tagsListProperty = new SimpleListProperty<>(tagsList); + private final ObservableList attachmentsList = FXCollections.observableArrayList(); @FXML public Label titleLabel; @@ -27,47 +41,103 @@ public class TransactionViewController { @FXML public Label timestampLabel; @FXML public Label descriptionLabel; + @FXML public Label vendorLabel; + @FXML public Circle categoryColorIndicator; + @FXML public Label categoryLabel; + @FXML public Label tagsLabel; + @FXML public Hyperlink debitAccountLink; @FXML public Hyperlink creditAccountLink; @FXML public AttachmentsViewPane attachmentsViewPane; @FXML public void initialize() { - configureAccountLinkBindings(debitAccountLink); - configureAccountLinkBindings(creditAccountLink); + titleLabel.textProperty().bind(transactionProperty.map(t -> "Transaction #" + t.id)); + amountLabel.textProperty().bind(transactionProperty.map(t -> CurrencyUtil.formatMoney(t.getMoneyAmount()))); + timestampLabel.textProperty().bind(transactionProperty.map(t -> DateUtil.formatUTCAsLocalWithZone(t.getTimestamp()))); + descriptionLabel.textProperty().bind(transactionProperty.map(Transaction::getDescription)); + + PropertiesPane vendorPane = (PropertiesPane) vendorLabel.getParent(); + BindingUtil.bindManagedAndVisible(vendorPane, vendorProperty.isNotNull()); + vendorLabel.textProperty().bind(vendorProperty.map(TransactionVendor::getName)); + + PropertiesPane categoryPane = (PropertiesPane) categoryLabel.getParent().getParent(); + BindingUtil.bindManagedAndVisible(categoryPane, categoryProperty.isNotNull()); + categoryLabel.textProperty().bind(categoryProperty.map(TransactionCategory::getName)); + categoryColorIndicator.fillProperty().bind(categoryProperty.map(TransactionCategory::getColor)); + + PropertiesPane tagsPane = (PropertiesPane) tagsLabel.getParent(); + BindingUtil.bindManagedAndVisible(tagsPane, tagsListProperty.emptyProperty().not()); + tagsLabel.textProperty().bind(tagsListProperty.map(tags -> String.join(", ", tags))); + + TextFlow debitText = (TextFlow) debitAccountLink.getParent(); + BindingUtil.bindManagedAndVisible(debitText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasDebit)); + debitAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasDebit() ? la.debitAccount().getShortName() : null)); + debitAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> { + if (la.hasDebit()) { + return event -> router.navigate("account", la.debitAccount()); + } + return event -> {}; + })); + TextFlow creditText = (TextFlow) creditAccountLink.getParent(); + BindingUtil.bindManagedAndVisible(creditText, linkedAccountsProperty.map(CreditAndDebitAccounts::hasCredit)); + creditAccountLink.textProperty().bind(linkedAccountsProperty.map(la -> la.hasCredit() ? la.creditAccount().getShortName() : null)); + creditAccountLink.onActionProperty().bind(linkedAccountsProperty.map(la -> { + if (la.hasCredit()) { + return event -> router.navigate("account", la.creditAccount()); + } + return event -> {}; + })); + attachmentsViewPane.hideIfEmpty(); + attachmentsViewPane.listProperty().bindContent(attachmentsList); + + transactionProperty.addListener((observable, oldValue, newValue) -> { + if (newValue == null) { + linkedAccountsProperty.set(null); + vendorProperty.set(null); + categoryProperty.set(null); + tagsList.clear(); + attachmentsList.clear(); + } else { + updateLinkedData(newValue); + } + }); } public void setTransaction(Transaction transaction) { - this.transaction = transaction; - if (transaction == null) return; - titleLabel.setText("Transaction #" + transaction.id); - amountLabel.setText(CurrencyUtil.formatMoney(transaction.getMoneyAmount())); - timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp())); - descriptionLabel.setText(transaction.getDescription()); - Profile.getCurrent().dataSource().useRepoAsync(TransactionRepository.class, repo -> { - CreditAndDebitAccounts accounts = repo.findLinkedAccounts(transaction.id); - List attachments = repo.findAttachments(transaction.id); - Platform.runLater(() -> { - if (accounts.hasDebit()) { - debitAccountLink.setText(accounts.debitAccount().getShortName()); - debitAccountLink.setOnAction(event -> router.navigate("account", accounts.debitAccount())); - } else { - debitAccountLink.setText(null); - } - if (accounts.hasCredit()) { - creditAccountLink.setText(accounts.creditAccount().getShortName()); - creditAccountLink.setOnAction(event -> router.navigate("account", accounts.creditAccount())); - } else { - creditAccountLink.setText(null); - } - attachmentsViewPane.setAttachments(attachments); - }); + this.transactionProperty.set(transaction); + } + + private void updateLinkedData(Transaction tx) { + var ds = Profile.getCurrent().dataSource(); + Thread.ofVirtual().start(() -> { + try ( + var transactionRepo = ds.getTransactionRepository(); + var vendorRepo = ds.getTransactionVendorRepository(); + var categoryRepo = ds.getTransactionCategoryRepository() + ) { + final var linkedAccounts = transactionRepo.findLinkedAccounts(tx.id); + final var vendor = tx.getVendorId() == null ? null : vendorRepo.findById(tx.getVendorId()).orElse(null); + final var category = tx.getCategoryId() == null ? null : categoryRepo.findById(tx.getCategoryId()).orElse(null); + final var attachments = transactionRepo.findAttachments(tx.id); + final var tags = transactionRepo.findTags(tx.id); + Platform.runLater(() -> { + linkedAccountsProperty.set(linkedAccounts); + vendorProperty.set(vendor); + categoryProperty.set(category); + attachmentsList.setAll(attachments); + tagsList.setAll(tags); + }); + } catch (Exception e) { + log.error("Failed to fetch additional transaction data.", e); + Popups.errorLater(titleLabel, e); + } }); } @FXML public void editTransaction() { - router.navigate("edit-transaction", this.transaction); + router.navigate("edit-transaction", this.transactionProperty.get()); } @FXML public void deleteTransaction() { @@ -82,15 +152,8 @@ public class TransactionViewController { "it's derived from the most recent balance-record, and transactions." ); if (confirm) { - Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(transaction.id)); + Profile.getCurrent().dataSource().useRepo(TransactionRepository.class, repo -> repo.delete(this.transactionProperty.get().id)); router.replace("transactions"); } } - - private void configureAccountLinkBindings(Hyperlink link) { - TextFlow parent = (TextFlow) link.getParent(); - parent.managedProperty().bind(parent.visibleProperty()); - parent.visibleProperty().bind(link.textProperty().isNotEmpty()); - link.setText(null); - } } diff --git a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java index d9a6d73..1de6d82 100644 --- a/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java +++ b/src/main/java/com/andrewlalis/perfin/control/TransactionsViewController.java @@ -11,6 +11,7 @@ import com.andrewlalis.perfin.data.util.Pair; import com.andrewlalis.perfin.model.Account; import com.andrewlalis.perfin.model.Profile; import com.andrewlalis.perfin.model.Transaction; +import com.andrewlalis.perfin.view.BindingUtil; import com.andrewlalis.perfin.view.SceneUtil; import com.andrewlalis.perfin.view.component.AccountSelectionBox; import com.andrewlalis.perfin.view.component.DataSourcePaginationControls; @@ -98,14 +99,11 @@ public class TransactionsViewController implements RouteSelectionListener { detailPanel.minWidthProperty().bind(halfWidthProp); detailPanel.maxWidthProperty().bind(halfWidthProp); detailPanel.prefWidthProperty().bind(halfWidthProp); - detailPanel.managedProperty().bind(detailPanel.visibleProperty()); - detailPanel.visibleProperty().bind(selectedTransaction.isNotNull()); + BindingUtil.bindManagedAndVisible(detailPanel, selectedTransaction.isNotNull()); Pair detailComponents = SceneUtil.loadNodeAndController("/transaction-view.fxml"); TransactionViewController transactionViewController = detailComponents.second(); BorderPane transactionDetailView = detailComponents.first(); - transactionDetailView.managedProperty().bind(transactionDetailView.visibleProperty()); - transactionDetailView.visibleProperty().bind(selectedTransaction.isNotNull()); detailPanel.getChildren().add(transactionDetailView); selectedTransaction.addListener((observable, oldValue, newValue) -> { transactionViewController.setTransaction(newValue); @@ -121,7 +119,7 @@ public class TransactionsViewController implements RouteSelectionListener { @Override public void onRouteSelected(Object context) { paginationControls.sorts.setAll(DEFAULT_SORTS); - transactionsVBox.getChildren().clear(); // Clear the transactions before reload initially. + selectedTransaction.set(null); // Initially set the selected transaction as null. // Refresh account filter options. Profile.getCurrent().dataSource().useRepoAsync(AccountRepository.class, repo -> { @@ -140,13 +138,13 @@ public class TransactionsViewController implements RouteSelectionListener { long offset = repo.countAllAfter(tx.id); int pageNumber = (int) (offset / paginationControls.getItemsPerPage()) + 1; Platform.runLater(() -> { - paginationControls.setPage(pageNumber).thenRun(() -> selectedTransaction.set(tx)); + paginationControls.setPage(pageNumber); + selectedTransaction.set(tx); }); }); }); } else { paginationControls.setPage(1); - selectedTransaction.set(null); } } diff --git a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java index 59819b7..da23d12 100644 --- a/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java +++ b/src/main/java/com/andrewlalis/perfin/data/impl/JdbcDataSourceFactory.java @@ -5,8 +5,12 @@ import com.andrewlalis.perfin.data.DataSourceFactory; import com.andrewlalis.perfin.data.ProfileLoadException; import com.andrewlalis.perfin.data.impl.migration.Migration; import com.andrewlalis.perfin.data.impl.migration.Migrations; +import com.andrewlalis.perfin.data.util.DbUtil; import com.andrewlalis.perfin.data.util.FileUtil; import com.andrewlalis.perfin.model.Profile; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,9 +19,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.sql.Connection; -import java.sql.SQLException; -import java.sql.Statement; +import java.sql.*; import java.util.Arrays; import java.util.List; @@ -77,6 +79,7 @@ public class JdbcDataSourceFactory implements DataSourceFactory { if (in == null) throw new IOException("Could not load database schema SQL file."); String schemaStr = new String(in.readAllBytes(), StandardCharsets.UTF_8); executeSqlScript(schemaStr, conn); + insertDefaultData(conn); try { writeCurrentSchemaVersion(profileName); } catch (IOException e) { @@ -97,6 +100,42 @@ public class JdbcDataSourceFactory implements DataSourceFactory { } } + private void insertDefaultData(Connection conn) throws IOException, SQLException { + try ( + var categoriesIn = JdbcDataSourceFactory.class.getResourceAsStream("/sql/data/default-categories.json"); + var stmt = conn.prepareStatement( + "INSERT INTO transaction_category (parent_id, name, color) VALUES (?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ) + ) { + if (categoriesIn == null) throw new IOException("Couldn't load default categories file."); + ObjectMapper mapper = new ObjectMapper(); + ArrayNode categoriesArray = mapper.readValue(categoriesIn, ArrayNode.class); + insertCategoriesRecursive(stmt, categoriesArray, null, "#FFFFFF"); + } + } + + private void insertCategoriesRecursive(PreparedStatement stmt, ArrayNode categoriesArray, Long parentId, String parentColorHex) throws SQLException { + for (JsonNode obj : categoriesArray) { + String name = obj.get("name").asText(); + String colorHex = parentColorHex; + if (obj.hasNonNull("color")) colorHex = obj.get("color").asText(parentColorHex); + if (parentId == null) { + stmt.setNull(1, Types.BIGINT); + } else { + stmt.setLong(1, parentId); + } + stmt.setString(2, name); + stmt.setString(3, colorHex.substring(1)); + int result = stmt.executeUpdate(); + if (result != 1) throw new SQLException("Failed to insert category."); + long id = DbUtil.getGeneratedId(stmt); + if (obj.hasNonNull("children") && obj.get("children").isArray()) { + insertCategoriesRecursive(stmt, obj.withArray("children"), id, colorHex); + } + } + } + private boolean testConnection(JdbcDataSource dataSource) { try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) { return stmt.execute("SELECT 1;"); diff --git a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java index 94b2cd6..29ef062 100644 --- a/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java +++ b/src/main/java/com/andrewlalis/perfin/data/util/DbUtil.java @@ -31,6 +31,15 @@ public final class DbUtil { setArgs(stmt, List.of(args)); } + public static long getGeneratedId(PreparedStatement stmt) { + try (ResultSet rs = stmt.getGeneratedKeys()) { + if (!rs.next()) throw new SQLException("No generated keys available."); + return rs.getLong(1); + } catch (SQLException e) { + throw new UncheckedSqlException(e); + } + } + public static List findAll(Connection conn, String query, List args, ResultSetMapper mapper) { try (var stmt = conn.prepareStatement(query)) { setArgs(stmt, args); @@ -116,9 +125,7 @@ public final class DbUtil { setArgs(stmt, args); int result = stmt.executeUpdate(); if (result != 1) throw new UncheckedSqlException("Insert query did not update 1 row."); - var rs = stmt.getGeneratedKeys(); - if (!rs.next()) throw new UncheckedSqlException("Insert query did not generate any keys."); - return rs.getLong(1); + return getGeneratedId(stmt); } catch (SQLException e) { throw new UncheckedSqlException(e); } @@ -155,7 +162,9 @@ public final class DbUtil { public static T doTransaction(Connection conn, SQLSupplier supplier) { try { conn.setAutoCommit(false); - return supplier.offer(); + T result = supplier.offer(); + conn.commit(); + return result; } catch (Exception e) { try { conn.rollback(); diff --git a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java index 6a233c5..f3d6389 100644 --- a/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java +++ b/src/main/java/com/andrewlalis/perfin/view/BindingUtil.java @@ -1,8 +1,10 @@ package com.andrewlalis.perfin.view; import javafx.beans.WeakListener; +import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; +import javafx.scene.Node; import java.lang.ref.WeakReference; import java.util.List; @@ -86,4 +88,9 @@ public class BindingUtil { return false; } } + + public static void bindManagedAndVisible(Node node, ObservableValue value) { + node.managedProperty().bind(node.visibleProperty()); + node.visibleProperty().bind(value); + } } diff --git a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java index f9cbdb9..eacddea 100644 --- a/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java +++ b/src/main/java/com/andrewlalis/perfin/view/component/AccountSelectionBox.java @@ -72,19 +72,21 @@ public class AccountSelectionBox extends ComboBox { showBalanceProperty.set(value); } - private static class CellFactory implements Callback, ListCell> { - private final BooleanProperty showBalanceProp; - - private CellFactory(BooleanProperty showBalanceProp) { - this.showBalanceProp = showBalanceProp; - } - + /** + * A simple cell factory that just returns instances of {@link AccountListCell}. + * @param showBalanceProp Whether to show the account's balance. + */ + private record CellFactory(BooleanProperty showBalanceProp) implements Callback, ListCell> { @Override public ListCell call(ListView param) { return new AccountListCell(showBalanceProp); } } + /** + * A list cell implementation which shows an account's name, and optionally, + * its current derived balance underneath. + */ private static class AccountListCell extends ListCell { private final BooleanProperty showBalanceProp; private final Label nameLabel = new Label(); diff --git a/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java new file mode 100644 index 0000000..3a60753 --- /dev/null +++ b/src/main/java/com/andrewlalis/perfin/view/component/CategorySelectionBox.java @@ -0,0 +1,81 @@ +package com.andrewlalis.perfin.view.component; + +import com.andrewlalis.perfin.data.TransactionCategoryRepository; +import com.andrewlalis.perfin.model.TransactionCategory; +import javafx.geometry.Insets; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.layout.HBox; +import javafx.scene.shape.Circle; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class CategorySelectionBox extends ComboBox { + private final Map categoryIndentationLevels = new HashMap<>(); + + public CategorySelectionBox() { + setCellFactory(view -> new CategoryListCell(categoryIndentationLevels)); + setButtonCell(new CategoryListCell(null)); + } + + public void loadCategories(List treeNodes) { + categoryIndentationLevels.clear(); + getItems().clear(); + populateCategories(treeNodes, 0); + getItems().add(null); + } + + private void populateCategories( + List treeNodes, + int depth + ) { + for (var node : treeNodes) { + getItems().add(node.category()); + categoryIndentationLevels.put(node.category(), depth); + populateCategories(node.children(), depth + 1); + } + } + + public void select(TransactionCategory category) { + setButtonCell(new CategoryListCell(null)); + getSelectionModel().select(category); + } + + private static class CategoryListCell extends ListCell { + private final Label nameLabel = new Label(); + private final Circle colorIndicator = new Circle(8); + private final Map categoryIndentationLevels; + + public CategoryListCell(Map categoryIndentationLevels) { + this.categoryIndentationLevels = categoryIndentationLevels; + nameLabel.getStyleClass().add("normal-color-text-fill"); + colorIndicator.managedProperty().bind(colorIndicator.visibleProperty()); + HBox container = new HBox(colorIndicator, nameLabel); + container.getStyleClass().add("std-spacing"); + setGraphic(container); + } + + @Override + protected void updateItem(TransactionCategory item, boolean empty) { + super.updateItem(item, empty); + if (item == null || empty) { + nameLabel.setText("None"); + colorIndicator.setVisible(false); + return; + } + + nameLabel.setText(item.getName()); + if (categoryIndentationLevels != null) { + HBox.setMargin( + colorIndicator, + new Insets(0, 0, 0, 10 * categoryIndentationLevels.getOrDefault(item, 0)) + ); + } + colorIndicator.setVisible(true); + colorIndicator.setFill(item.getColor()); + } + } +} diff --git a/src/main/resources/create-balance-record.fxml b/src/main/resources/create-balance-record.fxml index 98683f4..e3544fd 100644 --- a/src/main/resources/create-balance-record.fxml +++ b/src/main/resources/create-balance-record.fxml @@ -55,7 +55,7 @@ - +