@checkstack/automation-frontend 0.7.1 → 0.9.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.
@@ -42,10 +42,13 @@ import {
42
42
  Toggle,
43
43
  useToast,
44
44
  useInitOnceForKey,
45
+ useUnsavedChanges,
46
+ ConfirmationModal,
45
47
  Tabs,
46
48
  TabPanel,
49
+ toastError,
47
50
  } from "@checkstack/ui";
48
- import { extractErrorMessage, resolveRoute } from "@checkstack/common";
51
+ import { resolveRoute } from "@checkstack/common";
49
52
  import {
50
53
  GitOpsLockBanner,
51
54
  useProvenanceLock,
@@ -59,6 +62,11 @@ import { computeYamlMarkers } from "../editor/yaml-markers";
59
62
  import { partitionIssues } from "../editor/editor-validation";
60
63
  import { AutomationGroupCombobox } from "../components/AutomationGroupCombobox";
61
64
  import { RunAsServiceAccountPicker } from "../components/RunAsServiceAccountPicker";
65
+ import { SaveBlockersSummary } from "../components/SaveBlockersSummary";
66
+ import {
67
+ summarizeBlockingIssues,
68
+ type BlockingIssue,
69
+ } from "../editor/blocking-issues";
62
70
 
63
71
  const STARTER_DEFINITION: AutomationDefinition = {
64
72
  name: "New Automation",
@@ -161,6 +169,12 @@ const AutomationEditContent: React.FC = () => {
161
169
  Array<{ path: Array<string | number>; message: string }>
162
170
  >([]);
163
171
 
172
+ // Unsaved-changes tracking. Flipped true by any user edit (see `markDirty`),
173
+ // reset when the form is (re)seeded from a record/template and after a save.
174
+ // Drives the shared `useUnsavedChanges` guard below.
175
+ const [isDirty, setIsDirty] = React.useState(false);
176
+ const markDirty = React.useCallback(() => setIsDirty(true), []);
177
+
164
178
  // Seed local form state from the loaded automation, once per record.
165
179
  // `useInitOnceForKey` seeds during render (not in an effect), so it survives
166
180
  // StrictMode's double-mount even when the query resolves from a warm cache on
@@ -189,6 +203,8 @@ const AutomationEditContent: React.FC = () => {
189
203
  setStatusEnabled(a.status === "enabled");
190
204
  setDefinition(normalized);
191
205
  setYamlText(stringifyYaml(normalized));
206
+ // Seeding from the stored record is not a user edit.
207
+ setIsDirty(false);
192
208
  },
193
209
  );
194
210
 
@@ -243,7 +259,7 @@ const AutomationEditContent: React.FC = () => {
243
259
  // Don't switch tabs while YAML is unparseable — the operator
244
260
  // would silently lose their edits. The Monaco markers already
245
261
  // squiggle the syntax error in place.
246
- toast.error(`Cannot switch — YAML is invalid: ${extractErrorMessage(error)}`);
262
+ toastError(toast, "Cannot switch - YAML is invalid", error);
247
263
  return;
248
264
  }
249
265
  }
@@ -254,7 +270,7 @@ const AutomationEditContent: React.FC = () => {
254
270
  onSuccess: (result) => {
255
271
  setValidationErrors(result.valid ? [] : result.errors);
256
272
  },
257
- onError: (error) => toast.error(extractErrorMessage(error)),
273
+ onError: (error) => toastError(toast, "Failed to validate automation", error),
258
274
  });
259
275
 
260
276
  // Live validation — separate mutation instance from the save-path one
@@ -302,10 +318,16 @@ const AutomationEditContent: React.FC = () => {
302
318
 
303
319
  const createMutation = client.createAutomation.useMutation({
304
320
  onSuccess: (data) => {
321
+ // Saved: clear the dirty flag, then redirect on the next tick so the
322
+ // unsaved-changes guard has committed `isDirty = false` and doesn't
323
+ // block the post-create navigation.
324
+ setIsDirty(false);
305
325
  toast.success(`Created ${data.name}`);
306
- navigate(
307
- resolveRoute(automationRoutes.routes.edit, { automationId: data.id }),
308
- );
326
+ setTimeout(() => {
327
+ navigate(
328
+ resolveRoute(automationRoutes.routes.edit, { automationId: data.id }),
329
+ );
330
+ }, 0);
309
331
  },
310
332
  onError: (error) => {
311
333
  const inline = teamCreateErrorMessage(error);
@@ -313,15 +335,16 @@ const AutomationEditContent: React.FC = () => {
313
335
  setOwnerTeamError(inline);
314
336
  return;
315
337
  }
316
- toast.error(extractErrorMessage(error));
338
+ toastError(toast, "Failed to create automation", error);
317
339
  },
318
340
  });
319
341
 
320
342
  const updateMutation = client.updateAutomation.useMutation({
321
343
  onSuccess: (data) => {
344
+ setIsDirty(false);
322
345
  toast.success(`Saved ${data.name}`);
323
346
  },
324
- onError: (error) => toast.error(extractErrorMessage(error)),
347
+ onError: (error) => toastError(toast, "Failed to save automation", error),
325
348
  });
326
349
 
327
350
  const manualRunMutation = client.manualRun.useMutation({
@@ -336,7 +359,7 @@ const AutomationEditContent: React.FC = () => {
336
359
  );
337
360
  }
338
361
  },
339
- onError: (error) => toast.error(extractErrorMessage(error)),
362
+ onError: (error) => toastError(toast, "Failed to queue manual run", error),
340
363
  });
