feat(client/ws): integrate changes from main and create client-side WS service
This commit is contained in:
parent
9f67800372
commit
042a43e06a
7 changed files with 188 additions and 43 deletions
|
|
@ -22,12 +22,16 @@ import client.scenes.recipe.RecipeStepListCtrl;
|
|||
import client.utils.ConfigService;
|
||||
import client.utils.LocaleManager;
|
||||
import client.utils.ServerUtils;
|
||||
import client.utils.WebSocketDataService;
|
||||
import client.utils.WebSocketUtils;
|
||||
import com.google.inject.Binder;
|
||||
import com.google.inject.Module;
|
||||
import com.google.inject.Scopes;
|
||||
|
||||
import client.scenes.MainCtrl;
|
||||
import com.google.inject.TypeLiteral;
|
||||
import commons.Ingredient;
|
||||
import commons.Recipe;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
|
|
@ -45,6 +49,11 @@ public class MyModule implements Module {
|
|||
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
|
||||
|
||||
binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json")));
|
||||
|
||||
binder.bind(new TypeLiteral<WebSocketDataService<Long, Recipe>>() {}).toInstance(
|
||||
new WebSocketDataService<>()
|
||||
);
|
||||
binder.bind(new TypeLiteral<WebSocketDataService<Long, Ingredient>>() {}).toInstance(
|
||||
new WebSocketDataService<>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package client.exception;
|
||||
|
||||
public class InvalidModificationException extends RuntimeException {
|
||||
public InvalidModificationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ import java.io.IOException;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import client.exception.InvalidModificationException;
|
||||
import client.scenes.recipe.RecipeDetailCtrl;
|
||||
|
||||
import client.utils.Config;
|
||||
|
|
@ -15,11 +17,16 @@ import client.utils.DefaultValueFactory;
|
|||
import client.utils.LocaleAware;
|
||||
import client.utils.LocaleManager;
|
||||
import client.utils.ServerUtils;
|
||||
import client.utils.WebSocketDataService;
|
||||
import client.utils.WebSocketUtils;
|
||||
import commons.Recipe;
|
||||
|
||||
import commons.ws.Topics;
|
||||
import commons.ws.messages.CreateRecipeMessage;
|
||||
import commons.ws.messages.DeleteRecipeMessage;
|
||||
import commons.ws.messages.FavouriteRecipeMessage;
|
||||
import commons.ws.messages.Message;
|
||||
import commons.ws.messages.UpdateRecipeMessage;
|
||||
import jakarta.inject.Inject;
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
|
|
@ -29,11 +36,14 @@ import javafx.scene.control.Label;
|
|||
import javafx.scene.control.ListCell;
|
||||
import javafx.scene.control.ListView;
|
||||
import javafx.scene.control.ToggleButton;
|
||||
import org.apache.commons.lang3.NotImplementedException;
|
||||
import org.apache.commons.lang3.tuple.ImmutablePair;
|
||||
|
||||
public class FoodpalApplicationCtrl implements LocaleAware {
|
||||
private final ServerUtils server;
|
||||
private final WebSocketUtils webSocketUtils;
|
||||
private final LocaleManager localeManager;
|
||||
private final WebSocketDataService<Long, Recipe> dataService;
|
||||
|
||||
@FXML
|
||||
private RecipeDetailCtrl recipeDetailController;
|
||||
|
|
@ -72,14 +82,54 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
|||
ServerUtils server,
|
||||
WebSocketUtils webSocketUtils,
|
||||
LocaleManager localeManager,
|
||||
ConfigService configService
|
||||
ConfigService configService,
|
||||
WebSocketDataService<Long, Recipe> recipeDataService
|
||||
) {
|
||||
this.server = server;
|
||||
this.webSocketUtils = webSocketUtils;
|
||||
this.localeManager = localeManager;
|
||||
|
||||
this.configService = configService;
|
||||
|
||||
this.dataService = recipeDataService;
|
||||
recipeDataService.setMessageParser((msg) -> switch (msg) {
|
||||
case CreateRecipeMessage _ -> (m) -> {
|
||||
CreateRecipeMessage crm = (CreateRecipeMessage) m;
|
||||
this.recipeList.getItems().add(crm.getRecipe());
|
||||
dataService.add(crm.getRecipe().getId());
|
||||
return new ImmutablePair<>(crm.getRecipe().getId(), crm.getRecipe());
|
||||
};
|
||||
case UpdateRecipeMessage _ -> (m) -> {
|
||||
UpdateRecipeMessage upm = (UpdateRecipeMessage) m;
|
||||
Recipe recipe = upm.getRecipe();
|
||||
System.out.println("recipe: " + recipe.getId() + " of content: " + recipe);
|
||||
// Discover the first (and should be ONLY) instance of the recipe with the specified ID.
|
||||
// if not found, throw new InvalidModificationException with a message.
|
||||
this.recipeList.getItems().set(
|
||||
this.recipeList.getItems().indexOf(
|
||||
findRecipeById(recipe.getId())
|
||||
.orElseThrow(
|
||||
() -> new InvalidModificationException("Invalid recipe id during update: " + recipe.getId()))
|
||||
),
|
||||
recipe
|
||||
);
|
||||
System.out.println(recipe.getId());
|
||||
dataService.add(recipe.getId());
|
||||
return new ImmutablePair<>(recipe.getId(), recipe);
|
||||
};
|
||||
case DeleteRecipeMessage _ -> (m) -> {
|
||||
DeleteRecipeMessage drm = (DeleteRecipeMessage) m;
|
||||
this.recipeList.getItems().remove(findRecipeById(drm.getRecipeId()).orElseThrow(
|
||||
() -> new InvalidModificationException("Invalid recipe id during delete: " + drm.getRecipeId())
|
||||
));
|
||||
dataService.add(drm.getRecipeId());
|
||||
// TODO Make it an Optional<Recipe> so that we don't need to touch Null?
|
||||
return new ImmutablePair<>(drm.getRecipeId(), null);
|
||||
};
|
||||
case FavouriteRecipeMessage _ -> (m) -> {
|
||||
throw new NotImplementedException("TODO:: implement favourited recipe deletion warning");
|
||||
};
|
||||
default -> throw new IllegalStateException("Unexpected value: " + msg);
|
||||
});
|
||||
initializeWebSocket();
|
||||
}
|
||||
|
||||
|
|
@ -129,13 +179,17 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Optional<Recipe> findRecipeById(Long id) {
|
||||
return this.recipeList.getItems().stream()
|
||||
.filter(r -> r.getId().equals(id))
|
||||
.findFirst();
|
||||
}
|
||||
private void initializeWebSocket() {
|
||||
webSocketUtils.connect(() -> {
|
||||
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> {
|
||||
webSocketUtils.subscribe(Topics.RECIPES, (Message msg) -> {
|
||||
Platform.runLater(() -> {
|
||||
dataService.onMessage(msg);
|
||||
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
|
||||
refresh(); // refresh the left list
|
||||
if (selectedRecipe == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -220,15 +274,31 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
|||
Recipe newRecipe = DefaultValueFactory.getDefaultRecipe(); // Create default recipe
|
||||
try {
|
||||
newRecipe = server.addRecipe(newRecipe); // get the new recipe id
|
||||
refresh(); // refresh view with server recipes
|
||||
|
||||
// Map of pending item updated, indexed by recipe id.
|
||||
dataService.add(newRecipe.getId(), recipe -> {
|
||||
this.recipeList.getSelectionModel().select(recipe);
|
||||
// Select newly created recipe
|
||||
recipeList.getSelectionModel().select(recipeList.getItems().indexOf(newRecipe));
|
||||
// FIXME(1) Edit box isn't auto created anymore for some weird reason
|
||||
openSelectedRecipe();
|
||||
this.recipeDetailController.editRecipeTitle();
|
||||
});
|
||||
recipeList.refresh();
|
||||
} catch (IOException | InterruptedException e) {
|
||||
printError("Error occurred when adding recipe!");
|
||||
}
|
||||
// Calls edit after the recipe has been created, seamless name editing
|
||||
// editRecipeTitle();
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void removeSelectedRecipe() throws IOException, InterruptedException {
|
||||
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.deleteRecipe(selected.getId());
|
||||
dataService.add(selected.getId(), (r) -> {
|
||||
// nothing to do here.
|
||||
});
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
|
@ -241,22 +311,9 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
|||
this.recipeDetailController.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the selected recipe from the server and refreshes the recipe list.
|
||||
*/
|
||||
@FXML
|
||||
public void removeSelectedRecipe() throws IOException, InterruptedException {
|
||||
Recipe selected = recipeList.getSelectionModel().getSelectedItem();
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.deleteRecipe(selected.getId());
|
||||
|
||||
// prefer a global refresh (or WS-based update upon its impl)
|
||||
// rather than recipeList.getItems().remove(selected);
|
||||
// to maintain single source of truth from the server.
|
||||
refresh();
|
||||
private void makePrintable() {
|
||||
System.out.println("Recipe printed");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -269,8 +326,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
|
|||
}
|
||||
|
||||
Recipe cloned = server.cloneRecipe(selected.getId());
|
||||
refresh();
|
||||
dataService.add(cloned.getId(), recipe -> {
|
||||
recipeList.getSelectionModel().select(cloned);
|
||||
openSelectedRecipe();}
|
||||
);
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import client.utils.ConfigService;
|
|||
import client.utils.LocaleAware;
|
||||
import client.utils.LocaleManager;
|
||||
import client.utils.ServerUtils;
|
||||
import client.utils.WebSocketDataService;
|
||||
import com.google.inject.Inject;
|
||||
import commons.Recipe;
|
||||
import java.io.IOException;
|
||||
|
|
@ -33,6 +34,7 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
private final ServerUtils server;
|
||||
private final FoodpalApplicationCtrl appCtrl;
|
||||
private final ConfigService configService;
|
||||
private final WebSocketDataService<Long, Recipe> webSocketDataService;
|
||||
|
||||
@FXML
|
||||
private IngredientListCtrl ingredientListController;
|
||||
|
|
@ -43,13 +45,17 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
private Recipe recipe;
|
||||
|
||||
@Inject
|
||||
public RecipeDetailCtrl(LocaleManager localeManager, ServerUtils server,
|
||||
public RecipeDetailCtrl(
|
||||
LocaleManager localeManager,
|
||||
ServerUtils server,
|
||||
FoodpalApplicationCtrl appCtrl,
|
||||
ConfigService configService) {
|
||||
ConfigService configService,
|
||||
WebSocketDataService<Long, Recipe> webSocketDataService) {
|
||||
this.localeManager = localeManager;
|
||||
this.server = server;
|
||||
this.appCtrl = appCtrl;
|
||||
this.configService = configService;
|
||||
this.webSocketDataService = webSocketDataService;
|
||||
}
|
||||
|
||||
@FXML
|
||||
|
|
@ -154,6 +160,10 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
|
||||
try { // propagate changes to server
|
||||
server.updateRecipe(selectedRecipe);
|
||||
webSocketDataService.add(selectedRecipe.getId(), recipe -> {
|
||||
int idx = getParentRecipeList().getItems().indexOf(selectedRecipe);
|
||||
getParentRecipeList().getItems().set(idx, recipe);
|
||||
});
|
||||
} catch (IOException | InterruptedException e) {
|
||||
throw new UpdateException("Unable to update recipe to server for " +
|
||||
selectedRecipe);
|
||||
|
|
@ -179,9 +189,10 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
* Revised edit recipe control flow, deprecates the use of a separate
|
||||
* AddNameCtrl. This is automagically called when a new recipe is created,
|
||||
* making for a more seamless UX.
|
||||
* Public for reference by ApplicationCtrl.
|
||||
*/
|
||||
@FXML
|
||||
private void editRecipeTitle() {
|
||||
public void editRecipeTitle() {
|
||||
this.editableTitleArea.getChildren().clear();
|
||||
TextField edit = new TextField();
|
||||
|
||||
|
|
@ -217,15 +228,6 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
this.appCtrl.removeSelectedRecipe();
|
||||
}
|
||||
|
||||
@FXML
|
||||
/**
|
||||
* Print the currently viewed recipe.
|
||||
*/
|
||||
private void printRecipe() {
|
||||
// TODO: actually make it print?
|
||||
System.out.println("Recipe printed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the favourite button state based on whether the currently viewed
|
||||
* recipe is marked as a favourite in the application configuration.
|
||||
|
|
@ -242,6 +244,15 @@ public class RecipeDetailCtrl implements LocaleAware {
|
|||
: "☆");
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the currently viewed recipe.
|
||||
*/
|
||||
@FXML
|
||||
private void printRecipe() {
|
||||
// TODO: actually make it print?
|
||||
System.out.println("Recipe printed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the favourite status of the currently viewed recipe in the
|
||||
* application configuration and writes the changes to disk.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package client.utils;
|
|||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.inject.Inject;
|
||||
import commons.Recipe;
|
||||
import commons.RecipeIngredient;
|
||||
import jakarta.ws.rs.ProcessingException;
|
||||
|
|
@ -27,6 +28,7 @@ public class ServerUtils {
|
|||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final int statusOK = 200;
|
||||
|
||||
@Inject
|
||||
public ServerUtils() {
|
||||
client = HttpClient.newHttpClient();
|
||||
}
|
||||
|
|
@ -108,7 +110,6 @@ public class ServerUtils {
|
|||
if(response.statusCode() != statusOK){
|
||||
throw new IOException("Failed to add Recipe: " + newRecipe.toDetailedString() + "body: " + response.body());
|
||||
}
|
||||
|
||||
return objectMapper.readValue(response.body(),Recipe.class);
|
||||
}
|
||||
|
||||
|
|
|
|||
58
client/src/main/java/client/utils/WebSocketDataService.java
Normal file
58
client/src/main/java/client/utils/WebSocketDataService.java
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package client.utils;
|
||||
|
||||
import client.exception.InvalidModificationException;
|
||||
import commons.ws.messages.Message;
|
||||
import org.apache.commons.lang3.function.FailableFunction;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class WebSocketDataService<ID, Value> {
|
||||
public WebSocketDataService() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* dataRegister maps a CompletableFuture<T> to its fulfilment function.
|
||||
*/
|
||||
private final Map<ID, CompletableFuture<Value>> pendingRegister = new HashMap<>();
|
||||
private Function<Message, FailableFunction<Message, Pair<ID, Value>, InvalidModificationException>> parser;
|
||||
public void setMessageParser(
|
||||
Function<
|
||||
Message,
|
||||
FailableFunction<Message, Pair<ID, Value>, InvalidModificationException>
|
||||
> parser) {
|
||||
this.parser = parser;
|
||||
}
|
||||
|
||||
/**
|
||||
* On each WS message, the parser callback distinguishes what
|
||||
* consumer (crud function) to call. Then the pair of ID-Value
|
||||
* returned by the processor is used
|
||||
* @param message
|
||||
*/
|
||||
public void onMessage(Message message) {
|
||||
Pair<ID, Value> result = parser.apply(message).apply(message);
|
||||
pendingRegister.get(result.getKey()).complete(result.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an ID-reference to the map for an object whose update is pending.
|
||||
* If the key already exists, the caller should assume there is an ongoing
|
||||
* operation on the object.
|
||||
* @param id ID of the object.
|
||||
* @return Whether the key already exists.
|
||||
*/
|
||||
public boolean add(ID id, Consumer<Value> onComplete) {
|
||||
CompletableFuture<Value> future = new CompletableFuture<>();
|
||||
future.thenAccept(onComplete.andThen(_ -> pendingRegister.remove(id)));
|
||||
return pendingRegister.putIfAbsent(id, future) == null;
|
||||
}
|
||||
public boolean add(ID id) {
|
||||
return add(id, (_) -> {});
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ public class PrintExportTest {
|
|||
assertEquals("""
|
||||
Title: Banana Bread
|
||||
Recipe ID: 1234
|
||||
Ingredients: some Banana, some Bread,\s
|
||||
Ingredients: Some Banana, Some Bread,\s
|
||||
Steps:
|
||||
1: Mix Ingredients
|
||||
2: Heat in Oven
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue