| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import type { OAuthStatus, TickState, TriggerOption } from "./automationApi"; |
| import { WEEKDAYS, readCron, writeCron } from "./steps"; |
|
|
| interface Props { |
| |
| |
| |
| |
| |
| |
| triggers?: TriggerOption[]; |
| |
| current: string; |
| cron: string; |
| enabled: boolean; |
| |
| tick: TickState | null; |
| nextRunAt?: string; |
| cronPresets: { cron: string; label: string }[]; |
| busy: boolean; |
| |
| onPick: (key: string) => void; |
| onSchedule: (next: { cron: string; enabled: boolean }) => void; |
| |
| onCronText: (cron: string) => void; |
| |
| oauth: OAuthStatus | null; |
| |
| showCron: boolean; |
| onShowCron: (v: boolean) => void; |
| |
| |
| |
| |
| |
| |
| |
| hidePicker?: boolean; |
| |
| |
| |
| |
| |
| |
| |
| |
| schedules: boolean; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| function needsLabel(needs: string): string { |
| if (needs === "connect_gmail") return "Connect Gmail"; |
| |
| |
| return needs.replace(/_/g, " "); |
| } |
|
|
| |
| |
| |
| |
| |
| function optionLabel(t: TriggerOption): string { |
| if (t.planned) return `${t.label} β coming soon`; |
| return t.ready === false ? `${t.label} β needs setting up` : t.label; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function tickNote(tick: TickState | null): { text: string; tone: string } { |
| |
| |
| |
| |
| if (!tick || typeof tick.enabled !== "boolean") { |
| return { |
| text: "Whether this deployment runs schedules is not reported here yet β press Run now if you need it now.", |
| tone: "is-unknown", |
| }; |
| } |
| if (!tick.enabled) { |
| return { |
| text: "Schedules won't fire on this deployment β ask your admin to switch the scheduler on. Run now still works.", |
| tone: "is-off", |
| }; |
| } |
| if (tick.source === "external") { |
| return { |
| text: "Schedules fire when the external scheduler calls in. This deployment does not run them itself.", |
| tone: "is-on", |
| }; |
| } |
| return { text: "This deployment runs schedules itself.", tone: "is-on" }; |
| } |
|
|
| export default function AutomationTrigger({ |
| triggers, |
| current, |
| cron, |
| enabled, |
| tick, |
| nextRunAt, |
| cronPresets, |
| busy, |
| onPick, |
| onSchedule, |
| onCronText, |
| oauth, |
| showCron, |
| onShowCron, |
| hidePicker, |
| schedules, |
| }: Props) { |
| |
| |
| const options = triggers || []; |
| const picked = options.find((t) => t.key === current) || null; |
| |
| |
| |
| |
| |
| const isSchedule = schedules; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const shape = readCron(cron); |
| const note = tickNote(tick); |
| const every = showCron ? "custom" : shape.every; |
| |
| |
| |
| const waiting = !!picked && picked.ready === false && !picked.planned && !!picked.needs; |
| const provider = picked?.connect?.provider || ""; |
| const connected = !!(provider && oauth?.[provider]?.connected); |
| const startUrl = picked?.connect?.startUrl || ""; |
|
|
| const pickEvery = (next: string) => { |
| onShowCron(next === "custom"); |
| if (next !== "custom") |
| onSchedule({ cron: writeCron(next, shape.time || "06:00", cron), enabled }); |
| }; |
|
|
| return ( |
| <> |
| <div className="auto-field-row"> |
| {/* β CONDITIONALLY RENDERED, never `hidden` β `.auto-field` sets `display: flex`, which |
| beats the `hidden` attribute's UA `display: none`, so the attribute would have left |
| a second trigger picker on screen looking exactly as if it had been asked for. */} |
| {hidePicker ? null : ( |
| <div className="auto-field"> |
| <label htmlFor="auto-trigger-kind">When it runs</label> |
| <select |
| id="auto-trigger-kind" |
| className="auto-input" |
| value={current} |
| disabled={busy} |
| onChange={(e) => onPick(e.target.value)} |
| > |
| {/* |
| THE STORED VALUE IS ALWAYS AN OPTION. A <select> whose `value` matches |
| no <option> renders the FIRST one, so the next write persists a trigger |
| nobody chose β the defect this codebase has already paid for twice |
| ([[cg-condition-builder-items]]). |
| */} |
| {current && !options.some((t) => t.key === current) ? ( |
| <option value={current}>{current} (not offered here)</option> |
| ) : null} |
| {options.map((t) => ( |
| /* |
| Not-ready is stated ON the option rather than by disabling it: the option is |
| pickable, and picking it shows what it is waiting for. |
| β PLANNED IS THE ONE EXCEPTION, and it is not the same fact (R2). The server |
| REFUSES a planned key at the door, so leaving it pickable would offer a |
| control whose only possible outcome is a refusal. It still renders β the |
| question "can it run when a button is clicked" has an answer β it just is not |
| an offer. |
| */ |
| <option key={t.key} value={t.key} disabled={!!t.planned}> |
| {optionLabel(t)} |
| </option> |
| ))} |
| </select> |
| </div> |
| )} |
| |
| {/* β WAVE 32 Β· T44 β the second on/off control stood here ("Run on this schedule") and is |
| DELETED, not hidden. See the note on `isSchedule` above for why, and for where arming |
| lives now. `enabled` is still read on this component: every cron write below carries it |
| through unchanged, which is what stops editing a repeat interval from silently |
| disarming an automation. */} |
| |
| {isSchedule ? ( |
| <> |
| <div className="auto-field"> |
| <label htmlFor="auto-trigger-every">Repeat</label> |
| <select |
| id="auto-trigger-every" |
| className="auto-input" |
| value={every} |
| disabled={busy} |
| onChange={(e) => pickEvery(e.target.value)} |
| > |
| <option value="day">Every day</option> |
| {WEEKDAYS.map((d) => ( |
| <option key={d.value} value={d.value}> |
| {d.label} |
| </option> |
| ))} |
| {/* |
| THE ESCAPE HATCH, AND IT IS LOAD-BEARING. Daily and weekly do not cover |
| the schedules the engine accepts (hourly, every 15 minutes, monthly, a |
| weekday range), and a picker that cannot express a stored cron would |
| render its FIRST option instead β so the next Save would write "every |
| day at 06:00" over a schedule nobody changed. |
| */} |
| <option value="custom">Custom (cron)</option> |
| </select> |
| </div> |
| {every === "custom" ? ( |
| <> |
| <div className="auto-field"> |
| <label htmlFor="auto-trigger-preset">Or a preset</label> |
| {/* |
| β THE PRESET LIST IS THE SERVER'S. Daily and weekly are not everything |
| the engine runs β hourly, quarter-hourly and monthly are `CRON_PRESETS` |
| β and the parser that ACCEPTS a cron lives in Python, so a |
| client-invented preset is a schedule the server can refuse while the UI |
| looks fine. |
| */} |
| <select |
| id="auto-trigger-preset" |
| className="auto-input" |
| value={cronPresets.some((p) => p.cron === cron) ? cron : ""} |
| disabled={busy} |
| onChange={(e) => e.target.value && onSchedule({ cron: e.target.value, enabled })} |
| > |
| <option value="">Typed below</option> |
| {cronPresets.map((p) => ( |
| <option key={p.cron} value={p.cron}> |
| {p.label} |
| </option> |
| ))} |
| </select> |
| </div> |
| <div className="auto-field"> |
| <label htmlFor="auto-trigger-custom">Cron (minute hour day month weekday)</label> |
| {/* |
| β TYPING DOES NOT PATCH. Every other control here persists on change |
| because each is one discrete decision; a cron STRING is free text, so |
| per-keystroke saving would PATCH a half-typed schedule five times and |
| the server would refuse most of them out loud. It edits locally and |
| commits on BLUR. |
| */} |
| <input |
| id="auto-trigger-custom" |
| className="auto-input is-mono" |
| value={cron} |
| disabled={busy} |
| onChange={(e) => onCronText(e.target.value)} |
| onBlur={() => onSchedule({ cron, enabled })} |
| /> |
| </div> |
| </> |
| ) : ( |
| <div className="auto-field"> |
| <label htmlFor="auto-trigger-time">At</label> |
| <input |
| id="auto-trigger-time" |
| className="auto-input" |
| type="time" |
| value={shape.time || "06:00"} |
| disabled={busy} |
| onChange={(e) => onSchedule({ cron: writeCron(shape.every, e.target.value, cron), enabled })} |
| /> |
| </div> |
| )} |
| </> |
| ) : null} |
| </div> |
| |
| {/* |
| THE ABSENT LIST, SAID OUT LOUD (D-43). With the fallback deleted there is no |
| client-side vocabulary left to fall back TO, and that is the point: a picker that |
| quietly offers two options because a payload was short is a surface stating something |
| nobody measured. One line, no paragraph (R13). |
| */} |
| {!options.length && !hidePicker ? ( |
| <p className="auto-note">This server did not offer a trigger list.</p> |
| ) : null} |
| |
| {/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */} |
| {waiting ? ( |
| <div className="autob-needs"> |
| <span className="autob-needs-text"> |
| {connected |
| ? "Connected β this trigger is still being switched on for this deployment." |
| : "This trigger is not set up yet."} |
| </span> |
| {!connected && startUrl ? ( |
| <a className="auto-btn is-primary autob-connect" href={startUrl}> |
| {needsLabel(picked?.needs || "")} |
| </a> |
| ) : null} |
| </div> |
| ) : null} |
| {connected && provider && oauth?.[provider]?.email ? ( |
| <p className="auto-step-detail">Connected as {oauth[provider]?.email}.</p> |
| ) : null} |
| |
| {isSchedule ? <p className={"auto-tick-note " + note.tone}>{note.text}</p> : null} |
| <p className="auto-step-detail"> |
| {/* |
| β "NEXT RUN 12:30" IS A PROMISE, AND IT IS ONLY TRUE IF SOMETHING TICKS. The |
| server computes the next fire from the cron alone β it does not know whether a |
| scheduler is running β so printing it flat beside a line that has just said |
| "schedules won't fire" is the surface contradicting itself twice over. |
| */} |
| {isSchedule |
| ? nextRunAt |
| ? tick && tick.enabled === false |
| ? `It would run next at ${nextRunAt}, once a scheduler is running.` |
| : `Next run ${nextRunAt}.` |
| : "Schedules start from the moment they are switched on β turning on a daily job after today's time does not fire it today." |
| : current === "manual" |
| ? "It runs when you press Run now, and nothing else starts it." |
| : /* |
| β NOTHING. This line printed `It runs when ${label.toLowerCase()}` and the |
| server's label is "When an email arrives" β so the screen read "It runs when |
| when an email arrives". Composing a sentence out of somebody else's label is |
| how that happens, and the repair is not a better composition: the field above |
| is LABELLED "When it runs" and the picker already says the answer, so a |
| caption restating it was over-explaining even when it was grammatical (R13). |
| */ |
| ""} |
| </p> |
| </> |
| ); |
| } |
|
|