Add recipe title in import form (#4625)

This commit is contained in:
Amed Rodriguez
2025-09-12 16:33:04 -07:00
committed by GitHub
parent 62cd2661a1
commit cb3def45e1
3 changed files with 136 additions and 41 deletions
@@ -9,8 +9,8 @@ import { saveRecipe } from '../../recipe/recipeStorage';
import * as yaml from 'yaml'; import * as yaml from 'yaml';
import { toastSuccess, toastError } from '../../toasts'; import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useEscapeKey } from '../../hooks/useEscapeKey';
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField'; import { RecipeTitleField } from './shared/RecipeTitleField';
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils'; import { listSavedRecipes } from '../../recipe/recipeStorage';
import { import {
validateRecipe, validateRecipe,
getValidationErrorMessages, getValidationErrorMessages,
@@ -39,7 +39,15 @@ const importRecipeSchema = z
if (!file) return true; if (!file) return true;
return file.size <= 1024 * 1024; return file.size <= 1024 * 1024;
}, 'File is too large, max size is 1MB'), }, 'File is too large, max size is 1MB'),
recipeName: recipeNameSchema, 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(), global: z.boolean(),
}) })
.refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, { .refine((data) => (data.deeplink && data.deeplink.trim()) || data.recipeUploadFile, {
@@ -111,11 +119,34 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
return recipe as Recipe; return recipe as Recipe;
}; };
const validateTitleUniqueness = async (
title: string,
isGlobal: boolean
): Promise<string | undefined> => {
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 same title already exists`;
}
} catch (error) {
console.warn('Failed to validate title uniqueness:', error);
}
return undefined;
};
const importRecipeForm = useForm({ const importRecipeForm = useForm({
defaultValues: { defaultValues: {
deeplink: '', deeplink: '',
recipeUploadFile: null as File | null, recipeUploadFile: null as File | null,
recipeName: '', recipeTitle: '',
global: true, global: true,
}, },
validators: { validators: {
@@ -138,6 +169,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name); recipe = await parseRecipeUploadFile(fileContent, value.recipeUploadFile!.name);
} }
recipe.title = value.recipeTitle.trim();
const titleValidationError = await validateTitleUniqueness(
value.recipeTitle.trim(),
value.global
);
if (titleValidationError) {
throw new Error(titleValidationError);
}
const validationResult = validateRecipe(recipe); const validationResult = validateRecipe(recipe);
if (!validationResult.success) { if (!validationResult.success) {
const errorMessages = getValidationErrorMessages(validationResult.errors); const errorMessages = getValidationErrorMessages(validationResult.errors);
@@ -145,7 +186,8 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
} }
await saveRecipe(recipe, { await saveRecipe(recipe, {
name: value.recipeName.trim(), name: '',
title: value.recipeTitle.trim(),
global: value.global, global: value.global,
}); });
@@ -153,7 +195,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
importRecipeForm.reset({ importRecipeForm.reset({
deeplink: '', deeplink: '',
recipeUploadFile: null, recipeUploadFile: null,
recipeName: '', recipeTitle: '',
global: true, global: true,
}); });
onClose(); onClose();
@@ -161,7 +203,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
onSuccess(); onSuccess();
toastSuccess({ toastSuccess({
title: value.recipeName.trim(), title: value.recipeTitle.trim(),
msg: 'Recipe imported successfully', msg: 'Recipe imported successfully',
}); });
} catch (error) { } catch (error) {
@@ -183,16 +225,16 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
importRecipeForm.reset({ importRecipeForm.reset({
deeplink: '', deeplink: '',
recipeUploadFile: null, recipeUploadFile: null,
recipeName: '', recipeTitle: '',
global: true, global: true,
}); });
onClose(); onClose();
}; };
// Store reference to recipe name field for programmatic updates // Store reference to recipe title field for programmatic updates
let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null; 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 ( const handleDeeplinkChange = async (
value: string, value: string,
field: { handleChange: (value: string) => void } field: { handleChange: (value: string) => void }
@@ -204,13 +246,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
try { try {
const recipe = await parseDeeplink(value.trim()); const recipe = await parseDeeplink(value.trim());
if (recipe && recipe.title) { if (recipe && recipe.title) {
const suggestedName = generateRecipeNameFromTitle(recipe.title); // Use the recipe title field's handleChange method if available
if (recipeTitleFieldRef) {
// Use the recipe name field's handleChange method if available recipeTitleFieldRef.handleChange(recipe.title);
if (recipeNameFieldRef) {
recipeNameFieldRef.handleChange(suggestedName);
} else { } else {
importRecipeForm.setFieldValue('recipeName', suggestedName); importRecipeForm.setFieldValue('recipeTitle', recipe.title);
} }
} }
} catch (error) { } catch (error) {
@@ -218,11 +258,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
console.log('Could not parse deeplink for auto-suggest:', error); console.log('Could not parse deeplink for auto-suggest:', error);
} }
} else { } else {
// Clear the recipe name when deeplink is empty // Clear the recipe title when deeplink is empty
if (recipeNameFieldRef) { if (recipeTitleFieldRef) {
recipeNameFieldRef.handleChange(''); recipeTitleFieldRef.handleChange('');
} else { } else {
importRecipeForm.setFieldValue('recipeName', ''); importRecipeForm.setFieldValue('recipeTitle', '');
} }
} }
}; };
@@ -235,13 +275,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
const fileContent = await file.text(); const fileContent = await file.text();
const recipe = await parseRecipeUploadFile(fileContent, file.name); const recipe = await parseRecipeUploadFile(fileContent, file.name);
if (recipe.title) { if (recipe.title) {
const suggestedName = generateRecipeNameFromTitle(recipe.title); // Use the recipe title field's handleChange method if available
if (recipeTitleFieldRef) {
// Use the recipe name field's handleChange method if available recipeTitleFieldRef.handleChange(recipe.title);
if (recipeNameFieldRef) {
recipeNameFieldRef.handleChange(suggestedName);
} else { } else {
importRecipeForm.setFieldValue('recipeName', suggestedName); importRecipeForm.setFieldValue('recipeTitle', recipe.title);
} }
} }
} catch (error) { } catch (error) {
@@ -249,11 +287,11 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
console.log('Could not parse recipe file for auto-suggest:', error); console.log('Could not parse recipe file for auto-suggest:', error);
} }
} else { } else {
// Clear the recipe name when file is removed // Clear the recipe title when file is removed
if (recipeNameFieldRef) { if (recipeTitleFieldRef) {
recipeNameFieldRef.handleChange(''); recipeTitleFieldRef.handleChange('');
} else { } else {
importRecipeForm.setFieldValue('recipeName', ''); importRecipeForm.setFieldValue('recipeTitle', '');
} }
} }
}; };
@@ -397,14 +435,14 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
interface. interface.
</p> </p>
<importRecipeForm.Field name="recipeName"> <importRecipeForm.Field name="recipeTitle">
{(field) => { {(field) => {
// Store reference to the field for programmatic updates // Store reference to the field for programmatic updates
recipeNameFieldRef = field; recipeTitleFieldRef = field;
return ( return (
<RecipeNameField <RecipeTitleField
id="import-recipe-name" id="import-recipe-title"
value={field.state.value} value={field.state.value}
onChange={field.handleChange} onChange={field.handleChange}
onBlur={field.handleBlur} onBlur={field.handleBlur}
@@ -491,7 +529,7 @@ export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportR
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p> <p className="font-medium mb-3 text-text-standard">Expected Recipe Structure:</p>
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto whitespace-pre font-mono"> <pre className="text-xs bg-gray-800 p-4 rounded overflow-auto whitespace-pre font-mono">
{JSON.stringify(getRecipeJsonSchema(), null, 2)} {JSON.stringify(getRecipeJsonSchema(), null, 2)}
</pre> </pre>
<p className="mt-4 text-blue-700 text-sm"> <p className="mt-4 text-blue-700 text-sm">
@@ -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 (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-standard mb-2">
{label} {required && <span className="text-red-500">*</span>}
</label>
<input
id={id}
type="text"
value={value}
onChange={(e) => 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"
/>
<p className="text-xs text-text-muted mt-1">
This will be the display name shown in your recipe library
</p>
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
</div>
);
}
+17 -5
View File
@@ -5,6 +5,7 @@ import { validateRecipe, getValidationErrorMessages } from './validation';
export interface SaveRecipeOptions { export interface SaveRecipeOptions {
name: string; name: string;
title?: string;
global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/) global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
} }
@@ -70,12 +71,23 @@ async function saveRecipeToFile(recipe: SavedRecipe): Promise<boolean> {
* Save a recipe to a file using IPC. * Save a recipe to a file using IPC.
*/ */
export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> { export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise<string> {
const { name, global = true } = options; const { name, title, global = true } = options;
// Sanitize name let sanitizedName: string;
const sanitizedName = sanitizeRecipeName(name);
if (!sanitizedName) { if (title) {
throw new Error('Invalid recipe name'); recipe.title = title.trim();
sanitizedName = generateRecipeFilename(recipe);
if (!sanitizedName) {
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');
}
} }
const validationResult = validateRecipe(recipe); const validationResult = validateRecipe(recipe);