Added category view and editor.

This commit is contained in:
Andrew Lalis 2024-01-31 10:16:53 -05:00
parent 77291ba724
commit aaa1081ddf
13 changed files with 378 additions and 41 deletions

View File

@ -93,6 +93,8 @@ public class PerfinApp extends Application {
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"));
router.map("categories", PerfinApp.class.getResource("/categories-view.fxml"));
router.map("edit-category", PerfinApp.class.getResource("/edit-category.fxml"));
// Help pages.
helpRouter.map("home", PerfinApp.class.getResource("/help-pages/home.fxml"));

View File

@ -0,0 +1,33 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.view.BindingUtil;
import com.andrewlalis.perfin.view.component.CategoryTile;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.layout.VBox;
public class CategoriesViewController implements RouteSelectionListener {
@FXML public VBox categoriesVBox;
private final ObservableList<TransactionCategoryRepository.CategoryTreeNode> categoryTreeNodes = FXCollections.observableArrayList();
@FXML public void initialize() {
BindingUtil.mapContent(categoriesVBox.getChildren(), categoryTreeNodes, node -> new CategoryTile(node, this::refreshCategories));
}
@Override
public void onRouteSelected(Object context) {
refreshCategories();
}
private void refreshCategories() {
Profile.getCurrent().dataSource().mapRepoAsync(
TransactionCategoryRepository.class,
TransactionCategoryRepository::findTree
).thenAccept(nodes -> Platform.runLater(() -> categoryTreeNodes.setAll(nodes)));
}
}

View File

@ -0,0 +1,108 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionCategory;
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.ColorPicker;
import javafx.scene.control.TextField;
import javafx.scene.paint.Color;
import java.util.concurrent.CompletableFuture;
import static com.andrewlalis.perfin.PerfinApp.router;
public class EditCategoryController implements RouteSelectionListener {
public record CategoryRouteContext(TransactionCategory category) implements RouteContext {}
public record AddSubcategoryRouteContext(TransactionCategory parent) implements RouteContext {}
private sealed interface RouteContext permits AddSubcategoryRouteContext, CategoryRouteContext {}
private TransactionCategory category;
private TransactionCategory parent;
@FXML public TextField nameField;
@FXML public ColorPicker colorPicker;
@FXML public Button saveButton;
@FXML public void initialize() {
var nameValid = new ValidationApplier<>(new PredicateValidator<String>()
.addTerminalPredicate(s -> s != null && !s.isBlank(), "Name is required.")
.addPredicate(s -> s.strip().length() <= TransactionCategory.NAME_MAX_LENGTH, "Name is too long.")
.addAsyncPredicate(
s -> {
if (Profile.getCurrent() == null) return CompletableFuture.completedFuture(false);
return Profile.getCurrent().dataSource().mapRepoAsync(
TransactionCategoryRepository.class,
repo -> {
var categoryByName = repo.findByName(s).orElse(null);
if (this.category != null) {
return this.category.equals(categoryByName) || categoryByName == null;
}
return categoryByName == null;
}
);
},
"Category with this name already exists."
)
).validatedInitially().attachToTextField(nameField);
saveButton.disableProperty().bind(nameValid.not());
}
@Override
public void onRouteSelected(Object context) {
this.category = null;
this.parent = null;
if (context instanceof RouteContext ctx) {
switch (ctx) {
case CategoryRouteContext(var cat):
this.category = cat;
nameField.setText(cat.getName());
colorPicker.setValue(cat.getColor());
break;
case AddSubcategoryRouteContext(var par):
this.parent = par;
nameField.setText(null);
colorPicker.setValue(parent.getColor());
break;
}
} else {
nameField.setText(null);
colorPicker.setValue(Color.WHITE);
}
}
@FXML public void save() {
final String name = nameField.getText().strip();
final Color color = colorPicker.getValue();
if (this.category == null && this.parent == null) {
// New top-level category.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.insert(name, color)
);
} else if (this.category == null) {
// New subcategory.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.insert(parent.id, name, color)
);
} else if (this.parent == null) {
// Save edits to an existing category.
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.update(category.id, name, color)
);
}
router.replace("categories");
}
@FXML public void cancel() {
router.navigateBackAndClear();
}
}

View File