341
364
 
342
365
  /**
@@ -352,7 +375,7 @@ const AutomationEditContent: React.FC = () => {
352
375
  try {
353
376
  return parseYaml(yamlText) as AutomationDefinition;
354
377
  } catch (error) {
355
- toast.error(`Fix the YAML syntax error before saving: ${extractErrorMessage(error)}`);
378
+ toastError(toast, "Fix the YAML syntax error before saving", error);
356
379
  return null;
357
380
  }
358
381
  };
@@ -412,7 +435,7 @@ const AutomationEditContent: React.FC = () => {
412
435
  if (!committed) return;
413
436
  const firstTrigger = committed.triggers[0];
414
437
  if (!firstTrigger) {
415
- toast.error("Automation has no triggers — add one before running.");
438
+ toast.error("Automation has no triggers - add one before running.");
416
439
  return;
417
440
  }
418
441
  manualRunMutation.mutate({
@@ -457,6 +480,40 @@ const AutomationEditContent: React.FC = () => {
457
480
  const canSave =
458
481
  !nameError && !runAsError && validationErrors.length === 0 && !isSaving;
459
482
 
483
+ // Explain WHY Save is disabled: a flat, ordered list of blockers shown in a
484
+ // popover next to the button, each linking to the offending field/section.
485
+ const blockers = React.useMemo(
486
+ () =>
487
+ summarizeBlockingIssues({
488
+ nameError,
489
+ runAsError,
490
+ definitionIssues: validationErrors,
491
+ }),
492
+ [nameError, runAsError, validationErrors],
493
+ );
494
+
495
+ // Resolve a blocker: bring its surface into view and focus the field.
496
+ // Definition issues attach to the visual editor (their inline markers live
497
+ // there), so we ensure that tab is active.
498
+ const handleResolveBlocker = React.useCallback((blocker: BlockingIssue) => {
499
+ if (blocker.target.kind === "definition") {
500
+ setTab("visual");
501
+ return;
502
+ }
503
+ const elementId = blocker.target.field === "name" ? "name" : "runAs";
504
+ const element = document.querySelector(`#${elementId}`);
505
+ if (element instanceof HTMLElement) {
506
+ element.scrollIntoView({ block: "center" });
507
+ element.focus();
508
+ }
509
+ }, []);
510
+
511
+ // Unsaved-changes guard: native prompt on tab close / refresh and an in-app
512
+ // confirm on navigation while there are pending edits.
513
+ const { isBlocked, confirmDiscard, cancelDiscard } = useUnsavedChanges({
514
+ isDirty,
515
+ });
516
+
460
517
  return (
461
518
  <PageLayout
462
519
  title={isNew ? "New automation" : name || "Edit automation"}
@@ -498,14 +555,20 @@ const AutomationEditContent: React.FC = () => {
498
555
  </>
499
556
  )}
500
557
  {canManage && (
501
- <Button
502
- size="sm"
503
- onClick={handleSave}
504
- disabled={!canSave || validateMutation.isPending}
505
- >
506
- <Save className="mr-1 h-4 w-4" />
507
- Save
508
- </Button>
558
+ <>
559
+ <SaveBlockersSummary
560
+ blockers={blockers}
561
+ onResolve={handleResolveBlocker}
562
+ />
563
+ <Button
564
+ size="sm"
565
+ onClick={handleSave}
566
+ disabled={!canSave || validateMutation.isPending}
567
+ >
568
+ <Save className="mr-1 h-4 w-4" />
569
+ Save
570
+ </Button>
571
+ </>
509
572
  )}
510
573
  </div>
511
574
  }
@@ -532,8 +595,14 @@ const AutomationEditContent: React.FC = () => {
532
595
  <Label htmlFor="name">Name</Label>
533
596
  <Input
534
597
  id="name"
598
+ // Land focus in the first field when opening a fresh editor so
599
+ // keyboard-first users can type immediately.
600
+ autoFocus={isNew}
535
601
  value={name}
536
- onChange={(e) => setName(e.target.value)}
602
+ onChange={(e) => {
603
+ setName(e.target.value);
604
+ markDirty();
605
+ }}
537
606
  disabled={!canManage}
538
607
  placeholder="Open Jira issue when incident fires"
539
608
  aria-invalid={nameError ? true : undefined}
@@ -548,7 +617,10 @@ const AutomationEditContent: React.FC = () => {
548
617
  <Input
549
618
  id="description"
550
619
  value={description}
551
- onChange={(e) => setDescription(e.target.value)}
620
+ onChange={(e) => {
621
+ setDescription(e.target.value);
622
+ markDirty();
623
+ }}
552
624
  disabled={!canManage}
553
625
  placeholder="Optional"
554
626
  />
@@ -558,7 +630,10 @@ const AutomationEditContent: React.FC = () => {
558
630
  <AutomationGroupCombobox
559
631
  id="group"
560
632
  value={group}
561
- onValueChange={setGroup}
633
+ onValueChange={(next) => {
634
+ setGroup(next);
635
+ markDirty();
636
+ }}
562
637
  suggestions={groupSuggestions}
563
638
  disabled={!canManage}
564
639
  />
@@ -568,7 +643,10 @@ const AutomationEditContent: React.FC = () => {
568
643
  </div>
569
644
  <RunAsServiceAccountPicker
570
645
  value={runAsApplicationId}
571
- onValueChange={setRunAsApplicationId}
646
+ onValueChange={(next) => {
647
+ setRunAsApplicationId(next);
648
+ markDirty();
649
+ }}
572
650
  disabled={!canManage}
573
651
  showError={!!runAsError}
574
652
  />
@@ -579,6 +657,7 @@ const AutomationEditContent: React.FC = () => {
579
657
  onChange={(id) => {
580
658
  setOwnerTeamId(id);
581
659
  setOwnerTeamError(null);
660
+ markDirty();
582
661
  }}
583
662
  allowGlobal={allowGlobal}
584
663
  disabled={!canManage}
@@ -589,7 +668,10 @@ const AutomationEditContent: React.FC = () => {
589
668
  <Label htmlFor="enabled">Enabled</Label>
590
669
  <Toggle
591
670
  checked={statusEnabled}
592
- onCheckedChange={setStatusEnabled}
671
+ onCheckedChange={(next) => {
672
+ setStatusEnabled(next);
673
+ markDirty();
674
+ }}
593
675
  disabled={!canManage}
594
676
  aria-label="Enable automation"
595
677
  />
@@ -598,12 +680,13 @@ const AutomationEditContent: React.FC = () => {
598
680
  <Label htmlFor="mode">Concurrency mode</Label>
599
681
  <Select
600
682
  value={definition.mode}
601
- onValueChange={(value) =>
683
+ onValueChange={(value) => {
602
684
  setDefinition({
603
685
  ...definition,
604
686
  mode: value as AutomationDefinition["mode"],
605
- })
606
- }
687
+ });
688
+ markDirty();
689
+ }}
607
690
  disabled={!canManage}
608
691
  >
609
692
  <SelectTrigger id="mode">
@@ -625,12 +708,13 @@ const AutomationEditContent: React.FC = () => {
625
708
  min={1}
626
709
  max={1000}
627
710
  value={definition.max_runs}
628
- onChange={(e) =>
711
+ onChange={(e) => {
629
712
  setDefinition({
630
713
  ...definition,
631
714
  max_runs: Math.max(1, Number(e.target.value)),
632
- })
633
- }
715
+ });
716
+ markDirty();
717
+ }}
634
718
  disabled={!canManage}
635
719
  />
636
720
  </div>
@@ -664,7 +748,10 @@ const AutomationEditContent: React.FC = () => {
664
748
  computes. */}
