feat(desktop): support multiple cron delivery targets

This commit is contained in:
Carbon
2026-07-29 08:27:04 +03:00
committed by Teknium
parent d408fdbfc4
commit 67927808be
3 changed files with 93 additions and 21 deletions
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import {
cronEditorUpdates,
jobIsScriptOnly,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
} from './cron-job-model'
describe('jobIsScriptOnly', () => {
it('is true when no_agent is set and a script is present', () => {
@@ -31,6 +37,28 @@ describe('validateCronEditor', () => {
})
})
describe('cron delivery targets', () => {
it('parses comma-separated targets and removes duplicates', () => {
expect(parseCronDeliveryTargets('local, telegram,local')).toEqual(['local', 'telegram'])
})
it('falls back to local for an empty stored value', () => {
expect(parseCronDeliveryTargets('')).toEqual(['local'])
})
it('adds a second target in the scheduler comma-separated format', () => {
expect(toggleCronDeliveryTarget('local', 'origin', true)).toBe('local,origin')
})
it('removes one target while keeping the other selection', () => {
expect(toggleCronDeliveryTarget('local,origin', 'local', false)).toBe('origin')
})
it('does not allow the final delivery target to be unchecked', () => {
expect(toggleCronDeliveryTarget('origin', 'origin', false)).toBe('origin')
})
})
describe('cronEditorUpdates', () => {
it('omits prompt when saving a script-only job with an empty prompt', () => {
expect(
@@ -45,6 +45,29 @@ export interface CronEditorSaveValues {
schedule: string
}
export function parseCronDeliveryTargets(value: string): string[] {
const targets = value
.split(',')
.map(target => target.trim())
.filter(Boolean)
return targets.length > 0 ? [...new Set(targets)] : ['local']
}
export function toggleCronDeliveryTarget(value: string, target: string, checked: boolean): string {
const targets = parseCronDeliveryTargets(value)
if (checked) {
return targets.includes(target) ? targets.join(',') : [...targets, target].join(',')
}
if (!targets.includes(target) || targets.length === 1) {
return targets.join(',')
}
return targets.filter(candidate => candidate !== target).join(',')
}
/** Build the API update payload, preserving an empty prompt on script-only jobs. */
export function cronEditorUpdates(values: CronEditorSaveValues, options: { scriptOnlyJob: boolean }): CronJobUpdates {
const updates: CronJobUpdates = {
+41 -20
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
@@ -73,7 +74,13 @@ import {
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { BlueprintSlotControl, blueprintSlotHelp, cleanBlueprintFieldError, initialBlueprintValues } from './blueprints'
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
import {
cronEditorUpdates,
jobIsScriptOnly,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
} from './cron-job-model'
import { jobState, jobTitle, STATE_DOT } from './job-state'
const DEFAULT_DELIVER = 'local'
@@ -778,10 +785,11 @@ function deliverTargetLabel(target: CronDeliveryTarget, c: Translations['cron'])
return target.id !== 'local' && !target.home_target_set ? `${base} — ${c.deliverNeedsHomeChannel}` : base
}
// The delivery-target dropdown, shared by the manual cron editor and the
// blueprint form so both offer exactly the connected platforms (never a
// hardcoded list). While the targets load, keep the current value selectable.
function DeliverSelect({
// The delivery-target checkbox group, shared by the manual cron editor and the
// blueprint form. The scheduler accepts comma-separated targets, so users can
// keep results local while also sending them to connected platforms. Preserve
// selected targets missing from discovery so editing never drops a saved route.
function DeliverCheckboxes({
c,
id,
onChange,
@@ -794,21 +802,34 @@ function DeliverSelect({
targets: CronDeliveryTarget[]
value: string
}) {
const options = targets.length > 0 ? targets : [{ home_env_var: null, home_target_set: true, id: value, name: value }]
const selected = parseCronDeliveryTargets(value)
const knownIds = new Set(targets.map(target => target.id))
const options = [
...targets,
...selected
.filter(target => !knownIds.has(target))
.map(target => ({ home_env_var: null, home_target_set: true, id: target, name: target }))
]
return (
<Select onValueChange={onChange} value={value}>
<SelectTrigger className="h-9 rounded-md" id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map(target => (
<SelectItem key={target.id} value={target.id}>
{deliverTargetLabel(target, c)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-2 rounded-md border border-input px-3 py-2.5" id={id} role="group">
{options.map((target, index) => {
const checked = selected.includes(target.id)
const checkboxId = `${id}-${index}`
return (
<label className="flex items-center gap-2 text-sm" htmlFor={checkboxId} key={target.id}>
<Checkbox
checked={checked}
id={checkboxId}
onCheckedChange={next => onChange(toggleCronDeliveryTarget(value, target.id, next === true))}
/>
<span>{deliverTargetLabel(target, c)}</span>
</label>
)
})}
</div>
)
}
@@ -1041,7 +1062,7 @@ function CronEditorDialog({
// Use the shared, backend-sourced delivery targets (same as the
// manual editor) rather than the blueprint's static field.options,
// so both dialogs offer exactly the connected platforms.
<DeliverSelect
<DeliverCheckboxes
c={c}
id={fieldId}
onChange={next => setSlotValues(prev => ({ ...prev, [field.name]: next }))}
@@ -1122,7 +1143,7 @@ function CronEditorDialog({
</Field>
<Field htmlFor="cron-deliver" label={c.deliverLabel}>
<DeliverSelect
<DeliverCheckboxes
c={c}
id="cron-deliver"
onChange={setDeliver}