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:
2025-11-24 23:53:35 +09:00
parent 8c1a87ded0
commit 27cc2507cf
5 changed files with 206 additions and 2 deletions

View File

@@ -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);
}
}
} }
} }

View File

@@ -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)
{ {

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>;