fix: schedule node to be more user friendly
This commit is contained in:
parent
2e6fce188d
commit
5f5df47e25
8 changed files with 1157 additions and 476 deletions
|
|
@ -79,6 +79,26 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
[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 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 Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
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
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -287,6 +307,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
instanceId={instanceId}
|
||||
request={request}
|
||||
nodeType={node.type}
|
||||
onPatchParams={patchParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
||||
|
|
@ -32,6 +34,13 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
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<FieldRendererProps> = ({ param, value, onChan
|
|||
);
|
||||
};
|
||||
|
||||
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const CronBuilder: React.FC<FieldRendererProps> = ({ 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<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 (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
|
||||
<input
|
||||
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>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 6 }}>{param.description || param.name}</label>
|
||||
<SchedulePlanner value={spec} onChange={handlePlanner} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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<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 { 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<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
const { t } = useLanguage();
|
||||
const modeOptions = _getModeOptions(t);
|
||||
const intervalUnits = _getIntervalUnits(t);
|
||||
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 spec = useMemo(
|
||||
() => scheduleSpecFromParams(params as Record<string, unknown>),
|
||||
[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<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>
|
||||
);
|
||||
return <SchedulePlanner value={spec} onChange={push} />;
|
||||
};
|
||||
|
|
|
|||
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