Merge branch 'client/incremental-websocket-modelling' into 'main'

fix(client/ws): Detailed WebSocket modelling

Closes #31

See merge request cse1105/2025-2026/teams/csep-team-76!39
This commit is contained in:
Zhongheng Liu 2026-01-07 11:35:31 +01:00
commit b01d313c9a
8 changed files with 203 additions and 47 deletions

View file

@ -22,12 +22,16 @@ import client.scenes.recipe.RecipeStepListCtrl;
import client.utils.ConfigService; import client.utils.ConfigService;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils; import client.utils.ServerUtils;
import client.utils.WebSocketDataService;
import client.utils.WebSocketUtils; import client.utils.WebSocketUtils;
import com.google.inject.Binder; import com.google.inject.Binder;
import com.google.inject.Module; import com.google.inject.Module;
import com.google.inject.Scopes; import com.google.inject.Scopes;
import client.scenes.MainCtrl; import client.scenes.MainCtrl;
import com.google.inject.TypeLiteral;
import commons.Ingredient;
import commons.Recipe;
import java.nio.file.Path; import java.nio.file.Path;
@ -45,6 +49,11 @@ public class MyModule implements Module {
binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON); binder.bind(WebSocketUtils.class).in(Scopes.SINGLETON);
binder.bind(ConfigService.class).toInstance(new ConfigService(Path.of("config.json"))); 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<>()
);
} }
} }

View file

@ -0,0 +1,7 @@
package client.exception;
public class InvalidModificationException extends RuntimeException {
public InvalidModificationException(String message) {
super(message);
}
}

View file

