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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user