fix: schedule node to be more user friendly
This commit is contained in:
parent
dd26ea132d
commit
7e2ffb42fe
8 changed files with 1157 additions and 476 deletions
|
|
@ -79,6 +79,26 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
[onParametersChange]
|
[onParametersChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const patchParams = useCallback(
|
||||||
|
(patch: Record<string, unknown>) => {
|
||||||
|
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 dataFlow = useAutomation2DataFlow();
|
||||||
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||||
|
|
||||||
|
|
@ -239,7 +259,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
const frontendType = param.frontendType || 'text';
|
const frontendType = param.frontendType || 'text';
|
||||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
return (
|
return (
|
||||||
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -287,6 +307,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
request={request}
|
request={request}
|
||||||
nodeType={node.type}
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export interface FieldRendererProps {
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
request?: ApiRequestFunction;
|
request?: ApiRequestFunction;
|
||||||
nodeType?: string;
|
nodeType?: string;
|
||||||
|
/** Atomically merge several parameter keys (e.g. cron + schedule). */
|
||||||
|
onPatchParams?: (patch: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
|
|
@ -32,6 +34,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { SchedulePlanner } from '../../../SchedulePlanner';
|
||||||
|
import {
|
||||||
|
buildCronFromSpec,
|
||||||
|
scheduleSpecFromParams,
|
||||||
|
scheduleSpecToPersistentJson,
|
||||||
|
type ScheduleSpec,
|
||||||
|
} from '../runtime/scheduleCron';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
import { toApiGraph } from '../shared/graphUtils';
|
import { toApiGraph } from '../shared/graphUtils';
|
||||||
|
|
@ -773,19 +782,33 @@ const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams, onPatchParams }) => {
|
||||||
const { t } = useLanguage();
|
const spec = React.useMemo(
|
||||||
|
() =>
|
||||||
|
scheduleSpecFromParams({
|
||||||
|
...(allParams ?? {}),
|
||||||
|
cron:
|
||||||
|
(typeof value === 'string' && value
|
||||||
|
? value
|
||||||
|
: typeof allParams?.cron === 'string'
|
||||||
|
? allParams.cron
|
||||||
|
: '') as string,
|
||||||
|
} as Record<string, unknown>),
|
||||||
|
[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 (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
|
||||||
<input
|
<SchedulePlanner value={spec} onChange={handlePlanner} />
|
||||||
type="text"
|
|
||||||
value={typeof value === 'string' ? value : ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={t('0 9 * * *')}
|
|
||||||
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
|
|
||||||
/>
|
|
||||||
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,31 +4,47 @@
|
||||||
* Intervall Sekunden: 6 Felder (sec min hour dom month dow)
|
* 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';
|
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 type IntervalUnit = 'seconds' | 'minutes' | 'hours' | 'days' | 'years';
|
||||||
|
|
||||||
export interface ScheduleSpec {
|
export interface ScheduleSpec {
|
||||||
mode: ScheduleMode;
|
mode: ScheduleMode;
|
||||||
hour: number;
|
hour: number;
|
||||||
minute: number;
|
minute: number;
|
||||||
/** 0–6, cron DOW; nur bei mode === 'weekly' */
|
/** 0–6 cron DOW; für weeks / weekly / weekdays */
|
||||||
weekdays: number[];
|
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;
|
monthDay: number;
|
||||||
/** 1–12, nur bei calendar + yearly */
|
/** 1–12, nur bei calendar + yearly (Legacy) */
|
||||||
monthIndex: number;
|
monthIndex: number;
|
||||||
calendarPeriod: CalendarPeriod;
|
calendarPeriod: CalendarPeriod;
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
intervalUnit: IntervalUnit;
|
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;
|
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 }[] = [
|
export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
|
||||||
{ cronDow: 1, label: 'Mo' },
|
{ cronDow: 1, label: 'Mo' },
|
||||||
{ cronDow: 2, label: 'Di' },
|
{ cronDow: 2, label: 'Di' },
|
||||||
|
|
@ -39,17 +55,27 @@ export const WEEKDAYS_MO_SO: readonly { cronDow: number; label: string }[] = [
|
||||||
{ cronDow: 0, label: 'So' },
|
{ 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 {
|
export function defaultScheduleSpec(): ScheduleSpec {
|
||||||
return {
|
return {
|
||||||
mode: 'daily',
|
mode: 'days',
|
||||||
hour: 8,
|
hour: 9,
|
||||||
minute: 0,
|
minute: 0,
|
||||||
weekdays: [1, 2, 3, 4, 5],
|
weekdays: [1, 2, 3, 4, 5],
|
||||||
monthDay: 1,
|
monthDay: 1,
|
||||||
monthIndex: 1,
|
monthIndex: 1,
|
||||||
calendarPeriod: 'monthly',
|
calendarPeriod: 'monthly',
|
||||||
intervalValue: 15,
|
intervalValue: 1,
|
||||||
intervalUnit: 'minutes',
|
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));
|
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 */
|
/** Erzeugt einen Cron-String aus der benutzerfreundlichen Spezifikation */
|
||||||
export function buildCronFromSpec(spec: ScheduleSpec): string {
|
export function buildCronFromSpec(spec: ScheduleSpec): string {
|
||||||
const m = clamp(Math.floor(spec.minute), 0, 59);
|
const m = clamp(Math.floor(spec.minute), 0, 59);
|
||||||
const h = clamp(Math.floor(spec.hour), 0, 23);
|
const h = clamp(Math.floor(spec.hour), 0, 23);
|
||||||
|
|
||||||
switch (spec.mode) {
|
switch (spec.mode) {
|
||||||
case 'daily':
|
case 'minutes': {
|
||||||
return `${m} ${h} * * *`;
|
const mm = clamp(Math.floor(spec.intervalValue), 1, 59);
|
||||||
case 'weekdays':
|
return `*/${mm} * * * *`;
|
||||||
return `${m} ${h} * * 1-5`;
|
}
|
||||||
|
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': {
|
case 'weekly': {
|
||||||
const days = [...new Set(spec.weekdays)]
|
const days = [...new Set(spec.weekdays)]
|
||||||
.filter((d) => d >= 0 && d <= 6)
|
.filter((x) => x >= 0 && x <= 6)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const order = (x: number) => (x === 0 ? 7 : x);
|
const order = (x: number) => (x === 0 ? 7 : x);
|
||||||
return order(a) - order(b);
|
return order(a) - order(b);
|
||||||
});
|
});
|
||||||
|
// Phase 1: weeksInterval > 1 nicht als Cron abbildbar — gleicher weekly-Ausdruck
|
||||||
if (days.length === 0) return `${m} ${h} * * 1`;
|
if (days.length === 0) return `${m} ${h} * * 1`;
|
||||||
return `${m} ${h} * * ${days.join(',')}`;
|
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': {
|
case 'calendar': {
|
||||||
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
|
const dom = clamp(Math.floor(spec.monthDay), 1, 31);
|
||||||
if (spec.calendarPeriod === 'monthly') {
|
if (spec.calendarPeriod === 'monthly') {
|
||||||
|
|
@ -89,8 +208,8 @@ export function buildCronFromSpec(spec: ScheduleSpec): string {
|
||||||
const v = Math.max(1, Math.floor(spec.intervalValue));
|
const v = Math.max(1, Math.floor(spec.intervalValue));
|
||||||
switch (spec.intervalUnit) {
|
switch (spec.intervalUnit) {
|
||||||
case 'seconds': {
|
case 'seconds': {
|
||||||
const s = clamp(v, 1, 59);
|
const sec = clamp(v, 1, 59);
|
||||||
return `*/${s} * * * * *`;
|
return `*/${sec} * * * * *`;
|
||||||
}
|
}
|
||||||
case 'minutes': {
|
case 'minutes': {
|
||||||
const mm = clamp(v, 1, 59);
|
const mm = clamp(v, 1, 59);
|
||||||
|
|
@ -101,13 +220,12 @@ export function buildCronFromSpec(spec: ScheduleSpec): string {
|
||||||
return `0 */${hh} * * *`;
|
return `0 */${hh} * * *`;
|
||||||
}
|
}
|
||||||
case 'days': {
|
case 'days': {
|
||||||
if (v <= 1) return `0 0 * * *`;
|
if (v <= 1) return `${m} ${h} * * *`;
|
||||||
const d = clamp(v, 2, 31);
|
const d = clamp(v, 2, 31);
|
||||||
return `0 0 */${d} * *`;
|
return `${m} ${h} */${d} * *`;
|
||||||
}
|
}
|
||||||
case 'years':
|
case 'years':
|
||||||
default:
|
default:
|
||||||
// Standard-5-Feld-Cron hat kein Jahres-Intervall; 1. Jan. Mitternacht als Näherung
|
|
||||||
return `0 0 1 1 *`;
|
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 */
|
/** Best-effort Rückübersetzung für gespeicherte Cron-Zeilen */
|
||||||
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
||||||
if (!cron || typeof cron !== 'string') return 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) {
|
if (p.length === 6) {
|
||||||
const [secS, minS, hourS, domS, monthS, dowS] = p;
|
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;
|
if (p.length < 5) return null;
|
||||||
const [minS, hourS, domS, monthS, dowS] = p;
|
let [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;
|
|
||||||
|
|
||||||
if (minS.startsWith('*/') && p[1] === '*' && domS === '*') {
|
if (minS.startsWith('*/') && hourS === '*' && domS === '*') {
|
||||||
const iv = parseInt(minS.slice(2), 10);
|
const iv = parseInt(minS.slice(2), 10);
|
||||||
if (!Number.isNaN(iv)) {
|
if (!Number.isNaN(iv)) {
|
||||||
return {
|
return {
|
||||||
...defaultScheduleSpec(),
|
...defaultScheduleSpec(),
|
||||||
mode: 'interval',
|
mode: 'minutes',
|
||||||
intervalValue: iv,
|
intervalValue: iv,
|
||||||
intervalUnit: 'minutes',
|
|
||||||
minute: 0,
|
minute: 0,
|
||||||
hour: 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 === '*') {
|
if (minS === '0' && hourS.startsWith('*/') && domS === '*') {
|
||||||
const iv = parseInt(hourS.slice(2), 10);
|
const iv = parseInt(hourS.slice(2), 10);
|
||||||
if (!Number.isNaN(iv)) {
|
if (!Number.isNaN(iv)) {
|
||||||
return {
|
return {
|
||||||
...defaultScheduleSpec(),
|
...defaultScheduleSpec(),
|
||||||
mode: 'interval',
|
mode: 'hours',
|
||||||
intervalValue: iv,
|
intervalValue: iv,
|
||||||
intervalUnit: 'hours',
|
|
||||||
minute: 0,
|
minute: 0,
|
||||||
hour: 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 === '?')) {
|
if (minS === '0' && hourS === '0' && domS.startsWith('*/') && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
||||||
const iv = parseInt(domS.slice(2), 10);
|
const iv = parseInt(domS.slice(2), 10);
|
||||||
if (!Number.isNaN(iv)) {
|
if (!Number.isNaN(iv)) {
|
||||||
return {
|
return {
|
||||||
...defaultScheduleSpec(),
|
...defaultScheduleSpec(),
|
||||||
mode: 'interval',
|
mode: 'days',
|
||||||
intervalValue: iv,
|
intervalValue: iv,
|
||||||
intervalUnit: 'days',
|
|
||||||
minute: 0,
|
|
||||||
hour: 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 === '*') {
|
if (domS === '*' && dowS === '*') {
|
||||||
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
|
return { ...defaultScheduleSpec(), mode: 'daily', hour, minute };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domS === '*' && dowS === '1-5') {
|
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('/')) {
|
if (domS === '*' && dowS && dowS !== '*' && !dowS.includes('/')) {
|
||||||
|
|
@ -213,6 +407,7 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
||||||
hour,
|
hour,
|
||||||
minute,
|
minute,
|
||||||
weekdays: norm,
|
weekdays: norm,
|
||||||
|
weeksInterval: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +415,13 @@ export function parseCronToSpec(cron: string | undefined): ScheduleSpec | null {
|
||||||
const dom = parseInt(domS, 10);
|
const dom = parseInt(domS, 10);
|
||||||
const month = monthS === '*' ? NaN : parseInt(monthS, 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 === '?')) {
|
if (!Number.isNaN(dom) && dom >= 1 && dom <= 31 && monthS === '*' && (dowS === '*' || dowS === '?')) {
|
||||||
return {
|
return {
|
||||||
...defaultScheduleSpec(),
|
...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 {
|
function normalizeIntervalUnit(u: unknown): IntervalUnit {
|
||||||
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
|
if (u === 'seconds' || u === 'minutes' || u === 'hours' || u === 'days' || u === 'years') return u;
|
||||||
|
|
@ -276,21 +490,42 @@ export function scheduleSpecFromParams(params: Record<string, unknown>): Schedul
|
||||||
if (mode === 'calendar') {
|
if (mode === 'calendar') {
|
||||||
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
|
calendarPeriod = o.calendarPeriod === 'yearly' ? 'yearly' : 'monthly';
|
||||||
}
|
}
|
||||||
return {
|
const spec: ScheduleSpec = {
|
||||||
mode: mode as ScheduleMode,
|
mode: mode as ScheduleMode,
|
||||||
hour: clamp(Number(o.hour) || base.hour, 0, 23),
|
hour: Number.isFinite(Number(o.hour)) ? clamp(Number(o.hour), 0, 23) : base.hour,
|
||||||
minute: clamp(Number(o.minute) || base.minute, 0, 59),
|
minute: Number.isFinite(Number(o.minute)) ? clamp(Number(o.minute), 0, 59) : base.minute,
|
||||||
weekdays: Array.isArray(o.weekdays)
|
weekdays: Array.isArray(o.weekdays)
|
||||||
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
|
? (o.weekdays as unknown[]).map((x) => clamp(Number(x), 0, 6)).filter((x) => !Number.isNaN(x))
|
||||||
: base.weekdays,
|
: base.weekdays,
|
||||||
monthDay: clamp(Number(o.monthDay) || base.monthDay, 1, 31),
|
monthDay: clamp(Number(o.monthDay) ?? base.monthDay, 1, 31),
|
||||||
monthIndex: clamp(Number(o.monthIndex) || base.monthIndex, 1, 12),
|
monthIndex: clamp(Number(o.monthIndex) ?? base.monthIndex, 1, 12),
|
||||||
calendarPeriod,
|
calendarPeriod,
|
||||||
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
|
intervalValue: Math.max(1, Number(o.intervalValue) || base.intervalValue),
|
||||||
intervalUnit: normalizeIntervalUnit(o.intervalUnit),
|
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 : '';
|
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<string, unknown> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useCallback, useMemo } from 'react';
|
||||||
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
import { flushSync } from 'react-dom';
|
||||||
import type { NodeConfigRendererProps } from '../shared/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
|
import { SchedulePlanner } from '../../../SchedulePlanner';
|
||||||
import {
|
import {
|
||||||
type ScheduleSpec,
|
|
||||||
type ScheduleMode,
|
|
||||||
type IntervalUnit,
|
|
||||||
type CalendarPeriod,
|
|
||||||
buildCronFromSpec,
|
buildCronFromSpec,
|
||||||
scheduleSpecFromParams,
|
scheduleSpecFromParams,
|
||||||
WEEKDAYS_MO_SO,
|
scheduleSpecToPersistentJson,
|
||||||
|
type ScheduleSpec,
|
||||||
} from '../runtime/scheduleCron';
|
} 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<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
const { t } = useLanguage();
|
const spec = useMemo(
|
||||||
const modeOptions = _getModeOptions(t);
|
() => scheduleSpecFromParams(params as Record<string, unknown>),
|
||||||
const intervalUnits = _getIntervalUnits(t);
|
[params.cron, params.schedule]
|
||||||
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
|
);
|
||||||
const prefersReducedMotion = useReducedMotion();
|
|
||||||
const specModeRef = useRef(spec.mode);
|
|
||||||
specModeRef.current = spec.mode;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const derived = scheduleSpecFromParams(params as Record<string, unknown>);
|
|
||||||
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 push = useCallback(
|
const push = useCallback(
|
||||||
(next: ScheduleSpec) => {
|
(next: ScheduleSpec) => {
|
||||||
setSpec(next);
|
const sched = scheduleSpecToPersistentJson(next);
|
||||||
commitSpec(next, updateParam);
|
const cron = buildCronFromSpec(next);
|
||||||
|
flushSync(() => {
|
||||||
|
updateParam('schedule', sched);
|
||||||
|
});
|
||||||
|
updateParam('cron', cron);
|
||||||
},
|
},
|
||||||
[updateParam]
|
[updateParam]
|
||||||
);
|
);
|
||||||
|
return <SchedulePlanner value={spec} onChange={push} />;
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className={styles.schedulePanel}>
|
|
||||||
<p className={styles.startNodeDocIntro}>
|
|
||||||
{t(
|
|
||||||
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<LayoutGroup>
|
|
||||||
<div className={styles.scheduleModeStack}>
|
|
||||||
{modeOptions.map((o) => (
|
|
||||||
<motion.div
|
|
||||||
key={o.value}
|
|
||||||
data-schedule-mode={o.value}
|
|
||||||
data-active={spec.mode === o.value ? 'true' : 'false'}
|
|
||||||
className={
|
|
||||||
spec.mode === o.value
|
|
||||||
? `${styles.scheduleModeBlock} ${styles.scheduleModeBlockActive}`
|
|
||||||
: styles.scheduleModeBlock
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<motion.button
|
|
||||||
type="button"
|
|
||||||
className={styles.scheduleModeCard}
|
|
||||||
onPointerDown={(e) => 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 }}
|
|
||||||
>
|
|
||||||
<span className={styles.scheduleModeCardTitle}>{o.title}</span>
|
|
||||||
<span className={styles.scheduleModeCardSubtitle}>{o.subtitle}</span>
|
|
||||||
</motion.button>
|
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
|
||||||
{spec.mode === o.value && (
|
|
||||||
<motion.div
|
|
||||||
key={`panel-${o.value}`}
|
|
||||||
className={styles.scheduleModeConfigShell}
|
|
||||||
initial={{ height: 0, opacity: 0 }}
|
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
|
||||||
exit={{ height: 0, opacity: 0 }}
|
|
||||||
transition={panelTransition}
|
|
||||||
style={{ overflow: 'hidden' }}
|
|
||||||
>
|
|
||||||
<div className={styles.scheduleModeConfig}>
|
|
||||||
{o.value === 'daily' && (
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
step={60}
|
|
||||||
className={styles.scheduleTimeInput}
|
|
||||||
value={timeString(spec.hour, spec.minute)}
|
|
||||||
onChange={onTimeChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{o.value === 'weekdays' && (
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
step={60}
|
|
||||||
className={styles.scheduleTimeInput}
|
|
||||||
value={timeString(spec.hour, spec.minute)}
|
|
||||||
onChange={onTimeChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{o.value === 'weekly' && (
|
|
||||||
<>
|
|
||||||
<div className={styles.scheduleFieldCol}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
|
|
||||||
<div className={styles.scheduleWeekdayToggles}>
|
|
||||||
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
|
|
||||||
<button
|
|
||||||
key={cronDow}
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
spec.weekdays.includes(cronDow) ? styles.scheduleDayOn : styles.scheduleDayOff
|
|
||||||
}
|
|
||||||
onClick={() => toggleWeekday(cronDow)}
|
|
||||||
>
|
|
||||||
{cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
step={60}
|
|
||||||
className={styles.scheduleTimeInput}
|
|
||||||
value={timeString(spec.hour, spec.minute)}
|
|
||||||
onChange={onTimeChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{o.value === 'calendar' && (
|
|
||||||
<>
|
|
||||||
<div className={styles.scheduleSubModes}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
spec.calendarPeriod === 'monthly'
|
|
||||||
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
|
|
||||||
: styles.scheduleSubModeBtn
|
|
||||||
}
|
|
||||||
onClick={() => setCalendarPeriod('monthly')}
|
|
||||||
>
|
|
||||||
{t('Monatlich')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={
|
|
||||||
spec.calendarPeriod === 'yearly'
|
|
||||||
? `${styles.scheduleSubModeBtn} ${styles.scheduleSubModeBtnOn}`
|
|
||||||
: styles.scheduleSubModeBtn
|
|
||||||
}
|
|
||||||
onClick={() => setCalendarPeriod('yearly')}
|
|
||||||
>
|
|
||||||
{t('Jährlich')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{spec.calendarPeriod === 'monthly' && (
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
|
|
||||||
<select
|
|
||||||
className={styles.scheduleSelect}
|
|
||||||
value={spec.monthDay}
|
|
||||||
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
|
||||||
<option key={d} value={d}>
|
|
||||||
{d}.
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{spec.calendarPeriod === 'yearly' && (
|
|
||||||
<div className={styles.scheduleYearlyRow}>
|
|
||||||
<label className={styles.scheduleFieldRowGrow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
|
|
||||||
<select
|
|
||||||
className={styles.scheduleSelect}
|
|
||||||
value={spec.monthIndex}
|
|
||||||
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
|
|
||||||
>
|
|
||||||
{_monthNames(t).map((name, i) => (
|
|
||||||
<option key={i + 1} value={i + 1}>
|
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className={styles.scheduleFieldRowGrow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
|
|
||||||
<select
|
|
||||||
className={styles.scheduleSelect}
|
|
||||||
value={spec.monthDay}
|
|
||||||
onChange={(e) => push({ ...spec, monthDay: Number(e.target.value) })}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
|
|
||||||
<option key={d} value={d}>
|
|
||||||
{d}.
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className={styles.scheduleFieldRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
step={60}
|
|
||||||
className={styles.scheduleTimeInput}
|
|
||||||
value={timeString(spec.hour, spec.minute)}
|
|
||||||
onChange={onTimeChange}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{o.value === 'interval' && (
|
|
||||||
<div className={styles.scheduleIntervalRow}>
|
|
||||||
<span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className={styles.scheduleNumberInput}
|
|
||||||
value={spec.intervalValue}
|
|
||||||
onChange={(e) =>
|
|
||||||
push({
|
|
||||||
...spec,
|
|
||||||
intervalValue: clampInterval(Number(e.target.value) || 1, spec.intervalUnit),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className={styles.scheduleUnitSelect}
|
|
||||||
value={spec.intervalUnit}
|
|
||||||
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
|
|
||||||
title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
|
|
||||||
>
|
|
||||||
{intervalUnits.map((u) => (
|
|
||||||
<option key={u.value} value={u.value} title={u.title}>
|
|
||||||
{u.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</LayoutGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
254
src/components/SchedulePlanner/SchedulePlanner.module.css
Normal file
254
src/components/SchedulePlanner/SchedulePlanner.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
550
src/components/SchedulePlanner/SchedulePlanner.tsx
Normal file
550
src/components/SchedulePlanner/SchedulePlanner.tsx
Normal file
|
|
@ -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<SchedulePlannerProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const activeMode = useMemo(() => plannerModeFromSpec(value), [value]);
|
||||||
|
const [openId, setOpenId] = useState<PlannerModeId | null>(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 (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={59}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={v.intervalValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'minutes',
|
||||||
|
intervalValue: Math.min(59, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.unit}>{t('Minuten')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'hours':
|
||||||
|
return (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={23}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={v.intervalValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'hours',
|
||||||
|
intervalValue: Math.min(23, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.unit}>{t('Stunden')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Bei Minute')}</span>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.minute}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'hours',
|
||||||
|
minute: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{String(m).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'days':
|
||||||
|
return (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={31}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={v.intervalValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'days',
|
||||||
|
intervalValue: Math.min(31, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.unit}>{t('Tage')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.hour}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'days',
|
||||||
|
hour: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hourOptions.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{String(h).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className={styles.timeSep}>:</span>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.minute}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'days',
|
||||||
|
minute: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{String(m).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={styles.cronHint}>
|
||||||
|
{t(
|
||||||
|
'Hinweis: „Alle N Tage“ entspricht in Cron einem Schritt im Tag-des-Monats-Feld, nicht zwingend jedem Kalendertag.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'weeks':
|
||||||
|
return (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={52}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={v.weeksInterval ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'weeks',
|
||||||
|
weeksInterval: Math.min(52, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.unit}>{t('Wochen')}</span>
|
||||||
|
</div>
|
||||||
|
{(v.weeksInterval ?? 1) > 1 && (
|
||||||
|
<p className={styles.warn}>
|
||||||
|
{t(
|
||||||
|
'Mehr als jede Woche: der erzeugte Cron entspricht vorläufig wöchentlich (ein Wochen-Intervall > 1 ist im Standard-Cron nicht exakt abbildbar).'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.field} ${styles.fieldStart}`}>
|
||||||
|
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
|
||||||
|
{t('Wochentage')}
|
||||||
|
</span>
|
||||||
|
<div className={styles.daysRow}>
|
||||||
|
{WEEKDAYS_MO_SO.map(({ cronDow }) => (
|
||||||
|
<button
|
||||||
|
key={cronDow}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.dayBtn} ${v.weekdays.includes(cronDow) ? styles.dayBtnSel : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const next = { ...v, mode: 'weeks' as const };
|
||||||
|
const set = new Set(next.weekdays);
|
||||||
|
if (set.has(cronDow)) set.delete(cronDow);
|
||||||
|
else set.add(cronDow);
|
||||||
|
let wd = [...set];
|
||||||
|
if (wd.length === 0) wd = [cronDow];
|
||||||
|
wd.sort((a, b) => {
|
||||||
|
const o = (x: number) => (x === 0 ? 7 : x);
|
||||||
|
return o(a) - o(b);
|
||||||
|
});
|
||||||
|
push({ ...next, weekdays: wd });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cronDow === 1
|
||||||
|
? t('Mo')
|
||||||
|
: cronDow === 2
|
||||||
|
? t('Di')
|
||||||
|
: cronDow === 3
|
||||||
|
? t('Mi')
|
||||||
|
: cronDow === 4
|
||||||
|
? t('Do')
|
||||||
|
: cronDow === 5
|
||||||
|
? t('Fr')
|
||||||
|
: cronDow === 6
|
||||||
|
? t('Sa')
|
||||||
|
: t('So')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.hour}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'weeks',
|
||||||
|
hour: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hourOptions.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{String(h).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className={styles.timeSep}>:</span>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.minute}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'weeks',
|
||||||
|
minute: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{String(m).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'months':
|
||||||
|
return (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Alle')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={12}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={v.intervalValue}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'months',
|
||||||
|
intervalValue: Math.min(12, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className={styles.unit}>{t('Monate')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Tag des Monats')}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={28}
|
||||||
|
className={styles.numberInput}
|
||||||
|
value={Math.min(28, v.monthDay)}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'months',
|
||||||
|
monthDay: Math.min(28, Math.max(1, Number(e.target.value) || 1)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span className={styles.fieldLabel}>{t('Uhrzeit')}</span>
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.hour}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'months',
|
||||||
|
hour: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{hourOptions.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{String(h).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className={styles.timeSep}>:</span>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={v.minute}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'months',
|
||||||
|
minute: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{MINUTE_SELECT_OPTIONS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{String(m).padStart(2, '0')}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'custom':
|
||||||
|
return (
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<div className={`${styles.field} ${styles.fieldStart}`}>
|
||||||
|
<span className={`${styles.fieldLabel} ${styles.fieldLabelTop}`}>
|
||||||
|
{t('Ausdruck')}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.cronInput}
|
||||||
|
value={v.customCron}
|
||||||
|
onChange={(e) =>
|
||||||
|
push({
|
||||||
|
...v,
|
||||||
|
mode: 'custom',
|
||||||
|
customCron: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<div className={styles.cronHint}>
|
||||||
|
{t('Cron')}: {t('Minute')} · {t('Stunde')} · {t('Tag')} · {t('Monat')} ·{' '}
|
||||||
|
{t('Wochentag')}
|
||||||
|
{' · '}[{t('Sekunde')} {t('optional')}]
|
||||||
|
<br />
|
||||||
|
{t('z.B.')}{' '}
|
||||||
|
<code>0 9 * * 3,5</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.wrap} ${className} ${disabled ? styles.disabled : ''}`}>
|
||||||
|
<p className={styles.intro}>
|
||||||
|
{t(
|
||||||
|
'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der Cron-Ausdruck wird für die API erzeugt.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div role="list">
|
||||||
|
{PLANNER_MODE_IDS.map((mid) => {
|
||||||
|
const open = openId === mid;
|
||||||
|
const isSelected = activeMode === mid;
|
||||||
|
const meta = modeMeta[mid];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={mid}
|
||||||
|
role="listitem"
|
||||||
|
className={`${styles.item} ${open ? styles.itemExpanded : ''} ${isSelected ? styles.itemSelected : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.header}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => toggle(mid)}
|
||||||
|
>
|
||||||
|
<span className={styles.dotRail} aria-hidden>
|
||||||
|
<span
|
||||||
|
className={`${styles.modeDot} ${isSelected ? styles.modeDotOn : ''}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className={styles.title}>{meta.label}</span>
|
||||||
|
<span className={styles.chevron} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`${styles.bodyCollapse} ${open ? styles.bodyCollapseOpen : ''}`}
|
||||||
|
aria-hidden={!open}
|
||||||
|
>
|
||||||
|
<div className={styles.bodyInner}>
|
||||||
|
<div className={styles.bodyPad}>{renderBody(mid)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/SchedulePlanner/index.ts
Normal file
1
src/components/SchedulePlanner/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { SchedulePlanner, type SchedulePlannerProps, type PlannerModeId } from './SchedulePlanner';
|
||||||
2
src/test/setup.ts
Normal file
2
src/test/setup.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Vitest / jsdom setup (minimal).
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
Loading…
Reference in a new issue