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>
    </>
  );
}