diff --git a/ui/desktop/src/components/schedule/ScheduleModal.tsx b/ui/desktop/src/components/schedule/ScheduleModal.tsx index eb3aeff08..8dd15adfb 100644 --- a/ui/desktop/src/components/schedule/ScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/ScheduleModal.tsx @@ -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 = ({ @@ -76,6 +135,16 @@ export const ScheduleModal: React.FC = ({ const [cronExpression, setCronExpression] = useState('0 0 14 * * *'); const [internalValidationError, setInternalValidationError] = useState(null); const [isValid, setIsValid] = useState(true); + const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipesLoading, setSavedRecipesLoading] = useState(false); + const [savedRecipesError, setSavedRecipesError] = useState(null); + const [selectedSavedRecipeId, setSelectedSavedRecipeId] = useState(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 = ({ 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 = ({ 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 = ({ } }, [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 = ({ 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 = ({ 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 = ({ 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 ? ( +
+

+ ✓ {intl.formatMessage(i18n.recipeParsed)} +

+

+ {intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })} +

+

+ {intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })} +

+
+ ) : null; + return (
@@ -196,7 +407,9 @@ export const ScheduleModal: React.FC = ({ Clock

- {isEditMode ? intl.formatMessage(i18n.editSchedule) : intl.formatMessage(i18n.createNewSchedule)} + {isEditMode + ? intl.formatMessage(i18n.editSchedule) + : intl.formatMessage(i18n.createNewSchedule)}

{isEditMode &&

{schedule.id}

}
@@ -221,29 +434,31 @@ export const ScheduleModal: React.FC = ({ {!isEditMode && ( <> -
- - setScheduleId(e.target.value)} - placeholder={intl.formatMessage(i18n.namePlaceholder)} - required - /> -
+ {sourceType !== 'saved' && ( +
+ + setScheduleId(e.target.value)} + placeholder={intl.formatMessage(i18n.namePlaceholder)} + required + /> +
+ )}
+
{sourceType === 'file' && ( @@ -292,19 +518,39 @@ export const ScheduleModal: React.FC = ({ placeholder={intl.formatMessage(i18n.deepLinkPlaceholder)} className="rounded-full" /> - {parsedRecipe && ( -
-

- ✓ {intl.formatMessage(i18n.recipeParsed)} -

-

- {intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })} -

-

- {intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })} -

-
+ {recipeParsedPreview} +
+ )} + + {sourceType === 'saved' && ( +
+ {savedRecipesError ? ( +

{savedRecipesError}

+ ) : savedRecipes.length === 0 ? ( +

+ {savedRecipesLoading + ? intl.formatMessage(i18n.loadingRecipes) + : intl.formatMessage(i18n.noSavedRecipes)} +

+ ) : ( +