File size: 20,103 Bytes
37d92f0 c3e4cb4 b36e641 37d92f0 c3e4cb4 37d92f0 c3e4cb4 37d92f0 c3e4cb4 b36e641 37d92f0 c3e4cb4 37d92f0 b36e641 609fb78 b36e641 609fb78 b36e641 37d92f0 c3e4cb4 37d92f0 c3e4cb4 37d92f0 c3e4cb4 37d92f0 c3e4cb4 37d92f0 c3e4cb4 37d92f0 609fb78 b36e641 37d92f0 c3e4cb4 37d92f0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 | // ---------------------------------------------------------------------------
// automation/AutomationTrigger.tsx β what STARTS an automation (contract C3).
//
// ONE implementation, two frames. The board renders it as the strip above the
// lanes; the numbered-steps view renders it inside Step 1's card. It used to be
// written inline in `AutomationSteps`, and the moment the board needed the same
// controls that was a second copy of the cron round-trip, the tick honesty note
// and the next-run sentence β three things that go subtly wrong and stay wrong
// invisibly. There is one of each.
//
// β THE VOCABULARY IS THE SERVER'S (C3, and this is the rule the wave was
// explicit about). The options come from `triggers: [{key, label, ready, needs}]`
// on `GET /automations`. There is NO client union over the keys: a union means
// the two lists can diverge, and they diverge SILENTLY β the server offers a
// trigger the client has no arm for, the client draws nothing, and the product
// quietly loses a feature it shipped (the wave-9 silent-drop class). The keys are
// `string` here on purpose, and an unrecognised one still renders.
//
// A NOT-READY TRIGGER IS OFFERED, NOT HIDDEN. `ready: false` plus `needs`
// ("connect_gmail") renders as an option you can pick with a next step attached.
// R9 set that precedent with the disabled "when a field changes" slot and the
// reasoning holds: the question "can it run when an email arrives?" has an
// answer, and an option nobody can use yet SAYS it β a missing option says
// nothing and gets asked again.
// ---------------------------------------------------------------------------
import type { OAuthStatus, TickState, TriggerOption } from "./automationApi";
import { WEEKDAYS, readCron, writeCron } from "./steps";
interface Props {
/**
* The server's trigger list. ABSENT until the engine ships C3 β and absent is
* not "no triggers": the face falls back to the two shapes today's server can
* actually express, marked below, and stops consulting that fallback the moment
* the list rides.
*/
triggers?: TriggerOption[];
/** The trigger currently stored, as a key. */
current: string;
cron: string;
enabled: boolean;
/** `null` = the server did not report whether schedules fire here (R9's third state). */
tick: TickState | null;
nextRunAt?: string;
cronPresets: { cron: string; label: string }[];
busy: boolean;
/** Picking a trigger PERSISTS β a trigger you have to remember to Save is not a switch. */
onPick: (key: string) => void;
onSchedule: (next: { cron: string; enabled: boolean }) => void;
/** Free-text cron edits: local only, committed on blur. See the input's note. */
onCronText: (cron: string) => void;
/** Whether this USER has connected each provider (C5). `null` = not asked yet. */
oauth: OAuthStatus | null;
/** True when the raw cron face is open β a VIEW choice, not a schedule change. */
showCron: boolean;
onShowCron: (v: boolean) => void;
/**
* WAVE 23 β the BUILDER supplies its own "Trigger type" select (Properties β Trigger details),
* so this face drops its picker and contributes only the schedule half. It is a prop rather
* than a second component because the thing worth having exactly once is what sits BELOW the
* picker: the cron round-trip, the preset list and the tick-honesty note, three things that go
* wrong invisibly. Two pickers for one fact is the defect this file's header forbids.
*/
hidePicker?: boolean;
/**
* WAVE 24, owner item 7 β DOES THE CRON DRIVE THIS TRIGGER? REQUIRED, and it is a fact the
* CALLER establishes rather than one this component derives, because deriving it means naming
* keys and this file's header forbids a client union over the trigger vocabulary.
*
* `schedule` is no longer the only answer: `ig_profile_match` watches nothing β it MAKES rows,
* on the cron β so C-TRIG gives it the Scheduled-run controls "always".
*/
schedules: boolean;
}
/*
* β `FALLBACK_TRIGGERS` IS GONE (D-43, wave 23). It described the only two shapes a stored
* definition could express before C3 shipped (manual / on a schedule), and its own note set
* the deletion condition: "NEVER consulted once the list arrives; when C3 lands, deleting this
* constant changes nothing on screen." C3 has been live for two releases and the vocabulary is
* now nine keys plus two planned, so the fallback could only ever have been WRONG β a server
* hiccup would have painted a two-option picker that looks authoritative.
*
* The absent case is a STATE now, not a substitute list: no `triggers` means the surface says
* so in one line (below) rather than quietly offering a vocabulary of its own.
*/
/** The one-line next step a not-ready trigger carries. Server words where there are any. */
function needsLabel(needs: string): string {
if (needs === "connect_gmail") return "Connect Gmail";
// An unrecognised `needs` is still SAID, verbatim. A blank next step is how a
// not-ready trigger becomes a dead control.
return needs.replace(/_/g, " ");
}
/**
* What an option says about itself. THREE states, and they are not the same fact:
* ready (nothing), waiting on a connection the USER can make ("needs setting up"), and
* declared-but-not-built ("coming soon", R2) which is waiting on us.
*/
function optionLabel(t: TriggerOption): string {
if (t.planned) return `${t.label} β coming soon`;
return t.ready === false ? `${t.label} β needs setting up` : t.label;
}
/**
* What the deployment will actually do with a schedule.
*
* β THREE ANSWERS, AND ONE OF THEM IS "WE WERE NOT TOLD". A card that said
* "schedules won't fire" because a field was missing would be inventing a
* measurement, and it would be WRONG on this product's own production: the
* external EventBridge tick fires schedules on a deployment whose in-process
* scheduler is off ([[aws-automation-cron]]). So `external` says the door is open,
* not that the caller is alive β the server can only prove the first.
*/
function tickNote(tick: TickState | null): { text: string; tone: string } {
// β AN EXPLICIT BOOLEAN OR NOTHING. A payload carrying `"tick": {}` β a
// half-shipped field, a serialiser that dropped a false, a version skew β has no
// `enabled` at all, and reading `undefined` as "off" printed "schedules won't
// fire" as though it had been measured.
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) {
// β THE SERVER'S LIST OR NOTHING (D-43). An empty array is not a fallback trigger to
// invent β it is the one honest thing to say, and it is said below.
const options = triggers || [];
const picked = options.find((t) => t.key === current) || null;
/**
* β RENAMED FROM `current === "schedule"` (item 7). The cron face belongs to any trigger the
* cron DRIVES, which is now more than one key β and the caller is the only thing entitled to
* say which, so this reads the prop rather than the key it used to test.
*/
const isSchedule = schedules;
/**
* ββ WAVE 32 Β· T44 (owner item 2) β THE ARM SWITCH IS DELETED, AND ITS OWN NOTE ARGUED FOR
* DELETING IT.
*
* That note (kept below, because the reasoning is still correct and only its conclusion was
* wrong) said a switch beside the `schedule` picker *"would be two controls for one fact, which
* this component's siblings forbid"* β and then rendered exactly that for every cron-driven
* trigger that is not `schedule`, because the rail's own per-automation switch was not counted
* as one of the two. It is one: `AutomationSurface`'s `auto-row-switch` writes the SAME
* `schedule.enabled`, on every automation, from the row that carries its name.
*
* Owner, item 2: exactly one on/off control per automation, the one under its name β and the
* tell they reported is that the other *"keeps disappearing or appearing"*. That is this
* control's render condition read back verbatim: `schedules && current !== "schedule"` shows and
* hides it as the trigger changes, so the same automation has one switch or two depending on
* what it is triggered by.
*
* β THE CONDITION IS NOT WHAT GOES β THE CONTROL IS. Keeping the condition and hiding the
* switch leaves a control that exists in one state and not the other, which is the complaint.
* Nothing loses the ability to arm: the `schedule` trigger arms by being PICKED (`pickTrigger`
* writes `enabled: true`), and every other cron-driven trigger arms from the rail switch, which
* is reachable from every surface and does not come and go.
*
* β THE ORIGINAL REASONING, PRESERVED because the next reader will ask why a cron trigger does
* not arm itself: picking `ig_profile_match` deliberately stores `enabled: false`, because
* arming a cron the moment somebody chooses a trigger would put a BILLED vendor run on a timer
* nobody asked for (C's amendment A1: *"the seed cannot spend β¦ runs only when a person presses
* Run now or arms the schedule"*). That is still true. The arming is now the rail switch.
*/
const shape = readCron(cron);
const note = tickNote(tick);
const every = showCron ? "custom" : shape.every;
// The connector this trigger is waiting on, and the route to it, BOTH COMPOSED BY THE
// SERVER (`TriggerOption.connect`). No `connect` = we cannot name a route, which is a state
// the block below states in words instead of drawing a button that goes nowhere.
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>
</>
);
}
|