cron-expression-field.tsx124 lines · main
1'use client';
2
3import { useEffect, useState } from 'react';
4
5interface Props {
6 projectId: string;
7 apiOrigin: string;
8 /** Initial value, e.g. "0 4 * * *". */
9 defaultValue?: string;
10}
11
12type PreviewState =
13 | { kind: 'idle' }
14 | { kind: 'loading' }
15 | { kind: 'ok'; runs: string[] }
16 | { kind: 'error'; message: string };
17
18const DEBOUNCE_MS = 250;
19
20/**
21 * Cron expression input with a live "next 5 fire times" preview.
22 *
23 * The input itself is uncontrolled and posts to the parent form on
24 * submit (matching the rest of the page's server-action pattern). The
25 * preview state is owned here: every keystroke debounces a POST to the
26 * /preview endpoint the api already exposes, which validates the
27 * expression and returns the next five fire times in UTC.
28 */
29export function CronExpressionField({
30 projectId,
31 apiOrigin,
32 defaultValue = '0 4 * * *',
33}: Props) {
34 const [value, setValue] = useState(defaultValue);
35 const [state, setState] = useState<PreviewState>({ kind: 'idle' });
36
37 useEffect(() => {
38 if (!value.trim()) {
39 setState({ kind: 'idle' });
40 return;
41 }
42 const handle = setTimeout(() => {
43 void fetchPreview();
44 }, DEBOUNCE_MS);
45 return () => clearTimeout(handle);
46
47 async function fetchPreview() {
48 setState({ kind: 'loading' });
49 try {
50 const res = await fetch(
51 `${apiOrigin}/v1/projects/${projectId}/schedules/preview`,
52 {
53 method: 'POST',
54 credentials: 'include',
55 headers: { 'content-type': 'application/json' },
56 body: JSON.stringify({ cronExpression: value }),
57 },
58 );
59 const json = (await res.json()) as
60 | { ok: true; nextRuns: string[] }
61 | { ok: false; code: string; message: string };
62 if ('ok' in json && json.ok) {
63 setState({ kind: 'ok', runs: json.nextRuns });
64 } else {
65 setState({
66 kind: 'error',
67 message: 'message' in json ? json.message : 'invalid cron expression',
68 });
69 }
70 } catch (err) {
71 setState({
72 kind: 'error',
73 message: err instanceof Error ? err.message : 'preview failed',
74 });
75 }
76 }
77 }, [value, projectId, apiOrigin]);
78
79 return (
80 <>
81 <input
82 required
83 name="cronExpression"
84 value={value}
85 onChange={(e) => setValue(e.target.value)}
86 placeholder="0 4 * * *"
87 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
88 />
89 <PreviewPanel state={state} />
90 </>
91 );
92}
93
94function PreviewPanel({ state }: { state: PreviewState }) {
95 if (state.kind === 'idle') return null;
96 if (state.kind === 'loading') {
97 return (
98 <p className="font-mono text-[10px] text-[var(--color-text-subtle)]">checking expression…</p>
99 );
100 }
101 if (state.kind === 'error') {
102 return (
103 <p className="font-mono text-[10px] text-[var(--color-error)]">{state.message}</p>
104 );
105 }
106 return (
107 <details
108 open
109 className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] px-3 py-2"
110 >
111 <summary className="cursor-pointer font-mono text-[10px] uppercase tracking-wider text-[var(--color-primary)]">
112 next 5 fire times (utc)
113 </summary>
114 <ul className="mt-2 flex flex-col gap-0.5 font-mono text-[10px] text-[var(--color-text)]">
115 {state.runs.map((run, i) => (
116 <li key={run} className="flex gap-2">
117 <span className="w-4 text-right text-[var(--color-text-subtle)]">{i + 1}.</span>
118 <time dateTime={run}>{run.replace('T', ' ').replace('.000Z', ' utc')}</time>
119 </li>
120 ))}
121 </ul>
122 </details>
123 );
124}