@sanity/workflow-studio-plugin 0.20.0 → 0.22.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.
@@ -2,11 +2,13 @@ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
2
 
3
3
  import { Stack, Box, Flex, Text, Button, Card, TextInput, Dialog, Badge, useTheme_v2, Container, TabPanel } from "@sanity/ui";
4
4
 
5
+ import { useWorkflowTelemetry, editFieldTarget } from "@sanity/workflow-react";
6
+
5
7
  import { useState, useEffect, useMemo, useRef } from "react";
6
8
 
7
9
  import { useRouter } from "sanity/router";
8
10
 
9
- import { instanceNotice, StaleLock, ActivitiesList, TodoItemsList, isEvaluationStale, useWorkflowInstanceEntry, InvalidDocNotice, LoadingRow, useDefinition, openActivityGone, GroupHeading, ActivityDetailDialog, instanceBreadcrumb, HoverHint, instanceTitle, formatDate, StageChip, stageTitle, AbortedBadge, SpinnerSlot, DocPreviewLink, useWorkflowContext, readDeployedDefinitions, describeError, DismissablePopover, definitionSnapshotOf, findActivity, stateByActivity, findActivityNode, rowDateControlState, rowAssignControlState, isActivityAssignedTo, assigneeUserIdsOf, isOpenActivityStatus, committedRowFace, activityRowProps, parseStoredDateValue, gdrLocality, useUserDisplays, useProjectMembers, DocRefFace, useSaveField, dateControlValue, stopRowMouseDown, stopRowClick, parseDateFieldValue, serializeDateFieldValue, dateControlKind, editTargetOf, CollapsibleBand, LinkChip, BreadcrumbTail, StageFace, TrailingHairline, ActivityRow, UnreadableDocsNote, useAssignmentIdentity, TabSwitch, workflowsTabCodec } from "./index.js";
11
+ import { instanceNotice, StaleLock, ActivitiesList, TodoItemsList, isEvaluationStale, useWorkflowInstanceEntry, useLogEventOnMount, WorkflowInstanceDetailViewed, InvalidDocNotice, LoadingRow, useDefinition, openActivityGone, GroupHeading, ActivityDetailDialog, instanceBreadcrumb, HoverHint, instanceTitle, formatDate, StageChip, stageTitle, AbortedBadge, SpinnerSlot, DocPreviewLink, useWorkflowContext, readDeployedDefinitions, describeError, DismissablePopover, definitionSnapshotOf, findActivity, stateByActivity, findActivityNode, rowDateControlState, rowAssignControlState, isActivityAssignedTo, assigneeUserIdsOf, isOpenActivityStatus, committedRowFace, activityRowProps, gdrLocality, CautionNote, WORKFLOW_API_VERSION, useClosableToast, parseStoredDateValue, useUserDisplays, useProjectMembers, WorkflowTaskFiltersApplied, DocRefFace, useSaveField, dateControlValue, stopRowMouseDown, stopRowClick, parseDateFieldValue, serializeDateFieldValue, dateControlKind, CollapsibleBand, LinkChip, BreadcrumbTail, StageFace, TrailingHairline, ActivityRow, UnreadableDocsNote, useAssignmentIdentity, TabSwitch, WorkflowToolOpened, workflowsTabCodec } from "./index.js";
10
12
 
11
13
  import { ArrowLeftIcon } from "@sanity/icons/ArrowLeft";
12
14
 
@@ -18,6 +20,8 @@ import { AddIcon } from "@sanity/icons/Add";
18
20
 
19
21
  import { SearchIcon } from "@sanity/icons/Search";
20
22
 
23
+ import { useClient, useDocumentPreviewStore, useSchema } from "sanity";
24
+
21
25
  import { CheckmarkIcon } from "@sanity/icons/Checkmark";
22
26
 
23
27
  import { CloseIcon } from "@sanity/icons/Close";
@@ -30,8 +34,6 @@ import { endOfDay } from "date-fns/endOfDay";
30
34
 
31
35
  import { createPortal } from "react-dom";
32
36
 
33
- import { useDocumentPreviewStore, useSchema } from "sanity";
34
-
35
37
  import { CalendarIcon } from "@sanity/icons/Calendar";
36
38
 
37
39
  import { useWorkflowInstances } from "@sanity/workflow-studio";
@@ -52,14 +54,19 @@ function InstanceTaskLists({entry: entry, onOpenActivity: onOpenActivity}) {
52
54
  onOpenActivity: onOpenActivity
53
55
  }),
54
56
  /* @__PURE__ */ jsx(TodoItemsList, {
55
- entry: entry
57
+ entry: entry,
58
+ surface: "tool-instance-detail"
56
59
  }) ]
57
60
  })
58
61
  });
59
62
  }
60
63
 
