Convert recipe create and import forms to use tanstack form and zod schema validation (#4499)

This commit is contained in:
Zane
2025-09-03 13:18:58 -07:00
committed by GitHub
parent 7956e47344
commit 651456022c
16 changed files with 963 additions and 580 deletions
+81 -1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-form": "^1.19.3",
"@types/react-router-dom": "^5.3.3",
"ai": "^5.0.14",
"class-variance-authority": "^0.7.1",
@@ -56,7 +57,8 @@
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.3",
@@ -5409,6 +5411,72 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/form-core": {
"version": "1.19.3",
"resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.19.3.tgz",
"integrity": "sha512-VUDKpR24kfKHh43nGtDExZtwpq1MMNmN0mrYbqNOOjOoOUm9yZ0fPxWjPBfJQOdvmyncV2Svg9I5klUPjXqWzA==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "^0.7.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-form": {
"version": "1.19.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.19.3.tgz",
"integrity": "sha512-e9m/aAemSIfM9Qesk6xeXiBdXLRhZPnQp4QVp/epcaaA6peBCYS0rBL9fuLKGrL4LRyDIA4jA68XR0Mxq0yO4A==",
"license": "MIT",
"dependencies": {
"@tanstack/form-core": "1.19.3",
"@tanstack/react-store": "^0.7.4",
"decode-formdata": "^0.9.0",
"devalue": "^5.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-start": "^1.130.10",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@tanstack/react-start": {
"optional": true
}
}
},
"node_modules/@tanstack/react-store": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.4.tgz",
"integrity": "sha512-DyG1e5Qz/c1cNLt/NdFbCA7K1QGuFXQYT6EfUltYMJoQ4LzBOGnOl5IjuxepNcRtmIKkGpmdMzdFZEkevgU9bQ==",
"license": "MIT",
"dependencies": {
"@tanstack/store": "0.7.4",
"use-sync-external-store": "^1.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.4.tgz",
"integrity": "sha512-F1XqZQici1Aq6WigEfcxJSml92nW+85Om8ElBMokPNg5glCYVOmPkZGIQeieYFxcPiKTfwo0MTOQpUyJtwncrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -8036,6 +8104,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/decode-formdata": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
"integrity": "sha512-q5uwOjR3Um5YD+ZWPOF/1sGHVW9A5rCrRwITQChRXlmPkxDFBqCm4jNTIVdGHNH9OnR+V9MoZVgRhsFb+ARbUw==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -8253,6 +8327,12 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
"integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==",
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+3 -1
View File
@@ -53,6 +53,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/themes": "^3.2.1",
"@tanstack/react-form": "^1.19.3",
"@types/react-router-dom": "^5.3.3",
"ai": "^5.0.14",
"class-variance-authority": "^0.7.1",
@@ -86,7 +87,8 @@
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.3.6",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.3",
+2 -2
View File
@@ -30,8 +30,8 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
import { Recipe } from './recipe';
import RecipesView from './components/RecipesView';
import RecipeEditor from './components/RecipeEditor';
import RecipesView from './components/recipes/RecipesView';
import RecipeEditor from './components/recipes/RecipeEditor';
import { createNavigationHandler, View, ViewOptions } from './utils/navigationUtils';
import {
AgentState,
+1 -1
View File
@@ -47,7 +47,7 @@ import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import LoadingGoose from './LoadingGoose';
import RecipeActivities from './RecipeActivities';
import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { View, ViewOptions } from '../utils/navigationUtils';
@@ -0,0 +1,408 @@
import { useState, useEffect } from 'react';
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
import { FileText } from 'lucide-react';
import { Button } from '../ui/button';
import { Recipe } from '../../recipe';
import { saveRecipe } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
interface CreateRecipeFormProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Define Zod schema for the entire form
const createRecipeSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
description: z.string().min(10, 'Description must be at least 10 characters'),
instructions: z.string().min(20, 'Instructions must be at least 20 characters'),
prompt: z.string(),
activities: z.string(),
recipeName: recipeNameSchema,
global: z.boolean(),
});
export default function CreateRecipeForm({ isOpen, onClose, onSuccess }: CreateRecipeFormProps) {
const [creating, setCreating] = useState(false);
// Handle Esc key for modal
useEscapeKey(isOpen, onClose);
const createRecipeForm = useForm({
defaultValues: {
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
recipeName: '',
global: true,
},
validators: {
onChange: createRecipeSchema,
},
onSubmit: async ({ value }) => {
setCreating(true);
try {
// Parse activities from comma-separated string
const activities = value.activities
.split(',')
.map((activity) => activity.trim())
.filter((activity) => activity.length > 0);
// Create the recipe object
const recipe: Recipe = {
title: value.title.trim(),
description: value.description.trim(),
instructions: value.instructions.trim(),
prompt: value.prompt.trim() || undefined,
activities: activities.length > 0 ? activities : undefined,
};
await saveRecipe(recipe, {
name: value.recipeName.trim(),
global: value.global,
});
// Reset dialog state
createRecipeForm.reset({
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
recipeName: '',
global: true,
});
onClose();
onSuccess();
toastSuccess({
title: value.recipeName.trim(),
msg: 'Recipe created successfully',
});
} catch (error) {
console.error('Failed to create recipe:', error);
toastError({
title: 'Create Failed',
msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setCreating(false);
}
},
});
// Set default example values when the modal opens
useEffect(() => {
if (isOpen) {
// Set example values like the original did
createRecipeForm.setFieldValue('title', 'Python Development Assistant');
createRecipeForm.setFieldValue(
'description',
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
);
createRecipeForm.setFieldValue(
'instructions',
`You are an expert Python developer assistant. Help users with:
1. Writing clean, efficient Python code
2. Debugging and troubleshooting issues
3. Code review and optimization suggestions
4. Best practices and design patterns
5. Testing and documentation
Always provide clear explanations and working code examples.
Parameters you can use:
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
- {{python_version}}: Target Python version`
);
createRecipeForm.setFieldValue(
'prompt',
'What Python development task can I help you with today?'
);
createRecipeForm.setFieldValue('activities', 'coding, debugging, testing, documentation');
createRecipeForm.setFieldValue('recipeName', 'python-development-assistant');
createRecipeForm.setFieldValue('global', true);
}
}, [isOpen, createRecipeForm]);
const handleClose = () => {
// Reset form to default values
createRecipeForm.reset({
title: '',
description: '',
instructions: '',
prompt: '',
activities: '',
recipeName: '',
global: true,
});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
createRecipeForm.handleSubmit();
}}
>
<div className="space-y-4">
<createRecipeForm.Field name="title">
{(field) => (
<div>
<label
htmlFor="create-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Title <span className="text-red-500">*</span>
</label>
<input
id="create-title"
type="text"
value={field.state.value}
onChange={(e) => {
const value = e.target.value;
field.handleChange(value);
// Auto-generate recipe name when title changes
if (value.trim()) {
const suggestedName = generateRecipeNameFromTitle(value);
createRecipeForm.setFieldValue('recipeName', suggestedName);
}
}}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Recipe title"
autoFocus
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="description">
{(field) => (
<div>
<label
htmlFor="create-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<input
id="create-description"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Brief description of what this recipe does"
/>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="instructions">
{(field) => (
<div>
<label
htmlFor="create-instructions"
className="block text-sm font-medium text-text-standard mb-2"
>
Instructions <span className="text-red-500">*</span>
</label>
<textarea
id="create-instructions"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Detailed instructions for the AI agent..."
rows={8}
/>
<p className="text-xs text-text-muted mt-1">
Use {`{{parameter_name}}`} to define parameters that users can fill in
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="prompt">
{(field) => (
<div>
<label
htmlFor="create-prompt"
className="block text-sm font-medium text-text-standard mb-2"
>
Initial Prompt (Optional)
</label>
<textarea
id="create-prompt"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="First message to send when the recipe starts..."
rows={3}
/>
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="activities">
{(field) => (
<div>
<label
htmlFor="create-activities"
className="block text-sm font-medium text-text-standard mb-2"
>
Activities (Optional)
</label>
<input
id="create-activities"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="coding, debugging, testing, documentation (comma-separated)"
/>
<p className="text-xs text-text-muted mt-1">
Comma-separated list of activities this recipe helps with
</p>
</div>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="recipeName">
{(field) => (
<RecipeNameField
id="create-recipe-name"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
errors={field.state.meta.errors.map((error) =>
typeof error === 'string' ? error : error?.message || String(error)
)}
/>
)}
</createRecipeForm.Field>
<createRecipeForm.Field name="global">
{(field) => (
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={field.state.value === true}
onChange={() => field.handleChange(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={field.state.value === false}
onChange={() => field.handleChange(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</createRecipeForm.Field>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={creating}>
Cancel
</Button>
<createRecipeForm.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting, state.isValid]}
>
{([canSubmit, isSubmitting, isValid]) => {
// Debug logging to see what's happening
console.log('Form state:', { canSubmit, isSubmitting, isValid });
return (
<Button
type="submit"
disabled={!canSubmit || creating || isSubmitting}
variant="default"
>
{creating || isSubmitting ? 'Creating...' : 'Create Recipe'}
</Button>
);
}}
</createRecipeForm.Subscribe>
</div>
</form>
</div>
</div>
);
}
// Export the button component for easy access
export function CreateRecipeButton({ onClick }: { onClick: () => void }) {
return (
<Button onClick={onClick} variant="outline" size="sm" className="flex items-center gap-2">
<FileText className="w-4 h-4" />
Create Recipe
</Button>
);
}
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { useForm } from '@tanstack/react-form';
import { z } from 'zod';
import { Download } from 'lucide-react';
import { Button } from '../ui/button';
import { Recipe, decodeRecipe } from '../../recipe';
import { saveRecipe } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { RecipeNameField, recipeNameSchema } from './shared/RecipeNameField';
import { generateRecipeNameFromTitle } from './shared/recipeNameUtils';
interface ImportRecipeFormProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
// Define Zod schema for the import form
const importRecipeSchema = z.object({
deeplink: z
.string()
.min(1, 'Deeplink is required')
.refine(
(value) => value.trim().startsWith('goose://recipe?config='),
'Invalid deeplink format. Expected: goose://recipe?config=...'
),
recipeName: recipeNameSchema,
global: z.boolean(),
});
export default function ImportRecipeForm({ isOpen, onClose, onSuccess }: ImportRecipeFormProps) {
const [importing, setImporting] = useState(false);
// Handle Esc key for modal
useEscapeKey(isOpen, onClose);
// Function to parse deeplink and extract recipe
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
try {
const cleanLink = deeplink.trim();
if (!cleanLink.startsWith('goose://recipe?config=')) {
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
}
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
if (!recipeEncoded) {
throw new Error('No recipe configuration found in deeplink');
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
} catch (error) {
console.error('Failed to parse deeplink:', error);
return null;
}
};
const importRecipeForm = useForm({
defaultValues: {
deeplink: '',
recipeName: '',
global: true,
},
validators: {
onChange: importRecipeSchema,
},
onSubmit: async ({ value }) => {
setImporting(true);
try {
const recipe = await parseDeeplink(value.deeplink.trim());
if (!recipe) {
throw new Error('Invalid deeplink or recipe format');
}
await saveRecipe(recipe, {
name: value.recipeName.trim(),
global: value.global,
});
// Reset dialog state
importRecipeForm.reset({
deeplink: '',
recipeName: '',
global: true,
});
onClose();
onSuccess();
toastSuccess({
title: value.recipeName.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
console.error('Failed to import recipe:', error);
toastError({
title: 'Import Failed',
msg: `Failed to import recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setImporting(false);
}
},
});
const handleClose = () => {
// Reset form to default values
importRecipeForm.reset({
deeplink: '',
recipeName: '',
global: true,
});
onClose();
};
// Store reference to recipe name field for programmatic updates
let recipeNameFieldRef: { handleChange: (value: string) => void } | null = null;
// Auto-generate recipe name when deeplink changes
const handleDeeplinkChange = async (
value: string,
field: { handleChange: (value: string) => void }
) => {
// Use the proper field change handler to trigger validation
field.handleChange(value);
if (value.trim()) {
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);
} else {
importRecipeForm.setFieldValue('recipeName', suggestedName);
}
}
} catch (error) {
// Silently handle parsing errors during auto-suggest
console.log('Could not parse deeplink for auto-suggest:', error);
}
} else {
// Clear the recipe name when deeplink is empty
if (recipeNameFieldRef) {
recipeNameFieldRef.handleChange('');
} else {
importRecipeForm.setFieldValue('recipeName', '');
}
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
importRecipeForm.handleSubmit();
}}
>
<div className="space-y-4">
<importRecipeForm.Field name="deeplink">
{(field) => (
<div>
<label
htmlFor="import-deeplink"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Deeplink <span className="text-red-500">*</span>
</label>
<textarea
id="import-deeplink"
value={field.state.value}
onChange={(e) => handleDeeplinkChange(e.target.value, field)}
onBlur={field.handleBlur}
className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none ${
field.state.meta.errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
}`}
placeholder="Paste your goose://recipe?config=... deeplink here"
rows={3}
autoFocus
/>
<p className="text-xs text-text-muted mt-1">
Paste a recipe deeplink starting with "goose://recipe?config="
</p>
{field.state.meta.errors.length > 0 && (
<p className="text-red-500 text-sm mt-1">
{typeof field.state.meta.errors[0] === 'string'
? field.state.meta.errors[0]
: field.state.meta.errors[0]?.message || String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</importRecipeForm.Field>
<importRecipeForm.Field name="recipeName">
{(field) => {
// Store reference to the field for programmatic updates
recipeNameFieldRef = field;
return (
<RecipeNameField
id="import-recipe-name"
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
errors={field.state.meta.errors.map((error) =>
typeof error === 'string' ? error : error?.message || String(error)
)}
/>
);
}}
</importRecipeForm.Field>
<importRecipeForm.Field name="global">
{(field) => (
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === true}
onChange={() => field.handleChange(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={field.state.value === false}
onChange={() => field.handleChange(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
)}
</importRecipeForm.Field>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button type="button" onClick={handleClose} variant="ghost" disabled={importing}>
Cancel
</Button>
<importRecipeForm.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<Button
type="submit"
disabled={!canSubmit || importing || isSubmitting}
variant="default"
>
{importing || isSubmitting ? 'Importing...' : 'Import Recipe'}
</Button>
)}
</importRecipeForm.Subscribe>
</div>
</form>
</div>
</div>
);
}
// Export the button component for easy access
export function ImportRecipeButton({ onClick }: { onClick: () => void }) {
return (
<Button onClick={onClick} variant="default" size="sm" className="flex items-center gap-2">
<Download className="w-4 h-4" />
Import Recipe
</Button>
);
}
@@ -1,7 +1,7 @@
import { Card } from './ui/card';
import { Card } from '../ui/card';
import { gsap } from 'gsap';
import GooseLogo from './GooseLogo';
import MarkdownContent from './MarkdownContent';
import GooseLogo from '../GooseLogo';
import MarkdownContent from '../MarkdownContent';
// Register GSAP plugins
gsap.registerPlugin();
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Button } from '../ui/button';
export default function RecipeActivityEditor({
activities,
@@ -1,22 +1,22 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Recipe, generateDeepLink } from '../recipe';
import { Parameter } from '../recipe/index';
import { Recipe, generateDeepLink } from '../../recipe';
import { Parameter } from '../../recipe/index';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar } from 'lucide-react';
import { ExtensionConfig, useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
import ParameterInput from './parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
import { toastSuccess, toastError } from '../toasts';
import { Button } from './ui/button';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import ParameterInput from '../parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { Button } from '../ui/button';
import { useEscapeKey } from '../../hooks/useEscapeKey';
interface RecipeEditorProps {
config?: Recipe;
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { Button } from './ui/button';
import { Button } from '../ui/button';
interface RecipeExpandableInfoProps {
infoLabel: string;
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
interface RecipeInfoModalProps {
infoLabel?: string;
@@ -1,20 +1,17 @@
import { useState, useEffect } from 'react';
import {
listSavedRecipes,
saveRecipe,
generateRecipeFilename,
convertToLocaleDateString,
} from '../recipe/recipeStorage';
import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Skeleton } from './ui/skeleton';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import { Recipe, decodeRecipe, generateDeepLink } from '../recipe';
import { toastSuccess, toastError } from '../toasts';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../api';
import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
import { FileText, Trash2, Bot, Calendar, AlertCircle } from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Skeleton } from '../ui/skeleton';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Recipe, generateDeepLink } from '../../recipe';
import { toastSuccess, toastError } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../../api';
import CreateRecipeForm, { CreateRecipeButton } from './CreateRecipeForm';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
export default function RecipesView() {
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
@@ -24,44 +21,18 @@ export default function RecipesView() {
const [selectedRecipe, setSelectedRecipe] = useState<RecipeManifestResponse | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showContent, setShowContent] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
const [importDeeplink, setImportDeeplink] = useState('');
const [importRecipeName, setImportRecipeName] = useState('');
const [importGlobal, setImportGlobal] = useState(true);
const [importing, setImporting] = useState(false);
const [previewDeeplink, setPreviewDeeplink] = useState<string>('');
// Create Recipe state
// Form dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createTitle, setCreateTitle] = useState('');
const [createDescription, setCreateDescription] = useState('');
const [createInstructions, setCreateInstructions] = useState('');
const [createPrompt, setCreatePrompt] = useState('');
const [createActivities, setCreateActivities] = useState('');
const [createRecipeName, setCreateRecipeName] = useState('');
const [createGlobal, setCreateGlobal] = useState(true);
const [creating, setCreating] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
useEffect(() => {
loadSavedRecipes();
}, []);
// Handle Esc key for modals
// Handle Esc key for preview modal
useEscapeKey(showPreview, () => setShowPreview(false));
useEscapeKey(showImportDialog, () => {
setShowImportDialog(false);
setImportDeeplink('');
setImportRecipeName('');
});
useEscapeKey(showCreateDialog, () => {
setShowCreateDialog(false);
setCreateTitle('');
setCreateDescription('');
setCreateInstructions('');
setCreatePrompt('');
setCreateActivities('');
setCreateRecipeName('');
});
// Minimum loading time to prevent skeleton flash
useEffect(() => {
@@ -163,204 +134,6 @@ export default function RecipesView() {
}
};
// Function to parse deeplink and extract recipe
const parseDeeplink = async (deeplink: string): Promise<Recipe | null> => {
try {
const cleanLink = deeplink.trim();
if (!cleanLink.startsWith('goose://recipe?config=')) {
throw new Error('Invalid deeplink format. Expected: goose://recipe?config=...');
}
const recipeEncoded = cleanLink.replace('goose://recipe?config=', '');
if (!recipeEncoded) {
throw new Error('No recipe configuration found in deeplink');
}
const recipe = await decodeRecipe(recipeEncoded);
if (!recipe.title || !recipe.description) {
throw new Error('Recipe is missing required fields (title, description)');
}
if (!recipe.instructions && !recipe.prompt) {
throw new Error('Recipe must have either instructions or prompt');
}
return recipe;
} catch (error) {
console.error('Failed to parse deeplink:', error);
return null;
}
};
const handleImportRecipe = async () => {
if (!importDeeplink.trim() || !importRecipeName.trim()) {
return;
}
setImporting(true);
try {
const recipe = await parseDeeplink(importDeeplink.trim());
if (!recipe) {
throw new Error('Invalid deeplink or recipe format');
}
await saveRecipe(recipe, {
name: importRecipeName.trim(),
global: importGlobal,
});
// Reset dialog state
setShowImportDialog(false);
setImportDeeplink('');
setImportRecipeName('');
await loadSavedRecipes();
toastSuccess({
title: importRecipeName.trim(),
msg: 'Recipe imported successfully',
});
} catch (error) {
console.error('Failed to import recipe:', error);
toastError({
title: 'Import Failed',
msg: `Failed to import recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setImporting(false);
}
};
const handleImportClick = () => {
setImportDeeplink('');
setImportRecipeName('');
setImportGlobal(true);
setShowImportDialog(true);
};
// Auto-generate recipe name when deeplink changes
const handleDeeplinkChange = async (value: string) => {
setImportDeeplink(value);
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (recipe && recipe.title) {
const suggestedName = generateRecipeFilename(recipe);
setImportRecipeName(suggestedName);
}
} catch (error) {
// Silently handle parsing errors during auto-suggest
console.log('Could not parse deeplink for auto-suggest:', error);
}
}
};
// Create Recipe handlers
const handleCreateClick = () => {
// Reset form with example values
setCreateTitle('Python Development Assistant');
setCreateDescription(
'A helpful assistant for Python development tasks including coding, debugging, and code review.'
);
setCreateInstructions(`You are an expert Python developer assistant. Help users with:
1. Writing clean, efficient Python code
2. Debugging and troubleshooting issues
3. Code review and optimization suggestions
4. Best practices and design patterns
5. Testing and documentation
Always provide clear explanations and working code examples.
Parameters you can use:
- {{project_type}}: The type of Python project (web, data science, CLI, etc.)
- {{python_version}}: Target Python version`);
setCreatePrompt('What Python development task can I help you with today?');
setCreateActivities('coding, debugging, testing, documentation');
setCreateRecipeName('');
setCreateGlobal(true);
setShowCreateDialog(true);
};
const handleCreateRecipe = async () => {
if (
!createTitle.trim() ||
!createDescription.trim() ||
!createInstructions.trim() ||
!createRecipeName.trim()
) {
return;
}
setCreating(true);
try {
// Parse activities from comma-separated string
const activities = createActivities
.split(',')
.map((activity) => activity.trim())
.filter((activity) => activity.length > 0);
// Create the recipe object
const recipe: Recipe = {
title: createTitle.trim(),
description: createDescription.trim(),
instructions: createInstructions.trim(),
prompt: createPrompt.trim() || undefined,
activities: activities.length > 0 ? activities : undefined,
};
await saveRecipe(recipe, {
name: createRecipeName.trim(),
global: createGlobal,
});
// Reset dialog state
setShowCreateDialog(false);
setCreateTitle('');
setCreateDescription('');
setCreateInstructions('');
setCreatePrompt('');
setCreateActivities('');
setCreateRecipeName('');
await loadSavedRecipes();
toastSuccess({
title: createRecipeName.trim(),
msg: 'Recipe created successfully',
});
} catch (error) {
console.error('Failed to create recipe:', error);
toastError({
title: 'Create Failed',
msg: `Failed to create recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
traceback: error instanceof Error ? error.message : String(error),
});
} finally {
setCreating(false);
}
};
// Auto-generate recipe name when title changes
const handleCreateTitleChange = (value: string) => {
setCreateTitle(value);
if (value.trim() && !createRecipeName.trim()) {
const suggestedName = value
.toLowerCase()
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim();
setCreateRecipeName(suggestedName);
}
};
// Render a recipe item
const RecipeItem = ({
recipeManifestResponse,
@@ -498,24 +271,8 @@ Parameters you can use:
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Recipes</h1>
<div className="flex gap-2">
<Button
onClick={handleCreateClick}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Create Recipe
</Button>
<Button
onClick={handleImportClick}
variant="default"
size="sm"
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
Import Recipe
</Button>
<CreateRecipeButton onClick={() => setShowCreateDialog(true)} />
<ImportRecipeButton onClick={() => setShowImportDialog(true)} />
</div>
</div>
<p className="text-sm text-text-muted mb-1">
@@ -937,290 +694,18 @@ Parameters you can use:
</div>
)}
{/* Import Recipe Dialog */}
{showImportDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
<h3 className="text-lg font-medium text-text-standard mb-4">Import Recipe</h3>
{/* Use the extracted form components */}
<ImportRecipeForm
isOpen={showImportDialog}
onClose={() => setShowImportDialog(false)}
onSuccess={loadSavedRecipes}
/>
<div className="space-y-4">
<div>
<label
htmlFor="import-deeplink"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Deeplink
</label>
<textarea
id="import-deeplink"
value={importDeeplink}
onChange={(e) => handleDeeplinkChange(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="Paste your goose://recipe?config=... deeplink here"
rows={3}
autoFocus
/>
<p className="text-xs text-text-muted mt-1">
Paste a recipe deeplink starting with "goose://recipe?config="
</p>
</div>
<div>
<label
htmlFor="import-recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name
</label>
<input
id="import-recipe-name"
type="text"
value={importRecipeName}
onChange={(e) => setImportRecipeName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter a name for the imported recipe"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={importGlobal}
onChange={() => setImportGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="import-save-location"
checked={!importGlobal}
onChange={() => setImportGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={() => {
setShowImportDialog(false);
setImportDeeplink('');
setImportRecipeName('');
}}
variant="ghost"
disabled={importing}
>
Cancel
</Button>
<Button
onClick={handleImportRecipe}
disabled={!importDeeplink.trim() || !importRecipeName.trim() || importing}
variant="default"
>
{importing ? 'Importing...' : 'Import Recipe'}
</Button>
</div>
</div>
</div>
)}
{/* Create Recipe Dialog */}
{showCreateDialog && (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[700px] max-w-[90vw] max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-text-standard mb-4">Create New Recipe</h3>
<div className="space-y-4">
<div>
<label
htmlFor="create-title"
className="block text-sm font-medium text-text-standard mb-2"
>
Title <span className="text-red-500">*</span>
</label>
<input
id="create-title"
type="text"
value={createTitle}
onChange={(e) => handleCreateTitleChange(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Recipe title"
autoFocus
/>
</div>
<div>
<label
htmlFor="create-description"
className="block text-sm font-medium text-text-standard mb-2"
>
Description <span className="text-red-500">*</span>
</label>
<input
id="create-description"
type="text"
value={createDescription}
onChange={(e) => setCreateDescription(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Brief description of what this recipe does"
/>
</div>
<div>
<label
htmlFor="create-instructions"
className="block text-sm font-medium text-text-standard mb-2"
>
Instructions <span className="text-red-500">*</span>
</label>
<textarea
id="create-instructions"
value={createInstructions}
onChange={(e) => setCreateInstructions(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono text-sm"
placeholder="Detailed instructions for the AI agent..."
rows={8}
/>
<p className="text-xs text-text-muted mt-1">
Use {`{{parameter_name}}`} to define parameters that users can fill in
</p>
</div>
<div>
<label
htmlFor="create-prompt"
className="block text-sm font-medium text-text-standard mb-2"
>
Initial Prompt (Optional)
</label>
<textarea
id="create-prompt"
value={createPrompt}
onChange={(e) => setCreatePrompt(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="First message to send when the recipe starts..."
rows={3}
/>
</div>
<div>
<label
htmlFor="create-activities"
className="block text-sm font-medium text-text-standard mb-2"
>
Activities (Optional)
</label>
<input
id="create-activities"
type="text"
value={createActivities}
onChange={(e) => setCreateActivities(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="coding, debugging, testing, documentation (comma-separated)"
/>
<p className="text-xs text-text-muted mt-1">
Comma-separated list of activities this recipe helps with
</p>
</div>
<div>
<label
htmlFor="create-recipe-name"
className="block text-sm font-medium text-text-standard mb-2"
>
Recipe Name <span className="text-red-500">*</span>
</label>
<input
id="create-recipe-name"
type="text"
value={createRecipeName}
onChange={(e) => setCreateRecipeName(e.target.value)}
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="File name for the recipe"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-standard mb-2">
Save Location
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={createGlobal}
onChange={() => setCreateGlobal(true)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Global - Available across all Goose sessions
</span>
</label>
<label className="flex items-center">
<input
type="radio"
name="create-save-location"
checked={!createGlobal}
onChange={() => setCreateGlobal(false)}
className="mr-2"
/>
<span className="text-sm text-text-standard">
Directory - Available in the working directory
</span>
</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={() => {
setShowCreateDialog(false);
setCreateTitle('');
setCreateDescription('');
setCreateInstructions('');
setCreatePrompt('');
setCreateActivities('');
setCreateRecipeName('');
}}
variant="ghost"
disabled={creating}
>
Cancel
</Button>
<Button
onClick={handleCreateRecipe}
disabled={
!createTitle.trim() ||
!createDescription.trim() ||
!createInstructions.trim() ||
!createRecipeName.trim() ||
creating
}
variant="default"
>
{creating ? 'Creating...' : 'Create Recipe'}
</Button>
</div>
</div>
</div>
)}
<CreateRecipeForm
isOpen={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onSuccess={loadSavedRecipes}
/>
</>
);
}
@@ -1,19 +1,19 @@
import { useState, useEffect, useCallback } from 'react';
import { Recipe, generateDeepLink } from '../recipe';
import { Parameter } from '../recipe/index';
import { Geese } from './icons/Geese';
import Copy from './icons/Copy';
import { Recipe, generateDeepLink } from '../../recipe';
import { Parameter } from '../../recipe/index';
import { Geese } from '../icons/Geese';
import Copy from '../icons/Copy';
import { Check, Save, Calendar, X } from 'lucide-react';
import { ExtensionConfig, useConfig } from './ConfigContext';
import { FixedExtensionEntry } from './ConfigContext';
import { ExtensionConfig, useConfig } from '../ConfigContext';
import { FixedExtensionEntry } from '../ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
import ParameterInput from './parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../recipe/recipeStorage';
import { toastSuccess, toastError } from '../toasts';
import { Button } from './ui/button';
import { ScheduleFromRecipeModal } from '../schedule/ScheduleFromRecipeModal';
import ParameterInput from '../parameter/ParameterInput';
import { saveRecipe, generateRecipeFilename } from '../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../toasts';
import { Button } from '../ui/button';
interface ViewRecipeModalProps {
isOpen: boolean;
@@ -0,0 +1,66 @@
import { recipeNameSchema, RECIPE_NAME_PLACEHOLDER } from './recipeNameUtils';
interface RecipeNameFieldProps {
id: string;
value: string;
onChange: (value: string) => void;
onBlur: () => void;
errors: string[];
label?: string;
required?: boolean;
disabled?: boolean;
}
export function RecipeNameField({
id,
value,
onChange,
onBlur,
errors,
label = 'Recipe Name',
required = true,
disabled = false,
}: RecipeNameFieldProps) {
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) => {
// Allow typing normally, only filter out invalid characters but keep spaces
const rawValue = e.target.value;
const filtered = rawValue.replace(/[^a-zA-Z0-9\s-]/g, '');
onChange(filtered);
}}
onBlur={(e) => {
// Transform on blur: convert to lowercase and replace spaces with dashes
const rawValue = e.target.value;
const transformed = rawValue
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
onChange(transformed);
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={RECIPE_NAME_PLACEHOLDER}
/>
<p className="text-xs text-text-muted mt-1">
Will be automatically formatted (lowercase, dashes for spaces)
</p>
{errors.length > 0 && <p className="text-red-500 text-sm mt-1">{errors[0]}</p>}
</div>
);
}
export { recipeNameSchema };
@@ -0,0 +1,38 @@
import { z } from 'zod';
/**
* Validation schema for recipe names
*/
export const recipeNameSchema = z.string().min(3, 'Recipe name must be at least 3 characters');
/**
* Transform a string to a valid recipe name format:
* - Convert to lowercase
* - Replace spaces with dashes
* - Remove invalid characters
* - Trim whitespace and dashes
*/
export function transformToRecipeName(input: string): string {
return input
.toLowerCase()
.replace(/[^a-zA-Z0-9\s-]/g, '') // Remove invalid characters
.replace(/\s+/g, '-') // Replace spaces with dashes
.replace(/--+/g, '-') // Replace multiple dashes with single dash
.replace(/^-+|-+$/g, '') // Remove leading/trailing dashes
.trim();
}
/**
* Generate a recipe name from a title
*/
export function generateRecipeNameFromTitle(title: string): string {
if (!title.trim()) {
return '';
}
return transformToRecipeName(title);
}
/**
* Common placeholder text for recipe name inputs
*/
export const RECIPE_NAME_PLACEHOLDER = 'my-awesome-recipe';
@@ -19,7 +19,7 @@ import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover'
import { Recipe } from '../../../../recipe';
import { saveRecipe, generateRecipeFilename } from '../../../../recipe/recipeStorage';
import { toastSuccess, toastError } from '../../../../toasts';
import ViewRecipeModal from '../../../ViewRecipeModal';
import ViewRecipeModal from '../../../recipes/ViewRecipeModal';
interface ModelsBottomBarProps {
sessionId: string | null;