@dowel-ui/react 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
- package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
- package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
- package/dist/components/ai-extraction-review/extraction-model.js +91 -0
- package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
- package/dist/components/ai-extraction-review/index.d.ts +3 -0
- package/dist/components/ai-extraction-review/index.js +3 -0
- package/dist/components/ai-extraction-review/meta.js +17 -0
- package/dist/components/ai-extraction-review/meta.js.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
- package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
- package/dist/components/ai-suggested-value/index.d.ts +2 -0
- package/dist/components/ai-suggested-value/index.js +2 -0
- package/dist/components/ai-suggested-value/meta.js +17 -0
- package/dist/components/ai-suggested-value/meta.js.map +1 -0
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/avatar/avatar.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
- package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
- package/dist/components/confirm-typed/confirm-typed.js +106 -0
- package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
- package/dist/components/confirm-typed/index.d.ts +2 -0
- package/dist/components/confirm-typed/index.js +2 -0
- package/dist/components/confirm-typed/meta.js +17 -0
- package/dist/components/confirm-typed/meta.js.map +1 -0
- package/dist/components/cron-editor/cron-editor.d.ts +36 -0
- package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-editor.js +493 -0
- package/dist/components/cron-editor/cron-editor.js.map +1 -0
- package/dist/components/cron-editor/cron-expression.d.ts +82 -0
- package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
- package/dist/components/cron-editor/cron-expression.js +352 -0
- package/dist/components/cron-editor/cron-expression.js.map +1 -0
- package/dist/components/cron-editor/index.d.ts +3 -0
- package/dist/components/cron-editor/index.js +3 -0
- package/dist/components/cron-editor/meta.js +17 -0
- package/dist/components/cron-editor/meta.js.map +1 -0
- package/dist/components/dns-record/dns-record.d.ts +61 -0
- package/dist/components/dns-record/dns-record.d.ts.map +1 -0
- package/dist/components/dns-record/dns-record.js +227 -0
- package/dist/components/dns-record/dns-record.js.map +1 -0
- package/dist/components/dns-record/index.d.ts +2 -0
- package/dist/components/dns-record/index.js +2 -0
- package/dist/components/dns-record/meta.js +17 -0
- package/dist/components/dns-record/meta.js.map +1 -0
- package/dist/components/permission-matrix/index.d.ts +3 -0
- package/dist/components/permission-matrix/index.js +3 -0
- package/dist/components/permission-matrix/meta.js +17 -0
- package/dist/components/permission-matrix/meta.js.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
- package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-matrix.js +199 -0
- package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
- package/dist/components/permission-matrix/permission-model.d.ts +53 -0
- package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
- package/dist/components/permission-matrix/permission-model.js +64 -0
- package/dist/components/permission-matrix/permission-model.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/secret-field/index.d.ts +2 -0
- package/dist/components/secret-field/index.js +2 -0
- package/dist/components/secret-field/meta.js +17 -0
- package/dist/components/secret-field/meta.js.map +1 -0
- package/dist/components/secret-field/secret-field.d.ts +58 -0
- package/dist/components/secret-field/secret-field.d.ts.map +1 -0
- package/dist/components/secret-field/secret-field.js +168 -0
- package/dist/components/secret-field/secret-field.js.map +1 -0
- package/dist/components/session-expiry/index.d.ts +2 -0
- package/dist/components/session-expiry/index.js +2 -0
- package/dist/components/session-expiry/meta.js +17 -0
- package/dist/components/session-expiry/meta.js.map +1 -0
- package/dist/components/session-expiry/session-expiry.d.ts +64 -0
- package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
- package/dist/components/session-expiry/session-expiry.js +185 -0
- package/dist/components/session-expiry/session-expiry.js.map +1 -0
- package/dist/components/shortcut-recorder/index.d.ts +3 -0
- package/dist/components/shortcut-recorder/index.js +3 -0
- package/dist/components/shortcut-recorder/meta.js +17 -0
- package/dist/components/shortcut-recorder/meta.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
- package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
- package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
- package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
- package/dist/components/spinner/spinner.d.ts +1 -1
- package/dist/components/sync-status/index.d.ts +2 -0
- package/dist/components/sync-status/index.js +2 -0
- package/dist/components/sync-status/meta.js +17 -0
- package/dist/components/sync-status/meta.js.map +1 -0
- package/dist/components/sync-status/sync-status.d.ts +65 -0
- package/dist/components/sync-status/sync-status.d.ts.map +1 -0
- package/dist/components/sync-status/sync-status.js +103 -0
- package/dist/components/sync-status/sync-status.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +16 -2
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +73 -53
- package/dist/registry/components.js.map +1 -1
- package/package.json +4 -4
- package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
- package/src/components/ai-extraction-review/extraction-model.ts +159 -0
- package/src/components/ai-extraction-review/index.ts +13 -0
- package/src/components/ai-extraction-review/meta.ts +24 -0
- package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
- package/src/components/ai-suggested-value/index.ts +6 -0
- package/src/components/ai-suggested-value/meta.ts +24 -0
- package/src/components/confirm-typed/confirm-typed.tsx +191 -0
- package/src/components/confirm-typed/index.ts +1 -0
- package/src/components/confirm-typed/meta.ts +22 -0
- package/src/components/cron-editor/cron-editor.tsx +676 -0
- package/src/components/cron-editor/cron-expression.ts +552 -0
- package/src/components/cron-editor/index.ts +23 -0
- package/src/components/cron-editor/meta.ts +23 -0
- package/src/components/dns-record/dns-record.tsx +315 -0
- package/src/components/dns-record/index.ts +7 -0
- package/src/components/dns-record/meta.ts +21 -0
- package/src/components/permission-matrix/index.ts +14 -0
- package/src/components/permission-matrix/meta.ts +23 -0
- package/src/components/permission-matrix/permission-matrix.tsx +368 -0
- package/src/components/permission-matrix/permission-model.ts +120 -0
- package/src/components/secret-field/index.ts +6 -0
- package/src/components/secret-field/meta.ts +21 -0
- package/src/components/secret-field/secret-field.tsx +284 -0
- package/src/components/session-expiry/index.ts +9 -0
- package/src/components/session-expiry/meta.ts +23 -0
- package/src/components/session-expiry/session-expiry.tsx +321 -0
- package/src/components/shortcut-recorder/index.ts +19 -0
- package/src/components/shortcut-recorder/meta.ts +22 -0
- package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
- package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
- package/src/components/sync-status/index.ts +9 -0
- package/src/components/sync-status/meta.ts +21 -0
- package/src/components/sync-status/sync-status.tsx +217 -0
- package/src/index.ts +10 -0
- package/src/registry/components.ts +20 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
useSyncExternalStore,
|
|
10
|
+
type ComponentPropsWithRef,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
|
|
14
|
+
import { Input } from "@/components/input";
|
|
15
|
+
import {
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
} from "@/components/select";
|
|
22
|
+
import { focusRing } from "@/lib/styles";
|
|
23
|
+
import { cn } from "@/lib/utils";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
CronExpressionError,
|
|
27
|
+
describeCron,
|
|
28
|
+
nextRuns,
|
|
29
|
+
parseCron,
|
|
30
|
+
type CronSchedule,
|
|
31
|
+
} from "./cron-expression";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The "run this nightly" control.
|
|
35
|
+
*
|
|
36
|
+
* GitHub Actions, Vercel, Airflow, Sentry and every admin panel with a
|
|
37
|
+
* schedule draws one by hand: a few frequency controls that write a cron
|
|
38
|
+
* expression, the expression itself for anyone who can read it, the sentence
|
|
39
|
+
* beside it for everyone else, and the next few runs so a mistake is visible
|
|
40
|
+
* before it is saved. The packages that exist are bound to Ant Design or ship
|
|
41
|
+
* their own stylesheet — see `cron-expression.ts` for the value model, which
|
|
42
|
+
* is where the two mistakes every reimplementation makes actually live.
|
|
43
|
+
*
|
|
44
|
+
* Composed from the library's own Select and Input, and split into parts so
|
|
45
|
+
* an app can show the expression and its reading without the builder, or the
|
|
46
|
+
* next runs beside a saved schedule.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
interface CronContextValue {
|
|
50
|
+
value: string;
|
|
51
|
+
setValue: (expression: string) => void;
|
|
52
|
+
schedule: CronSchedule | null;
|
|
53
|
+
timeZone?: string;
|
|
54
|
+
locale?: string;
|
|
55
|
+
/** Null until the clock is known, which on a server render is never. */
|
|
56
|
+
now: Date | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CronContext = createContext<CronContextValue | null>(null);
|
|
60
|
+
|
|
61
|
+
function useCron(component: string): CronContextValue {
|
|
62
|
+
const context = useContext(CronContext);
|
|
63
|
+
if (!context) throw new Error(`${component} must be used inside <Cron>.`);
|
|
64
|
+
return context;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* The clock, for callers who did not supply one — the same arrangement as
|
|
68
|
+
time-range-picker: a null server snapshot, so nothing clock-dependent is
|
|
69
|
+
rendered until the real instant arrives after hydration. */
|
|
70
|
+
const subscribeToNothing = () => () => undefined;
|
|
71
|
+
let sharedClock: Date | null = null;
|
|
72
|
+
const readSharedClock = () => (sharedClock ??= new Date());
|
|
73
|
+
const readNoClock = () => null;
|
|
74
|
+
|
|
75
|
+
export interface CronProps extends Omit<
|
|
76
|
+
ComponentPropsWithRef<"div">,
|
|
77
|
+
"defaultValue" | "onChange" | "children"
|
|
78
|
+
> {
|
|
79
|
+
/** The expression. Controlled. */
|
|
80
|
+
value?: string;
|
|
81
|
+
defaultValue?: string;
|
|
82
|
+
onValueChange?: (expression: string) => void;
|
|
83
|
+
/** IANA zone the expression's times are in. Defaults to the runtime zone. */
|
|
84
|
+
timeZone?: string;
|
|
85
|
+
locale?: string;
|
|
86
|
+
/** The instant next runs are counted from. Pass one that changes when you refresh. */
|
|
87
|
+
now?: Date;
|
|
88
|
+
children: ReactNode;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function Cron({
|
|
92
|
+
className,
|
|
93
|
+
value: valueProp,
|
|
94
|
+
defaultValue = "0 9 * * 1",
|
|
95
|
+
onValueChange,
|
|
96
|
+
timeZone,
|
|
97
|
+
locale,
|
|
98
|
+
now,
|
|
99
|
+
children,
|
|
100
|
+
...props
|
|
101
|
+
}: CronProps) {
|
|
102
|
+
const [uncontrolled, setUncontrolled] = useState(defaultValue);
|
|
103
|
+
const value = valueProp ?? uncontrolled;
|
|
104
|
+
|
|
105
|
+
const fallbackClock = useSyncExternalStore(subscribeToNothing, readSharedClock, readNoClock);
|
|
106
|
+
const clock = now ?? fallbackClock;
|
|
107
|
+
|
|
108
|
+
const schedule = useMemo(() => {
|
|
109
|
+
try {
|
|
110
|
+
return parseCron(value);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}, [value]);
|
|
115
|
+
|
|
116
|
+
const context = useMemo<CronContextValue>(
|
|
117
|
+
() => ({
|
|
118
|
+
value,
|
|
119
|
+
setValue: (expression) => {
|
|
120
|
+
setUncontrolled(expression);
|
|
121
|
+
onValueChange?.(expression);
|
|
122
|
+
},
|
|
123
|
+
schedule,
|
|
124
|
+
timeZone,
|
|
125
|
+
locale,
|
|
126
|
+
now: clock,
|
|
127
|
+
}),
|
|
128
|
+
[value, schedule, timeZone, locale, clock, onValueChange],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<CronContext.Provider value={context}>
|
|
133
|
+
<div data-slot="cron-editor" className={cn("flex flex-col gap-4", className)} {...props}>
|
|
134
|
+
{children}
|
|
135
|
+
</div>
|
|
136
|
+
</CronContext.Provider>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
/* Builder */
|
|
142
|
+
/* ------------------------------------------------------------------ */
|
|
143
|
+
|
|
144
|
+
type Frequency = "minute" | "hour" | "day" | "week" | "month" | "year" | "custom";
|
|
145
|
+
|
|
146
|
+
const FREQUENCIES: { value: Frequency; label: string }[] = [
|
|
147
|
+
{ value: "minute", label: "Every minute" },
|
|
148
|
+
{ value: "hour", label: "Every hour" },
|
|
149
|
+
{ value: "day", label: "Every day" },
|
|
150
|
+
{ value: "week", label: "Every week" },
|
|
151
|
+
{ value: "month", label: "Every month" },
|
|
152
|
+
{ value: "year", label: "Every year" },
|
|
153
|
+
{ value: "custom", label: "Custom" },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
interface BuilderState {
|
|
157
|
+
frequency: Frequency;
|
|
158
|
+
minuteStep: number;
|
|
159
|
+
hourStep: number;
|
|
160
|
+
minute: number;
|
|
161
|
+
hour: number;
|
|
162
|
+
/** 0 = Sunday. */
|
|
163
|
+
days: number[];
|
|
164
|
+
dayOfMonth: number;
|
|
165
|
+
month: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const isNumber = (token: string) => /^\d+$/.test(token);
|
|
169
|
+
const isStar = (token: string) => token === "*";
|
|
170
|
+
const stepOf = (token: string) => (/^\*\/\d+$/.test(token) ? Number(token.slice(2)) : null);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Which builder shape an expression fits, if any. Anything the controls
|
|
174
|
+
* cannot express — every 15 minutes during office hours, say — is "custom",
|
|
175
|
+
* and the expression field becomes the editor rather than the builder quietly
|
|
176
|
+
* showing something else.
|
|
177
|
+
*/
|
|
178
|
+
function classify(schedule: CronSchedule | null): BuilderState {
|
|
179
|
+
const state: BuilderState = {
|
|
180
|
+
frequency: "custom",
|
|
181
|
+
minuteStep: 1,
|
|
182
|
+
hourStep: 1,
|
|
183
|
+
minute: 0,
|
|
184
|
+
hour: 9,
|
|
185
|
+
days: [1],
|
|
186
|
+
dayOfMonth: 1,
|
|
187
|
+
month: 1,
|
|
188
|
+
};
|
|
189
|
+
if (!schedule) return state;
|
|
190
|
+
|
|
191
|
+
const [m = "", h = "", dom = "", mon = "", dow = ""] = schedule.expression.split(" ");
|
|
192
|
+
if (isNumber(m)) state.minute = schedule.minutes[0] ?? 0;
|
|
193
|
+
if (isNumber(h)) state.hour = schedule.hours[0] ?? 0;
|
|
194
|
+
|
|
195
|
+
const restStar = isStar(dom) && isStar(mon) && isStar(dow);
|
|
196
|
+
const timeFixed = isNumber(m) && isNumber(h);
|
|
197
|
+
|
|
198
|
+
if ((isStar(m) || stepOf(m) !== null) && isStar(h) && restStar) {
|
|
199
|
+
state.frequency = "minute";
|
|
200
|
+
state.minuteStep = stepOf(m) ?? 1;
|
|
201
|
+
} else if (isNumber(m) && (isStar(h) || stepOf(h) !== null) && restStar) {
|
|
202
|
+
state.frequency = "hour";
|
|
203
|
+
state.hourStep = stepOf(h) ?? 1;
|
|
204
|
+
} else if (timeFixed && restStar) {
|
|
205
|
+
state.frequency = "day";
|
|
206
|
+
} else if (timeFixed && isStar(dom) && isStar(mon) && !dow.includes("/")) {
|
|
207
|
+
state.frequency = "week";
|
|
208
|
+
state.days = schedule.daysOfWeek;
|
|
209
|
+
} else if (timeFixed && isNumber(dom) && isStar(mon) && isStar(dow)) {
|
|
210
|
+
state.frequency = "month";
|
|
211
|
+
state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
|
|
212
|
+
} else if (
|
|
213
|
+
timeFixed &&
|
|
214
|
+
isNumber(dom) &&
|
|
215
|
+
schedule.months.length === 1 &&
|
|
216
|
+
!/[-,/]/.test(mon) &&
|
|
217
|
+
isStar(dow)
|
|
218
|
+
) {
|
|
219
|
+
state.frequency = "year";
|
|
220
|
+
state.dayOfMonth = schedule.daysOfMonth[0] ?? 1;
|
|
221
|
+
state.month = schedule.months[0] ?? 1;
|
|
222
|
+
}
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `1,2,3,4,5` written the way people write it: `1-5`. Runs of two stay a list. */
|
|
227
|
+
function compressDays(days: number[]): string {
|
|
228
|
+
const sorted = [...new Set(days)].sort((a, b) => a - b);
|
|
229
|
+
const parts: string[] = [];
|
|
230
|
+
for (let i = 0; i < sorted.length;) {
|
|
231
|
+
let j = i;
|
|
232
|
+
while (j + 1 < sorted.length && sorted[j + 1] === (sorted[j] ?? 0) + 1) j += 1;
|
|
233
|
+
const first = String(sorted[i] ?? 0);
|
|
234
|
+
const last = String(sorted[j] ?? 0);
|
|
235
|
+
parts.push(j - i >= 2 ? `${first}-${last}` : j > i ? `${first},${last}` : first);
|
|
236
|
+
i = j + 1;
|
|
237
|
+
}
|
|
238
|
+
return parts.join(",");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function build(state: BuilderState, current: string): string {
|
|
242
|
+
const { minute, hour, dayOfMonth, month } = state;
|
|
243
|
+
switch (state.frequency) {
|
|
244
|
+
case "minute":
|
|
245
|
+
return `${state.minuteStep > 1 ? `*/${String(state.minuteStep)}` : "*"} * * * *`;
|
|
246
|
+
case "hour":
|
|
247
|
+
return `${String(minute)} ${state.hourStep > 1 ? `*/${String(state.hourStep)}` : "*"} * * *`;
|
|
248
|
+
case "day":
|
|
249
|
+
return `${String(minute)} ${String(hour)} * * *`;
|
|
250
|
+
case "week":
|
|
251
|
+
return `${String(minute)} ${String(hour)} * * ${compressDays(state.days)}`;
|
|
252
|
+
case "month":
|
|
253
|
+
return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} * *`;
|
|
254
|
+
case "year":
|
|
255
|
+
return `${String(minute)} ${String(hour)} ${String(dayOfMonth)} ${String(month)} *`;
|
|
256
|
+
case "custom":
|
|
257
|
+
return current;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Monday first, Sunday last. */
|
|
262
|
+
const WEEK = [1, 2, 3, 4, 5, 6, 0];
|
|
263
|
+
// 4 January 2026 is a Sunday; 1 January 2026 is in month 1.
|
|
264
|
+
const weekdayName = (day: number, locale: string | undefined, style: "long" | "short") =>
|
|
265
|
+
new Intl.DateTimeFormat(locale, { weekday: style }).format(new Date(2026, 0, 4 + day));
|
|
266
|
+
const monthName = (month: number, locale: string | undefined) =>
|
|
267
|
+
new Intl.DateTimeFormat(locale, { month: "long" }).format(new Date(2026, month - 1, 1));
|
|
268
|
+
|
|
269
|
+
export type CronBuilderProps = Omit<ComponentPropsWithRef<"div">, "children">;
|
|
270
|
+
|
|
271
|
+
export function CronBuilder({ className, ...props }: CronBuilderProps) {
|
|
272
|
+
const { value, setValue, schedule, locale } = useCron("CronBuilder");
|
|
273
|
+
const uid = useId();
|
|
274
|
+
const state = useMemo(() => classify(schedule), [schedule]);
|
|
275
|
+
|
|
276
|
+
const update = (patch: Partial<BuilderState>) => {
|
|
277
|
+
setValue(build({ ...state, ...patch }, value));
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const numberField = (
|
|
281
|
+
key: "minuteStep" | "hourStep" | "minute" | "dayOfMonth",
|
|
282
|
+
label: string,
|
|
283
|
+
min: number,
|
|
284
|
+
max: number,
|
|
285
|
+
describedBy?: string,
|
|
286
|
+
) => (
|
|
287
|
+
<NumberField
|
|
288
|
+
id={`${uid}-${key}`}
|
|
289
|
+
label={label}
|
|
290
|
+
min={min}
|
|
291
|
+
max={max}
|
|
292
|
+
value={state[key]}
|
|
293
|
+
describedBy={describedBy}
|
|
294
|
+
onCommit={(next) => {
|
|
295
|
+
update({ [key]: next });
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const hasTime = ["day", "week", "month", "year"].includes(state.frequency);
|
|
301
|
+
const hasDayOfMonth = state.frequency === "month" || state.frequency === "year";
|
|
302
|
+
const dayHintId = `${uid}-day-hint`;
|
|
303
|
+
const daysHintId = `${uid}-days-hint`;
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div
|
|
307
|
+
data-slot="cron-builder"
|
|
308
|
+
data-frequency={state.frequency}
|
|
309
|
+
className={cn("flex flex-wrap items-end gap-3", className)}
|
|
310
|
+
{...props}
|
|
311
|
+
>
|
|
312
|
+
<div className="flex flex-col gap-1.5">
|
|
313
|
+
<label htmlFor={`${uid}-frequency`} className="text-xs font-medium">
|
|
314
|
+
Frequency
|
|
315
|
+
</label>
|
|
316
|
+
<Select
|
|
317
|
+
value={state.frequency}
|
|
318
|
+
onValueChange={(frequency) => {
|
|
319
|
+
update({ frequency: frequency as Frequency });
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
<SelectTrigger id={`${uid}-frequency`} className="w-40">
|
|
323
|
+
<SelectValue />
|
|
324
|
+
</SelectTrigger>
|
|
325
|
+
<SelectContent>
|
|
326
|
+
{FREQUENCIES.map((option) => (
|
|
327
|
+
<SelectItem key={option.value} value={option.value}>
|
|
328
|
+
{option.label}
|
|
329
|
+
</SelectItem>
|
|
330
|
+
))}
|
|
331
|
+
</SelectContent>
|
|
332
|
+
</Select>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{state.frequency === "minute"
|
|
336
|
+
? numberField("minuteStep", "Every how many minutes", 1, 59)
|
|
337
|
+
: null}
|
|
338
|
+
|
|
339
|
+
{state.frequency === "hour" ? (
|
|
340
|
+
<>
|
|
341
|
+
{numberField("hourStep", "Every how many hours", 1, 23)}
|
|
342
|
+
{numberField("minute", "At minute", 0, 59)}
|
|
343
|
+
</>
|
|
344
|
+
) : null}
|
|
345
|
+
|
|
346
|
+
{hasTime ? (
|
|
347
|
+
<div className="flex flex-col gap-1.5">
|
|
348
|
+
<label htmlFor={`${uid}-time`} className="text-xs font-medium">
|
|
349
|
+
At
|
|
350
|
+
</label>
|
|
351
|
+
<Input
|
|
352
|
+
id={`${uid}-time`}
|
|
353
|
+
type="time"
|
|
354
|
+
inputSize="sm"
|
|
355
|
+
className="w-32"
|
|
356
|
+
value={`${String(state.hour).padStart(2, "0")}:${String(state.minute).padStart(2, "0")}`}
|
|
357
|
+
onChange={(event) => {
|
|
358
|
+
const [hour, minute] = event.target.value.split(":").map(Number);
|
|
359
|
+
if (
|
|
360
|
+
hour === undefined ||
|
|
361
|
+
minute === undefined ||
|
|
362
|
+
Number.isNaN(hour) ||
|
|
363
|
+
Number.isNaN(minute)
|
|
364
|
+
) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
update({ hour, minute });
|
|
368
|
+
}}
|
|
369
|
+
/>
|
|
370
|
+
</div>
|
|
371
|
+
) : null}
|
|
372
|
+
|
|
373
|
+
{state.frequency === "week" ? (
|
|
374
|
+
<div className="flex flex-col gap-1.5">
|
|
375
|
+
<span id={`${uid}-days-label`} className="text-xs font-medium">
|
|
376
|
+
On
|
|
377
|
+
</span>
|
|
378
|
+
<div
|
|
379
|
+
role="group"
|
|
380
|
+
aria-labelledby={`${uid}-days-label`}
|
|
381
|
+
aria-describedby={daysHintId}
|
|
382
|
+
className="flex gap-1"
|
|
383
|
+
>
|
|
384
|
+
{WEEK.map((day) => {
|
|
385
|
+
const pressed = state.days.includes(day);
|
|
386
|
+
const last = pressed && state.days.length === 1;
|
|
387
|
+
return (
|
|
388
|
+
<button
|
|
389
|
+
key={day}
|
|
390
|
+
type="button"
|
|
391
|
+
aria-pressed={pressed}
|
|
392
|
+
aria-label={weekdayName(day, locale, "long")}
|
|
393
|
+
className={cn(
|
|
394
|
+
"h-8 min-w-9 rounded-md border px-2 text-xs font-medium transition-colors",
|
|
395
|
+
pressed
|
|
396
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
397
|
+
: "border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
398
|
+
focusRing,
|
|
399
|
+
)}
|
|
400
|
+
onClick={() => {
|
|
401
|
+
// The last day stays: a week schedule with no day is not a
|
|
402
|
+
// schedule. The group's description says so, so the refusal
|
|
403
|
+
// is not silent.
|
|
404
|
+
if (last) return;
|
|
405
|
+
update({
|
|
406
|
+
days: pressed
|
|
407
|
+
? state.days.filter((d) => d !== day)
|
|
408
|
+
: [...state.days, day],
|
|
409
|
+
});
|
|
410
|
+
}}
|
|
411
|
+
>
|
|
412
|
+
{weekdayName(day, locale, "short")}
|
|
413
|
+
</button>
|
|
414
|
+
);
|
|
415
|
+
})}
|
|
416
|
+
</div>
|
|
417
|
+
<p id={daysHintId} className="text-xs text-muted-foreground">
|
|
418
|
+
Pick at least one day.
|
|
419
|
+
</p>
|
|
420
|
+
</div>
|
|
421
|
+
) : null}
|
|
422
|
+
|
|
423
|
+
{hasDayOfMonth ? (
|
|
424
|
+
<div className="flex flex-col gap-1.5">
|
|
425
|
+
{numberField(
|
|
426
|
+
"dayOfMonth",
|
|
427
|
+
"On day",
|
|
428
|
+
1,
|
|
429
|
+
31,
|
|
430
|
+
state.dayOfMonth > 28 ? dayHintId : undefined,
|
|
431
|
+
)}
|
|
432
|
+
{state.dayOfMonth > 28 ? (
|
|
433
|
+
<p id={dayHintId} data-slot="cron-day-hint" className="text-xs text-warning">
|
|
434
|
+
Months with fewer days skip this run.
|
|
435
|
+
</p>
|
|
436
|
+
) : null}
|
|
437
|
+
</div>
|
|
438
|
+
) : null}
|
|
439
|
+
|
|
440
|
+
{state.frequency === "year" ? (
|
|
441
|
+
<div className="flex flex-col gap-1.5">
|
|
442
|
+
<label htmlFor={`${uid}-month`} className="text-xs font-medium">
|
|
443
|
+
In
|
|
444
|
+
</label>
|
|
445
|
+
<Select
|
|
446
|
+
value={String(state.month)}
|
|
447
|
+
onValueChange={(month) => {
|
|
448
|
+
update({ month: Number(month) });
|
|
449
|
+
}}
|
|
450
|
+
>
|
|
451
|
+
<SelectTrigger id={`${uid}-month`} className="w-36">
|
|
452
|
+
<SelectValue />
|
|
453
|
+
</SelectTrigger>
|
|
454
|
+
<SelectContent>
|
|
455
|
+
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
|
|
456
|
+
<SelectItem key={month} value={String(month)}>
|
|
457
|
+
{monthName(month, locale)}
|
|
458
|
+
</SelectItem>
|
|
459
|
+
))}
|
|
460
|
+
</SelectContent>
|
|
461
|
+
</Select>
|
|
462
|
+
</div>
|
|
463
|
+
) : null}
|
|
464
|
+
|
|
465
|
+
{state.frequency === "custom" ? (
|
|
466
|
+
<p className="text-xs text-muted-foreground">
|
|
467
|
+
This schedule needs the expression. Edit it below.
|
|
468
|
+
</p>
|
|
469
|
+
) : null}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A number the builder owns, with the text the reader is typing kept apart
|
|
476
|
+
* from the value — so clearing the field to type a new number does not snap
|
|
477
|
+
* it back to the old one after the first keystroke.
|
|
478
|
+
*/
|
|
479
|
+
function NumberField({
|
|
480
|
+
id,
|
|
481
|
+
label,
|
|
482
|
+
min,
|
|
483
|
+
max,
|
|
484
|
+
value,
|
|
485
|
+
describedBy,
|
|
486
|
+
onCommit,
|
|
487
|
+
}: {
|
|
488
|
+
id: string;
|
|
489
|
+
label: string;
|
|
490
|
+
min: number;
|
|
491
|
+
max: number;
|
|
492
|
+
value: number;
|
|
493
|
+
describedBy?: string;
|
|
494
|
+
onCommit: (value: number) => void;
|
|
495
|
+
}) {
|
|
496
|
+
const [draft, setDraft] = useState(String(value));
|
|
497
|
+
const [seen, setSeen] = useState(value);
|
|
498
|
+
if (seen !== value) {
|
|
499
|
+
setSeen(value);
|
|
500
|
+
setDraft(String(value));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return (
|
|
504
|
+
<div className="flex flex-col gap-1.5">
|
|
505
|
+
<label htmlFor={id} className="text-xs font-medium">
|
|
506
|
+
{label}
|
|
507
|
+
</label>
|
|
508
|
+
<Input
|
|
509
|
+
id={id}
|
|
510
|
+
type="number"
|
|
511
|
+
inputSize="sm"
|
|
512
|
+
min={min}
|
|
513
|
+
max={max}
|
|
514
|
+
value={draft}
|
|
515
|
+
aria-describedby={describedBy}
|
|
516
|
+
className="w-24"
|
|
517
|
+
onChange={(event) => {
|
|
518
|
+
const text = event.target.value;
|
|
519
|
+
setDraft(text);
|
|
520
|
+
const next = Number(text);
|
|
521
|
+
if (text === "" || !Number.isInteger(next) || next < min || next > max) return;
|
|
522
|
+
if (next !== value) onCommit(next);
|
|
523
|
+
}}
|
|
524
|
+
onBlur={() => {
|
|
525
|
+
// Whatever was left half-typed goes back to the value in force.
|
|
526
|
+
if (draft !== String(value)) setDraft(String(value));
|
|
527
|
+
}}
|
|
528
|
+
/>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* ------------------------------------------------------------------ */
|
|
534
|
+
/* Expression */
|
|
535
|
+
/* ------------------------------------------------------------------ */
|
|
536
|
+
|
|
537
|
+
export type CronExpressionProps = Omit<ComponentPropsWithRef<"div">, "children">;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The expression, editable, with its reading underneath — or the reason it is
|
|
541
|
+
* invalid. An invalid entry is never applied: the last valid schedule stands
|
|
542
|
+
* until the field says something that parses.
|
|
543
|
+
*/
|
|
544
|
+
export function CronExpression({ className, ...props }: CronExpressionProps) {
|
|
545
|
+
const { value, setValue, locale } = useCron("CronExpression");
|
|
546
|
+
const id = useId();
|
|
547
|
+
|
|
548
|
+
const [draft, setDraft] = useState(value);
|
|
549
|
+
// The value can change under the field — the builder, or the consumer.
|
|
550
|
+
const [lastValue, setLastValue] = useState(value);
|
|
551
|
+
if (value !== lastValue) {
|
|
552
|
+
setLastValue(value);
|
|
553
|
+
setDraft(value);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const feedback = useMemo(() => {
|
|
557
|
+
try {
|
|
558
|
+
return { text: describeCron(draft, { locale }), error: null as string | null };
|
|
559
|
+
} catch (thrown) {
|
|
560
|
+
return {
|
|
561
|
+
text: null,
|
|
562
|
+
error: thrown instanceof CronExpressionError ? thrown.message : "Invalid schedule.",
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
}, [draft, locale]);
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<div
|
|
569
|
+
data-slot="cron-expression"
|
|
570
|
+
className={cn("flex flex-col gap-1.5", className)}
|
|
571
|
+
{...props}
|
|
572
|
+
>
|
|
573
|
+
<label htmlFor={`${id}-input`} className="text-xs font-medium">
|
|
574
|
+
Expression
|
|
575
|
+
</label>
|
|
576
|
+
<Input
|
|
577
|
+
id={`${id}-input`}
|
|
578
|
+
inputSize="sm"
|
|
579
|
+
value={draft}
|
|
580
|
+
spellCheck={false}
|
|
581
|
+
autoComplete="off"
|
|
582
|
+
aria-invalid={feedback.error ? true : undefined}
|
|
583
|
+
aria-describedby={`${id}-feedback`}
|
|
584
|
+
className="font-mono"
|
|
585
|
+
onChange={(event) => {
|
|
586
|
+
const next = event.target.value;
|
|
587
|
+
setDraft(next);
|
|
588
|
+
try {
|
|
589
|
+
parseCron(next);
|
|
590
|
+
setValue(next.trim());
|
|
591
|
+
} catch {
|
|
592
|
+
// Said in the feedback; the last valid schedule stands.
|
|
593
|
+
}
|
|
594
|
+
}}
|
|
595
|
+
/>
|
|
596
|
+
{/* One element for both, so a reader hears the error replace the
|
|
597
|
+
reading rather than both at once. */}
|
|
598
|
+
<p
|
|
599
|
+
id={`${id}-feedback`}
|
|
600
|
+
data-slot="cron-feedback"
|
|
601
|
+
className={cn("text-xs", feedback.error ? "text-destructive" : "text-muted-foreground")}
|
|
602
|
+
>
|
|
603
|
+
{feedback.error ?? feedback.text}
|
|
604
|
+
</p>
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* ------------------------------------------------------------------ */
|
|
610
|
+
/* Next runs */
|
|
611
|
+
/* ------------------------------------------------------------------ */
|
|
612
|
+
|
|
613
|
+
export interface CronNextRunsProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
614
|
+
count?: number;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* When it will fire, in the zone it is defined in. The zone is in the
|
|
619
|
+
* heading because a time with no zone is the classic scheduling mistake, and
|
|
620
|
+
* a schedule that never runs says so rather than showing an empty list.
|
|
621
|
+
*/
|
|
622
|
+
export function CronNextRuns({ className, count = 5, ...props }: CronNextRunsProps) {
|
|
623
|
+
const { value, schedule, timeZone, locale, now } = useCron("CronNextRuns");
|
|
624
|
+
const headingId = useId();
|
|
625
|
+
|
|
626
|
+
const runs = useMemo(
|
|
627
|
+
() => (now && schedule ? nextRuns(value, { from: now, count, timeZone }) : null),
|
|
628
|
+
[value, schedule, now, count, timeZone],
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
// Nothing clock-dependent before the clock is known.
|
|
632
|
+
if (!runs) return null;
|
|
633
|
+
|
|
634
|
+
const format = new Intl.DateTimeFormat(locale, {
|
|
635
|
+
dateStyle: "medium",
|
|
636
|
+
timeStyle: "short",
|
|
637
|
+
timeZone,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
return (
|
|
641
|
+
<div
|
|
642
|
+
data-slot="cron-next-runs"
|
|
643
|
+
className={cn("flex flex-col gap-1.5", className)}
|
|
644
|
+
{...props}
|
|
645
|
+
>
|
|
646
|
+
<p id={headingId} className="text-xs font-medium">
|
|
647
|
+
Next {count === 1 ? "run" : `${String(count)} runs`}
|
|
648
|
+
<span className="font-normal text-muted-foreground">
|
|
649
|
+
{" · "}
|
|
650
|
+
{timeZone ?? "local time"}
|
|
651
|
+
</span>
|
|
652
|
+
</p>
|
|
653
|
+
{runs.length === 0 ? (
|
|
654
|
+
<p data-slot="cron-never" className="text-xs text-destructive">
|
|
655
|
+
Never runs: no day matches this schedule.
|
|
656
|
+
</p>
|
|
657
|
+
) : (
|
|
658
|
+
<ol
|
|
659
|
+
aria-labelledby={headingId}
|
|
660
|
+
className="m-0 flex list-none flex-col gap-0.5 p-0 text-sm"
|
|
661
|
+
>
|
|
662
|
+
{runs.map((run) => (
|
|
663
|
+
<li key={run.toISOString()}>
|
|
664
|
+
<time dateTime={run.toISOString()} className="tabular-nums">
|
|
665
|
+
{format.format(run)}
|
|
666
|
+
</time>
|
|
667
|
+
</li>
|
|
668
|
+
))}
|
|
669
|
+
{runs.length < count ? (
|
|
670
|
+
<li className="text-xs text-muted-foreground">Then nothing within five years.</li>
|
|
671
|
+
) : null}
|
|
672
|
+
</ol>
|
|
673
|
+
)}
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
}
|