@ -62,7 +62,9 @@ public class EditTransactionController implements RouteSelectionListener {
@FXML public ComboBox<String> vendorComboBox;
@FXML public Hyperlink vendorsHyperlink;
@FXML public ComboBox<String> categoryComboBox;
@FXML public Hyperlink categoriesHyperlink;
@FXML public ComboBox<String> tagsComboBox;
@FXML public Hyperlink tagsHyperlink;
@FXML public Button addTagButton;
@FXML public VBox tagsVBox;
private final ObservableList<String> selectedTags = FXCollections.observableArrayList();
@ -92,6 +94,8 @@ public class EditTransactionController implements RouteSelectionListener {
initializeTagSelectionUi();
// Setup hyperlinks.
vendorsHyperlink.setOnAction(event -> router.navigate("vendors"));
categoriesHyperlink.setOnAction(event -> router.navigate("categories"));
// tagsHyperlink.setOnAction(event -> router.navigate("tags"));
var formValid = timestampValid.and(amountValid).and(descriptionValid).and(linkedAccountsValid);
saveButton.disableProperty().bind(formValid.not());

View File

@ -5,15 +5,11 @@ 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 com.andrewlalis.perfin.view.component.VendorTile;
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;
@ -25,36 +21,7 @@ public class VendorsViewController implements RouteSelectionListener {
private final ObservableList<TransactionVendor> vendors = FXCollections.observableArrayList();
@FXML public void initialize() {
BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, this::buildVendorTile);
}
private Node buildVendorTile(TransactionVendor transactionVendor) {
BorderPane pane = new BorderPane();
pane.getStyleClass().addAll("tile", "std-spacing");
pane.setOnMouseClicked(event -> router.navigate("edit-vendor", transactionVendor));
Label nameLabel = new Label(transactionVendor.getName());
nameLabel.getStyleClass().addAll("bold-text");
Label descriptionLabel = new Label(transactionVendor.getDescription());
descriptionLabel.setWrapText(true);
VBox contentVBox = new VBox(nameLabel, descriptionLabel);
contentVBox.getStyleClass().addAll("std-spacing");
pane.setCenter(contentVBox);
BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> {
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions with assigned to this vendor will have their vendor field cleared. This cannot be undone.");
if (confirm) {
Profile.getCurrent().dataSource().useRepo(TransactionVendorRepository.class, repo -> {
repo.deleteById(transactionVendor.id);
});
refreshVendors();
}
});
pane.setRight(removeButton);
return pane;
BindingUtil.mapContent(vendorsVBox.getChildren(), vendors, vendor -> new VendorTile(vendor, this::refreshVendors));
}
@Override

View File

@ -13,5 +13,9 @@ public interface TransactionCategoryRepository extends Repository, AutoCloseable
List<TransactionCategory> findAll();
long insert(long parentId, String name, Color color);
long insert(String name, Color color);
void update(long id, String name, Color color);
void deleteById(long id);
record CategoryTreeNode(TransactionCategory category, List<CategoryTreeNode> children){}
List<CategoryTreeNode> findTree();
}

View File

@ -9,6 +9,7 @@ import javafx.scene.paint.Color;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ -69,11 +70,62 @@ public record JdbcTransactionCategoryRepository(Connection conn) implements Tran
);
}
@Override
public void update(long id, String name, Color color) {
DbUtil.doTransaction(conn, () -> {
TransactionCategory category = findById(id).orElseThrow();
if (!category.getName().equals(name)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_category SET name = ? WHERE id = ?",
name,
id
);
}
if (!category.getColor().equals(color)) {
DbUtil.updateOne(
conn,
"UPDATE transaction_category SET color = ? WHERE id = ?",
ColorUtil.toHex(color),
id
);
}
});
}
@Override
public void deleteById(long id) {
DbUtil.updateOne(conn, "DELETE FROM transaction_category WHERE id = ?", id);
}
@Override
public List<CategoryTreeNode> findTree() {
List<TransactionCategory> rootCategories = DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
JdbcTransactionCategoryRepository::parseCategory
);
List<CategoryTreeNode> rootNodes = new ArrayList<>(rootCategories.size());
for (var category : rootCategories) {
rootNodes.add(findTreeRecursive(category));
}
return rootNodes;
}
private CategoryTreeNode findTreeRecursive(TransactionCategory root) {
CategoryTreeNode node = new CategoryTreeNode(root, new ArrayList<>());
List<TransactionCategory> childCategories = DbUtil.findAll(
conn,
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
List.of(root.id),
JdbcTransactionCategoryRepository::parseCategory
);
for (var childCategory : childCategories) {
node.children().add(findTreeRecursive(childCategory));
}
return node;
}
@Override
public void close() throws Exception {
conn.close();

View File

@ -0,0 +1,65 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.EditCategoryController;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.TransactionCategoryRepository;
import com.andrewlalis.perfin.model.Profile;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.shape.Circle;
import static com.andrewlalis.perfin.PerfinApp.router;
public class CategoryTile extends VBox {
public CategoryTile(
TransactionCategoryRepository.CategoryTreeNode treeNode,
Runnable categoriesRefresh
) {
this.getStyleClass().addAll("tile", "hand-cursor");
this.setStyle("-fx-border-width: 1px; -fx-border-color: grey;");
this.setOnMouseClicked(event -> {
event.consume();
router.navigate(
"edit-category",
new EditCategoryController.CategoryRouteContext(treeNode.category())
);
});
BorderPane borderPane = new BorderPane();
borderPane.getStyleClass().addAll("std-padding");
Label nameLabel = new Label(treeNode.category().getName());
nameLabel.getStyleClass().addAll("bold-text");
Circle colorCircle = new Circle(10, treeNode.category().getColor());
HBox contentBox = new HBox(colorCircle, nameLabel);
contentBox.getStyleClass().addAll("std-spacing");
borderPane.setLeft(contentBox);
Button addChildButton = new Button("Add Subcategory");
addChildButton.setOnAction(event -> router.navigate(
"edit-category",
new EditCategoryController.AddSubcategoryRouteContext(treeNode.category())
));
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> {
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this category? It will permanently remove the category from all linked transactions, and all subcategories will also be removed. This cannot be undone.");
if (confirm) {
Profile.getCurrent().dataSource().useRepo(
TransactionCategoryRepository.class,
repo -> repo.deleteById(treeNode.category().id)
);
categoriesRefresh.run();
}
});
HBox buttonsBox = new HBox(addChildButton, removeButton);
buttonsBox.getStyleClass().addAll("std-spacing");
borderPane.setRight(buttonsBox);
this.getChildren().add(borderPane);
for (var child : treeNode.children()) {
this.getChildren().add(new CategoryTile(child, categoriesRefresh));
}
}
}

