"use client" import * as React from 'react' import { CalendarClock, Hand, Plus, Radio, Trash2, TriangleAlert } from 'lucide-react' // Deep import, NOT the package root: `@open-mercato/scheduler`'s index also // exports `SchedulerService`, which reaches `shared/lib/i18n/server` and its // `require('server-only')`. Pulling that into a "use client" module breaks the // build with 'server-only' cannot be imported from a Client Component module. // `lib/cronParser` depends on `cron-parser` alone and is safe in the browser. import { validateCronExpression } from '@open-mercato/scheduler/modules/scheduler/lib/cronParser' import { Button } from '@open-mercato/ui/primitives/button' import { IconButton } from '@open-mercato/ui/primitives/icon-button' import { Input } from '@open-mercato/ui/primitives/input' import { Switch } from '@open-mercato/ui/primitives/switch' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { useT } from '@open-mercato/shared/lib/i18n/context' import { PROCESS_TRIGGERS_MAX, isValidIanaTimeZone, processEventTriggerConfigSchema, type ProcessManualTrigger, type ProcessTrigger, } from '../../../data/validators' import { parseGrantedFeaturesText, listTimeZones } from './formHelpers' const TIMEZONE_DATALIST_ID = 'om-process-trigger-timezones' type Translate = ReturnType export type TriggerEditorProps = { value: ProcessTrigger[] onChange: (next: ProcessTrigger[]) => void /** Reported to the parent so it can block a save on a cron that would never fire. */ onValidityChange?: (valid: boolean) => void disabled?: boolean locale: string t: Translate } function replaceAt(list: ProcessTrigger[], index: number, next: ProcessTrigger): ProcessTrigger[] { return list.map((item, position) => (position === index ? next : item)) } function formatNextRuns(cron: string, timezoneRaw: string, locale: string): { ok: boolean; text: string } { const timezone = timezoneRaw && isValidIanaTimeZone(timezoneRaw) ? timezoneRaw : 'UTC' const result = validateCronExpression(cron, { timezone, count: 3 }) if (!result.ok || !result.nextRuns?.length) return { ok: false, text: result.error ?? '' } return { ok: true, text: result.nextRuns .map((run) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(run)) .join(' · '), } } /** A schedule trigger is invalid when its cron would never fire — caught at save, not at fire time. */ export function invalidScheduleIndexes(triggers: ProcessTrigger[]): number[] { const invalid: number[] = [] triggers.forEach((trigger, index) => { if (trigger.kind !== 'schedule') return const timezone = isValidIanaTimeZone(trigger.timezone) ? trigger.timezone : 'UTC' if (!validateCronExpression(trigger.cron, { timezone, count: 1 }).ok) invalid.push(index) }) return invalid } function TriggerCard({ icon: Icon, title, onRemove, removeLabel, disabled, children, }: { icon: typeof CalendarClock title: string onRemove?: () => void removeLabel?: string disabled?: boolean children: React.ReactNode }) { return (
{title} {onRemove ? ( ) : null}
{children}
) } /** * The declared-trigger editor — the `"use client"` island the triggered process * model calls for (cron validation and add/remove are interaction, not * convenience). Edits the ONE `triggers` list that replaced cron columns, the * event-trigger table and the undocumented run route. * * The event `config` is edited as JSON on purpose: it carries typed ARRAYS * (`filterConditions`, `contextMapping`) whose full expressiveness a two-field * form would quietly truncate. */ export function TriggerEditor({ value, onChange, onValidityChange, disabled, locale, t }: TriggerEditorProps) { const timeZones = React.useMemo(() => listTimeZones(), []) const [configDrafts, setConfigDrafts] = React.useState>({}) const [configErrors, setConfigErrors] = React.useState>({}) const invalidSchedules = React.useMemo(() => new Set(invalidScheduleIndexes(value)), [value]) const hasConfigError = Object.values(configErrors).some(Boolean) React.useEffect(() => { onValidityChange?.(invalidSchedules.size === 0 && !hasConfigError) }, [invalidSchedules, hasConfigError, onValidityChange]) const manualIndex = value.findIndex((trigger) => trigger.kind === 'manual') const manual = manualIndex >= 0 ? (value[manualIndex] as ProcessManualTrigger) : null const atCap = value.length >= PROCESS_TRIGGERS_MAX const setManual = React.useCallback( (next: boolean) => { if (next && manualIndex < 0) { onChange([...value, { kind: 'manual', requireFeatures: [] }]) } else if (!next && manualIndex >= 0) { onChange(value.filter((_, index) => index !== manualIndex)) } }, [manualIndex, onChange, value], ) const addSchedule = React.useCallback(() => { onChange([...value, { kind: 'schedule', cron: '0 7 * * *', timezone: 'UTC', enabled: true }]) }, [onChange, value]) const addEvent = React.useCallback(() => { onChange([...value, { kind: 'event', eventPattern: '', priority: 0, enabled: true }]) }, [onChange, value]) const removeAt = React.useCallback( (index: number) => { setConfigDrafts({}) setConfigErrors({}) onChange(value.filter((_, position) => position !== index)) }, [onChange, value], ) return (
{timeZones.map((zone) => (
{t('agent_orchestrator.processDefinitions.triggers.manual.toggle')}
{manual ? (