feat(desktop): select saved recipes when creating a schedule (#10892)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Vincenzo Palazzo
2026-08-21 05:38:29 +00:00
committed by GitHub
parent 7f8685a6f8
commit 384b6ccfd3
21 changed files with 846 additions and 90 deletions
@@ -1,46 +1,107 @@
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
import React, { useState, useEffect, useMemo, FormEvent, useCallback, useRef } from 'react';
import type { ScheduledJobDto } from '@aaif/goose-sdk';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Select } from '../ui/Select';
import { CronPicker } from './CronPicker';
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
import type { RecipeManifest } from '../../recipe';
import { listSavedRecipes } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
import { defineMessages, useIntl } from '../../i18n';
const i18n = defineMessages({
editSchedule: { id: 'scheduleModal.editSchedule', defaultMessage: 'Edit Schedule' },
createNewSchedule: { id: 'scheduleModal.createNewSchedule', defaultMessage: 'Create New Schedule' },
createNewSchedule: {
id: 'scheduleModal.createNewSchedule',
defaultMessage: 'Create New Schedule',
},
nameLabel: { id: 'scheduleModal.nameLabel', defaultMessage: 'Name:' },
namePlaceholder: { id: 'scheduleModal.namePlaceholder', defaultMessage: 'e.g., daily-summary-job' },
namePlaceholder: {
id: 'scheduleModal.namePlaceholder',
defaultMessage: 'e.g., daily-summary-job',
},
sourceLabel: { id: 'scheduleModal.sourceLabel', defaultMessage: 'Source:' },
yaml: { id: 'scheduleModal.yaml', defaultMessage: 'YAML' },
deepLink: { id: 'scheduleModal.deepLink', defaultMessage: 'Deep link' },
savedRecipes: { id: 'scheduleModal.savedRecipes', defaultMessage: 'Saved recipes' },
browseYaml: { id: 'scheduleModal.browseYaml', defaultMessage: 'Browse for YAML file...' },
selected: { id: 'scheduleModal.selected', defaultMessage: 'Selected: {path}' },
deepLinkPlaceholder: { id: 'scheduleModal.deepLinkPlaceholder', defaultMessage: 'Paste goose://recipe link here...' },
deepLinkPlaceholder: {
id: 'scheduleModal.deepLinkPlaceholder',
defaultMessage: 'Paste goose://recipe link here...',
},
selectRecipePlaceholder: {
id: 'scheduleModal.selectRecipePlaceholder',
defaultMessage: 'Select a saved recipe',
},
alreadyScheduledOption: {
id: 'scheduleModal.alreadyScheduledOption',
defaultMessage: '{title} (already scheduled)',
},
recipeAlreadyScheduled: {
id: 'scheduleModal.recipeAlreadyScheduled',
defaultMessage: 'This recipe already has a schedule. Edit it from the Recipes list.',
},
loadingRecipes: { id: 'scheduleModal.loadingRecipes', defaultMessage: 'Loading recipes...' },
noSavedRecipes: { id: 'scheduleModal.noSavedRecipes', defaultMessage: 'No saved recipes found.' },
failedLoadRecipes: {
id: 'scheduleModal.failedLoadRecipes',
defaultMessage: 'Failed to load recipes.',
},
recipeParsed: { id: 'scheduleModal.recipeParsed', defaultMessage: 'Recipe parsed successfully' },
recipeTitle: { id: 'scheduleModal.recipeTitle', defaultMessage: 'Title: {title}' },
recipeDescription: { id: 'scheduleModal.recipeDescription', defaultMessage: 'Description: {description}' },
recipeDescription: {
id: 'scheduleModal.recipeDescription',
defaultMessage: 'Description: {description}',
},
scheduleLabel: { id: 'scheduleModal.scheduleLabel', defaultMessage: 'Schedule:' },
cancel: { id: 'scheduleModal.cancel', defaultMessage: 'Cancel' },
updating: { id: 'scheduleModal.updating', defaultMessage: 'Updating...' },
creating: { id: 'scheduleModal.creating', defaultMessage: 'Creating...' },
updateSchedule: { id: 'scheduleModal.updateSchedule', defaultMessage: 'Update Schedule' },
createSchedule: { id: 'scheduleModal.createSchedule', defaultMessage: 'Create Schedule' },
invalidDeepLink: { id: 'scheduleModal.invalidDeepLink', defaultMessage: 'Invalid deep link. Please use a goose://recipe link.' },
failedReadFile: { id: 'scheduleModal.failedReadFile', defaultMessage: 'Failed to read the selected file.' },
failedParseRecipe: { id: 'scheduleModal.failedParseRecipe', defaultMessage: 'Failed to parse recipe from file.' },
invalidFileType: { id: 'scheduleModal.invalidFileType', defaultMessage: 'Invalid file type: Please select a YAML file (.yaml or .yml)' },
scheduleIdRequired: { id: 'scheduleModal.scheduleIdRequired', defaultMessage: 'Schedule ID is required.' },
provideValidRecipe: { id: 'scheduleModal.provideValidRecipe', defaultMessage: 'Please provide a valid recipe source.' },
invalidDeepLink: {
id: 'scheduleModal.invalidDeepLink',
defaultMessage: 'Invalid deep link. Please use a goose://recipe link.',
},
failedReadFile: {
id: 'scheduleModal.failedReadFile',
defaultMessage: 'Failed to read the selected file.',
},
failedParseRecipe: {
id: 'scheduleModal.failedParseRecipe',
defaultMessage: 'Failed to parse recipe from file.',
},
invalidFileType: {
id: 'scheduleModal.invalidFileType',
defaultMessage: 'Invalid file type: Please select a YAML file (.yaml or .yml)',
},
scheduleIdRequired: {
id: 'scheduleModal.scheduleIdRequired',
defaultMessage: 'Schedule ID is required.',
},
provideValidRecipe: {
id: 'scheduleModal.provideValidRecipe',
defaultMessage: 'Please provide a valid recipe source.',
},
});
export interface NewSchedulePayload {
id: string;
recipe: Recipe;
cron: string;
}
export type SourceType = 'file' | 'deeplink' | 'saved';
export type NewSchedulePayload =
| {
sourceType: 'file' | 'deeplink';
id: string;
recipe: Recipe;
cron: string;
}
| {
sourceType: 'saved';
recipeId: string;
cron: string;
};
interface ScheduleModalProps {
isOpen: boolean;
@@ -52,8 +113,6 @@ interface ScheduleModalProps {
initialDeepLink: string | null;
}
type SourceType = 'file' | 'deeplink';
const modalLabelClassName = 'block text-sm font-medium text-text-primary mb-1';
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
@@ -76,6 +135,16 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
const [cronExpression, setCronExpression] = useState<string>('0 0 14 * * *');
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
const [isValid, setIsValid] = useState(true);
const [savedRecipes, setSavedRecipes] = useState<RecipeManifest[]>([]);
const [savedRecipesLoading, setSavedRecipesLoading] = useState(false);
const [savedRecipesError, setSavedRecipesError] = useState<string | null>(null);
const [selectedSavedRecipeId, setSelectedSavedRecipeId] = useState<string | null>(null);
const sourceRequestIdRef = useRef(0);
const bumpSourceRequest = () => {
sourceRequestIdRef.current += 1;
return sourceRequestIdRef.current;
};
const setScheduleIdFromTitle = (title: string) => {
const cleanId = title
@@ -85,26 +154,76 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
setScheduleId(cleanId);
};
const handleDeepLinkChange = useCallback(async (value: string) => {
setDeepLinkInput(value);
const clearRecipeSourceState = useCallback(() => {
setParsedRecipe(null);
setRecipeSourcePath('');
setDeepLinkInput('');
setSelectedSavedRecipeId(null);
setInternalValidationError(null);
}, []);
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (!recipe) throw new Error();
setParsedRecipe(recipe);
if (recipe.title) {
setScheduleIdFromTitle(recipe.title);
}
} catch {
setParsedRecipe(null);
setInternalValidationError(intl.formatMessage(i18n.invalidDeepLink));
const handleSourceTypeChange = useCallback(
(next: SourceType) => {
if (next === sourceType) {
return;
}
} else {
setParsedRecipe(null);
}
}, [intl]);
bumpSourceRequest();
clearRecipeSourceState();
setSourceType(next);
},
[clearRecipeSourceState, sourceType]
);
const handleSelectSavedRecipe = useCallback(
(manifest: RecipeManifest | null) => {
bumpSourceRequest();
if (manifest?.schedule_cron) {
setSelectedSavedRecipeId(null);
setParsedRecipe(null);
setInternalValidationError(intl.formatMessage(i18n.recipeAlreadyScheduled));
return;
}
setSelectedSavedRecipeId(manifest?.id ?? null);
setInternalValidationError(null);
if (manifest) {
setParsedRecipe(manifest.recipe);
} else {
setParsedRecipe(null);
}
},
[intl]
);
const handleDeepLinkChange = useCallback(
async (value: string) => {
const requestId = bumpSourceRequest();
setDeepLinkInput(value);
setInternalValidationError(null);
if (value.trim()) {
try {
const recipe = await parseDeeplink(value.trim());
if (requestId !== sourceRequestIdRef.current) {
return;
}
if (!recipe) throw new Error();
setParsedRecipe(recipe);
if (recipe.title) {
setScheduleIdFromTitle(recipe.title);
}
} catch {
if (requestId !== sourceRequestIdRef.current) {
return;
}
setParsedRecipe(null);
setInternalValidationError(intl.formatMessage(i18n.invalidDeepLink));
}
} else {
setParsedRecipe(null);
}
},
[intl]
);
useEffect(() => {
if (isOpen) {
@@ -113,12 +232,17 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
setScheduleId(schedule.id);
setCronExpression(schedule.cron);
} else {
bumpSourceRequest();
setScheduleId('');
setSourceType('file');
setRecipeSourcePath('');
setDeepLinkInput('');
setParsedRecipe(null);
setCronExpression('0 0 14 * * *');
setSavedRecipes([]);
setSavedRecipesLoading(false);
setSavedRecipesError(null);
setSelectedSavedRecipeId(null);
if (initialDeepLink) {
setSourceType('deeplink');
handleDeepLinkChange(initialDeepLink);
@@ -127,18 +251,61 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
}
}, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]);
useEffect(() => {
if (sourceType !== 'saved') {
return;
}
let cancelled = false;
setSavedRecipesLoading(true);
setSavedRecipesError(null);
listSavedRecipes()
.then((recipes) => {
if (!cancelled) {
setSavedRecipes(recipes);
setSavedRecipesLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setSavedRecipesError(intl.formatMessage(i18n.failedLoadRecipes));
setSavedRecipesLoading(false);
}
});
return () => {
cancelled = true;
};
}, [sourceType, intl]);
const savedRecipeOptions = useMemo(
() =>
savedRecipes.map((m) => ({
value: m.id,
label: m.schedule_cron
? intl.formatMessage(i18n.alreadyScheduledOption, { title: m.recipe.title || m.id })
: m.recipe.title || m.id,
})),
[savedRecipes, intl]
);
const handleBrowseFile = async () => {
const fileResponse = await window.electron.selectRecipeFile();
if (fileResponse) {
if (fileResponse.filePath.endsWith('.yaml') || fileResponse.filePath.endsWith('.yml')) {
const requestId = bumpSourceRequest();
setRecipeSourcePath(fileResponse.filePath);
setInternalValidationError(null);
try {
if (requestId !== sourceRequestIdRef.current) {
return;
}
if (!fileResponse.found || fileResponse.error) {
throw new Error(intl.formatMessage(i18n.failedReadFile));
}
const recipe = await parseRecipeFromFile(fileResponse.file);
if (requestId !== sourceRequestIdRef.current) {
return;
}
if (!recipe) {
throw new Error(intl.formatMessage(i18n.failedParseRecipe));
}
@@ -147,6 +314,9 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
setScheduleIdFromTitle(recipe.title);
}
} catch (e) {
if (requestId !== sourceRequestIdRef.current) {
return;
}
setParsedRecipe(null);
setInternalValidationError(
e instanceof Error ? e.message : intl.formatMessage(i18n.failedParseRecipe)
@@ -167,6 +337,24 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
return;
}
if (sourceType === 'saved') {
const selected = savedRecipes.find((m) => m.id === selectedSavedRecipeId);
if (!selected) {
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
return;
}
if (selected.schedule_cron) {
setInternalValidationError(intl.formatMessage(i18n.recipeAlreadyScheduled));
return;
}
await onSubmit({
sourceType: 'saved',
recipeId: selected.id,
cron: cronExpression,
});
return;
}
if (!scheduleId.trim()) {
setInternalValidationError(intl.formatMessage(i18n.scheduleIdRequired));
return;
@@ -177,17 +365,40 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
return;
}
const newSchedulePayload: NewSchedulePayload = {
if (sourceType === 'file' && !recipeSourcePath) {
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
return;
}
if (sourceType === 'deeplink' && !deepLinkInput.trim()) {
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
return;
}
await onSubmit({
sourceType,
id: scheduleId.trim(),
recipe: parsedRecipe,
cron: cronExpression,
};
await onSubmit(newSchedulePayload);
});
};
if (!isOpen) return null;
const recipeParsedPreview = parsedRecipe ? (
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
✓ {intl.formatMessage(i18n.recipeParsed)}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })}
</p>
</div>
) : null;
return (
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-background-primary shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
@@ -196,7 +407,9 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
<img src={ClockIcon} alt="Clock" className="w-8 h-8" />
<div className="flex-1">
<h2 className="text-base font-semibold text-text-primary">
{isEditMode ? intl.formatMessage(i18n.editSchedule) : intl.formatMessage(i18n.createNewSchedule)}
{isEditMode
? intl.formatMessage(i18n.editSchedule)
: intl.formatMessage(i18n.createNewSchedule)}
</h2>
{isEditMode && <p className="text-sm text-text-secondary">{schedule.id}</p>}
</div>
@@ -221,29 +434,31 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
{!isEditMode && (
<>
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
{intl.formatMessage(i18n.nameLabel)} <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder={intl.formatMessage(i18n.namePlaceholder)}
required
/>
</div>
{sourceType !== 'saved' && (
<div>
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
{intl.formatMessage(i18n.nameLabel)} <span className="text-red-500">*</span>
</label>
<Input
type="text"
id="scheduleId-modal"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
placeholder={intl.formatMessage(i18n.namePlaceholder)}
required
/>
</div>
)}
<div>
<label className={modalLabelClassName}>
{intl.formatMessage(i18n.sourceLabel)} <span className="text-red-500">*</span>
{intl.formatMessage(i18n.sourceLabel)} <span className="text-red-500">*</span>
</label>
<div className="space-y-2">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
<button
type="button"
onClick={() => setSourceType('file')}
onClick={() => handleSourceTypeChange('file')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'file'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
@@ -254,7 +469,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
</button>
<button
type="button"
onClick={() => setSourceType('deeplink')}
onClick={() => handleSourceTypeChange('deeplink')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'deeplink'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
@@ -263,6 +478,17 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
>
{intl.formatMessage(i18n.deepLink)}
</button>
<button
type="button"
onClick={() => handleSourceTypeChange('saved')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
sourceType === 'saved'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
{intl.formatMessage(i18n.savedRecipes)}
</button>
</div>
{sourceType === 'file' && (
@@ -292,19 +518,39 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
placeholder={intl.formatMessage(i18n.deepLinkPlaceholder)}
className="rounded-full"
/>
{parsedRecipe && (
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
✓ {intl.formatMessage(i18n.recipeParsed)}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })}
</p>
<p className="text-xs text-green-600 dark:text-green-400">
{intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })}
</p>
</div>
{recipeParsedPreview}
</div>
)}
{sourceType === 'saved' && (
<div data-testid="saved-recipe-picker">
{savedRecipesError ? (
<p className="text-sm text-text-danger">{savedRecipesError}</p>
) : savedRecipes.length === 0 ? (
<p className="text-sm text-text-secondary">
{savedRecipesLoading
? intl.formatMessage(i18n.loadingRecipes)
: intl.formatMessage(i18n.noSavedRecipes)}
</p>
) : (
<Select
options={savedRecipeOptions}
value={
savedRecipeOptions.find((o) => o.value === selectedSavedRecipeId) ??
null
}
onChange={(option) => {
const selectedId = (option as { value?: string } | null)?.value ?? null;
handleSelectSavedRecipe(
selectedId
? (savedRecipes.find((m) => m.id === selectedId) ?? null)
: null
);
}}
placeholder={intl.formatMessage(i18n.selectRecipePlaceholder)}
/>
)}
{recipeParsedPreview}
</div>
)}
</div>
@@ -11,6 +11,7 @@ import {
acpKillRunningJob,
acpInspectRunningJob,
} from '../../acp/schedules';
import { scheduleRecipe } from '../../recipe/recipe_management';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -40,25 +41,41 @@ const i18n = defineMessages({
refreshing: { id: 'schedulesView.refreshing', defaultMessage: 'Refreshing...' },
refresh: { id: 'schedulesView.refresh', defaultMessage: 'Refresh' },
createSchedule: { id: 'schedulesView.createSchedule', defaultMessage: 'Create Schedule' },
description: { id: 'schedulesView.description', defaultMessage: 'Create and manage scheduled tasks to run recipes automatically at specified times.' },
description: {
id: 'schedulesView.description',
defaultMessage:
'Create and manage scheduled tasks to run recipes automatically at specified times.',
},
errorPrefix: { id: 'schedulesView.errorPrefix', defaultMessage: 'Error: {error}' },
noSchedules: { id: 'schedulesView.noSchedules', defaultMessage: 'No schedules yet' },
scheduleUpdated: { id: 'schedulesView.scheduleUpdated', defaultMessage: 'Schedule Updated' },
scheduleUpdatedMsg: { id: 'schedulesView.scheduleUpdatedMsg', defaultMessage: 'Successfully updated schedule "{id}"' },
scheduleUpdatedMsg: {
id: 'schedulesView.scheduleUpdatedMsg',
defaultMessage: 'Successfully updated schedule "{id}"',
},
confirmDelete: {
id: 'schedulesView.confirmDelete',
defaultMessage: 'Remove schedule "{id}"? The recipe will be kept.',
},
schedulePaused: { id: 'schedulesView.schedulePaused', defaultMessage: 'Schedule Paused' },
schedulePausedMsg: { id: 'schedulesView.schedulePausedMsg', defaultMessage: 'Successfully paused schedule "{id}"' },
schedulePausedMsg: {
id: 'schedulesView.schedulePausedMsg',
defaultMessage: 'Successfully paused schedule "{id}"',
},
pauseError: { id: 'schedulesView.pauseError', defaultMessage: 'Pause Schedule Error' },
scheduleUnpaused: { id: 'schedulesView.scheduleUnpaused', defaultMessage: 'Schedule Unpaused' },
scheduleUnpausedMsg: { id: 'schedulesView.scheduleUnpausedMsg', defaultMessage: 'Successfully unpaused schedule "{id}"' },
scheduleUnpausedMsg: {
id: 'schedulesView.scheduleUnpausedMsg',
defaultMessage: 'Successfully unpaused schedule "{id}"',
},
unpauseError: { id: 'schedulesView.unpauseError', defaultMessage: 'Unpause Schedule Error' },
jobKilled: { id: 'schedulesView.jobKilled', defaultMessage: 'Job Killed' },
killError: { id: 'schedulesView.killError', defaultMessage: 'Kill Job Error' },
jobInspection: { id: 'schedulesView.jobInspection', defaultMessage: 'Job Inspection' },
inspectNoInfo: { id: 'schedulesView.inspectNoInfo', defaultMessage: 'No detailed information available for this job' },
inspectNoInfo: {
id: 'schedulesView.inspectNoInfo',
defaultMessage: 'No detailed information available for this job',
},
inspectError: { id: 'schedulesView.inspectError', defaultMessage: 'Inspect Job Error' },
});
@@ -296,9 +313,16 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
});
} else {
const newPayload = payload as NewSchedulePayload;
await acpCreateSchedule(newPayload);
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
trackScheduleCreated(sourceType, true);
if (newPayload.sourceType === 'saved') {
await scheduleRecipe(newPayload.recipeId, newPayload.cron);
} else {
await acpCreateSchedule({
id: newPayload.id,
recipe: newPayload.recipe,
cron: newPayload.cron,
});
}
trackScheduleCreated(newPayload.sourceType, true);
}
await fetchSchedules();
setIsModalOpen(false);
@@ -309,8 +333,13 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
setSubmitApiError(errorMsg);
if (!editingSchedule) {
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
trackScheduleCreated(sourceType, false, getErrorType(error));
const failedSourceType =
typeof payload === 'object' && payload && 'sourceType' in payload
? payload.sourceType
: pendingDeepLink
? 'deeplink'
: 'file';
trackScheduleCreated(failedSourceType, false, getErrorType(error));
}
} finally {
setIsSubmitting(false);
@@ -493,7 +522,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? intl.formatMessage(i18n.refreshing) : intl.formatMessage(i18n.refresh)}
{isRefreshing
? intl.formatMessage(i18n.refreshing)
: intl.formatMessage(i18n.refresh)}
</Button>
<Button
onClick={() => {
@@ -519,7 +550,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
<div className="h-full relative">
{apiError && (
<div className="mb-4 p-4 bg-background-danger border border-border-danger rounded-md">
<p className="text-text-danger text-sm">{intl.formatMessage(i18n.errorPrefix, { error: apiError })}</p>
<p className="text-text-danger text-sm">
{intl.formatMessage(i18n.errorPrefix, { error: apiError })}
</p>
</div>
)}
@@ -1,9 +1,16 @@
import { describe, it, expect, vi } from 'vitest';
import { render, type RenderOptions, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, type RenderOptions, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ScheduledJobDto } from '@aaif/goose-sdk';
import { ScheduleModal } from '../ScheduleModal';
import { IntlTestWrapper } from '../../../i18n/test-utils';
import { listSavedRecipes } from '../../../recipe/recipe_management';
import type { RecipeManifest } from '../../../recipe';
vi.mock('../../../recipe/recipe_management', () => ({
listSavedRecipes: vi.fn(),
getStorageDirectory: vi.fn(() => ''),
}));
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
@@ -21,7 +28,27 @@ const baseProps = {
initialDeepLink: null,
};
const savedRecipeManifest = {
id: 'my-recipe',
recipe: { title: 'My Recipe', description: 'A test recipe' },
file_path: '/recipes/my-recipe.yaml',
last_modified: '',
} as unknown as RecipeManifest;
const alreadyScheduledManifest = {
id: 'already-scheduled',
recipe: { title: 'Already Scheduled', description: 'Has a cron' },
file_path: '/recipes/already-scheduled.yaml',
last_modified: '',
schedule_cron: '0 0 9 * * *',
} as unknown as RecipeManifest;
describe('ScheduleModal', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listSavedRecipes).mockResolvedValue([savedRecipeManifest]);
});
it('preserves the form when the recipe picker is cancelled', async () => {
const user = userEvent.setup();
const selectRecipeFile = vi.fn().mockResolvedValue(null);
@@ -50,4 +77,119 @@ describe('ScheduleModal', () => {
expect(screen.getByText('Edit Schedule')).toBeInTheDocument();
expect(screen.queryByText('Please provide a valid recipe source.')).not.toBeInTheDocument();
});
it('loads saved recipes into a picker and creates a schedule from the selected one', async () => {
const user = userEvent.setup();
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
await waitFor(() => {
expect(listSavedRecipes).toHaveBeenCalledTimes(1);
});
const picker = within(screen.getByTestId('saved-recipe-picker'));
await user.click(await picker.findByRole('combobox'));
const option = await picker.findByRole('option', { name: 'My Recipe' });
await user.click(option);
await waitFor(() => {
expect(screen.getByText('Title: My Recipe')).toBeInTheDocument();
});
const createButton = screen.getByRole('button', { name: 'Create Schedule' });
expect(createButton).not.toBeDisabled();
await user.click(createButton);
expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument();
await waitFor(() => {
expect(baseProps.onSubmit).toHaveBeenCalledWith({
sourceType: 'saved',
recipeId: 'my-recipe',
cron: expect.any(String),
});
});
});
it('shows an empty state when there are no saved recipes', async () => {
vi.mocked(listSavedRecipes).mockResolvedValue([]);
const user = userEvent.setup();
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
await waitFor(() => {
expect(screen.getByText('No saved recipes found.')).toBeInTheDocument();
});
});
it('surfaces recipe list failures instead of an empty state', async () => {
vi.mocked(listSavedRecipes).mockRejectedValue(new Error('ACP down'));
const user = userEvent.setup();
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
await waitFor(() => {
expect(screen.getByText('Failed to load recipes.')).toBeInTheDocument();
});
expect(screen.queryByText('No saved recipes found.')).not.toBeInTheDocument();
});
it('blocks creating a schedule from an already-scheduled recipe', async () => {
vi.mocked(listSavedRecipes).mockResolvedValue([alreadyScheduledManifest]);
const user = userEvent.setup();
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
await waitFor(() => {
expect(listSavedRecipes).toHaveBeenCalled();
});
const picker = within(screen.getByTestId('saved-recipe-picker'));
await user.click(await picker.findByRole('combobox'));
await user.click(
await picker.findByRole('option', { name: /Already Scheduled \(already scheduled\)/i })
);
await waitFor(() => {
expect(
screen.getByText('This recipe already has a schedule. Edit it from the Recipes list.')
).toBeInTheDocument();
});
expect(baseProps.onSubmit).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Create Schedule' }));
expect(baseProps.onSubmit).not.toHaveBeenCalled();
});
it('clears a previously parsed recipe when switching source tabs', async () => {
const user = userEvent.setup();
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
await waitFor(() => {
expect(listSavedRecipes).toHaveBeenCalled();
});
const picker = within(screen.getByTestId('saved-recipe-picker'));
await user.click(await picker.findByRole('combobox'));
await user.click(await picker.findByRole('option', { name: 'My Recipe' }));
await waitFor(() => {
expect(screen.getByText('Title: My Recipe')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: 'YAML' }));
expect(screen.queryByText('Title: My Recipe')).not.toBeInTheDocument();
await user.type(screen.getByLabelText(/name/i), 'stale-job');
await user.click(screen.getByRole('button', { name: 'Create Schedule' }));
await waitFor(() => {
expect(screen.getByText('Please provide a valid recipe source.')).toBeInTheDocument();
});
expect(baseProps.onSubmit).not.toHaveBeenCalled();
});
});