From 1638eb8dfbe37b67a66cc06734864beae5398009 Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Thu, 11 Sep 2025 15:43:45 -0700 Subject: [PATCH 1/7] add RecipeTitleField --- .../recipes/shared/RecipeTitleField.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx diff --git a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx new file mode 100644 index 000000000000..52fc68cef083 --- /dev/null +++ b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx @@ -0,0 +1,45 @@ +interface RecipeTitleFieldProps { + id: string; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + errors: string[]; + label?: string; + required?: boolean; + disabled?: boolean; +} + +export function RecipeTitleField({ + id, + value, + onChange, + onBlur, + errors, + label = 'Recipe Title', + required = true, + disabled = false, +}: RecipeTitleFieldProps) { + return ( +
+ + onChange(e.target.value)} + onBlur={onBlur} + disabled={disabled} + className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.length > 0 ? 'border-red-500' : 'border-border-subtle' + } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`} + placeholder="My Recipe Title" + /> +

+ This will be the display name shown in your recipe library +

+ {errors.length > 0 &&

{errors[0]}

} +
+ ); +} From a443acd72c8376c300c199619f3efeaf9182590a Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 10:51:06 -0700 Subject: [PATCH 2/7] ImportRecipeForm use recipeTitle field instead of recipeName --- .../components/recipes/ImportRecipeForm.tsx | 66 +++++++++---------- ui/desktop/src/recipe/recipeStorage.ts | 20 ++++-- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index e8a36e46eb7e..a26b84185b95 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -9,8 +9,7 @@ import { saveRecipe } from '../../recipe/recipeStorage'; import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; -import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField'; -import { generateRecipeNameFromTitle } from './shared/recipeNameUtils'; +import { RecipeTitleField } from './shared/RecipeTitleField'; import { validateRecipe, getValidationErrorMessages, @@ -39,7 +38,7 @@ const importRecipeSchema = z if (!file) return true; return file.size <= 1024 * 1024; }, 'File is too large, max size is 1MB'), - recipeName: recipeNameSchema, + recipeTitle: z.string().min(1, 'Recipe title is required'), global: z.boolean(), }) .refine((data) => (data.deeplink && data.deeplink.trim()) || data.yamlFile, { @@ -102,7 +101,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR defaultValues: { deeplink: '', yamlFile: null as File | null, - recipeName: '', + recipeTitle: '', global: true, }, validators: { @@ -132,7 +131,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR } await saveRecipe(recipe, { - name: value.recipeName.trim(), + name: '', + title: value.recipeTitle.trim(), global: value.global, }); @@ -140,7 +140,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR importRecipeForm.reset({ deeplink: '', yamlFile: null, - recipeName: '', + recipeTitle: '', global: true, }); onClose(); @@ -148,7 +148,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR onSuccess(); toastSuccess({ - title: value.recipeName.trim(), + title: value.recipeTitle.trim(), msg: 'Recipe imported successfully', }); } catch (error) { @@ -170,16 +170,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR importRecipeForm.reset({ deeplink: '', yamlFile: null, - recipeName: '', + recipeTitle: '', global: true, }); onClose(); }; - // Store reference to recipe name field for programmatic updates - let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null; + // Store reference to recipe title field for programmatic updates + let recipeTitleFieldRef: { handleChange: (value: string) => void } | null = null; - // Auto-generate recipe name when deeplink changes + // Auto-populate recipe title when deeplink changes const handleDeeplinkChange = async ( value: string, field: { handleChange: (value: string) => void } @@ -191,13 +191,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR try { const recipe = await parseDeeplink(value.trim()); if (recipe && recipe.title) { - const suggestedName = generateRecipeNameFromTitle(recipe.title); - - // Use the recipe name field's handleChange method if available - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(suggestedName); + // Use the recipe title field's handleChange method if available + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(recipe.title); } else { - importRecipeForm.setFieldValue('recipeName', suggestedName); + importRecipeForm.setFieldValue('recipeTitle', recipe.title); } } } catch (error) { @@ -205,11 +203,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR console.log('Could not parse deeplink for auto-suggest:', error); } } else { - // Clear the recipe name when deeplink is empty - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(''); + // Clear the recipe title when deeplink is empty + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(''); } else { - importRecipeForm.setFieldValue('recipeName', ''); + importRecipeForm.setFieldValue('recipeTitle', ''); } } }; @@ -222,13 +220,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR const fileContent = await file.text(); const recipe = await parseYamlFile(fileContent); if (recipe.title) { - const suggestedName = generateRecipeNameFromTitle(recipe.title); - - // Use the recipe name field's handleChange method if available - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(suggestedName); + // Use the recipe title field's handleChange method if available + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(recipe.title); } else { - importRecipeForm.setFieldValue('recipeName', suggestedName); + importRecipeForm.setFieldValue('recipeTitle', recipe.title); } } } catch (error) { @@ -236,11 +232,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR console.log('Could not parse YAML file for auto-suggest:', error); } } else { - // Clear the recipe name when file is removed - if (recipeNameFieldRef) { - recipeNameFieldRef.handleChange(''); + // Clear the recipe title when file is removed + if (recipeTitleFieldRef) { + recipeTitleFieldRef.handleChange(''); } else { - importRecipeForm.setFieldValue('recipeName', ''); + importRecipeForm.setFieldValue('recipeTitle', ''); } } }; @@ -383,14 +379,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR Ensure you review contents of YAML files before adding them to your goose interface.

- + {(field) => { // Store reference to the field for programmatic updates - recipeNameFieldRef = field; + recipeTitleFieldRef = field; return ( - { * Save a recipe to a file using IPC. */ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise { - const { name, global = true } = options; + const { name, title, global = true } = options; - // Sanitize name - const sanitizedName = sanitizeRecipeName(name); - if (!sanitizedName) { - throw new Error('Invalid recipe name'); + let sanitizedName: string; + + if (title) { + recipe.title = title.trim(); + sanitizedName = generateRecipeFilename(recipe); + if (!sanitizedName) { + throw new Error('Invalid recipe title - cannot generate filename'); + } + } else { + sanitizedName = sanitizeRecipeName(name); + if (!sanitizedName) { + throw new Error('Invalid recipe name'); + } } const validationResult = validateRecipe(recipe); From 1b6d6b8fd8ee0abcb16c5f903aa31e4f92cb989c Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 11:36:58 -0700 Subject: [PATCH 3/7] add title uniqueness validation --- .../components/recipes/ImportRecipeForm.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index a26b84185b95..fc250a4bbeaa 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -10,6 +10,7 @@ import * as yaml from 'yaml'; import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { RecipeTitleField } from './shared/RecipeTitleField'; +import { listSavedRecipes } from '../../recipe/recipeStorage'; import { validateRecipe, getValidationErrorMessages, @@ -97,6 +98,29 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR return recipe as Recipe; }; + const validateTitleUniqueness = async ( + title: string, + isGlobal: boolean + ): Promise => { + if (!title.trim()) return undefined; + + try { + const existingRecipes = await listSavedRecipes(); + const titleExists = existingRecipes.some( + (recipe) => + recipe.recipe.title?.toLowerCase() === title.toLowerCase() && recipe.isGlobal === isGlobal + ); + + if (titleExists) { + return `A recipe with the title "${title}" already exists in ${isGlobal ? 'global' : 'directory'} recipes`; + } + } catch (error) { + console.warn('Failed to validate title uniqueness:', error); + } + + return undefined; + }; + const importRecipeForm = useForm({ defaultValues: { deeplink: '', @@ -124,6 +148,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe = await parseYamlFile(fileContent); } + const titleValidationError = await validateTitleUniqueness( + value.recipeTitle.trim(), + value.global + ); + if (titleValidationError) { + throw new Error(titleValidationError); + } + const validationResult = validateRecipe(recipe); if (!validationResult.success) { const errorMessages = getValidationErrorMessages(validationResult.errors); From 9c29327e61558afa8cedd8aab286551b54b67e89 Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 11:50:45 -0700 Subject: [PATCH 4/7] update form schema validation for title requirements --- ui/desktop/src/components/recipes/ImportRecipeForm.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index fc250a4bbeaa..c81dd1546b2e 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -39,7 +39,15 @@ const importRecipeSchema = z if (!file) return true; return file.size <= 1024 * 1024; }, 'File is too large, max size is 1MB'), - recipeTitle: z.string().min(1, 'Recipe title is required'), + recipeTitle: z + .string() + .min(1, 'Recipe title is required') + .max(100, 'Recipe title must be 100 characters or less') + .refine((title) => title.trim().length > 0, 'Recipe title cannot be empty') + .refine( + (title) => /^[^<>:"/\\|?*]+$/.test(title.trim()), + 'Recipe title contains invalid characters (< > : " / \\ | ? *)' + ), global: z.boolean(), }) .refine((data) => (data.deeplink && data.deeplink.trim()) || data.yamlFile, { From 5aff0b0c2d10b386bd5300c93e43e0bfb7db3b24 Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 14:37:16 -0700 Subject: [PATCH 5/7] set title onsubmit --- ui/desktop/src/components/recipes/ImportRecipeForm.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index c81dd1546b2e..96d30ff4f48e 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -156,6 +156,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR recipe = await parseYamlFile(fileContent); } + recipe.title = value.recipeTitle.trim(); + const titleValidationError = await validateTitleUniqueness( value.recipeTitle.trim(), value.global From ab1c25fbd3e614e20b511d925b964637886cee61 Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 14:42:18 -0700 Subject: [PATCH 6/7] cosmetics --- ui/desktop/src/components/recipes/ImportRecipeForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx index 96d30ff4f48e..ce2a563d377b 100644 --- a/ui/desktop/src/components/recipes/ImportRecipeForm.tsx +++ b/ui/desktop/src/components/recipes/ImportRecipeForm.tsx @@ -120,7 +120,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR ); if (titleExists) { - return `A recipe with the title "${title}" already exists in ${isGlobal ? 'global' : 'directory'} recipes`; + return `A recipe with the same title already exists`; } } catch (error) { console.warn('Failed to validate title uniqueness:', error); @@ -515,7 +515,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR

Expected Recipe Structure:

-
+              
                 {JSON.stringify(getRecipeJsonSchema(), null, 2)}
               

From 9f253e5c33e38416de7546784571d4088d1b19de Mon Sep 17 00:00:00 2001 From: Amed Rodriguez Date: Fri, 12 Sep 2025 16:00:48 -0700 Subject: [PATCH 7/7] add deprecation notice --- ui/desktop/src/recipe/recipeStorage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 4778071888b7..c012c5dc9bd1 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -82,6 +82,8 @@ export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Pr throw new Error('Invalid recipe title - cannot generate filename'); } } else { + // This branch should now be considered deprecated and will be removed once the same functionality + // is incorporated in CreateRecipeForm sanitizedName = sanitizeRecipeName(name); if (!sanitizedName) { throw new Error('Invalid recipe name');