Visual cron expression editor with field-by-field builder, presets select, human-readable description, and live schedule preview showing next occurrences. Registered in tools registry with CronIcon. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
9.8 KiB
TypeScript
263 lines
9.8 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useMemo } from 'react';
|
||
import { cn } from '@/lib/utils/cn';
|
||
import {
|
||
parseField,
|
||
rebuildFieldFromValues,
|
||
validateCronField,
|
||
FIELD_CONFIGS,
|
||
MONTH_SHORT_NAMES,
|
||
DOW_SHORT_NAMES,
|
||
type FieldType,
|
||
} from '@/lib/cron/cron-engine';
|
||
|
||
// ── Per-field presets ─────────────────────────────────────────────────────────
|
||
|
||
interface Preset { label: string; value: string }
|
||
|
||
const FIELD_PRESETS: Record<FieldType, Preset[]> = {
|
||
second: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: '*/5', value: '*/5' },
|
||
{ label: '*/10', value: '*/10' },
|
||
{ label: '*/15', value: '*/15' },
|
||
{ label: '*/30', value: '*/30' },
|
||
],
|
||
minute: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: ':00', value: '0' },
|
||
{ label: ':30', value: '30' },
|
||
{ label: '*/5', value: '*/5' },
|
||
{ label: '*/10', value: '*/10' },
|
||
{ label: '*/15', value: '*/15' },
|
||
{ label: '*/30', value: '*/30' },
|
||
],
|
||
hour: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: 'Midnight', value: '0' },
|
||
{ label: '6 AM', value: '6' },
|
||
{ label: '9 AM', value: '9' },
|
||
{ label: 'Noon', value: '12' },
|
||
{ label: '6 PM', value: '18' },
|
||
{ label: 'Every 4h', value: '*/4' },
|
||
{ label: 'Every 6h', value: '*/6' },
|
||
{ label: '9–17', value: '9-17' },
|
||
],
|
||
dom: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: '1st', value: '1' },
|
||
{ label: '10th', value: '10' },
|
||
{ label: '15th', value: '15' },
|
||
{ label: '20th', value: '20' },
|
||
{ label: '1,15', value: '1,15' },
|
||
{ label: '1–7', value: '1-7' },
|
||
],
|
||
month: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: 'Q1', value: '1-3' },
|
||
{ label: 'Q2', value: '4-6' },
|
||
{ label: 'Q3', value: '7-9' },
|
||
{ label: 'Q4', value: '10-12' },
|
||
{ label: 'H1', value: '1-6' },
|
||
{ label: 'H2', value: '7-12' },
|
||
],
|
||
dow: [
|
||
{ label: 'Any (*)', value: '*' },
|
||
{ label: 'Weekdays', value: '1-5' },
|
||
{ label: 'Weekends', value: '0,6' },
|
||
{ label: 'Mon', value: '1' },
|
||
{ label: 'Wed', value: '3' },
|
||
{ label: 'Fri', value: '5' },
|
||
{ label: 'Sun', value: '0' },
|
||
],
|
||
};
|
||
|
||
// ── Grid configuration ────────────────────────────────────────────────────────
|
||
|
||
const GRID_COLS: Record<FieldType, string> = {
|
||
second: 'grid-cols-10',
|
||
minute: 'grid-cols-10',
|
||
hour: 'grid-cols-8',
|
||
dom: 'grid-cols-7',
|
||
month: 'grid-cols-4',
|
||
dow: 'grid-cols-7',
|
||
};
|
||
|
||
// ── Component ─────────────────────────────────────────────────────────────────
|
||
|
||
interface CronFieldEditorProps {
|
||
fieldType: FieldType;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}
|
||
|
||
export function CronFieldEditor({ fieldType, value, onChange }: CronFieldEditorProps) {
|
||
const [rawInput, setRawInput] = useState('');
|
||
const [showRaw, setShowRaw] = useState(false);
|
||
const [rawError, setRawError] = useState('');
|
||
|
||
const config = FIELD_CONFIGS[fieldType];
|
||
const parsed = useMemo(() => parseField(value, config), [value, config]);
|
||
const presets = FIELD_PRESETS[fieldType];
|
||
|
||
const isWildcard = parsed?.isWildcard ?? false;
|
||
const isSelected = (v: number) => parsed?.values.has(v) ?? false;
|
||
|
||
const cellLabel = (v: number): string => {
|
||
if (fieldType === 'month') return MONTH_SHORT_NAMES[v - 1];
|
||
if (fieldType === 'dow') return DOW_SHORT_NAMES[v];
|
||
return String(v).padStart(fieldType === 'second' || fieldType === 'minute' ? 2 : 1, '0');
|
||
};
|
||
|
||
const handleCellClick = (v: number) => {
|
||
if (!parsed) return;
|
||
if (isWildcard) { onChange(String(v)); return; }
|
||
const next = new Set(parsed.values);
|
||
if (next.has(v)) {
|
||
next.delete(v);
|
||
if (next.size === 0) { onChange('*'); return; }
|
||
} else {
|
||
next.add(v);
|
||
if (next.size === config.max - config.min + 1) { onChange('*'); return; }
|
||
}
|
||
onChange(rebuildFieldFromValues(next, config));
|
||
};
|
||
|
||
const handleRawSubmit = () => {
|
||
const { valid, error } = validateCronField(rawInput, fieldType);
|
||
if (valid) {
|
||
onChange(rawInput);
|
||
setShowRaw(false);
|
||
setRawInput('');
|
||
setRawError('');
|
||
} else {
|
||
setRawError(error ?? 'Invalid');
|
||
}
|
||
};
|
||
|
||
const cells = Array.from({ length: config.max - config.min + 1 }, (_, i) => i + config.min);
|
||
// Pad to complete rows for DOM (31 cells, 7 cols → pad to 35)
|
||
const colCount = parseInt(GRID_COLS[fieldType].replace('grid-cols-', ''), 10);
|
||
const rem = cells.length % colCount;
|
||
const padded: (number | null)[] = [...cells, ...(rem === 0 ? [] : Array<null>(colCount - rem).fill(null))];
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
|
||
{config.label}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground/50 font-mono">
|
||
{config.min}–{config.max}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{isWildcard && (
|
||
<span className="text-[10px] font-mono text-primary/60 bg-primary/5 px-2 py-0.5 rounded border border-primary/15">
|
||
any value
|
||
</span>
|
||
)}
|
||
<span className="font-mono text-sm text-primary bg-primary/10 px-2.5 py-1 rounded-lg border border-primary/25">
|
||
{value}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Presets */}
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{presets.map((preset) => (
|
||
<button
|
||
key={preset.value}
|
||
onClick={() => onChange(preset.value)}
|
||
className={cn(
|
||
'px-2.5 py-1 text-[11px] font-mono rounded-lg border transition-all',
|
||
value === preset.value
|
||
? 'bg-primary/20 border-primary/50 text-primary shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
||
: 'glass border-border/30 text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-primary/5',
|
||
)}
|
||
>
|
||
{preset.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Value grid */}
|
||
<div className={cn('grid gap-1', GRID_COLS[fieldType])}>
|
||
{padded.map((v, i) => {
|
||
if (v === null) return <div key={`pad-${i}`} />;
|
||
const selected = isSelected(v);
|
||
return (
|
||
<button
|
||
key={v}
|
||
onClick={() => handleCellClick(v)}
|
||
title={fieldType === 'month' ? MONTH_SHORT_NAMES[v - 1] : fieldType === 'dow' ? DOW_SHORT_NAMES[v] : String(v)}
|
||
className={cn(
|
||
'flex items-center justify-center text-[10px] font-mono rounded-md border transition-all',
|
||
fieldType === 'month' || fieldType === 'dow'
|
||
? 'py-2 px-1'
|
||
: 'aspect-square',
|
||
isWildcard
|
||
? 'bg-primary/8 border-primary/20 text-primary/50 hover:bg-primary/15 hover:border-primary/40 hover:text-primary'
|
||
: selected
|
||
? 'bg-primary/25 border-primary/55 text-primary font-semibold shadow-[0_0_6px_rgba(139,92,246,0.25)]'
|
||
: 'glass border-border/20 text-muted-foreground/50 hover:border-primary/35 hover:text-foreground hover:bg-primary/5',
|
||
)}
|
||
>
|
||
{cellLabel(v)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Custom raw input */}
|
||
<div className="pt-1 border-t border-border/10">
|
||
{showRaw ? (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={rawInput}
|
||
onChange={(e) => { setRawInput(e.target.value); setRawError(''); }}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleRawSubmit();
|
||
if (e.key === 'Escape') { setShowRaw(false); setRawError(''); }
|
||
}}
|
||
placeholder={`e.g. ${fieldType === 'minute' ? '*/15 or 0,30' : fieldType === 'hour' ? '9-17' : fieldType === 'dow' ? '1-5' : '*'}`}
|
||
className={cn(
|
||
'flex-1 px-3 py-1.5 text-xs font-mono bg-muted/20 border rounded-lg focus:outline-none transition-colors',
|
||
rawError ? 'border-destructive/50 focus:border-destructive' : 'border-border/30 focus:border-primary/50',
|
||
)}
|
||
autoFocus
|
||
/>
|
||
<button
|
||
onClick={handleRawSubmit}
|
||
className="px-3 py-1.5 text-xs font-mono bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-all"
|
||
>
|
||
Set
|
||
</button>
|
||
<button
|
||
onClick={() => { setShowRaw(false); setRawError(''); }}
|
||
className="px-3 py-1.5 text-xs font-mono glass border-border/30 text-muted-foreground rounded-lg hover:text-foreground transition-all"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
{rawError && (
|
||
<p className="text-[10px] text-destructive font-mono">{rawError}</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => { setRawInput(value); setShowRaw(true); }}
|
||
className="text-[11px] font-mono text-muted-foreground/40 hover:text-primary/70 transition-colors"
|
||
>
|
||
Enter custom expression →
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|