61
64
  function WorkflowInstanceDetail({instanceId: instanceId, onBack: onBack}) {
62
- const entry = useWorkflowInstanceEntry(instanceId), [graceOver, setGraceOver] = useState(!1);
65
+ const entry = useWorkflowInstanceEntry(instanceId);
66
+ useLogEventOnMount(WorkflowInstanceDetailViewed, {
67
+ instanceId: instanceId
68
+ });
69
+ const [graceOver, setGraceOver] = useState(!1);
63
70
  return useEffect(() => {
64
71
  const timer = setTimeout(() => setGraceOver(!0), 2500);
65
72
  return () => clearTimeout(timer);
@@ -153,7 +160,8 @@ function InstanceDetailPanel({entry: entry, onBack: onBack}) {
153
160
  breadcrumb: instanceBreadcrumb(instance, definition),
154
161
  definition: definition,
155
162
  entry: entry,
156
- onClose: () => setOpenActivity(null)
163
+ onClose: () => setOpenActivity(null),
164
+ source: "tool-instance-detail"
157
165
  }) : null ]
158
166
  });
159
167
  }
@@ -294,7 +302,8 @@ function NewWorkflowButton() {
294
302
  onPick: definition => {
295
303
  setOpen(!1), openStartDialog({
296
304
  definition: definition.name,
297
- label: definition.title
305
+ label: definition.title,
306
+ source: "tool"
298
307
  });
299
308
  }
300
309
  }) : null,
@@ -401,7 +410,8 @@ function ToolActivityDialog({target: target, onClose: onClose}) {
401
410
  definition: definition,
402
411
  document: documentRefsOf(entry.instance)[0],
403
412
  entry: entry,
404
- onClose: onClose
413
+ onClose: onClose,
414
+ source: "tool-task-list"
405
415
  }) : /* @__PURE__ */ jsx(Dialog, {
406
416
  header: "Loading activity…",
407
417
  id: "workflow-activity-detail",
@@ -416,6 +426,147 @@ function ToolActivityDialog({target: target, onClose: onClose}) {
416
426
  });
417
427
  }
418
428
 
429
+ const GROUP_DEFAULT_OPEN = !0;
430
+
431
+ function instanceBandDefaultOpen(segment) {
432
+ return segment === "for-me";
433
+ }
434
+
435
+ const UNGROUPED_BAND_KEY = "section:no-document", PAYLOAD_VERSION = 1, MAX_ENTRIES = 200;
436
+
437
+ function bandStateStorageKey(scope) {
438
+ return `sanity.workflows.tool.bands:${scope}`;
439
+ }
440
+
441
+ function isRecord(value) {
442
+ return typeof value == "object" && value !== null && !Array.isArray(value);
443
+ }
444
+
445
+ function bandEntryOf(value) {
446
+ if (!isRecord(value)) return;
447
+ const {open: open, touched: touched} = value;
448
+ if (!(typeof open != "boolean" || typeof touched != "number") && Number.isFinite(touched)) return {
449
+ open: open,
450
+ touched: touched
451
+ };
452
+ }
453
+
454
+ function parsePayload(raw) {
455
+ try {
456
+ const parsed = JSON.parse(raw);
457
+ if (!isRecord(parsed) || parsed.version !== PAYLOAD_VERSION || !isRecord(parsed.bands)) return;
458
+ const entries = [];
459
+ for (const [key, value] of Object.entries(parsed.bands)) {
460
+ const entry = bandEntryOf(value);
461
+ if (entry === void 0) return;
462
+ entries.push([ key, entry ]);
463
+ }
464
+ return Object.fromEntries(entries);
465
+ } catch {
466
+ return;
467
+ }
468
+ }
469
+
470
+ function readStored(storage, storageKey) {
471
+ if (!storage) return {
472
+ entries: void 0,
473
+ corrupt: !1
474
+ };
475
+ let raw;
476
+ try {
477
+ raw = storage.getItem(storageKey);
478
+ } catch {
479
+ return {
480
+ entries: void 0,
481
+ corrupt: !1
482
+ };
483
+ }
484
+ if (raw === null) return {
485
+ entries: {},
486
+ corrupt: !1
487
+ };
488
+ const entries = parsePayload(raw);
489
+ return {
490
+ entries: entries,
491
+ corrupt: entries === void 0
492
+ };
493
+ }
494
+
495
+ function writeStored(args) {
496
+ if (!args.storage) return !1;
497
+ try {
498
+ return args.storage.setItem(args.storageKey, JSON.stringify({
499
+ version: PAYLOAD_VERSION,
500
+ bands: args.entries
501
+ })), !0;
502
+ } catch {
503
+ return !1;
504
+ }
505
+ }
506
+
507
+ function baseEntries(args) {
508
+ return args.stored === void 0 ? args.held : args.unpersisted ? mergeByTouched(args.stored, args.held) : args.stored;
509
+ }
510
+
511
+ function mergeByTouched(stored, held) {
512
+ const merged = {
513
+ ...stored
514
+ };
515
+ for (const [key, entry] of Object.entries(held)) {
516
+ const persisted = merged[key];
517
+ (persisted === void 0 || entry.touched >= persisted.touched) && (merged[key] = entry);
518
+ }
519
+ return merged;
520
+ }
521
+
522
+ function prune(entries) {
523
+ const held = Object.entries(entries);
524
+ return held.length <= MAX_ENTRIES ? entries : Object.fromEntries(held.sort(([, a], [, b]) => b.touched - a.touched).slice(0, MAX_ENTRIES));
525
+ }
526
+
527
+ function togglesOf(entries) {
528
+ return new Map(Object.entries(entries).map(([key, entry]) => [ key, entry.open ]));
529
+ }
530
+
531
+ function defaultStorage() {
532
+ try {
533
+ return globalThis.localStorage;
534
+ } catch {
535
+ return;
536
+ }
537
+ }
538
+
539
+ function createBandStateStore(args) {
540
+ const storage = args.storage ?? defaultStorage(), initial = readStored(storage, args.storageKey);
541
+ initial.corrupt && writeStored({
542
+ storage: storage,
543
+ storageKey: args.storageKey,
544
+ entries: {}
545
+ });
546
+ let held = initial.entries ?? {}, unpersisted = !1;
547
+ return {
548
+ read: () => togglesOf(held),
549
+ setOpen(key, open) {
550
+ const stored = readStored(storage, args.storageKey).entries, base = baseEntries({
551
+ stored: stored,
552
+ held: held,
553
+ unpersisted: unpersisted
554
+ });
555
+ return held = prune({
556
+ ...base,
557
+ [key]: {
558
+ open: open,
559
+ touched: Date.now()
560
+ }
561
+ }), unpersisted = !writeStored({
562
+ storage: storage,
563
+ storageKey: args.storageKey,
564
+ entries: held
565
+ }), togglesOf(held);
566
+ }
567
+ };
568
+ }
569
+
419
570
  function datesOf(state) {
420
571
  return state.filter(entry => entry._type === "date" || entry._type === "datetime").map(entry => entry.value).filter(value => typeof value == "string" && value !== "");
421
572
  }
