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