diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index daabb62..ebd30fb 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -79,6 +79,26 @@ export const NodeConfigPanel: React.FC = ({ node, [onParametersChange] ); + const patchParams = useCallback( + (patch: Record) => { + setParams((prev) => { + const next = { ...prev, ...patch }; + const id = nodeIdRef.current; + if (id) { + if (notifyParentTimeoutRef.current != null) { + clearTimeout(notifyParentTimeoutRef.current); + } + notifyParentTimeoutRef.current = setTimeout(() => { + notifyParentTimeoutRef.current = null; + onParametersChange(id, next); + }, 0); + } + return next; + }); + }, + [onParametersChange] + ); + const dataFlow = useAutomation2DataFlow(); const portTypeCatalog: Record = (dataFlow?.portTypeCatalog as Record | undefined) ?? {}; @@ -239,7 +259,7 @@ export const NodeConfigPanel: React.FC = ({ node, const frontendType = param.frontendType || 'text'; const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; return ( -
+
= ({ node, instanceId={instanceId} request={request} nodeType={node.type} + onPatchParams={patchParams} />
); diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index 4f62365..bd2c049 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -23,6 +23,8 @@ export interface FieldRendererProps { instanceId?: string; request?: ApiRequestFunction; nodeType?: string; + /** Atomically merge several parameter keys (e.g. cron + schedule). */ + onPatchParams?: (patch: Record) => void; } export type FieldRendererComponent = ComponentType; @@ -32,6 +34,13 @@ export type FieldRendererComponent = ComponentType; // --------------------------------------------------------------------------- import React from 'react'; +import { SchedulePlanner } from '../../../SchedulePlanner'; +import { + buildCronFromSpec, + scheduleSpecFromParams, + scheduleSpecToPersistentJson, + type ScheduleSpec, +} from '../runtime/scheduleCron'; import { useLanguage } from '../../../../providers/language/LanguageContext'; import { toApiGraph } from '../shared/graphUtils'; @@ -773,19 +782,33 @@ const KeyValueRowsEditor: React.FC = ({ param, value, onChan ); }; -const CronBuilder: React.FC = ({ param, value, onChange }) => { - const { t } = useLanguage(); +const CronBuilder: React.FC = ({ param, value, onChange, allParams, onPatchParams }) => { + const spec = React.useMemo( + () => + scheduleSpecFromParams({ + ...(allParams ?? {}), + cron: + (typeof value === 'string' && value + ? value + : typeof allParams?.cron === 'string' + ? allParams.cron + : '') as string, + } as Record), + [allParams, value] + ); + const handlePlanner = React.useCallback( + (next: ScheduleSpec) => { + const cron = buildCronFromSpec(next); + const schedule = scheduleSpecToPersistentJson(next); + if (onPatchParams) onPatchParams({ cron, schedule }); + else onChange(cron); + }, + [onChange, onPatchParams] + ); return (
- - onChange(e.target.value)} - placeholder={t('0 9 * * *')} - style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }} - /> -

{t('Cron: Min Stunde Tag Monat')}

+ +
); }; diff --git a/src/components/FlowEditor/nodes/runtime/scheduleCron.ts b/src/components/FlowEditor/nodes/runtime/scheduleCron.ts index 6355d56..489917f 100644 --- a/src/components/FlowEditor/nodes/runtime/scheduleCron.ts +++ b/src/components/FlowEditor/nodes/runtime/scheduleCron.ts @@ -4,31 +4,47 @@ * Intervall Sekunden: 6 Felder (sec min hour dom month dow) */ -export type ScheduleMode = 'daily' | 'weekdays' | 'weekly' | 'calendar' | 'interval'; +/** Primary planner modes (+ legacy aliases still read/written). */ +export type ScheduleMode = + | 'minutes' + | 'hours' + | 'days' + | 'weeks' + | 'months' + | 'custom' + | 'daily' + | 'weekdays' + | 'weekly' + | 'calendar' + | 'interval'; export type CalendarPeriod = 'monthly' | 'yearly'; -/** sek, min, h, T (Tage), a (Jahre) — Cron nur näherungsweise für T/a */ +/** sek, min, h, T (Tage), a (Jahre) — legacy interval mode */ export type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years'; export interface ScheduleSpec { mode: ScheduleMode; hour: number; minute: number; - /** 0–6, cron DOW; nur bei mode === 'weekly' */ + /** 0–6 cron DOW; für weeks / weekly / weekdays */ weekdays: number[]; - /** Monatlich: Tag 1–31; Jährlich: Tag im gewählten Monat */ + /** Tag des Monats 1–31 (Planner months: 1–28 empfohlen) */ monthDay: number; - /** 1–12, nur bei calendar + yearly */ + /** 1–12, nur bei calendar + yearly (Legacy) */ monthIndex: number; calendarPeriod: CalendarPeriod; intervalValue: number; intervalUnit: IntervalUnit; + /** mode === 'custom': Roh-Cron (5 oder 6 Felder) */ + customCron: string; + /** mode === 'weeks': alle W Wochen (Phase 1: Cron entspricht W===1; W>1 nur in schedule persistiert) */ + weeksInterval: number; } export const WEEKDAY_LABELS_DE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] as const; -/** Anzeige Mo–So (cronDow wie oben) */ +/** Anzeige Mo–So (cron DOW) */ export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [ { cronDow: 1, label: 'Mo' }, { cronDow: 2, label: 'Di' }, @@ -39,17 +55,27 @@ export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [ { cronDow: 0, label: 'So' }, ]; +/** Minuten-Optionen für Dropdowns (wie Prototyp + feinere Schritte bis 59) */ +export const MINUTE_SELECT_OPTIONS: readonly number[] = (() => { + const base = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]; + const set = new Set(base); + for (let i = 0; i < 60; i++) if (!set.has(i)) set.add(i); + return [...set].sort((a, b) => a - b); +})(); + export function defaultScheduleSpec(): ScheduleSpec { return { - mode: 'daily', - hour: 8, + mode: 'days', + hour: 9, minute: 0, weekdays: [1, 2, 3, 4, 5], monthDay: 1, monthIndex: 1, calendarPeriod: 'monthly', - intervalValue: 15, + intervalValue: 1, intervalUnit: 'minutes', + customCron: '0 9 * * 1-5', + weeksInterval: 1, }; } @@ -57,26 +83,119 @@ function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)); } +/** Normalisiert Legacy-Modi für Planner-UI (ohne Cron neu zu bauen). */ +export function normalizeSpecForPlanner(spec: ScheduleSpec): ScheduleSpec { + const s = { ...spec }; + switch (s.mode) { + case 'daily': + return { ...s, mode: 'days', intervalValue: 1 }; + case 'weekdays': + return { + ...s, + mode: 'weeks', + weeksInterval: 1, + weekdays: [1, 2, 3, 4, 5], + }; + case 'weekly': + return { ...s, mode: 'weeks', weeksInterval: s.weeksInterval || 1 }; + case 'calendar': + if (s.calendarPeriod === 'yearly') { + return { + ...defaultScheduleSpec(), + mode: 'custom', + customCron: buildCronFromSpec({ ...s, mode: 'calendar' }), + }; + } + return { + ...s, + mode: 'months', + intervalValue: 1, + monthDay: clamp(s.monthDay, 1, 28), + }; + case 'interval': { + const u = s.intervalUnit; + if (u === 'minutes') + return { ...s, mode: 'minutes', intervalValue: Math.max(1, s.intervalValue) }; + if (u === 'hours') + return { + ...s, + mode: 'hours', + intervalValue: Math.max(1, s.intervalValue), + minute: 0, + }; + if (u === 'days') + return { + ...s, + mode: 'days', + intervalValue: Math.max(1, s.intervalValue), + hour: 0, + minute: 0, + }; + if (u === 'seconds') + return { + ...defaultScheduleSpec(), + mode: 'custom', + customCron: buildCronFromSpec({ ...s, mode: 'interval' }), + }; + return { + ...defaultScheduleSpec(), + mode: 'custom', + customCron: buildCronFromSpec({ ...s, mode: 'interval' }), + }; + } + default: + return s; + } +} + /** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */ export function buildCronFromSpec(spec: ScheduleSpec): string { const m = clamp(Math.floor(spec.minute), 0, 59); const h = clamp(Math.floor(spec.hour), 0, 23); switch (spec.mode) { - case 'daily': - return `${m} ${h} * * *`; - case 'weekdays': - return `${m} ${h} * * 1-5`; + case 'minutes': { + const mm = clamp(Math.floor(spec.intervalValue), 1, 59); + return `*/${mm} * * * *`; + } + case 'hours': { + const hh = clamp(Math.floor(spec.intervalValue), 1, 23); + return `${m} */${hh} * * *`; + } + case 'days': { + const d = Math.max(1, Math.floor(spec.intervalValue)); + if (d <= 1) return `${m} ${h} * * *`; + const step = clamp(d, 2, 31); + return `${m} ${h} */${step} * *`; + } + case 'weeks': case 'weekly': { const days = [...new Set(spec.weekdays)] - .filter((d) => d >= 0 && d <= 6) + .filter((x) => x >= 0 && x <= 6) .sort((a, b) => { const order = (x: number) => (x === 0 ? 7 : x); return order(a) - order(b); }); + // Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck if (days.length === 0) return `${m} ${h} * * 1`; return `${m} ${h} * * ${days.join(',')}`; } + case 'months': { + const dom = clamp(Math.floor(spec.monthDay), 1, 28); + const monIv = Math.max(1, Math.floor(spec.intervalValue)); + if (monIv <= 1) return `${m} ${h} ${dom} * *`; + const step = clamp(monIv, 2, 12); + return `${m} ${h} ${dom} */${step} *`; + } + case 'custom': { + const c = (spec.customCron || '').trim(); + if (!c) return '0 9 * * *'; + return c; + } + case 'daily': + return `${m} ${h} * * *`; + case 'weekdays': + return `${m} ${h} * * 1-5`; case 'calendar': { const dom = clamp(Math.floor(spec.monthDay), 1, 31); if (spec.calendarPeriod === 'monthly') { @@ -89,8 +208,8 @@ export function buildCronFromSpec(spec: ScheduleSpec): string { const v = Math.max(1, Math.floor(spec.intervalValue)); switch (spec.intervalUnit) { case 'seconds': { - const s = clamp(v, 1, 59); - return `*/${s} * * * * *`; + const sec = clamp(v, 1, 59); + return `*/${sec} * * * * *`; } case 'minutes': { const mm = clamp(v, 1, 59); @@ -101,13 +220,12 @@ export function buildCronFromSpec(spec: ScheduleSpec): string { return `0 */${hh} * * *`; } case 'days': { - if (v <= 1) return `0 0 * * *`; + if (v <= 1) return `${m} ${h} * * *`; const d = clamp(v, 2, 31); - return `0 0 */${d} * *`; + return `${m} ${h} */${d} * *`; } case 'years': default: - // Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung return `0 0 1 1 *`; } } @@ -116,10 +234,17 @@ export function buildCronFromSpec(spec: ScheduleSpec): string { } } +/** Validiert 5- oder 6-Feld-Cron (Whitespace-getrennt). */ +export function isValidCronFieldCount(cron: string): boolean { + const n = cron.trim().split(/\s+/).length; + return n === 5 || n === 6; +} + /** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null { if (!cron || typeof cron !== 'string') return null; - const p = cron.trim().split(/\s+/); + const trimmed = cron.trim(); + const p = trimmed.split(/\s+/); if (p.length === 6) { const [secS, minS, hourS, domS, monthS, dowS] = p; @@ -143,63 +268,132 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null { }; } } - return null; + const base = { ...defaultScheduleSpec(), mode: 'custom' as const, customCron: trimmed }; + return base; } if (p.length < 5) return null; - const [minS, hourS, domS, monthS, dowS] = p; - const minute = parseInt(minS, 10); - const hour = parseInt(hourS, 10); - if (Number.isNaN(minute) || Number.isNaN(hour)) return null; + let [minS, hourS, domS, monthS, dowS] = p; - if (minS.startsWith('*/') && p[1] === '*' && domS === '*') { + if (minS.startsWith('*/') && hourS === '*' && domS === '*') { const iv = parseInt(minS.slice(2), 10); if (!Number.isNaN(iv)) { return { ...defaultScheduleSpec(), - mode: 'interval', + mode: 'minutes', intervalValue: iv, - intervalUnit: 'minutes', minute: 0, hour: 0, }; } } + const minNum = parseInt(minS, 10); + const hourNum = parseInt(hourS, 10); + if ( + !minS.startsWith('*/') && + !Number.isNaN(minNum) && + hourS.startsWith('*/') && + domS === '*' && + monthS === '*' && + (dowS === '*' || dowS === '?') + ) { + const iv = parseInt(hourS.slice(2), 10); + if (!Number.isNaN(iv)) { + return { + ...defaultScheduleSpec(), + mode: 'hours', + intervalValue: iv, + minute: minNum, + hour: 0, + }; + } + } + if (minS === '0' && hourS.startsWith('*/') && domS === '*') { const iv = parseInt(hourS.slice(2), 10); if (!Number.isNaN(iv)) { return { ...defaultScheduleSpec(), - mode: 'interval', + mode: 'hours', intervalValue: iv, - intervalUnit: 'hours', minute: 0, hour: 0, }; } } + if ( + !Number.isNaN(minNum) && + !Number.isNaN(hourNum) && + domS.startsWith('*/') && + monthS === '*' && + (dowS === '*' || dowS === '?') + ) { + const iv = parseInt(domS.slice(2), 10); + if (!Number.isNaN(iv)) { + return { + ...defaultScheduleSpec(), + mode: 'days', + intervalValue: iv, + hour: hourNum, + minute: minNum, + }; + } + } + if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) { const iv = parseInt(domS.slice(2), 10); if (!Number.isNaN(iv)) { return { ...defaultScheduleSpec(), - mode: 'interval', + mode: 'days', intervalValue: iv, - intervalUnit: 'days', - minute: 0, hour: 0, + minute: 0, }; } } + if ( + !Number.isNaN(minNum) && + !Number.isNaN(hourNum) && + !domS.startsWith('*/') && + monthS.startsWith('*/') && + (dowS === '*' || dowS === '?') + ) { + const dom = parseInt(domS, 10); + const iv = parseInt(monthS.slice(2), 10); + if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && !Number.isNaN(iv)) { + return { + ...defaultScheduleSpec(), + mode: 'months', + intervalValue: iv, + monthDay: dom, + hour: hourNum, + minute: minNum, + }; + } + } + + const minute = minNum; + const hour = hourNum; + if (Number.isNaN(minute) || Number.isNaN(hour)) { + return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed }; + } + if (domS === '*' && dowS === '*') { return { ...defaultScheduleSpec(), mode: 'daily', hour, minute }; } if (domS === '*' && dowS === '1-5') { - return { ...defaultScheduleSpec(), mode: 'weekdays', hour, minute }; + return { + ...defaultScheduleSpec(), + mode: 'weekdays', + hour, + minute, + weekdays: [1, 2, 3, 4, 5], + }; } if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) { @@ -213,6 +407,7 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null { hour, minute, weekdays: norm, + weeksInterval: 1, }; } } @@ -220,6 +415,13 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null { const dom = parseInt(domS, 10); const month = monthS === '*' ? NaN : parseInt(monthS, 10); + if ( + (domS.includes(',') || domS.includes('-') || domS.includes('/')) && + !domS.startsWith('*/') + ) { + return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed }; + } + if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) { return { ...defaultScheduleSpec(), @@ -251,10 +453,22 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null { }; } - return null; + return { ...defaultScheduleSpec(), mode: 'custom', customCron: trimmed }; } -const VALID_MODES: ScheduleMode[] = ['daily', 'weekdays', 'weekly', 'calendar', 'interval']; +const VALID_MODES: ScheduleMode[] = [ + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', + 'custom', + 'daily', + 'weekdays', + 'weekly', + 'calendar', + 'interval', +]; function normalizeIntervalUnit(u: unknown): IntervalUnit { if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u; @@ -276,21 +490,42 @@ export function scheduleSpecFromParams(params: Record): Schedul if (mode === 'calendar') { calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly'; } - return { + const spec: ScheduleSpec = { mode: mode as ScheduleMode, - hour: clamp(Number(o.hour) || base.hour, 0, 23), - minute: clamp(Number(o.minute) || base.minute, 0, 59), + hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour, + minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute, weekdays: Array.isArray(o.weekdays) ? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x)) : base.weekdays, - monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31), - monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12), + monthDay: clamp(Number(o.monthDay) ?? base.monthDay, 1, 31), + monthIndex: clamp(Number(o.monthIndex) ?? base.monthIndex, 1, 12), calendarPeriod, intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue), intervalUnit: normalizeIntervalUnit(o.intervalUnit), + customCron: typeof o.customCron === 'string' ? o.customCron : base.customCron, + weeksInterval: Math.max(1, Number(o.weeksInterval) || base.weeksInterval), }; + return normalizeSpecForPlanner(spec); } } const cron = typeof params.cron === 'string' ? params.cron : ''; - return parseCronToSpec(cron) ?? defaultScheduleSpec(); + const parsed = parseCronToSpec(cron); + return normalizeSpecForPlanner(parsed ?? defaultScheduleSpec()); +} + +/** Serialisiert Spec für parameters.schedule (persistiert). */ +export function scheduleSpecToPersistentJson(spec: ScheduleSpec): Record { + return { + mode: spec.mode, + hour: spec.hour, + minute: spec.minute, + weekdays: spec.weekdays, + monthDay: spec.monthDay, + monthIndex: spec.monthIndex, + calendarPeriod: spec.calendarPeriod, + intervalValue: spec.intervalValue, + intervalUnit: spec.intervalUnit, + customCron: spec.customCron, + weeksInterval: spec.weeksInterval, + }; } diff --git a/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx b/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx index d144771..1236de9 100644 --- a/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx +++ b/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx @@ -1,438 +1,33 @@ /** - * Start node (Zeitplan) — Karten-UI mit Konfiguration unter der gewählten Option. + * Start node (Zeitplan) — Accordion planner; gespeichert werden cron + schedule. */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion'; +import React, { useCallback, useMemo } from 'react'; +import { flushSync } from 'react-dom'; import type { NodeConfigRendererProps } from '../shared/types'; +import { SchedulePlanner } from '../../../SchedulePlanner'; import { - type ScheduleSpec, - type ScheduleMode, - type IntervalUnit, - type CalendarPeriod, buildCronFromSpec, scheduleSpecFromParams, - WEEKDAYS_MO_SO, + scheduleSpecToPersistentJson, + type ScheduleSpec, } from '../runtime/scheduleCron'; -import styles from '../../editor/Automation2FlowEditor.module.css'; - -import { useLanguage } from '../../../../providers/language/LanguageContext'; - -function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] { - return [ - { value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') }, - { value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') }, - { value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') }, - { value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') }, - { value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') }, - ]; -} - -function _monthNames(t: (k: string) => string): string[] { - return [ - t('Januar'), t('Februar'), t('März'), t('April'), - t('Mai'), t('Juni'), t('Juli'), t('August'), - t('September'), t('Oktober'), t('November'), t('Dezember'), - ]; -} - -function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] { - return [ - { value: 'seconds', label: t('Sekunde'), title: t('Sekunden') }, - { value: 'minutes', label: t('Minute'), title: t('Minuten') }, - { value: 'hours', label: t('Stunde'), title: t('Stunden') }, - { value: 'days', label: t('Tag'), title: t('Tage') }, - { value: 'years', label: t('Jahr'), title: t('Jahre') }, - ]; -} - -function timeString(hour: number, minute: number): string { - return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`; -} - -function commitSpec(next: ScheduleSpec, updateParam: (key: string, value: unknown) => void) { - updateParam('schedule', next); - updateParam('cron', buildCronFromSpec(next)); -} - -function clampInterval(value: number, unit: IntervalUnit): number { - const v = Math.max(1, Math.floor(value) || 1); - switch (unit) { - case 'seconds': - return Math.min(59, v); - case 'minutes': - return Math.min(59, v); - case 'hours': - return Math.min(23, v); - case 'days': - return Math.min(31, v); - case 'years': - return Math.min(99, v); - default: - return v; - } -} - -const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const; export const ScheduleStartNodeConfig: React.FC = ({ params, updateParam }) => { - const { t } = useLanguage(); - const modeOptions = _getModeOptions(t); - const intervalUnits = _getIntervalUnits(t); - const [spec, setSpec] = useState(() => scheduleSpecFromParams(params)); - const prefersReducedMotion = useReducedMotion(); - const specModeRef = useRef(spec.mode); - specModeRef.current = spec.mode; - - useEffect(() => { - const derived = scheduleSpecFromParams(params as Record); - console.log('[ScheduleStartNode] useEffect params → setSpec', { - paramsCron: params.cron, - paramsSchedule: params.schedule, - derivedMode: derived.mode, - previousSpecMode: specModeRef.current, - }); - setSpec(derived); - }, [params.cron, params.schedule]); - - useEffect(() => { - console.log('[ScheduleStartNode] spec.mode changed (UI sollte passen)', { - specMode: spec.mode, - cssBlockBase: styles.scheduleModeBlock, - cssBlockActive: styles.scheduleModeBlockActive, - }); - }, [spec.mode]); - + const spec = useMemo( + () => scheduleSpecFromParams(params as Record), + [params.cron, params.schedule] + ); const push = useCallback( (next: ScheduleSpec) => { - setSpec(next); - commitSpec(next, updateParam); + const sched = scheduleSpecToPersistentJson(next); + const cron = buildCronFromSpec(next); + flushSync(() => { + updateParam('schedule', sched); + }); + updateParam('cron', cron); }, [updateParam] ); - - const setMode = (mode: ScheduleMode) => { - console.log('[ScheduleStartNode] setMode', { - from: spec.mode, - to: mode, - refMode: specModeRef.current, - }); - const base: ScheduleSpec = { ...spec, mode }; - if (mode === 'weekly' && base.weekdays.length === 0) { - base.weekdays = [1, 2, 3, 4, 5]; - } - if (mode === 'calendar') { - base.calendarPeriod = base.calendarPeriod ?? 'monthly'; - } - push(base); - }; - - const onModeCardPointerEvent = ( - phase: 'pointerdown' | 'click', - e: React.PointerEvent | React.MouseEvent, - o: { value: ScheduleMode; title: string; subtitle: string } - ) => { - const el = e.target as HTMLElement; - const cur = e.currentTarget as HTMLElement; - const isLast = o.value === 'interval'; - if (!isLast) return; - const cx = 'clientX' in e ? e.clientX : 0; - const cy = 'clientY' in e ? e.clientY : 0; - const hit = typeof document !== 'undefined' ? document.elementFromPoint(cx, cy) : null; - const hitH = hit as HTMLElement | null; - console.log(`[ScheduleStartNode] ${phase} — unterstes Element (Intervall)`, { - intendedMode: o.value, - title: o.title, - specModeClosure: spec.mode, - specModeRef: specModeRef.current, - eventPhase: e.nativeEvent.eventPhase, - target: { tag: el?.tagName, className: el?.className, id: el?.id }, - currentTarget: { tag: cur?.tagName, className: cur?.className }, - clientX: cx, - clientY: cy, - pointerId: 'pointerId' in e ? (e as React.PointerEvent).pointerId : undefined, - isTrusted: e.nativeEvent.isTrusted, - elementFromPoint: hitH - ? { - tag: hitH.tagName, - className: hitH.className, - dataScheduleMode: hitH.closest?.('[data-schedule-mode]')?.getAttribute('data-schedule-mode'), - textSlice: (hitH.textContent ?? '').slice(0, 60), - } - : null, - }); - }; - - const onTimeChange = (ev: React.ChangeEvent) => { - const v = ev.target.value; - if (!v) return; - const [hs, ms] = v.split(':'); - const hour = Number(hs); - const minute = Number(ms); - if (Number.isNaN(hour) || Number.isNaN(minute)) return; - push({ ...spec, hour, minute }); - }; - - const toggleWeekday = (cronDow: number) => { - const set = new Set(spec.weekdays); - if (set.has(cronDow)) set.delete(cronDow); - else set.add(cronDow); - let weekdays = [...set]; - if (weekdays.length === 0) weekdays = [cronDow]; - push({ ...spec, weekdays }); - }; - - const setCalendarPeriod = (calendarPeriod: CalendarPeriod) => { - push({ ...spec, calendarPeriod }); - }; - - const setIntervalUnit = (intervalUnit: IntervalUnit) => { - const next = { ...spec, intervalUnit }; - next.intervalValue = clampInterval(next.intervalValue, intervalUnit); - push(next); - }; - - const panelTransition = prefersReducedMotion - ? { duration: 0 } - : { - height: { duration: 0.44, ease: EASE_SMOOTH }, - opacity: { duration: 0.3, ease: 'easeOut' as const }, - }; - - return ( -
-

- {t( - 'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.' - )} -

- - -
- {modeOptions.map((o) => ( - - onModeCardPointerEvent('pointerdown', e, o)} - onClick={(e) => { - onModeCardPointerEvent('click', e, o); - setMode(o.value); - }} - whileTap={prefersReducedMotion ? undefined : { scale: 0.992 }} - transition={{ type: 'spring', stiffness: 520, damping: 32 }} - > - {o.title} - {o.subtitle} - - - - {spec.mode === o.value && ( - -
- {o.value === 'daily' && ( - - )} - - {o.value === 'weekdays' && ( - - )} - - {o.value === 'weekly' && ( - <> -
- {t('Wochentage')} -
- {WEEKDAYS_MO_SO.map(({ cronDow }) => ( - - ))} -
-
- - - )} - - {o.value === 'calendar' && ( - <> -
- - -
- - {spec.calendarPeriod === 'monthly' && ( - - )} - - {spec.calendarPeriod === 'yearly' && ( -
- - -
- )} - - - - )} - - {o.value === 'interval' && ( -
- {t('Alle')} - - push({ - ...spec, - intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit), - }) - } - /> - -
- )} -
-
- )} -
-
- ))} -
- -
-
- ); + return ; }; diff --git a/src/components/SchedulePlanner/SchedulePlanner.module.css b/src/components/SchedulePlanner/SchedulePlanner.module.css new file mode 100644 index 0000000..375feae --- /dev/null +++ b/src/components/SchedulePlanner/SchedulePlanner.module.css @@ -0,0 +1,254 @@ +/** + * Schedule planner accordion — tokens aligned with FlowEditor / app theme. + */ +.wrap { + max-width: 560px; + font-size: 14px; + color: var(--text-primary, #1a1a1a); +} + +.intro { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary, #666); +} + +.item { + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); + border-radius: var(--radius-md, 8px); + margin-bottom: 6px; + overflow: hidden; + background: var(--bg-primary, #fff); +} + +/** Gespeicherte / gültige Option — nur Dezent, Indikator ist der Punkt links */ +.itemSelected { + border-color: var(--border-color, rgba(0, 0, 0, 0.14)); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + margin: 0; + border: none; + cursor: pointer; + user-select: none; + text-align: left; + background: transparent; + color: inherit; + font: inherit; + outline: none; +} + + + +.dotRail { + display: flex; + align-items: center; + justify-content: center; + width: 14px; + flex-shrink: 0; +} + +.modeDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: transparent; + box-shadow: inset 0 0 0 1px var(--border-color, rgba(0, 0, 0, 0.22)); + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.modeDotOn { + background: var(--text-primary, #1a1a1a); + box-shadow: none; +} + +.title { + flex: 1; + min-width: 0; + font-size: 14px; + font-weight: 500; + color: var(--text-primary, #1a1a1a); +} + +.chevron { + flex-shrink: 0; + margin-left: auto; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-tertiary, #888); + transition: transform 0.2s ease; + transform-origin: 50% 40%; +} + +.itemExpanded .chevron { + transform: rotate(180deg); +} + +/* + * Höhe: 0fr → 1fr interpoliert gegen die echte Inhaltshöhe (kein max-height-Ruckeln). + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-rows#interpolation + */ +.bodyCollapse { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.36s cubic-bezier(0.33, 1, 0.68, 1); + pointer-events: none; +} + +.bodyCollapseOpen { + grid-template-rows: 1fr; + pointer-events: auto; +} + +@media (prefers-reduced-motion: reduce) { + .bodyCollapse { + transition: none; + } +} + +.bodyInner { + min-height: 0; + overflow: hidden; +} + +.bodyPad { + padding: 0 12px 12px; +} + +.fields { + display: grid; + gap: 10px; + padding-top: 8px; +} + +.field { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.fieldStart { + align-items: flex-start; +} + +.fieldLabel { + font-size: 13px; + color: var(--text-secondary, #666); + width: 120px; + flex-shrink: 0; +} + +.fieldLabelTop { + padding-top: 4px; +} + +.unit { + font-size: 13px; + color: var(--text-secondary, #666); +} + +.timeRow { + display: flex; + align-items: center; + gap: 6px; +} + +.timeSep { + font-size: 16px; + font-weight: 500; + color: var(--text-secondary, #666); +} + +.daysRow { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.dayBtn { + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.15)); + background: var(--bg-secondary, rgba(0, 0, 0, 0.05)); + color: var(--text-secondary, #555); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.dayBtn:hover { + border-color: var(--text-secondary, #888); +} + +.dayBtnSel { + background: var(--text-primary, #1a1a1a); + color: var(--bg-primary, #fff); + border-color: var(--text-primary, #1a1a1a); +} + +.numberInput { + width: 72px; + font-size: 14px; + padding: 6px 8px; + border: 1px solid var(--border-color, #ccc); + border-radius: var(--radius-md, 6px); + background: var(--bg-primary, #fff); + color: inherit; +} + +.select { + font-size: 14px; + padding: 6px 8px; + border: 1px solid var(--border-color, #ccc); + border-radius: var(--radius-md, 6px); + background: var(--bg-primary, #fff); + color: inherit; + min-width: 4.5rem; +} + +.cronInput { + font-family: ui-monospace, monospace; + font-size: 13px; + width: 100%; + max-width: 320px; + padding: 8px 10px; + border: 1px solid var(--border-color, #ccc); + border-radius: var(--radius-md, 6px); + background: var(--bg-primary, #fff); + color: inherit; +} + +.cronHint { + font-size: 11px; + color: var(--text-tertiary, #888); + margin-top: 8px; + line-height: 1.7; + max-width: 320px; +} + +.warn { + font-size: 11px; + color: var(--warning-color, #b45309); + margin-top: 8px; + line-height: 1.5; +} + +.disabled { + opacity: 0.55; + pointer-events: none; +} diff --git a/src/components/SchedulePlanner/SchedulePlanner.tsx b/src/components/SchedulePlanner/SchedulePlanner.tsx new file mode 100644 index 0000000..f47d37b --- /dev/null +++ b/src/components/SchedulePlanner/SchedulePlanner.tsx @@ -0,0 +1,550 @@ +/** + * Accordion schedule planner → updates ScheduleSpec; parent derives cron via buildCronFromSpec. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + MINUTE_SELECT_OPTIONS, + WEEKDAYS_MO_SO, + type ScheduleSpec, +} from '../FlowEditor/nodes/runtime/scheduleCron'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import styles from './SchedulePlanner.module.css'; + +export type PlannerModeId = 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'custom'; + +const PLANNER_MODE_IDS: PlannerModeId[] = [ + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', + 'custom', +]; + +function plannerModeFromSpec(spec: ScheduleSpec): PlannerModeId { + const m = spec.mode; + if (m === 'interval') { + if (spec.intervalUnit === 'hours') return 'hours'; + if (spec.intervalUnit === 'days') return 'days'; + if (spec.intervalUnit === 'minutes') return 'minutes'; + return 'custom'; + } + if (m === 'minutes') return 'minutes'; + if (m === 'hours') return 'hours'; + if (m === 'days' || m === 'daily') return 'days'; + if (m === 'weeks' || m === 'weekly' || m === 'weekdays') return 'weeks'; + if (m === 'months' || (m === 'calendar' && spec.calendarPeriod === 'monthly')) return 'months'; + return 'custom'; +} + +function withPlannerMode(spec: ScheduleSpec, id: PlannerModeId): ScheduleSpec { + const base = { ...spec }; + switch (id) { + case 'minutes': + return { + ...base, + mode: 'minutes', + intervalValue: Math.max(1, base.intervalValue || 15), + }; + case 'hours': + return { + ...base, + mode: 'hours', + intervalValue: Math.max(1, base.intervalValue || 1), + }; + case 'days': + return { + ...base, + mode: 'days', + intervalValue: Math.max(1, base.intervalValue || 1), + }; + case 'weeks': + return { + ...base, + mode: 'weeks', + weeksInterval: Math.max(1, base.weeksInterval || 1), + weekdays: + base.weekdays.length > 0 ? [...base.weekdays] : [1, 2, 3, 4, 5], + }; + case 'months': + return { + ...base, + mode: 'months', + intervalValue: Math.max(1, base.intervalValue || 1), + monthDay: Math.min(28, Math.max(1, base.monthDay || 1)), + }; + case 'custom': + return { + ...base, + mode: 'custom', + customCron: (base.customCron || '0 9 * * *').trim(), + }; + default: + return base; + } +} + +export interface SchedulePlannerProps { + value: ScheduleSpec; + onChange: (next: ScheduleSpec) => void; + className?: string; + disabled?: boolean; +} + +export const SchedulePlanner: React.FC = ({ + value, + onChange, + className = '', + disabled = false, +}) => { + const { t } = useLanguage(); + const activeMode = useMemo(() => plannerModeFromSpec(value), [value]); + const [openId, setOpenId] = useState(null); + + const modeMeta = useMemo( + () => + ({ + minutes: { label: t('Alle X Minuten') }, + hours: { label: t('Alle X Stunden') }, + days: { label: t('Täglich / alle X Tage') }, + weeks: { label: t('Wöchentlich') }, + months: { label: t('Monatlich') }, + custom: { label: t('Custom (Cron)') }, + }) as const, + [t] + ); + + const push = useCallback( + (next: ScheduleSpec) => { + if (!disabled) onChange(next); + }, + [disabled, onChange] + ); + + const toggle = (id: PlannerModeId) => { + const nextOpen: PlannerModeId | null = openId === id ? null : id; + setOpenId(nextOpen); + if (nextOpen != null) { + push(withPlannerMode(value, nextOpen)); + } + }; + + const hourOptions = useMemo( + () => Array.from({ length: 24 }, (_, i) => i), + [] + ); + + const renderBody = (id: PlannerModeId) => { + const v = plannerModeFromSpec(value) === id ? value : withPlannerMode(value, id); + switch (id) { + case 'minutes': + return ( +
+
+ {t('Alle')} + + push({ + ...v, + mode: 'minutes', + intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> + {t('Minuten')} +
+
+ ); + case 'hours': + return ( +
+
+ {t('Alle')} + + push({ + ...v, + mode: 'hours', + intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> + {t('Stunden')} +
+
+ {t('Bei Minute')} + +
+
+ ); + case 'days': + return ( +
+
+ {t('Alle')} + + push({ + ...v, + mode: 'days', + intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> + {t('Tage')} +
+
+ {t('Uhrzeit')} +
+ + : + +
+
+

+ {t( + 'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.' + )} +

+
+ ); + case 'weeks': + return ( +
+
+ {t('Alle')} + + push({ + ...v, + mode: 'weeks', + weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> + {t('Wochen')} +
+ {(v.weeksInterval ?? 1) > 1 && ( +

+ {t( + 'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).' + )} +

+ )} +
+ + {t('Wochentage')} + +
+ {WEEKDAYS_MO_SO.map(({ cronDow }) => ( + + ))} +
+
+
+ {t('Uhrzeit')} +
+ + : + +
+
+
+ ); + case 'months': + return ( +
+
+ {t('Alle')} + + push({ + ...v, + mode: 'months', + intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> + {t('Monate')} +
+
+ {t('Tag des Monats')} + + push({ + ...v, + mode: 'months', + monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)), + }) + } + /> +
+
+ {t('Uhrzeit')} +
+ + : + +
+
+
+ ); + case 'custom': + return ( +
+
+ + {t('Ausdruck')} + +
+ + push({ + ...v, + mode: 'custom', + customCron: e.target.value, + }) + } + spellCheck={false} + /> +
+ {t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '} + {t('Wochentag')} + {' · '}[{t('Sekunde')} {t('optional')}] +
+ {t('z.B.')}{' '} + 0 9 * * 3,5 +
+
+
+
+ ); + default: + return null; + } + }; + + return ( +
+

+ {t( + 'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.' + )} +

+
+ {PLANNER_MODE_IDS.map((mid) => { + const open = openId === mid; + const isSelected = activeMode === mid; + const meta = modeMeta[mid]; + return ( +
+ +
+
+
{renderBody(mid)}
+
+
+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/SchedulePlanner/index.ts b/src/components/SchedulePlanner/index.ts new file mode 100644 index 0000000..5a1a6ff --- /dev/null +++ b/src/components/SchedulePlanner/index.ts @@ -0,0 +1 @@ +export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner'; diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..1556f64 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,2 @@ +// Vitest / jsdom setup (minimal). +import '@testing-library/jest-dom/vitest';