@ -5,8 +5,10 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import client.exception.InvalidModificationException;
import client.scenes.recipe.RecipeDetailCtrl; import client.scenes.recipe.RecipeDetailCtrl;
import client.utils.Config; import client.utils.Config;
@ -15,11 +17,16 @@ import client.utils.DefaultValueFactory;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils; import client.utils.ServerUtils;
import client.utils.WebSocketDataService;
import client.utils.WebSocketUtils; import client.utils.WebSocketUtils;
import commons.Recipe; import commons.Recipe;
import commons.ws.Topics; 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.Message;
import commons.ws.messages.UpdateRecipeMessage;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import javafx.application.Platform; import javafx.application.Platform;
import javafx.fxml.FXML; import javafx.fxml.FXML;
@ -29,11 +36,15 @@ import javafx.scene.control.Label;
import javafx.scene.control.ListCell; import javafx.scene.control.ListCell;
import javafx.scene.control.ListView; import javafx.scene.control.ListView;
import javafx.scene.control.ToggleButton; import javafx.scene.control.ToggleButton;
import org.apache.commons.lang3.NotImplementedException;
import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair;
public class FoodpalApplicationCtrl implements LocaleAware { public class FoodpalApplicationCtrl implements LocaleAware {
private final ServerUtils server; private final ServerUtils server;
private final WebSocketUtils webSocketUtils; private final WebSocketUtils webSocketUtils;
private final LocaleManager localeManager; private final LocaleManager localeManager;
private final WebSocketDataService<Long, Recipe> dataService;
@FXML @FXML
private RecipeDetailCtrl recipeDetailController; private RecipeDetailCtrl recipeDetailController;
@ -72,16 +83,72 @@ public class FoodpalApplicationCtrl implements LocaleAware {
ServerUtils server, ServerUtils server,
WebSocketUtils webSocketUtils, WebSocketUtils webSocketUtils,
LocaleManager localeManager, LocaleManager localeManager,
ConfigService configService ConfigService configService,
WebSocketDataService<Long, Recipe> recipeDataService
) { ) {
this.server = server; this.server = server;
this.webSocketUtils = webSocketUtils; this.webSocketUtils = webSocketUtils;
this.localeManager = localeManager; this.localeManager = localeManager;
this.configService = configService; this.configService = configService;
this.dataService = recipeDataService;
setupDataService();
initializeWebSocket(); initializeWebSocket();
} }
private void setupDataService() {
dataService.setMessageParser((msg) -> switch (msg) {
case CreateRecipeMessage _ -> (m) -> {
CreateRecipeMessage crm = (CreateRecipeMessage) m;
return handleCreateRecipeMessage(crm);
};
case UpdateRecipeMessage _ -> (m) -> {
UpdateRecipeMessage urm = (UpdateRecipeMessage) m;
return handleUpdateRecipeMessage(urm);
};
case DeleteRecipeMessage _ -> (m) -> {
DeleteRecipeMessage drm = (DeleteRecipeMessage) m;
return handleDeleteRecipeMessage(drm);
};
case FavouriteRecipeMessage _ -> (m) -> {
FavouriteRecipeMessage frm = (FavouriteRecipeMessage) m;
return handleFavouriteRecipeMessage(frm);
};
default -> throw new IllegalStateException("Unexpected value: " + msg);
});
}
private Pair<Long, Recipe> handleCreateRecipeMessage(CreateRecipeMessage crm) {
this.recipeList.getItems().add(crm.getRecipe());
dataService.add(crm.getRecipe().getId());
return new ImmutablePair<>(crm.getRecipe().getId(), crm.getRecipe());
}
private Pair<Long, Recipe> handleUpdateRecipeMessage(UpdateRecipeMessage urm) {
Recipe recipe = urm.getRecipe();
// 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
);
dataService.add(recipe.getId());
return new ImmutablePair<>(recipe.getId(), recipe);
}
private Pair<Long, Recipe> handleDeleteRecipeMessage(DeleteRecipeMessage drm) {
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);
}
// TODO Implementation
private Pair<Long, Recipe> handleFavouriteRecipeMessage(FavouriteRecipeMessage frm) {
throw new NotImplementedException("TODO:: implement favourited recipe deletion warning");
}
private void initRecipeList() { private void initRecipeList() {
// Show recipe name in the list // Show recipe name in the list
@ -129,13 +196,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() { private void initializeWebSocket() {
webSocketUtils.connect(() -> { webSocketUtils.connect(() -> {
webSocketUtils.subscribe(Topics.RECIPES, (Message _) -> { webSocketUtils.subscribe(Topics.RECIPES, (Message msg) -> {
Platform.runLater(() -> { Platform.runLater(() -> {
dataService.onMessage(msg);
Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem(); Recipe selectedRecipe = recipeList.getSelectionModel().getSelectedItem();
refresh(); // refresh the left list
if (selectedRecipe == null) { if (selectedRecipe == null) {
return; return;
} }
@ -220,15 +291,29 @@ public class FoodpalApplicationCtrl implements LocaleAware {
Recipe newRecipe = DefaultValueFactory.getDefaultRecipe(); // Create default recipe Recipe newRecipe = DefaultValueFactory.getDefaultRecipe(); // Create default recipe
try { try {
newRecipe = server.addRecipe(newRecipe); // get the new recipe id 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 -> {
// Select newly created recipe this.recipeList.getSelectionModel().select(recipe);
recipeList.getSelectionModel().select(recipeList.getItems().indexOf(newRecipe)); openSelectedRecipe();
Platform.runLater(() -> this.recipeDetailController.editRecipeTitle());
});
recipeList.refresh();
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
printError("Error occurred when adding recipe!"); 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 @FXML
@ -241,24 +326,6 @@ public class FoodpalApplicationCtrl implements LocaleAware {
this.recipeDetailController.setVisible(true); 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();
}
/** /**
* Clones a recipe, when clicking on the button "clone". * Clones a recipe, when clicking on the button "clone".
*/ */
@ -269,8 +336,10 @@ public class FoodpalApplicationCtrl implements LocaleAware {
} }
Recipe cloned = server.cloneRecipe(selected.getId()); Recipe cloned = server.cloneRecipe(selected.getId());
refresh(); dataService.add(cloned.getId(), recipe -> {
recipeList.getSelectionModel().select(cloned); recipeList.getSelectionModel().select(recipe);
openSelectedRecipe();}
);
} }
@FXML @FXML

View file

@ -146,7 +146,6 @@ public class IngredientListCtrl implements LocaleAware {
@Override @Override
public void updateText() { public void updateText() {
System.out.println("updatetext called on ingredientListCtrl");
ingredientsLabel.setText(getLocaleString("menu.label.ingredients")); ingredientsLabel.setText(getLocaleString("menu.label.ingredients"));
addIngredientButton.setText(getLocaleString("menu.button.add.ingredient")); addIngredientButton.setText(getLocaleString("menu.button.add.ingredient"));
deleteIngredientButton.setText(getLocaleString("menu.button.remove.ingredient")); deleteIngredientButton.setText(getLocaleString("menu.button.remove.ingredient"));

View file

@ -7,6 +7,7 @@ import client.utils.ConfigService;
import client.utils.LocaleAware; import client.utils.LocaleAware;
import client.utils.LocaleManager; import client.utils.LocaleManager;
import client.utils.ServerUtils; import client.utils.ServerUtils;
import client.utils.WebSocketDataService;
import com.google.inject.Inject; import com.google.inject.Inject;
import commons.Recipe; import commons.Recipe;
import java.io.IOException; import java.io.IOException;
@ -22,6 +23,7 @@ import javafx.scene.input.KeyCode;
import javafx.scene.layout.HBox; import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox; import javafx.scene.layout.VBox;
import javafx.scene.text.Font; import javafx.scene.text.Font;
import org.apache.commons.lang3.NotImplementedException;
/** /**
* Controller for the recipe detail view. * Controller for the recipe detail view.
@ -33,6 +35,7 @@ public class RecipeDetailCtrl implements LocaleAware {
private final ServerUtils server; private final ServerUtils server;
private final FoodpalApplicationCtrl appCtrl; private final FoodpalApplicationCtrl appCtrl;
private final ConfigService configService; private final ConfigService configService;
private final WebSocketDataService<Long, Recipe> webSocketDataService;
@FXML @FXML
private IngredientListCtrl ingredientListController; private IngredientListCtrl ingredientListController;
@ -43,13 +46,17 @@ public class RecipeDetailCtrl implements LocaleAware {
private Recipe recipe; private Recipe recipe;
@Inject @Inject
public RecipeDetailCtrl(LocaleManager localeManager, ServerUtils server, public RecipeDetailCtrl(
LocaleManager localeManager,
ServerUtils server,
FoodpalApplicationCtrl appCtrl, FoodpalApplicationCtrl appCtrl,
ConfigService configService) { ConfigService configService,
WebSocketDataService<Long, Recipe> webSocketDataService) {
this.localeManager = localeManager; this.localeManager = localeManager;
this.server = server; this.server = server;
this.appCtrl = appCtrl; this.appCtrl = appCtrl;
this.configService = configService; this.configService = configService;
this.webSocketDataService = webSocketDataService;
} }
@FXML @FXML
@ -154,6 +161,10 @@ public class RecipeDetailCtrl implements LocaleAware {
try { // propagate changes to server try { // propagate changes to server
server.updateRecipe(selectedRecipe); server.updateRecipe(selectedRecipe);
webSocketDataService.add(selectedRecipe.getId(), recipe -> {
int idx = getParentRecipeList().getItems().indexOf(selectedRecipe);
getParentRecipeList().getItems().set(idx, recipe);
});
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
throw new UpdateException("Unable to update recipe to server for " + throw new UpdateException("Unable to update recipe to server for " +
selectedRecipe); selectedRecipe);
@ -179,9 +190,10 @@ public class RecipeDetailCtrl implements LocaleAware {
* Revised edit recipe control flow, deprecates the use of a separate * Revised edit recipe control flow, deprecates the use of a separate
* AddNameCtrl. This is automagically called when a new recipe is created, * AddNameCtrl. This is automagically called when a new recipe is created,
* making for a more seamless UX. * making for a more seamless UX.
* Public for reference by ApplicationCtrl.
*/ */
@FXML @FXML
private void editRecipeTitle() { public void editRecipeTitle() {
this.editableTitleArea.getChildren().clear(); this.editableTitleArea.getChildren().clear();
TextField edit = new TextField(); TextField edit = new TextField();
@ -217,15 +229,6 @@ public class RecipeDetailCtrl implements LocaleAware {
this.appCtrl.removeSelectedRecipe(); 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 * Refreshes the favourite button state based on whether the currently viewed
* recipe is marked as a favourite in the application configuration. * recipe is marked as a favourite in the application configuration.
@ -242,6 +245,16 @@ public class RecipeDetailCtrl implements LocaleAware {
: ""); : "");
} }
/**
* Print the currently viewed recipe.
*/
@FXML
private void printRecipe() {
// TODO: actually make it print?
throw new NotImplementedException("TODO:: Integrate with Print/Export service");
// System.out.println("Recipe printed");
}
/** /**
* Toggles the favourite status of the currently viewed recipe in the * Toggles the favourite status of the currently viewed recipe in the
* application configuration and writes the changes to disk. * application configuration and writes the changes to disk.

View file

@ -2,6 +2,7 @@ package client.utils;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject;
import commons.Recipe; import commons.Recipe;
import commons.RecipeIngredient; import commons.RecipeIngredient;
import jakarta.ws.rs.ProcessingException; import jakarta.ws.rs.ProcessingException;
@ -27,6 +28,7 @@ public class ServerUtils {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final int statusOK = 200; private final int statusOK = 200;
@Inject
public ServerUtils() { public ServerUtils() {
client = HttpClient.newHttpClient(); client = HttpClient.newHttpClient();
} }
@ -108,7 +110,6 @@ public class ServerUtils {
if(response.statusCode() != statusOK){ if(response.statusCode() != statusOK){
throw new IOException("Failed to add Recipe: " + newRecipe.toDetailedString() + "body: " + response.body()); throw new IOException("Failed to add Recipe: " + newRecipe.toDetailedString() + "body: " + response.body());
} }
return objectMapper.readValue(response.body(),Recipe.class); return objectMapper.readValue(response.body(),Recipe.class);
} }

View 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&lt;T&gt; 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, (_) -> {});
}
}

View file

@ -39,7 +39,7 @@ public class PrintExportTest {
assertEquals(""" assertEquals("""
Title: Banana Bread Title: Banana Bread
Recipe ID: 1234 Recipe ID: 1234
Ingredients: some Banana, some Bread,\s Ingredients: Some Banana, Some Bread,\s
Steps: Steps:
1: Mix Ingredients 1: Mix Ingredients
2: Heat in Oven 2: Heat in Oven