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();
});
});
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Zeitplan-ID wird angezeigt: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (bereits geplant)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Nach YAML-Datei suchen..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Zeitplan bearbeiten"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Rezepte konnten nicht geladen werden."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Rezept konnte nicht aus der Datei gelesen werden."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Ungültiger Dateityp: Bitte wählen Sie eine YAML-Datei (.yaml oder .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Rezepte werden geladen..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Name:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "z. B. daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Keine gespeicherten Rezepte gefunden."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Bitte geben Sie eine gültige Rezeptquelle an."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Dieses Rezept hat bereits einen Zeitplan. Bearbeiten Sie ihn in der Rezeptliste."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Beschreibung: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Titel: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Gespeicherte Rezepte"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Zeitplan-ID ist erforderlich."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Zeitplan:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Ein gespeichertes Rezept auswählen"
},
"scheduleModal.selected": {
"defaultMessage": "Ausgewählt: {path}"
},
+21
View File
@@ -3455,6 +3455,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Viewing Schedule ID: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (already scheduled)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Browse for YAML file..."
},
@@ -3479,6 +3482,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Edit Schedule"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Failed to load recipes."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Failed to parse recipe from file."
},
@@ -3491,15 +3497,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Invalid file type: Please select a YAML file (.yaml or .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Loading recipes..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Name:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "e.g., daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "No saved recipes found."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Please provide a valid recipe source."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "This recipe already has a schedule. Edit it from the Recipes list."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Description: {description}"
},
@@ -3509,12 +3524,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Title: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Saved recipes"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Schedule ID is required."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Schedule:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Select a saved recipe"
},
"scheduleModal.selected": {
"defaultMessage": "Selected: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Viendo el ID de programación: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (ya programada)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Buscar archivo YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Editar programación"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Error al cargar las recetas."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "No se pudo analizar la receta del archivo."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Tipo de archivo no válido: selecciona un archivo YAML (.yaml o .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Cargando recetas..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nombre:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "p. ej., resumen-diario"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "No se encontraron recetas guardadas."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Proporciona un origen de receta válido."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Esta receta ya tiene una programación. Edítala desde la lista de Recetas."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Descripción: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Título: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Recetas guardadas"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Se requiere el ID de la programación."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Programación:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Selecciona una receta guardada"
},
"scheduleModal.selected": {
"defaultMessage": "Seleccionado: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Affichage de l'ID de planification : {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (déjà planifiée)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Parcourir pour un fichier YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Modifier la planification"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Échec du chargement des recettes."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Échec de l'analyse de la recette à partir du fichier."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Type de fichier invalide : veuillez sélectionner un fichier YAML (.yaml ou .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Chargement des recettes..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nom :"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "p. ex. daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Aucune recette enregistrée trouvée."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Veuillez fournir une source de recette valide."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Cette recette a déjà une planification. Modifiez-la depuis la liste des Recettes."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Description : {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Titre : {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Recettes enregistrées"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "L'ID de planification est requis."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Planification :"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Sélectionner une recette enregistrée"
},
"scheduleModal.selected": {
"defaultMessage": "Sélectionné : {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "शेड्यूल आईडी देखना: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (पहले से शेड्यूल)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "YAML फ़ाइल ब्राउज़ करें..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "शेड्यूल संपादित करें"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "रेसिपी लोड करने में विफल।"
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "फ़ाइल से रेसिपी पार्स करने में विफल।"
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "अमान्य फ़ाइल प्रकार: कृपया YAML file (.yaml या .yml) चुनें"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "रेसिपी लोड हो रही हैं..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "नाम:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "उदाहरण के लिए, दैनिक-सारांश-कार्य"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "कोई सहेजी गई रेसिपी नहीं मिली।"
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "कृपया एक मान्य रेसिपी स्रोत दें।"
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "इस रेसिपी का पहले से शेड्यूल है। रेसिपी सूची से इसे संपादित करें।"
},
"scheduleModal.recipeDescription": {
"defaultMessage": "विवरण: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "शीर्षक: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "सहेजी गई रेसिपी"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "शेड्यूल आईडी आवश्यक है."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "अनुसूची:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "एक सहेजी गई रेसिपी चुनें"
},
"scheduleModal.selected": {
"defaultMessage": "चयनित: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Melihat ID Jadwal: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (sudah dijadwalkan)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Telusuri berkas YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Edit Jadwal"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Gagal memuat resep."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Gagal mengurai resep dari berkas."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Jenis berkas tidak valid: Harap pilih berkas YAML (.yaml atau .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Memuat resep..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nama:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "mis., daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Tidak ada resep tersimpan ditemukan."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Harap berikan sumber resep yang valid."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Resep ini sudah memiliki jadwal. Edit dari daftar Resep."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Deskripsi: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Judul: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Resep tersimpan"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "ID jadwal diperlukan."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Jadwal:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Pilih resep tersimpan"
},
"scheduleModal.selected": {
"defaultMessage": "Dipilih: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Visualizzazione ID pianificazione: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (già pianificata)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Sfoglia file YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Modifica pianificazione"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Caricamento delle ricette non riuscito."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Impossibile analizzare la ricetta dal file."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Tipo di file non valido: seleziona un file YAML (.yaml o .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Caricamento ricette..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nome:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "es. daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Nessuna ricetta salvata trovata."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Fornisci un'origine ricetta valida."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Questa ricetta ha già una pianificazione. Modificala dall'elenco Ricette."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Descrizione: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Titolo: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Ricette salvate"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "L'ID pianificazione è obbligatorio."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Pianificazione:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Seleziona una ricetta salvata"
},
"scheduleModal.selected": {
"defaultMessage": "Selezionato: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "表示中のスケジュールID: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title}(スケジュール済み)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "YAMLファイルを選択..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "スケジュールを編集"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "レシピの読み込みに失敗しました。"
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "ファイルからレシピを解析できませんでした。"
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "無効なファイル形式です。YAMLファイル(.yaml または .yml)を選択してください"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "レシピを読み込み中..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "名前:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "例: daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "保存したレシピが見つかりません。"
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "有効なレシピソースを指定してください。"
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "このレシピには既にスケジュールがあります。レシピ一覧から編集してください。"
},
"scheduleModal.recipeDescription": {
"defaultMessage": "説明: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "タイトル: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "保存したレシピ"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "スケジュールIDは必須です。"
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "スケジュール:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "保存したレシピを選択"
},
"scheduleModal.selected": {
"defaultMessage": "選択済み: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "일정 ID 보기: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (이미 예약됨)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "YAML 파일 찾아보기..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "일정 편집"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "레시피를 불러오지 못했습니다."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "파일에서 레시피를 구문 분석하지 못했습니다."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "잘못된 파일 형식: YAML 파일(.yaml 또는 .yml)을 선택하세요."
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "레시피 불러오는 중..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "이름:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "예: daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "저장된 레시피가 없습니다."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "유효한 레시피 소스를 입력하세요."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "이 레시피에는 이미 일정이 있습니다. 레시피 목록에서 편집하세요."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "설명: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "제목: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "저장된 레시피"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "일정 ID가 필요합니다."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "일정:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "저장된 레시피 선택"
},
"scheduleModal.selected": {
"defaultMessage": "선택됨: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Melihat ID Jadual: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (sudah dijadualkan)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Layari fail YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Edit Jadual"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Gagal memuatkan resipi."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Gagal menghuraikan resipi daripada fail."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Jenis fail tidak sah: Sila pilih fail YAML (.yaml atau .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Memuatkan resipi..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nama:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "cth., daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Tiada resipi tersimpan dijumpai."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Sila sediakan sumber resipi yang sah."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Resipi ini sudah mempunyai jadual. Edit dari senarai Resipi."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Penerangan: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Tajuk: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Resipi tersimpan"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "ID jadual diperlukan."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Jadual:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Pilih resipi tersimpan"
},
"scheduleModal.selected": {
"defaultMessage": "Dipilih: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "A visualizar o ID do agendamento: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (já agendada)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Procurar ficheiro YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Editar agendamento"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Falha ao carregar receitas."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Falha ao analisar a receita a partir do ficheiro."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Tipo de ficheiro inválido: selecione um ficheiro YAML (.yaml ou .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Carregando receitas..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Nome:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "p. ex., tarefa-resumo-diario"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Nenhuma receita salva encontrada."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Forneça uma origem de receita válida."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Esta receita já tem um agendamento. Edite-o na lista de Receitas."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Descrição: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Título: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Receitas salvas"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "O ID do agendamento é obrigatório."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Agendamento:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Selecionar uma receita salva"
},
"scheduleModal.selected": {
"defaultMessage": "Selecionado: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Просмотр ID расписания: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (уже запланирован)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Выбрать YAML-файл..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Изменить расписание"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Не удалось загрузить рецепты."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Не удалось разобрать рецепт из файла."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Неверный тип файла: выберите YAML-файл (.yaml или .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Загрузка рецептов..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Имя:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "например, daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Сохранённые рецепты не найдены."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Укажите корректный источник рецепта."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "У этого рецепта уже есть расписание. Измените его в списке рецептов."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Описание: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Название: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Сохранённые рецепты"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Требуется ID расписания."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Расписание:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Выберите сохранённый рецепт"
},
"scheduleModal.selected": {
"defaultMessage": "Выбрано: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Program Kimliğini Görüntüleme: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (zaten zamanlanmış)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "YAML dosyasına göz atın..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Programı Düzenle"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Tarifeler yüklenemedi."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Dosyadan tarif ayrıştırılamadı."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Geçersiz dosya türü: Lütfen bir YAML dosyası seçin (.yaml veya.yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Tarifeler yükleniyor..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "İsim:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "örneğin günlük özet işi"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Kayıtlı tarife bulunamadı."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Lütfen geçerli bir tarif kaynağı sağlayın."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Bu tarifin zaten bir zamanlaması var. Tarif listesinden düzenleyin."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Açıklama: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Başlık: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Kayıtlı tarifeler"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Program kimliği gerekli."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Program:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Kayıtlı bir tarife seç"
},
"scheduleModal.selected": {
"defaultMessage": "Seçili: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "Đang xem ID lịch trình: {id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title} (đã lên lịch)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "Duyệt tìm tệp YAML..."
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "Sửa lịch trình"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "Không thể tải công thức."
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "Không thể phân tích công thức từ tệp."
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "Loại tệp không hợp lệ: Vui lòng chọn tệp YAML (.yaml hoặc .yml)"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "Đang tải công thức..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "Tên:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "ví dụ: daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "Không tìm thấy công thức đã lưu."
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "Vui lòng cung cấp nguồn công thức hợp lệ."
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "Công thức này đã có lịch. Chỉnh sửa từ danh sách Công thức."
},
"scheduleModal.recipeDescription": {
"defaultMessage": "Mô tả: {description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "Tiêu đề: {title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "Công thức đã lưu"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "Bắt buộc phải có ID lịch trình."
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "Lịch trình:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "Chọn một công thức đã lưu"
},
"scheduleModal.selected": {
"defaultMessage": "Đã chọn: {path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "正在查看计划 ID{id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title}(已安排)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "浏览 YAML 文件…"
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "编辑计划"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "加载方案失败。"
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "从文件解析配方失败。"
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "文件类型无效:请选择 YAML 文件(.yaml 或 .yml"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "正在加载方案..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "名称:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "例如:daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "未找到已保存的方案。"
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "请提供有效的配方来源。"
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "此配方已有计划。请从配方列表中编辑。"
},
"scheduleModal.recipeDescription": {
"defaultMessage": "描述:{description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "标题:{title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "已保存的方案"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "计划 ID 为必填项。"
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "计划:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "选择一个已保存的方案"
},
"scheduleModal.selected": {
"defaultMessage": "已选择:{path}"
},
+21
View File
@@ -3425,6 +3425,9 @@
"scheduleDetailView.viewingScheduleId": {
"defaultMessage": "正在檢視排程 ID{id}"
},
"scheduleModal.alreadyScheduledOption": {
"defaultMessage": "{title}(已排程)"
},
"scheduleModal.browseYaml": {
"defaultMessage": "瀏覽 YAML 檔案…"
},
@@ -3449,6 +3452,9 @@
"scheduleModal.editSchedule": {
"defaultMessage": "編輯排程"
},
"scheduleModal.failedLoadRecipes": {
"defaultMessage": "載入方案失敗。"
},
"scheduleModal.failedParseRecipe": {
"defaultMessage": "無法從檔案剖析食譜。"
},
@@ -3461,15 +3467,24 @@
"scheduleModal.invalidFileType": {
"defaultMessage": "無效的檔案類型:請選擇 YAML 檔案(.yaml 或 .yml"
},
"scheduleModal.loadingRecipes": {
"defaultMessage": "正在載入方案..."
},
"scheduleModal.nameLabel": {
"defaultMessage": "名稱:"
},
"scheduleModal.namePlaceholder": {
"defaultMessage": "例如:daily-summary-job"
},
"scheduleModal.noSavedRecipes": {
"defaultMessage": "找不到已儲存的方案。"
},
"scheduleModal.provideValidRecipe": {
"defaultMessage": "請提供有效的食譜來源。"
},
"scheduleModal.recipeAlreadyScheduled": {
"defaultMessage": "此配方已有排程。請從配方清單中編輯。"
},
"scheduleModal.recipeDescription": {
"defaultMessage": "描述:{description}"
},
@@ -3479,12 +3494,18 @@
"scheduleModal.recipeTitle": {
"defaultMessage": "標題:{title}"
},
"scheduleModal.savedRecipes": {
"defaultMessage": "已儲存的方案"
},
"scheduleModal.scheduleIdRequired": {
"defaultMessage": "排程 ID 為必填。"
},
"scheduleModal.scheduleLabel": {
"defaultMessage": "排程:"
},
"scheduleModal.selectRecipePlaceholder": {
"defaultMessage": "選擇一個已儲存的方案"
},
"scheduleModal.selected": {
"defaultMessage": "已選取:{path}"
},
+1 -6
View File
@@ -30,12 +30,7 @@ export const saveRecipe = async (
};
export const listSavedRecipes = async (): Promise<RecipeManifest[]> => {
try {
return await acpListRecipes();
} catch (error) {
console.warn('Failed to list saved recipes:', error);
return [];
}
return await acpListRecipes();
};
export const deleteRecipe = async (id: string): Promise<void> => {
+6 -2
View File
@@ -70,7 +70,11 @@ export type AnalyticsEvent =
}
| {
name: 'schedule_created';
properties: { source_type: 'file' | 'deeplink'; success: boolean; error_details?: string };
properties: {
source_type: 'file' | 'deeplink' | 'saved';
success: boolean;
error_details?: string;
};
}
| { name: 'schedule_deleted'; properties: { success: boolean; error_details?: string } }
| { name: 'schedule_run_now'; properties: { success: boolean; error_details?: string } }
@@ -419,7 +423,7 @@ export function trackExtensionDeleted(
// ============================================================================
export function trackScheduleCreated(
sourceType: 'file' | 'deeplink',
sourceType: 'file' | 'deeplink' | 'saved',
success: boolean,
errorDetails?: string
): void {