View File

@ -0,0 +1,46 @@
package com.andrewlalis.perfin.view.component;
import com.andrewlalis.perfin.control.Popups;
import com.andrewlalis.perfin.data.TransactionVendorRepository;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.TransactionVendor;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import static com.andrewlalis.perfin.PerfinApp.router;
public class VendorTile extends BorderPane {
public VendorTile(TransactionVendor vendor, Runnable vendorRefresh) {
this.getStyleClass().addAll("tile", "std-spacing", "hand-cursor");
this.setOnMouseClicked(event -> router.navigate("edit-vendor", vendor));
Label nameLabel = new Label(vendor.getName());
nameLabel.getStyleClass().addAll("bold-text");
Label descriptionLabel = new Label(vendor.getDescription());
descriptionLabel.setWrapText(true);
VBox contentVBox = new VBox(nameLabel, descriptionLabel);
contentVBox.getStyleClass().addAll("std-spacing");
this.setCenter(contentVBox);
BorderPane.setAlignment(contentVBox, Pos.TOP_LEFT);
this.setRight(getRemoveButton(vendor, vendorRefresh));
}
private Button getRemoveButton(TransactionVendor transactionVendor, Runnable vendorRefresh) {
Button removeButton = new Button("Remove");
removeButton.setOnAction(event -> {
boolean confirm = Popups.confirm(removeButton, "Are you sure you want to remove this vendor? Any transactions 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)
);
vendorRefresh.run();
}
});
return removeButton;
}
}

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ScrollPane?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.CategoriesViewController"
>
<top>
<Label text="Transaction Categories" styleClass="large-font,bold-text,std-padding"/>
</top>
<center>
<VBox>
<HBox styleClass="std-padding, std-spacing" VBox.vgrow="NEVER">
<Button text="Add Category"/>
</HBox>
<ScrollPane styleClass="tile-container-scroll" VBox.vgrow="ALWAYS">
<VBox fx:id="categoriesVBox" styleClass="tile-container"/>
</ScrollPane>
</VBox>
</center>
</BorderPane>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.andrewlalis.perfin.view.component.PropertiesPane?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.EditCategoryController"
>
<top>
<Label text="Edit Transaction Category" styleClass="bold-text,large-font,std-padding"/>
</top>
<center>
<VBox>
<PropertiesPane hgap="5" vgap="5" styleClass="std-padding" maxWidth="500">
<columnConstraints>
<ColumnConstraints hgrow="NEVER" halignment="LEFT" minWidth="150"/>
<ColumnConstraints hgrow="ALWAYS" halignment="RIGHT"/>
</columnConstraints>
<Label text="Name" labelFor="${nameField}"/>
<TextField fx:id="nameField"/>
<Label text="Color" labelFor="${colorPicker}"/>
<ColorPicker fx:id="colorPicker"/>
</PropertiesPane>
<HBox styleClass="std-padding, std-spacing">
<Button text="Save" fx:id="saveButton" onAction="#save"/>
<Button text="Cancel" onAction="#cancel"/>
</HBox>
</VBox>
</center>
</BorderPane>

View File

@ -68,13 +68,13 @@
<VBox>
<Label text="Category" labelFor="${categoryComboBox}" styleClass="bold-text"/>
<Hyperlink text="Manage categories" styleClass="small-font"/>
<Hyperlink fx:id="categoriesHyperlink" text="Manage categories" styleClass="small-font"/>
</VBox>
<ComboBox fx:id="categoryComboBox" editable="true" maxWidth="Infinity"/>
<VBox>
<Label text="Tags" labelFor="${tagsComboBox}" styleClass="bold-text"/>
<Hyperlink text="Manage tags" styleClass="small-font"/>
<Hyperlink fx:id="tagsHyperlink" text="Manage tags" styleClass="small-font"/>
</VBox>
<VBox maxWidth="Infinity">
<HBox styleClass="std-spacing">

View File

@ -3,10 +3,7 @@
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.text.Text?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.VendorsViewController"