665
749
  <AutomationDefinitionEditor
666
750
  value={definition}
667
- onChange={setDefinition}
751
+ onChange={(next) => {
752
+ setDefinition(next);
753
+ markDirty();
754
+ }}
668
755
  disabled={!canManage}
669
756
  automationId={isNew ? undefined : automationId}
670
757
  structuralIssues={validationErrors}
@@ -675,7 +762,10 @@ const AutomationEditContent: React.FC = () => {
675
762
  <CardContent className="p-0">
676
763
  <CodeEditor
677
764
  value={yamlText}
678
- onChange={setYamlText}
765
+ onChange={(next) => {
766
+ setYamlText(next);
767
+ markDirty();
768
+ }}
679
769
  language="yaml"
680
770
  minHeight="520px"
681
771
  readOnly={!canManage}
@@ -688,6 +778,16 @@ const AutomationEditContent: React.FC = () => {
688
778
  </div>
689
779
  </div>
690
780
  )}
781
+ <ConfirmationModal
782
+ isOpen={isBlocked}
783
+ onClose={cancelDiscard}
784
+ onConfirm={confirmDiscard}
785
+ title="Discard unsaved changes?"
786
+ message="You have unsaved changes to this automation. Leaving now will discard them."
787
+ confirmText="Discard changes"
788
+ cancelText="Keep editing"
789
+ variant="warning"
790
+ />
691
791
  </PageLayout>
692
792
  );
693
793
  };