fix(recipe): close modal when canceling parameter form (#9195)

Signed-off-by: Erik Nilsen <enilsen16@live.com>
Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
This commit is contained in:
Erik Nilsen
2026-06-15 08:18:39 -07:00
committed by GitHub
parent c4d2d84827
commit 0f8a202d9f
3 changed files with 177 additions and 25 deletions
+1
View File
@@ -68,6 +68,7 @@ module.exports = [
CustomEvent: 'readonly',
HTMLElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLSelectElement: 'readonly',
HTMLTextAreaElement: 'readonly',
HTMLButtonElement: 'readonly',
HTMLDivElement: 'readonly',
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useId, useState, useEffect } from 'react';
import { Parameter } from '../recipe';
import { Button } from './ui/button';
import { defineMessages, useIntl } from '../i18n';
@@ -68,11 +68,12 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
initialValues,
}) => {
const intl = useIntl();
const fieldIdPrefix = useId();
const fieldId = (key: string): string => `${fieldIdPrefix}-${key}`;
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
const [showCancelOptions, setShowCancelOptions] = useState(false);
// Pre-fill the form with default values from the recipe and initialValues from deeplink
useEffect(() => {
const defaultValues: Record<string, string> = {};
parameters.forEach((param) => {
@@ -90,10 +91,8 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
};
const handleSubmit = (): void => {
// Clear previous validation errors
setValidationErrors({});
// Check if all *required* parameters are filled
const requiredParams: Parameter[] = parameters.filter((p) => p.requirement === 'required');
const errors: Record<string, string> = {};
@@ -113,7 +112,6 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
};
const handleCancel = (): void => {
// Always show cancel options if recipe has any parameters (required or optional)
const hasAnyParams = parameters.length > 0;
if (hasAnyParams) {
@@ -123,18 +121,9 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
}
};
const handleCancelOption = (option: 'new-chat' | 'back-to-form'): void => {
if (option === 'new-chat') {
onClose();
} else {
setShowCancelOptions(false); // Go back to the parameter form
}
};
return (
<div className="fixed inset-0 backdrop-blur-sm z-50 flex justify-center items-center animate-[fadein_200ms_ease-in]">
{showCancelOptions ? (
// Cancel options modal
<div className="bg-background-primary border border-border-primary rounded-xl p-8 shadow-2xl w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">
{intl.formatMessage(i18n.cancelRecipeSetup)}
@@ -142,25 +131,19 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
<p className="text-text-primary mb-6">{intl.formatMessage(i18n.whatToDo)}</p>
<div className="flex flex-col gap-3">
<Button
onClick={() => handleCancelOption('back-to-form')}
onClick={() => setShowCancelOptions(false)}
variant="default"
size="lg"
className="w-full rounded-full"
>
{intl.formatMessage(i18n.backToForm)}
</Button>
<Button
onClick={() => handleCancelOption('new-chat')}
variant="outline"
size="lg"
className="w-full rounded-full"
>
<Button onClick={onClose} variant="outline" size="lg" className="w-full rounded-full">
{intl.formatMessage(i18n.startNewChat)}
</Button>
</div>
</div>
) : (
// Main parameter form
<div className="bg-background-primary border border-border-primary rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden">
<div className="p-8 pb-4 flex-shrink-0">
<h2 className="text-xl font-bold text-text-primary mb-6">
@@ -171,16 +154,19 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
<form onSubmit={handleSubmit} className="space-y-4 mb-4">
{parameters.map((param) => (
<div key={param.key}>
<label className="block text-md font-medium text-text-primary mb-2">
<label
htmlFor={fieldId(param.key)}
className="block text-md font-medium text-text-primary mb-2"
>
{param.description || param.key}
{param.requirement === 'required' && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{/* Render different input types */}
{param.input_type === 'select' && param.options ? (
<select
id={fieldId(param.key)}
value={inputValues[param.key] || ''}
onChange={(e) => handleChange(param.key, e.target.value)}
className={`w-full p-3 border rounded-lg bg-background-secondary text-text-primary focus:outline-none focus:ring-2 ${
@@ -198,6 +184,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
</select>
) : param.input_type === 'boolean' ? (
<select
id={fieldId(param.key)}
value={inputValues[param.key] || ''}
onChange={(e) => handleChange(param.key, e.target.value)}
className={`w-full p-3 border rounded-lg bg-background-secondary text-text-primary focus:outline-none focus:ring-2 ${
@@ -212,6 +199,7 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
</select>
) : (
<input
id={fieldId(param.key)}
type={param.input_type === 'number' ? 'number' : 'text'}
value={inputValues[param.key] || ''}
onChange={(e) => handleChange(param.key, e.target.value)}
@@ -220,7 +208,9 @@ const ParameterInputModal: React.FC<ParameterInputModalProps> = ({
? 'border-red-500 focus:ring-red-500'
: 'border-border-primary focus:ring-border-secondary'
}`}
placeholder={param.default || intl.formatMessage(i18n.enterValue, { key: param.key })}
placeholder={
param.default || intl.formatMessage(i18n.enterValue, { key: param.key })
}
/>
)}
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, type RenderOptions, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ParameterInputModal from '../ParameterInputModal';
import { IntlTestWrapper } from '../../i18n/test-utils';
import type { Parameter } from '../../recipe';
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
const mockParameters: Parameter[] = [
{
key: 'param1',
description: 'Test parameter 1',
input_type: 'string',
requirement: 'required',
},
{
key: 'param2',
description: 'Test parameter 2',
input_type: 'select',
requirement: 'optional',
options: ['option1', 'option2'],
default: 'option1',
},
{
key: 'param3',
description: 'Boolean parameter',
input_type: 'boolean',
requirement: 'optional',
default: 'true',
},
];
describe('ParameterInputModal', () => {
const defaultProps = {
parameters: mockParameters,
onSubmit: vi.fn(),
onClose: vi.fn(),
initialValues: {},
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders modal with parameters', () => {
renderWithIntl(<ParameterInputModal {...defaultProps} />);
expect(screen.getByText('Recipe Parameters')).toBeInTheDocument();
expect(screen.getByText('Test parameter 1')).toBeInTheDocument();
expect(screen.getByText('Test parameter 2')).toBeInTheDocument();
expect(screen.getByText('Boolean parameter')).toBeInTheDocument();
});
it('shows required indicator for required parameters', () => {
renderWithIntl(<ParameterInputModal {...defaultProps} />);
const requiredParam = screen.getByText('Test parameter 1');
expect(requiredParam.parentElement?.querySelector('.text-red-500')).toBeInTheDocument();
});
});
describe('Form Submission', () => {
it('calls onSubmit with parameter values when submitted', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} />);
await user.type(screen.getByLabelText(/test parameter 1/i), 'test value');
await user.selectOptions(screen.getByLabelText(/test parameter 2/i), 'option2');
const submitButton = screen.getByText('Start Recipe');
await user.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalledWith({
param1: 'test value',
param2: 'option2',
param3: 'true',
});
});
it('shows validation errors for required parameters', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} />);
const submitButton = screen.getByText('Start Recipe');
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/is required/)).toBeInTheDocument();
});
expect(defaultProps.onSubmit).not.toHaveBeenCalled();
});
});
describe('Cancel Behavior', () => {
it('shows cancel options when cancel is clicked and parameters exist', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
await user.click(cancelButton);
expect(screen.getByText('Cancel Recipe Setup')).toBeInTheDocument();
expect(screen.getByText('What would you like to do?')).toBeInTheDocument();
});
it('calls onClose directly when cancel is clicked and no parameters exist', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} parameters={[]} />);
const cancelButton = screen.getByText('Cancel');
await user.click(cancelButton);
expect(defaultProps.onClose).toHaveBeenCalled();
});
it('calls onClose when "Start New Chat" option is selected', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} />);
await user.click(screen.getByText('Cancel'));
await user.click(screen.getByText('Start New Chat (No Recipe)'));
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
it('returns to parameter form when "Back to Parameter Form" is clicked', async () => {
const user = userEvent.setup();
renderWithIntl(<ParameterInputModal {...defaultProps} />);
const cancelButton = screen.getByText('Cancel');
await user.click(cancelButton);
const backButton = screen.getByText('Back to Parameter Form');
await user.click(backButton);
expect(screen.getByText('Recipe Parameters')).toBeInTheDocument();
expect(defaultProps.onClose).not.toHaveBeenCalled();
});
});
describe('Initial Values', () => {
it('pre-fills form with initial values', () => {
renderWithIntl(
<ParameterInputModal {...defaultProps} initialValues={{ param1: 'initial value' }} />
);
expect((screen.getByLabelText(/test parameter 1/i) as HTMLInputElement).value).toBe(
'initial value'
);
});
it('pre-fills form with default values from parameters', () => {
renderWithIntl(<ParameterInputModal {...defaultProps} />);
expect((screen.getByLabelText(/boolean parameter/i) as HTMLSelectElement).value).toBe('true');
});
});
});