Added primitive pagination controls to transaction view, with expandability for other paginated entities.

This commit is contained in:
Andrew Lalis 2023-12-29 12:06:43 -05:00
parent 633cd60572
commit 01d08154e0
12 changed files with 336 additions and 100 deletions

View File

@ -0,0 +1,7 @@
package com.andrewlalis.perfin;
public record Pair<A, B>(A first, B second) {
public static <A, B> Pair<A, B> of(A first, B second) {
return new Pair<>(first, second);
}
}

View File

@ -10,6 +10,17 @@ import java.net.URL;
import java.util.function.Consumer;
public class SceneUtil {
public static <N, C> Pair<N, C> loadNodeAndController(String fxml) {
FXMLLoader loader = new FXMLLoader(SceneUtil.class.getResource(fxml));
try {
N node = loader.load();
C controller = loader.getController();
return Pair.of(node, controller);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static <T> Parent loadNode(String fxml, Consumer<T> controllerConfig) {
FXMLLoader loader = new FXMLLoader(SceneUtil.class.getResource(fxml));
try {

View File

@ -1,6 +1,5 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.data.CurrencyUtil;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
@ -23,7 +22,7 @@ import java.util.List;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionViewController implements RouteSelectionListener {
public class TransactionViewController {
private Transaction transaction;
@FXML public Label amountLabel;
@ -37,9 +36,9 @@ public class TransactionViewController implements RouteSelectionListener {
@FXML public HBox attachmentsHBox;
private final ObservableList<TransactionAttachment> attachmentsList = FXCollections.observableArrayList();
@Override
public void onRouteSelected(Object context) {
this.transaction = (Transaction) context;
public void setTransaction(Transaction transaction) {
this.transaction = transaction;
if (transaction == null) return;
amountLabel.setText(CurrencyUtil.formatMoney(transaction.getAmount(), transaction.getCurrency()));
timestampLabel.setText(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
descriptionLabel.setText(transaction.getDescription());
@ -75,6 +74,7 @@ public class TransactionViewController implements RouteSelectionListener {
new Label(attachment.getFilename()),
new Label(attachment.getContentType())
);
// TODO: Custom attachment preview element.
return vbox;
});
}

View File

@ -1,23 +1,82 @@
package com.andrewlalis.perfin.control;
import com.andrewlalis.javafx_scene_router.RouteSelectionListener;
import com.andrewlalis.perfin.Pair;
import com.andrewlalis.perfin.SceneUtil;
import com.andrewlalis.perfin.control.component.DataSourcePaginationControls;
import com.andrewlalis.perfin.control.component.TransactionTile;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import static com.andrewlalis.perfin.PerfinApp.router;
public class TransactionsViewController implements RouteSelectionListener {
@FXML
public VBox transactionsVBox;
@FXML public BorderPane transactionsListBorderPane;
@FXML public VBox transactionsVBox;
private DataSourcePaginationControls paginationControls;
@FXML public VBox detailPanel;
private final ObjectProperty<Transaction> selectedTransaction = new SimpleObjectProperty<>(null);
@FXML public void initialize() {
// Initialize the left-hand paginated transactions list.
this.paginationControls = new DataSourcePaginationControls(
transactionsVBox.getChildren(),
new DataSourcePaginationControls.PageFetcherFunction() {
@Override
public Page<? extends Node> fetchPage(PageRequest pagination) throws Exception {
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
return repo.findAll(pagination).map(TransactionsViewController.this::makeTile);
}
}
@Override
public int getTotalCount() throws Exception {
try (var repo = Profile.getCurrent().getDataSource().getTransactionRepository()) {
return (int) repo.countAll();
}
}
}
);
transactionsListBorderPane.setBottom(paginationControls);
// Initialize the right-hand transaction detail view.
HBox container = (HBox) detailPanel.getParent();
ObservableValue<Double> halfWidthProp = container.widthProperty().map(n -> n.doubleValue() * 0.5);
detailPanel.minWidthProperty().bind(halfWidthProp);
detailPanel.maxWidthProperty().bind(halfWidthProp);
detailPanel.prefWidthProperty().bind(halfWidthProp);
detailPanel.managedProperty().bind(detailPanel.visibleProperty());
detailPanel.visibleProperty().bind(selectedTransaction.isNotNull());
Pair<BorderPane, TransactionViewController> 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);
});
}
@Override
public void onRouteSelected(Object context) {
refreshTransactions();
paginationControls.sorts.setAll(Sort.desc("timestamp"));
this.paginationControls.setPage(1);
}
@FXML
@ -25,13 +84,37 @@ public class TransactionsViewController implements RouteSelectionListener {
router.navigate("create-transaction");
}
private void refreshTransactions() {
Thread.ofVirtual().start(() -> {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp")));
var components = page.items().stream().map(transaction -> new TransactionTile(transaction, this::refreshTransactions)).toList();
Platform.runLater(() -> transactionsVBox.getChildren().setAll(components));
});
});
private TransactionTile makeTile(Transaction transaction) {
var tile = new TransactionTile(transaction);
tile.setOnMouseClicked(event -> {
if (selectedTransaction.get() == null || selectedTransaction.get().getId() != transaction.getId()) {
selectedTransaction.set(transaction);
} else {
selectedTransaction.set(null);
}
});
tile.selected.bind(selectedTransaction.map(t -> t != null && t.getId() == transaction.getId()));
return tile;
}
// private void refreshTransactions() {
// Thread.ofVirtual().start(() -> {
// Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
// var page = repo.findAll(PageRequest.unpaged(Sort.desc("timestamp")));
// var components = page.items().stream().map(transaction -> {
// var tile = new TransactionTile(transaction, this::refreshTransactions);
// tile.setOnMouseClicked(event -> {
// if (selectedTransaction.get() == null || selectedTransaction.get().getId() != transaction.getId()) {
// selectedTransaction.set(transaction);
// } else {
// selectedTransaction.set(null);
// }
// });
// tile.selected.bind(selectedTransaction.map(t -> t != null && t.getId() == transaction.getId()));
// return tile;
// }).toList();
// Platform.runLater(() -> transactionsVBox.getChildren().setAll(components));
// });
// });
// }
}

View File

@ -0,0 +1,96 @@
package com.andrewlalis.perfin.control.component;
import com.andrewlalis.perfin.data.pagination.Page;
import com.andrewlalis.perfin.data.pagination.PageRequest;
import com.andrewlalis.perfin.data.pagination.Sort;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
/**
* A pane that contains some controls for navigating a paginated data source.
* That includes going to the next/previous page, setting the preferred page
* size.
*/
public class DataSourcePaginationControls extends BorderPane {
public interface PageFetcherFunction {
Page<? extends Node> fetchPage(PageRequest pagination) throws Exception;
default int getTotalCount() throws Exception {
return -1;
}
}
public final IntegerProperty currentPage = new SimpleIntegerProperty(1);
public final IntegerProperty maxPages = new SimpleIntegerProperty(-1);
public final IntegerProperty itemsPerPage = new SimpleIntegerProperty(5);
public final ObservableList<Sort> sorts = FXCollections.observableArrayList();
private final BooleanProperty fetching = new SimpleBooleanProperty(false);
private final ObservableList<Node> target;
private final PageFetcherFunction fetcher;
public DataSourcePaginationControls(ObservableList<Node> target, PageFetcherFunction fetcher) {
this.target = target;
this.fetcher = fetcher;
Text currentPageLabel = new Text();
currentPageLabel.textProperty().bind(currentPage.asString());
Text maxPagesLabel = new Text();
maxPagesLabel.textProperty().bind(maxPages.asString());
TextFlow maxPagesText = new TextFlow(new Text(" / "), maxPagesLabel);
maxPagesText.managedProperty().bind(maxPagesText.visibleProperty());
maxPagesText.visibleProperty().bind(maxPages.isNotEqualTo(-1));
TextFlow pageText = new TextFlow(new Text("Page "), currentPageLabel, maxPagesText);
Button previousPageButton = new Button("Previous Page");
previousPageButton.disableProperty().bind(currentPage.lessThan(2).or(fetching));
previousPageButton.setOnAction(event -> setPage(currentPage.get() - 1));
Button nextPageButton = new Button("Next Page");
nextPageButton.disableProperty().bind(fetching.or(currentPage.greaterThanOrEqualTo(maxPages)));
nextPageButton.setOnAction(event -> setPage(currentPage.get() + 1));
sorts.addListener((ListChangeListener<Sort>) c -> {
setPage(1);
});
HBox hbox = new HBox(
previousPageButton,
pageText,
nextPageButton
);
setCenter(hbox);
}
public void setPage(int page) {
try {
fetching.set(true);
PageRequest pagination = new PageRequest(page - 1, itemsPerPage.get(), sorts);
var p = fetcher.fetchPage(pagination);
int totalResults = fetcher.getTotalCount();
target.setAll(p.items());
if (totalResults != -1) {
int max = totalResults / itemsPerPage.get();
if (totalResults % itemsPerPage.get() != 0) {
max += 1;
}
maxPages.set(max);
}
currentPage.set(page);
} catch (Exception e) {
target.clear();
e.printStackTrace(System.err);
} finally {
fetching.set(false);
}
}
}

View File

@ -2,20 +2,21 @@ package com.andrewlalis.perfin.control.component;
import com.andrewlalis.perfin.data.CurrencyUtil;
import com.andrewlalis.perfin.data.DateUtil;
import com.andrewlalis.perfin.model.*;
import com.andrewlalis.perfin.model.CreditAndDebitAccounts;
import com.andrewlalis.perfin.model.Profile;
import com.andrewlalis.perfin.model.Transaction;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.Node;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import javafx.util.Pair;
import java.util.concurrent.CompletableFuture;
@ -25,21 +26,32 @@ import static com.andrewlalis.perfin.PerfinApp.router;
* A tile that displays a transaction's basic information.
*/
public class TransactionTile extends BorderPane {
public TransactionTile(Transaction transaction, Runnable refresh) {
setStyle("""
public final BooleanProperty selected = new SimpleBooleanProperty(false);
private static final String UNSELECTED_STYLE = """
-fx-border-color: lightgray;
-fx-border-width: 1px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
-fx-max-width: 500px;
-fx-cursor: hand;
""");
""";
private static final String SELECTED_STYLE = """
-fx-border-color: white;
-fx-border-width: 1px;
-fx-border-style: solid;
-fx-border-radius: 5px;
-fx-padding: 5px;
-fx-cursor: hand;
""";
public TransactionTile(Transaction transaction) {
setStyle(UNSELECTED_STYLE);
setTop(getHeader(transaction));
setCenter(getBody(transaction));
setBottom(getFooter(transaction, refresh));
addEventHandler(MouseEvent.MOUSE_CLICKED, event -> router.navigate("transaction", transaction));
setBottom(getFooter(transaction));
styleProperty().bind(selected.map(value -> value ? SELECTED_STYLE : UNSELECTED_STYLE));
}
private Node getHeader(Transaction transaction) {
@ -64,31 +76,23 @@ public class TransactionTile extends BorderPane {
accounts.ifCredit(acc -> {
Hyperlink link = new Hyperlink(acc.getShortName());
link.setOnAction(event -> router.navigate("account", acc));
TextFlow text = new TextFlow(new Text("Credited from"), link);
Platform.runLater(() -> bodyVBox.getChildren().add(text));
Text prefix = new Text("Credited from");
prefix.setFill(Color.RED);
Platform.runLater(() -> bodyVBox.getChildren().add(new TextFlow(prefix, link)));
});
accounts.ifDebit(acc -> {
Hyperlink link = new Hyperlink(acc.getShortName());
link.setOnAction(event -> router.navigate("account", acc));
TextFlow text = new TextFlow(new Text("Debited to"), link);
Platform.runLater(() -> bodyVBox.getChildren().add(text));
Text prefix = new Text("Debited to");
prefix.setFill(Color.GREEN);
Platform.runLater(() -> bodyVBox.getChildren().add(new TextFlow(prefix, link)));
});
});
return bodyVBox;
}
private Node getFooter(Transaction transaction, Runnable refresh) {
private Node getFooter(Transaction transaction) {
Label timestampLabel = new Label(DateUtil.formatUTCAsLocalWithZone(transaction.getTimestamp()));
Hyperlink deleteLink = new Hyperlink("Delete this transaction");
deleteLink.setOnAction(event -> {
var confirmResult = new Alert(Alert.AlertType.CONFIRMATION, "Are you sure you want to delete this transaction?").showAndWait();
if (confirmResult.isPresent() && confirmResult.get() == ButtonType.OK) {
Profile.getCurrent().getDataSource().useTransactionRepository(repo -> {
repo.delete(transaction.getId());
});
refresh.run();
}
});
HBox footerHBox = new HBox(
timestampLabel
);

View File

@ -12,6 +12,7 @@ public interface TransactionRepository extends AutoCloseable {
long insert(Transaction transaction, Map<Long, AccountEntry.Type> accountsMap);
void addAttachments(long transactionId, List<TransactionAttachment> attachments);
Page<Transaction> findAll(PageRequest pagination);
long countAll();
Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination);
Map<AccountEntry, Account> findEntriesWithAccounts(long transactionId);
CreditAndDebitAccounts findLinkedAccounts(long transactionId);

View File

@ -91,6 +91,11 @@ public record JdbcTransactionRepository(Connection conn) implements TransactionR
);
}
@Override
public long countAll() {
return DbUtil.findOne(conn, "SELECT COUNT(id) FROM transaction", Collections.emptyList(), rs -> rs.getLong(1)).orElse(0L);
}
@Override
public Page<Transaction> findAllByAccounts(Set<Long> accountIds, PageRequest pagination) {
String idsStr = accountIds.stream().map(String::valueOf).collect(Collectors.joining(","));

View File

@ -1,5 +1,15 @@
package com.andrewlalis.perfin.data.pagination;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
public record Page<T>(List<T> items, PageRequest pagination) {}
public record Page<T>(List<T> items, PageRequest pagination) {
public Stream<T> stream() {
return items.stream();
}
public <U> Page<U> map(Function<T, U> mapper) {
return new Page<>(items.stream().map(mapper).toList(), pagination);
}
}

View File

@ -26,3 +26,24 @@
.std-spacing {
-fx-spacing: 3px;
}
.spacing-extra {
-fx-spacing: 6px;
}
/* DEBUG BORDERS */
.debug-border-1 {
-fx-border-color: red;
}
.debug-border-2 {
-fx-border-color: lime;
}
.debug-border-3 {
-fx-border-color: blue;
}
.debug-border-4 {
-fx-border-color: magenta;
}

View File

@ -8,12 +8,8 @@
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionViewController"
>
<top>
<HBox styleClass="std-padding,std-spacing">
<Label text="Transaction" styleClass="large-text,bold-text"/>
</HBox>
</top>
<center>
<ScrollPane fitToHeight="true" fitToWidth="true">
<VBox styleClass="std-padding,std-spacing">
<VBox>
<Label text="Amount" styleClass="bold-text"/>
@ -25,7 +21,7 @@
</VBox>
<VBox>
<Label text="Description" styleClass="bold-text"/>
<Label fx:id="descriptionLabel" wrapText="true"/>
<Label fx:id="descriptionLabel" wrapText="true" style="-fx-min-height: 100px;" alignment="TOP_LEFT"/>
</VBox>
<Separator/>
<VBox>
@ -45,12 +41,11 @@
<HBox fx:id="attachmentsHBox" styleClass="std-padding,std-spacing"/>
</ScrollPane>
</VBox>
</VBox>
</center>
<right>
<VBox styleClass="std-padding,std-spacing">
<Label text="Actions" styleClass="bold-text"/>
<Separator/>
<FlowPane styleClass="std-padding, std-spacing">
<Button text="Delete" onAction="#deleteTransaction"/>
</FlowPane>
</VBox>
</right>
</ScrollPane>
</center>
</BorderPane>

View File

@ -1,24 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.ScrollPane?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
fx:controller="com.andrewlalis.perfin.control.TransactionsViewController"
>
<top>
<HBox style="-fx-padding: 3px;">
<HBox styleClass="std-padding,std-spacing">
<Button text="Add Transaction" onAction="#addTransaction"/>
</HBox>
</top>
<center>
<Label text="Center"/>
<HBox>
<BorderPane fx:id="transactionsListBorderPane" HBox.hgrow="ALWAYS">
<center>
<ScrollPane fitToHeight="true" fitToWidth="true">
<VBox fx:id="transactionsVBox" style="-fx-padding: 3px; -fx-spacing: 5px;"/>
<VBox fx:id="transactionsVBox" styleClass="std-padding,spacing-extra"/>
</ScrollPane>
</center>
</BorderPane>
<VBox fx:id="detailPanel"/>
</HBox>
</center>
</BorderPane>