@@ -573,6 +724,223 @@ function taskRowRender(row, entry) {
573
724
  };
574
725
  }
575
726
 
727
+ const NO_ORPHANS = {
728
+ groupKeys: /* @__PURE__ */ new Set,
729
+ workflows: []
730
+ };
731
+
732
+ function localBareIds(group, contentResource) {
733
+ const ids = group.documents.map(doc => gdrLocality(doc.id, contentResource));
734
+ if (!ids.some(locality => !locality.parsed || !locality.local)) return ids.flatMap(locality => locality.parsed ? [ locality.parsed.documentId ] : []);
735
+ }
736
+
737
+ function orphanProbeIds(groups, contentResource) {
738
+ const ids = /* @__PURE__ */ new Set;
739
+ for (const group of groups.withDocuments) for (const id of localBareIds(group, contentResource) ?? []) ids.add(id);
740
+ return [ ...ids ].sort();
741
+ }
742
+
743
+ function orphanVerdict(args) {
744
+ const groupKeys = /* @__PURE__ */ new Set, workflows = /* @__PURE__ */ new Map;
745
+ for (const group of args.groups.withDocuments) {
746
+ const ids = localBareIds(group, args.contentResource);
747
+ if (!(ids === void 0 || !ids.every(id => args.missing.has(id)))) {
748
+ groupKeys.add(group.key);
749
+ for (const band of group.instances) workflows.set(band.instanceId, {
750
+ instanceId: band.instanceId,
751
+ title: band.title,
752
+ documentIds: ids
753
+ });
754
+ }
755
+ }
756
+ return groupKeys.size === 0 ? NO_ORPHANS : {
757
+ groupKeys: groupKeys,
758
+ workflows: [ ...workflows.values() ]
759
+ };
760
+ }
761
+
762
+ function hideOrphanedGroups(groups, orphaned) {
763
+ return orphaned.size === 0 ? groups : cutTaskGroups(groups, {
764
+ keepRow: () => !0,
765
+ keepGroup: group => !orphaned.has(group.key)
766
+ });
767
+ }
768
+
769
+ const DRAFT_PREFIX = "drafts.";
770
+
771
+ async function missingDocIds(client, ids) {
772
+ const present = await client.fetch("*[_id in $ids || _id in $drafts]._id", {
773
+ ids: ids,
774
+ drafts: ids.map(id => `${DRAFT_PREFIX}${id}`)
775
+ }, {
776
+ perspective: "raw"
777
+ }), found = new Set(present.map(id => id.startsWith(DRAFT_PREFIX) ? id.slice(DRAFT_PREFIX.length) : id)), candidates = ids.filter(id => !found.has(id)), versions = await Promise.all(candidates.map(id => client.fetch("*[_id in path($version)][0]._id", {
778
+ version: `versions.*.${id}`
779
+ }, {
780
+ perspective: "raw"
781
+ })));
782
+ return new Set(candidates.filter((_, index) => versions[index] === null));
783
+ }
784
+
785
+ async function settleOrphans(args) {
786
+ const ids = [ ...new Set(args.workflows.flatMap(workflow => workflow.documentIds)) ].sort(), missing = await args.probeMissing(ids), stillOrphaned = workflow => workflow.documentIds.every(id => missing.has(id));
787
+ let settled = 0;
788
+ const failures = [];
789
+ for (const workflow of args.workflows.filter(stillOrphaned)) try {
790
+ await args.abort({
791
+ instanceId: workflow.instanceId,
792
+ reason: "Subject document deleted"
793
+ }), settled += 1;
794
+ } catch (err) {
795
+ failures.push({
796
+ workflow: workflow,
797
+ message: describeError(err)
798
+ });
799
+ }
800
+ return {
801
+ settled: settled,
802
+ skipped: args.workflows.filter(workflow => !stillOrphaned(workflow)),
803
+ failures: failures
804
+ };
805
+ }
806
+
807
+ function workflowTally(workflows) {
808
+ const counts = /* @__PURE__ */ new Map;
809
+ for (const workflow of workflows) counts.set(workflow.title, (counts.get(workflow.title) ?? 0) + 1);
810
+ return [ ...counts ].map(([title, count]) => count === 1 ? title : `${title} ×${count}`);
811
+ }
812
+
813
+ function settleToast(outcome) {
814
+ const total = outcome.settled + outcome.skipped.length + outcome.failures.length;
815
+ if (outcome.skipped.length === 0 && outcome.failures.length === 0) return {
816
+ status: "success",
817
+ title: outcome.settled === 1 ? "Cleaned up 1 workflow" : `Cleaned up ${outcome.settled} workflows`
818
+ };
819
+ const lines = [ ...outcome.skipped.map(workflow => `${workflow.title}: its document exists again — left running`), ...outcome.failures.map(failure => `${failure.workflow.title}: ${failure.message}`) ];
820
+ return {
821
+ status: "warning",
822
+ title: `Cleaned up ${outcome.settled} of ${total} workflows`,
823
+ description: lines.join(`\n`)
824
+ };
825
+ }
826
+
827
+ function recheckFailedToast(err) {
828
+ return {
829
+ status: "error",
830
+ title: "Nothing was cleaned up — the documents could not be re-checked",
831
+ description: describeError(err)
832
+ };
833
+ }
834
+
835
+ function OrphanedWorkflowsNote({orphans: orphans}) {
836
+ const [confirming, setConfirming] = useState(!1), count = orphans.workflows.length;
837
+ if (count === 0) return null;
838
+ const label = count === 1 ? "1 workflow is attached to a deleted document — its tasks aren’t listed." : `${count} workflows are attached to deleted documents — their tasks aren’t listed.`;
839
+ /* @__PURE__ */
840
+ return jsxs(Fragment, {
841
+ children: [
842
+ /* @__PURE__ */ jsx(CautionNote, {
843
+ action: /* @__PURE__ */ jsx(Button, {
844
+ fontSize: 1,
845
+ mode: "ghost",
846
+ onClick: () => setConfirming(!0),
847
+ padding: 2,
848
+ text: "Clean up…"
849
+ }),
850
+ label: label
851
+ }), confirming ? /* @__PURE__ */ jsx(SettleOrphansDialog, {
852
+ onClose: () => setConfirming(!1),
853
+ workflows: orphans.workflows
854
+ }) : null ]
855
+ });
856
+ }
857
+
858
+ function SettleOrphansDialog({workflows: workflows, onClose: onClose}) {
859
+ const {engine: engine} = useWorkflowContext(), client = useClient({
860
+ apiVersion: WORKFLOW_API_VERSION
861
+ }), toast = useClosableToast(), [pending, setPending] = useState(!1), confirm = async () => {
862
+ setPending(!0);
863
+ try {
864
+ const outcome = await settleOrphans({
865
+ workflows: workflows,
866
+ probeMissing: ids => missingDocIds(client, ids),
867
+ abort: args => engine.abortInstance(args)
868
+ });
869
+ toast.push(settleToast(outcome));
870
+ } catch (err) {
871
+ toast.push(recheckFailedToast(err));
872
+ }
873
+ onClose();
874
+ };
875
+ /* @__PURE__ */
876
+ return jsx(Dialog, {
877
+ footer: /* @__PURE__ */ jsx(Box, {
878
+ padding: 3,
879
+ children: /* @__PURE__ */ jsxs(Flex, {
880
+ gap: 2,
881
+ justify: "flex-end",
882
+ children: [
883
+ /* @__PURE__ */ jsx(Button, {
884
+ disabled: pending,
885
+ fontSize: 1,
886
+ mode: "bleed",
887
+ onClick: onClose,
888
+ padding: 2,
889
+ text: "Cancel"
890
+ }),
891
+ /* @__PURE__ */ jsx(Button, {
892
+ fontSize: 1,
893
+ loading: pending,
894
+ onClick: () => {
895
+ confirm();
896
+ },
897
+ padding: 2,
898
+ text: workflows.length === 1 ? "Clean up 1 workflow" : `Clean up ${workflows.length} workflows`
899
+ }) ]
900
+ })
901
+ }),
902
+ header: "Clean up workflows",
903
+ id: "settle-orphaned-workflows",
904
+ onClose: onClose,
905
+ width: 0,
906
+ children: /* @__PURE__ */ jsx(Box, {
907
+ padding: 4,
908
+ children: /* @__PURE__ */ jsxs(Stack, {
909
+ gap: 4,
910
+ children: [
911
+ /* @__PURE__ */ jsx(Text, {
912
+ muted: !0,
913
+ size: 1,
914
+ children: "The following workflows exist for documents which have since been deleted:"
915
+ }),
916
+ /* @__PURE__ */ jsx(Stack, {
917
+ as: "ul",
918
+ gap: 2,
919
+ style: {
920
+ listStyle: "disc",
921
+ margin: 0,
922
+ paddingLeft: "1.25em"
923
+ },
924
+ children: workflowTally(workflows).map(line => /* @__PURE__ */ jsx(Text, {
925
+ as: "li",
926
+ size: 1,
927
+ style: {
928
+ display: "list-item"
929
+ },
930
+ weight: "medium",
931
+ children: line
932
+ }, line))
933
+ }),
934
+ /* @__PURE__ */ jsx(Text, {
935
+ muted: !0,
936
+ size: 1,
937
+ children: "Cleaning up these workflows will close them."
938
+ }) ]
939
+ })
940
+ })
941
+ });
942
+ }
943
+
576
944
  function taskFilterOptions(groups) {
577
945
  const workflows = /* @__PURE__ */ new Map, documents = /* @__PURE__ */ new Map;
578
946
  for (const group of groups.withDocuments) {
@@ -589,8 +957,33 @@ function taskFilterOptions(groups) {
589
957
  };
590
958
  }
591
959
 
960
+ function activeEntries(filters) {
961
+ return Object.entries(filters).filter(([, value]) => value !== void 0 && (!Array.isArray(value) || value.length > 0));
962
+ }
963
+
592
964
  function hasActiveFilters(filters) {
593
- return Object.values(filters).some(value => value !== void 0 && (!Array.isArray(value) || value.length > 0));
965
+ return activeEntries(filters).length > 0;
966
+ }
967
+
968
+ function sameFilters(a, b) {
969
+ return sameValueSet(a.statuses, b.statuses) && sameValueSet(a.workflows, b.workflows) && sameValueSet(a.documentIds, b.documentIds) && sameValueSet(a.assigneeIds, b.assigneeIds) && sameDate(a.date, b.date);
970
+ }
971
+
972
+ function sameValueSet(a, b) {
973
+ const av = a ?? [], bv = b ?? [];
974
+ return av.length === bv.length && av.every(value => bv.includes(value));
975
+ }
976
+
977
+ function sameDate(a, b) {
978
+ return typeof a == "object" && typeof b == "object" ? a.before === b.before : a === b;
979
+ }
980
+
981
+ function taskFilterTelemetry(filters) {
982
+ const entries = activeEntries(filters);
983
+ return {
984
+ filterKinds: entries.map(([key]) => key).sort(),
985
+ filterCount: entries.reduce((n, [, value]) => n + (Array.isArray(value) ? value.length : 1), 0)
986
+ };
594
987
  }
595
988
 
596
989
  function toggledValue(args) {
@@ -745,7 +1138,12 @@ function useDocPreviewTitles(documents) {
745
1138
  const HITS_CAP = 6;
746
1139
 
747
1140
  function TaskFilterMenu({filters: filters, options: options, onChange: onChange}) {
748
- const [open, setOpen] = useState(!1), active = hasActiveFilters(filters);
1141
+ const [open, setOpen] = useState(!1), telemetry = useWorkflowTelemetry(), openSnapshot = useRef(void 0), openMenu = () => {
1142
+ openSnapshot.current = filters, setOpen(!0);
1143
+ }, close = () => {
1144
+ setOpen(!1), openSnapshot.current !== void 0 && !sameFilters(openSnapshot.current, filters) && telemetry.log(WorkflowTaskFiltersApplied, taskFilterTelemetry(filters)),
1145
+ openSnapshot.current = void 0;
1146
+ }, active = hasActiveFilters(filters);
749
1147
  /* @__PURE__ */
750
1148
  return jsx(DismissablePopover, {
751
1149
  content: open ? /* @__PURE__ */ jsx(FilterPanel, {
@@ -753,7 +1151,7 @@ function TaskFilterMenu({filters: filters, options: options, onChange: onChange}
753
1151
  onChange: onChange,
754
1152
  options: options
755
1153
  }) : null,
756
- onDismiss: () => setOpen(!1),
1154
+ onDismiss: close,
757
1155
  open: open,
758
1156
  overflow: "visible",
759
1157
  children: /* @__PURE__ */ jsx(HoverHint, {
@@ -764,7 +1162,7 @@ function TaskFilterMenu({filters: filters, options: options, onChange: onChange}
764
1162
  fontSize: 1,
765
1163
  icon: FilterIcon,
766
1164
  mode: "bleed",
767
- onClick: () => setOpen(v => !v),
1165
+ onClick: () => open ? close() : openMenu(),
768
1166
  padding: 2,
769
1167
  selected: active || open
770
1168
  })
@@ -1260,7 +1658,7 @@ function ActivityDateControl({instanceId: instanceId, state: state, dates: dates
1260
1658
  });
1261
1659
  const kind = dateControlKind(state), commit = (mode, value) => {
1262
1660
  state.kind === "editable" && save(() => editFieldFor(instanceId, {
1263
- target: editTargetOf(state.field),
1661
+ target: editFieldTarget(state.field),
1264
1662
  ...mode === "set" ? {
1265
1663
  mode: mode,
1266
1664
  value: value
@@ -1361,13 +1759,15 @@ function RefChips({refs: refs, max: max = 2}) {
1361
1759
 
1362
1760
  const EMPTY_NOTE = "No tasks match — adjust the filters, or start a workflow", UNGROUPED_TITLE = "Workflows without Studio documents";
1363
1761
 
1364
- function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1762
+ function TaskGroupList({bands: bands, groups: groups, instanceDefaultOpen: instanceDefaultOpen, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1365
1763
  return groups.withDocuments.length === 0 && groups.withoutDocuments.length === 0 ? /* @__PURE__ */ jsx(MutedNote, {
1366
1764
  text: EMPTY_NOTE
1367
1765
  }) : /* @__PURE__ */ jsxs(Stack, {
1368
1766
  gap: 2,
1369
1767
  children: [ groups.withDocuments.map(group => /* @__PURE__ */ jsx(CollapsibleBand, {
1370
1768
  background: !0,
1769
+ onToggle: () => bands.toggle(group.key, GROUP_DEFAULT_OPEN),
1770
+ open: bands.isOpen(group.key, GROUP_DEFAULT_OPEN),
1371
1771
  sticky: !0,
1372
1772
  title: toBareId(group.document.id),
1373
1773
  header: /* @__PURE__ */ jsx(Flex, {
@@ -1382,6 +1782,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1382
1782
  })
1383
1783
  }),
1384
1784
  children: /* @__PURE__ */ jsx(InstanceBands, {
1785
+ bands: bands,
1786
+ instanceDefaultOpen: instanceDefaultOpen,
1385
1787
  instances: group.instances,
1386
1788
  onOpenTask: onOpenTask,
1387
1789
  onOpenWorkflow: onOpenWorkflow,
@@ -1389,6 +1791,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1389
1791
  })
1390
1792
  }, group.key)), groups.withoutDocuments.length > 0 ? /* @__PURE__ */ jsx(CollapsibleBand, {
1391
1793
  background: !0,
1794
+ onToggle: () => bands.toggle(UNGROUPED_BAND_KEY, GROUP_DEFAULT_OPEN),
1795
+ open: bands.isOpen(UNGROUPED_BAND_KEY, GROUP_DEFAULT_OPEN),
1392
1796
  sticky: !0,
1393
1797
  title: UNGROUPED_TITLE,
1394
1798
  header: /* @__PURE__ */ jsx(Text, {
@@ -1397,6 +1801,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1397
1801
  children: UNGROUPED_TITLE
1398
1802
  }),
1399
1803
  children: /* @__PURE__ */ jsx(InstanceBands, {
1804
+ bands: bands,
1805
+ instanceDefaultOpen: instanceDefaultOpen,
1400
1806
  instances: groups.withoutDocuments,
1401
1807
  onOpenTask: onOpenTask,
1402
1808
  onOpenWorkflow: onOpenWorkflow,
@@ -1414,12 +1820,14 @@ function targetOf(row) {
1414
1820
  };
1415
1821
  }
1416
1822
 
1417
- function InstanceBands({instances: instances, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1823
+ function InstanceBands({bands: bands, instanceDefaultOpen: instanceDefaultOpen, instances: instances, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1418
1824
  /* @__PURE__ */
1419
1825
  return jsx(Stack, {
1420
1826
  gap: 1,
1421
1827
  children: instances.map(band => /* @__PURE__ */ jsx(InstanceBand, {
1422
1828
  band: band,
1829
+ bands: bands,
1830
+ instanceDefaultOpen: instanceDefaultOpen,
1423
1831
  onOpenTask: onOpenTask,
1424
1832
  onOpenWorkflow: onOpenWorkflow,
1425
1833
  showTerminalActions: showTerminalActions
@@ -1427,10 +1835,12 @@ function InstanceBands({instances: instances, onOpenTask: onOpenTask, onOpenWork
1427
1835
  });
1428
1836
  }
1429
1837
 
1430
- function InstanceBand({band: band, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1838
+ function InstanceBand({band: band, bands: bands, instanceDefaultOpen: instanceDefaultOpen, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, showTerminalActions: showTerminalActions}) {
1431
1839
  const {resolvePathFromState: resolvePathFromState} = useRouter();
1432
1840
  /* @__PURE__ */
1433
1841
  return jsx(CollapsibleBand, {
1842
+ onToggle: () => bands.toggle(band.instanceId, instanceDefaultOpen),
1843
+ open: bands.isOpen(band.instanceId, instanceDefaultOpen),
1434
1844
  title: band.title,
1435
1845
  header: /* @__PURE__ */ jsxs(Fragment, {
1436
1846
  children: [
@@ -1517,6 +1927,61 @@ function TaskActivityRow({row: row, onOpen: onOpen, showTerminalActions: showTer
1517
1927
  });
1518
1928
  }
1519
1929
 
1930
+ function useBandOpenState(args) {
1931
+ const {segment: segment, scope: scope} = args, store = useMemo(() => createBandStateStore({
1932
+ storageKey: bandStateStorageKey(scope)
1933
+ }), [ scope ]), [held, setHeld] = useState(() => ({
1934
+ store: store,
1935
+ toggles: store.read()
1936
+ }));
1937
+ held.store !== store && setHeld({
1938
+ store: store,
1939
+ toggles: store.read()
1940
+ });
1941
+ const qualify = key => `${segment}/${key}`, isOpen = (key, defaultOpen) => held.toggles.get(qualify(key)) ?? defaultOpen;
1942
+ return {
1943
+ isOpen: isOpen,
1944
+ toggle: (key, defaultOpen) => {
1945
+ setHeld({
1946
+ store: store,
1947
+ toggles: store.setOpen(qualify(key), !isOpen(key, defaultOpen))
1948
+ });
1949
+ }
1950
+ };
1951
+ }
1952
+
1953
+ const NO_MISSING = /* @__PURE__ */ new Set;
1954
+
1955
+ function sameIdSet(a, b) {
1956
+ if (a.size !== b.size) return !1;
1957
+ for (const id of a) if (!b.has(id)) return !1;
1958
+ return !0;
1959
+ }
1960
+
1961
+ function useOrphanedWorkflows(groups) {
1962
+ const {actualTypes: actualTypes, binding: binding} = useWorkflowContext(), contentResource = binding.contentResource, key = useMemo(() => orphanProbeIds(groups, contentResource), [ groups, contentResource ]).join("|"), [missing, setMissing] = useState(NO_MISSING);
1963
+ return useEffect(() => {
1964
+ if (key === "") {
1965
+ setMissing(NO_MISSING);
1966
+ return;
1967
+ }
1968
+ const tracked = key.split("|"), releases = tracked.map(id => actualTypes.track(id)), recompute = () => {
1969
+ const next = new Set(tracked.filter(id => actualTypes.read(id) === null));
1970
+ setMissing(current => sameIdSet(current, next) ? current : next);
1971
+ };
1972
+ recompute();
1973
+ const unsubscribe = actualTypes.subscribe(recompute);
1974
+ return () => {
1975
+ unsubscribe();
1976
+ for (const release of releases) release();
1977
+ };
1978
+ }, [ actualTypes, key ]), useMemo(() => missing.size === 0 ? NO_ORPHANS : orphanVerdict({
1979
+ groups: groups,
1980
+ contentResource: contentResource,
1981
+ missing: missing
1982
+ }), [ groups, contentResource, missing ]);
1983
+ }
1984
+
1520
1985
  const INSTANCE_CAP = 200, NEWEST_INSTANCES = {
1521
1986
  includeCompleted: !0,
1522
1987
  limit: INSTANCE_CAP
@@ -1539,18 +2004,21 @@ function useToolInstances() {
1539
2004
  }, [ instances, loading, unreadable ]);
1540
2005
  }
1541
2006
 
1542
- function TasksTab({filterSlot: filterSlot, instances: instances, loading: loading, identity: identity, mineOnly: mineOnly, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, truncated: truncated, unreadable: unreadable}) {
1543
- const [filters, setFilters] = useState({}), groups = useMemo(() => deriveTaskGroups({
2007
+ function TasksTab({filterSlot: filterSlot, instances: instances, loading: loading, identity: identity, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, segment: segment, truncated: truncated, unreadable: unreadable}) {
2008
+ const mineOnly = segment === "for-me", {binding: binding} = useWorkflowContext(), bands = useBandOpenState({
2009
+ segment: segment,
2010
+ scope: binding.engineResource.id
2011
+ }), [filters, setFilters] = useState({}), groups = useMemo(() => deriveTaskGroups({
1544
2012
  instances: instances,
1545
2013
  identity: identity
1546
- }), [ instances, identity ]), options = useMemo(() => taskFilterOptions(groups), [ groups ]), visible = useMemo(() => {
2014
+ }), [ instances, identity ]), orphans = useOrphanedWorkflows(groups), listed = useMemo(() => hideOrphanedGroups(groups, orphans.groupKeys), [ groups, orphans.groupKeys ]), options = useMemo(() => taskFilterOptions(listed), [ listed ]), visible = useMemo(() => {
1547
2015
  const filtered = applyTaskFilters({
1548
- groups: groups,
2016
+ groups: listed,
1549
2017
  filters: filters,
1550
2018
  now: /* @__PURE__ */ new Date
1551
2019
  });
1552
2020
  return mineOnly ? onlyMyTasks(filtered) : filtered;
1553
- }, [ groups, filters, mineOnly ]);
2021
+ }, [ listed, filters, mineOnly ]);
1554
2022
  return loading ?
1555
2023
  /* @__PURE__ */ jsx(Box, {
1556
2024
  paddingLeft: 2,
@@ -1571,8 +2039,13 @@ function TasksTab({filterSlot: filterSlot, instances: instances, loading: loadin
1571
2039
  /* @__PURE__ */ jsx(UnreadableDocsNote, {
1572
2040
  unreadable: unreadable
1573
2041
  }),
2042
+ /* @__PURE__ */ jsx(OrphanedWorkflowsNote, {
2043
+ orphans: orphans
2044
+ }),
1574
2045
  /* @__PURE__ */ jsx(TaskGroupList, {
2046
+ bands: bands,
1575
2047
  groups: visible,
2048
+ instanceDefaultOpen: instanceBandDefaultOpen(segment),
1576
2049
  onOpenTask: onOpenTask,
1577
2050
  onOpenWorkflow: onOpenWorkflow,
1578
2051
  showTerminalActions: mineOnly
@@ -1613,7 +2086,12 @@ function carryingSearchParams(state, searchParams) {
1613
2086
  }
1614
2087
 
1615
2088
  function WorkflowsToolRoot() {
1616
- const router = useRouter(), {seedInstance: seedInstance} = useWorkflowContext(), instanceId = typeof router.state.instanceId == "string" ? router.state.instanceId : void 0;
2089
+ const router = useRouter(), {seedInstance: seedInstance} = useWorkflowContext(), {tab: tab} = useToolTab();
2090
+ useLogEventOnMount(WorkflowToolOpened, {
2091
+ tab: tab,
2092
+ via: "open"
2093
+ });
2094
+ const instanceId = typeof router.state.instanceId == "string" ? router.state.instanceId : void 0;
1617
2095
  return instanceId ? /* @__PURE__ */ jsx(WorkflowInstanceDetail, {
1618
2096
  instanceId: instanceId,
1619
2097
  onBack: () => router.navigate(carryingSearchParams({}, router.state._searchParams))
@@ -1627,7 +2105,7 @@ function WorkflowsToolRoot() {
1627
2105
  }
1628
2106
 
1629
2107
  function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1630
- const {tab: tab, setTab: setTab} = useToolTab(), instances = useToolInstances(), identity = useAssignmentIdentity(), [openTask, setOpenTask] = useState(null), openInstance = target => {
2108
+ const {tab: tab, setTab: setTab} = useToolTab(), telemetry = useWorkflowTelemetry(), instances = useToolInstances(), identity = useAssignmentIdentity(), [openTask, setOpenTask] = useState(null), openInstance = target => {
1631
2109
  if (typeof target != "string") return onOpenInstance(target);
1632
2110
  const held = [ ...instances.inFlight, ...instances.settled ].find(i => i._id === target);
1633
2111
  onOpenInstance(held ?? target);
@@ -1671,7 +2149,12 @@ function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1671
2149
  /* @__PURE__ */ jsx(TabSwitch, {
1672
2150
  ariaControls: "workflows-tool-panel",
1673
2151
  idPrefix: "workflows-tool-tab",
1674
- onSelect: setTab,
2152
+ onSelect: next => {
2153
+ next !== tab && telemetry.log(WorkflowToolOpened, {
2154
+ tab: next,
2155
+ via: "tab-switch"
2156
+ }), setTab(next);
2157
+ },
1675
2158
  options: TOOL_TABS,
1676
2159
  selected: tab
1677
2160
  }),
@@ -1702,9 +2185,9 @@ function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1702
2185
  identity: identity,
1703
2186
  instances: [ ...instances.inFlight, ...instances.settled ],
1704
2187
  loading: instances.loading,
1705
- mineOnly: tab === "for-me",
1706
2188
  onOpenTask: setOpenTask,
1707
2189
  onOpenWorkflow: openInstance,
2190
+ segment: tab,
1708
2191
  truncated: instances.truncated,
1709
2192
  unreadable: instances.unreadable
1710
2193
  })