feat: Implement recipe copy and delete functionality
Backend changes (C#): - Add CopyRecipe method to MachineBridge - Generates new GUID for copied recipe - Returns new recipe with current timestamp - Add DeleteRecipe method to MachineBridge - Prevents deletion of currently selected recipe - Returns success/failure status - Add COPY_RECIPE and DELETE_RECIPE handlers in WebSocketServer Frontend changes (React/TypeScript): - Add CopyRecipe and DeleteRecipe to Window interface types - Add copyRecipe and deleteRecipe methods to communication layer - Implement handleCopy in RecipePage - Prompts user for new name - Adds copied recipe to list - Selects newly copied recipe - Implement handleDelete in RecipePage - Shows confirmation dialog - Removes recipe from list - Selects next available recipe - Connect Copy and Delete buttons with handlers - Disable buttons when no recipe is selected 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -153,5 +153,58 @@ namespace HMIWeb
|
|||||||
|
|
||||||
return Newtonsoft.Json.JsonConvert.SerializeObject(recipes);
|
return Newtonsoft.Json.JsonConvert.SerializeObject(recipes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string CopyRecipe(string recipeId, string newName)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[C#] Copying Recipe: {recipeId} as {newName}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// In real app, copy recipe data from database/file
|
||||||
|
// Generate new ID
|
||||||
|
string newId = System.Guid.NewGuid().ToString().Substring(0, 8);
|
||||||
|
string timestamp = System.DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
var response = new {
|
||||||
|
success = true,
|
||||||
|
message = "Recipe copied successfully",
|
||||||
|
newRecipe = new {
|
||||||
|
id = newId,
|
||||||
|
name = newName,
|
||||||
|
lastModified = timestamp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var response = new { success = false, message = ex.Message };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DeleteRecipe(string recipeId)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[C#] Deleting Recipe: {recipeId}");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// In real app, delete recipe from database/file
|
||||||
|
// Check if recipe is in use
|
||||||
|
if (recipeId == _host.GetCurrentRecipe())
|
||||||
|
{
|
||||||
|
var response = new { success = false, message = "Cannot delete currently selected recipe" };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new { success = true, message = "Recipe deleted successfully", recipeId = recipeId };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var response = new { success = false, message = ex.Message };
|
||||||
|
return JsonConvert.SerializeObject(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,23 @@ namespace HMIWeb
|
|||||||
var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
var response = new { type = "RECIPE_SELECTED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
||||||
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
}
|
}
|
||||||
|
else if (type == "COPY_RECIPE")
|
||||||
|
{
|
||||||
|
string recipeId = json.recipeId;
|
||||||
|
string newName = json.newName;
|
||||||
|
var bridge = new MachineBridge(_mainForm);
|
||||||
|
string resultJson = bridge.CopyRecipe(recipeId, newName);
|
||||||
|
var response = new { type = "RECIPE_COPIED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
||||||
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
|
else if (type == "DELETE_RECIPE")
|
||||||
|
{
|
||||||
|
string recipeId = json.recipeId;
|
||||||
|
var bridge = new MachineBridge(_mainForm);
|
||||||
|
string resultJson = bridge.DeleteRecipe(recipeId);
|
||||||
|
var response = new { type = "RECIPE_DELETED", data = Newtonsoft.Json.JsonConvert.DeserializeObject(resultJson) };
|
||||||
|
await Send(socket, Newtonsoft.Json.JsonConvert.SerializeObject(response));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -221,6 +221,66 @@ class CommunicationLayer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async copyRecipe(recipeId: string, newName: string): Promise<{ success: boolean; message: string; newRecipe?: any }> {
|
||||||
|
if (isWebView) {
|
||||||
|
const resultJson = await window.chrome!.webview!.hostObjects.machine.CopyRecipe(recipeId, newName);
|
||||||
|
return JSON.parse(resultJson);
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
reject({ success: false, message: "Recipe copy timeout" });
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (data.type === 'RECIPE_COPIED') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
resolve(data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listeners.push(handler);
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'COPY_RECIPE', recipeId, newName }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteRecipe(recipeId: string): Promise<{ success: boolean; message: string; recipeId?: string }> {
|
||||||
|
if (isWebView) {
|
||||||
|
const resultJson = await window.chrome!.webview!.hostObjects.machine.DeleteRecipe(recipeId);
|
||||||
|
return JSON.parse(resultJson);
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
reject({ success: false, message: "Recipe delete timeout" });
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const handler = (data: any) => {
|
||||||
|
if (data.type === 'RECIPE_DELETED') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
|
resolve(data.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.listeners.push(handler);
|
||||||
|
this.ws?.send(JSON.stringify({ type: 'DELETE_RECIPE', recipeId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const comms = new CommunicationLayer();
|
export const comms = new CommunicationLayer();
|
||||||
|
|||||||
@@ -41,6 +41,66 @@ export const RecipePage: React.FC = () => {
|
|||||||
// In real app: await comms.saveRecipe(editedRecipe);
|
// In real app: await comms.saveRecipe(editedRecipe);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
|
||||||
|
const selectedRecipe = recipes.find(r => r.id === selectedId);
|
||||||
|
if (!selectedRecipe) return;
|
||||||
|
|
||||||
|
const newName = prompt(`Copy "${selectedRecipe.name}" as:`, `${selectedRecipe.name}_Copy`);
|
||||||
|
if (!newName || newName.trim() === '') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await comms.copyRecipe(selectedId, newName.trim());
|
||||||
|
if (result.success && result.newRecipe) {
|
||||||
|
const newRecipeList = [...recipes, result.newRecipe];
|
||||||
|
setRecipes(newRecipeList);
|
||||||
|
setSelectedId(result.newRecipe.id);
|
||||||
|
setEditedRecipe(result.newRecipe);
|
||||||
|
console.log("Recipe copied successfully:", result.newRecipe);
|
||||||
|
} else {
|
||||||
|
alert(`Failed to copy recipe: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Error copying recipe: ${error.message || 'Unknown error'}`);
|
||||||
|
console.error('Recipe copy error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
|
||||||
|
const selectedRecipe = recipes.find(r => r.id === selectedId);
|
||||||
|
if (!selectedRecipe) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`Are you sure you want to delete "${selectedRecipe.name}"?`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await comms.deleteRecipe(selectedId);
|
||||||
|
if (result.success) {
|
||||||
|
const newRecipeList = recipes.filter(r => r.id !== selectedId);
|
||||||
|
setRecipes(newRecipeList);
|
||||||
|
|
||||||
|
// Select first recipe or clear selection
|
||||||
|
if (newRecipeList.length > 0) {
|
||||||
|
setSelectedId(newRecipeList[0].id);
|
||||||
|
setEditedRecipe(newRecipeList[0]);
|
||||||
|
} else {
|
||||||
|
setSelectedId(null);
|
||||||
|
setEditedRecipe(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Recipe deleted successfully");
|
||||||
|
} else {
|
||||||
|
alert(`Failed to delete recipe: ${result.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Error deleting recipe: ${error.message || 'Unknown error'}`);
|
||||||
|
console.error('Recipe delete error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full p-6 flex flex-col gap-4">
|
<div className="w-full h-full p-6 flex flex-col gap-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -93,10 +153,22 @@ export const RecipePage: React.FC = () => {
|
|||||||
<TechButton variant="default" className="flex justify-center" title="Add New">
|
<TechButton variant="default" className="flex justify-center" title="Add New">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</TechButton>
|
</TechButton>
|
||||||
<TechButton variant="default" className="flex justify-center" title="Copy Selected">
|
<TechButton
|
||||||
|
variant="default"
|
||||||
|
className="flex justify-center"
|
||||||
|
title="Copy Selected"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!selectedId}
|
||||||
|
>
|
||||||
<Copy className="w-4 h-4" />
|
<Copy className="w-4 h-4" />
|
||||||
</TechButton>
|
</TechButton>
|
||||||
<TechButton variant="danger" className="flex justify-center" title="Delete Selected">
|
<TechButton
|
||||||
|
variant="danger"
|
||||||
|
className="flex justify-center"
|
||||||
|
title="Delete Selected"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!selectedId}
|
||||||
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</TechButton>
|
</TechButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ declare global {
|
|||||||
SetIO(id: number, isInput: boolean, state: boolean): Promise<void>;
|
SetIO(id: number, isInput: boolean, state: boolean): Promise<void>;
|
||||||
SystemControl(command: string): Promise<void>;
|
SystemControl(command: string): Promise<void>;
|
||||||
SelectRecipe(recipeId: string): Promise<string>;
|
SelectRecipe(recipeId: string): Promise<string>;
|
||||||
|
CopyRecipe(recipeId: string, newName: string): Promise<string>;
|
||||||
|
DeleteRecipe(recipeId: string): Promise<string>;
|
||||||
GetConfig(): Promise<string>;
|
GetConfig(): Promise<string>;
|
||||||
GetIOList(): Promise<string>;
|
GetIOList(): Promise<string>;
|
||||||
GetRecipeList(): Promise<string>;
|
GetRecipeList(): Promise<string>;
|
||||||
|
|||||||
Reference in New Issue
Block a user