@sanity/workflow-studio-plugin 0.5.0 → 0.21.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.
@@ -1,23 +1,27 @@
1
1
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
2
 
3
- import { Flex, Spinner, Stack, Box, Text, Button, Card, TextInput, Dialog, Badge, useTheme_v2, Container, TabPanel } from "@sanity/ui";
3
+ import { Stack, Box, Flex, Text, Button, Card, TextInput, Dialog, Badge, useTheme_v2, Container, TabPanel } from "@sanity/ui";
4
+
5
+ import { useWorkflowTelemetry, editFieldTarget } from "@sanity/workflow-react";
4
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, 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
 
13
15
  import { WorkflowDiagram } from "@sanity/workflow-diagram";
14
16
 
15
- import { entryDocRefs, terminalState, latestDeployedDefinitions, isStartableDefinition, extractDocumentId, findOpenStageEntry } from "@sanity/workflow-engine";
17
+ import { entryDocRefs, terminalState, latestDeployedDefinitions, isStartableDefinition, toBareId, findOpenStageEntry } from "@sanity/workflow-engine";
16
18
 
17
19
  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,28 +34,10 @@ 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";
38
40
 
39
- function SpinnerSlot({busy: busy}) {
40
- /* @__PURE__ */
41
- return jsx(Flex, {
42
- align: "center",
43
- flex: "none",
44
- justify: "center",
45
- style: {
46
- width: 19,
47
- height: 19
48
- },
49
- children: busy ? /* @__PURE__ */ jsx(Spinner, {
50
- muted: !0
51
- }) : null
52
- });
53
- }
54
-
55
41
  function documentRefsOf(instance) {
56
42
  const seen = /* @__PURE__ */ new Map;
57
43
  for (const ref of entryDocRefs(instance.fields)) seen.has(ref.id) || seen.set(ref.id, ref);
@@ -68,14 +54,19 @@ function InstanceTaskLists({entry: entry, onOpenActivity: onOpenActivity}) {
68
54
  onOpenActivity: onOpenActivity
69
55
  }),
70
56
  /* @__PURE__ */ jsx(TodoItemsList, {
71
- entry: entry
57
+ entry: entry,
58
+ surface: "tool-instance-detail"
72
59
  }) ]
73
60
  })
74
61
  });
75
62
  }
76
63
 
77
64
  function WorkflowInstanceDetail({instanceId: instanceId, onBack: onBack}) {
78
- 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);
79
70
  return useEffect(() => {
80
71
  const timer = setTimeout(() => setGraceOver(!0), 2500);
81
72
  return () => clearTimeout(timer);
@@ -169,7 +160,8 @@ function InstanceDetailPanel({entry: entry, onBack: onBack}) {
169
160
  breadcrumb: instanceBreadcrumb(instance, definition),
170
161
  definition: definition,
171
162
  entry: entry,
172
- onClose: () => setOpenActivity(null)
163
+ onClose: () => setOpenActivity(null),
164
+ source: "tool-instance-detail"
173
165
  }) : null ]
174
166
  });
175
167
  }
@@ -310,7 +302,8 @@ function NewWorkflowButton() {
310
302
  onPick: definition => {
311
303
  setOpen(!1), openStartDialog({
312
304
  definition: definition.name,
313
- label: definition.title
305
+ label: definition.title,
306
+ source: "tool"
314
307
  });
315
308
  }
316
309
  }) : null,
@@ -417,7 +410,8 @@ function ToolActivityDialog({target: target, onClose: onClose}) {
417
410
  definition: definition,
418
411
  document: documentRefsOf(entry.instance)[0],
419
412
  entry: entry,
420
- onClose: onClose
413
+ onClose: onClose,
414
+ source: "tool-task-list"
421
415
  }) : /* @__PURE__ */ jsx(Dialog, {
422
416
  header: "Loading activity…",
423
417
  id: "workflow-activity-detail",
@@ -432,13 +426,154 @@ function ToolActivityDialog({target: target, onClose: onClose}) {
432
426
  });
433
427
  }
434
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
+
435
570
  function datesOf(state) {
436
571
  return state.filter(entry => entry._type === "date" || entry._type === "datetime").map(entry => entry.value).filter(value => typeof value == "string" && value !== "");
437
572
  }
438
573
 
439
574
  function spawnOrigins(instances) {
440
575
  const origins = /* @__PURE__ */ new Map;
441
- for (const parent of instances) for (const row of parent.subworkflows ?? []) origins.set(extractDocumentId(row.ref.id), {
576
+ for (const parent of instances) for (const row of parent.subworkflows ?? []) origins.set(toBareId(row.ref.id), {
442
577
  parent: parent,
443
578
  activityName: row.activity,
444
579
  cohortStage: row.cohortStage
@@ -448,7 +583,7 @@ function spawnOrigins(instances) {
448
583
 
449
584
  function rootOf(instance, byId) {
450
585
  const rootRef = instance.ancestors[0];
451
- return rootRef ? byId.get(extractDocumentId(rootRef.id)) ?? instance : instance;
586
+ return rootRef ? byId.get(toBareId(rootRef.id)) ?? instance : instance;
452
587
  }
453
588
 
454
589
  function taskRowsOf(args) {
@@ -589,6 +724,223 @@ function taskRowRender(row, entry) {
589
724
  };
590
725
  }
591
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
+
592
944
  function taskFilterOptions(groups) {
593
945
  const workflows = /* @__PURE__ */ new Map, documents = /* @__PURE__ */ new Map;
594
946
  for (const group of groups.withDocuments) {
@@ -605,8 +957,33 @@ function taskFilterOptions(groups) {
605
957
  };
606
958
  }
607
959
 
960
+ function activeEntries(filters) {
961
+ return Object.entries(filters).filter(([, value]) => value !== void 0 && (!Array.isArray(value) || value.length > 0));
962
+ }
963
+
608
964
  function hasActiveFilters(filters) {
609
- 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
+ };
610
987
  }
611
988
 
612
989
  function toggledValue(args) {
@@ -761,7 +1138,12 @@ function useDocPreviewTitles(documents) {
761
1138
  const HITS_CAP = 6;
762
1139
 
763
1140
  function TaskFilterMenu({filters: filters, options: options, onChange: onChange}) {
764
- 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);
765
1147
  /* @__PURE__ */
766
1148
  return jsx(DismissablePopover, {
767
1149
  content: open ? /* @__PURE__ */ jsx(FilterPanel, {
@@ -769,7 +1151,7 @@ function TaskFilterMenu({filters: filters, options: options, onChange: onChange}
769
1151
  onChange: onChange,
770
1152
  options: options
771
1153
  }) : null,
772
- onDismiss: () => setOpen(!1),
1154
+ onDismiss: close,
773
1155
  open: open,
774
1156
  overflow: "visible",
775
1157
  children: /* @__PURE__ */ jsx(HoverHint, {
@@ -780,7 +1162,7 @@ function TaskFilterMenu({filters: filters, options: options, onChange: onChange}
780
1162
  fontSize: 1,
781
1163
  icon: FilterIcon,
782
1164
  mode: "bleed",
783
- onClick: () => setOpen(v => !v),
1165
+ onClick: () => open ? close() : openMenu(),
784
1166
  padding: 2,
785
1167
  selected: active || open
786
1168
  })
@@ -1122,7 +1504,7 @@ function DocumentField({documents: documents, activeIds: activeIds, onToggle: on
1122
1504
  if (documents.length === 0) return null;
1123
1505
  const rows = documents.map(doc => ({
1124
1506
  key: doc.id,
1125
- text: `${titles.get(doc.id) ?? ""} ${doc.type} ${extractDocumentId(doc.id)}`,
1507
+ text: `${titles.get(doc.id) ?? ""} ${doc.type} ${toBareId(doc.id)}`,
1126
1508
  node: /* @__PURE__ */ jsx(DocRefFace, {
1127
1509
  gdr: doc
1128
1510
  })
@@ -1207,7 +1589,7 @@ function useDocumentChipLabels(documentIds, documents) {
1207
1589
  const schema = useSchema(), activeDocs = documents.filter(doc => documentIds.includes(doc.id)), {titles: titles, resolved: resolved} = useDocPreviewTitles(activeDocs), labels = /* @__PURE__ */ new Map;
1208
1590
  for (const doc of activeDocs) {
1209
1591
  if (!resolved.has(doc.id)) continue;
1210
- const label = titles.get(doc.id) ?? schemaTypeTitle(schema, doc.type) ?? extractDocumentId(doc.id);
1592
+ const label = titles.get(doc.id) ?? schemaTypeTitle(schema, doc.type) ?? toBareId(doc.id);
1211
1593
  labels.set(doc.id, label);
1212
1594
  }
1213
1595
  return labels;
@@ -1276,7 +1658,7 @@ function ActivityDateControl({instanceId: instanceId, state: state, dates: dates
1276
1658
  });
1277
1659
  const kind = dateControlKind(state), commit = (mode, value) => {
1278
1660
  state.kind === "editable" && save(() => editFieldFor(instanceId, {
1279
- target: editTargetOf(state.field),
1661
+ target: editFieldTarget(state.field),
1280
1662
  ...mode === "set" ? {
1281
1663
  mode: mode,
1282
1664
  value: value
@@ -1334,6 +1716,28 @@ function ActivityDateControl({instanceId: instanceId, state: state, dates: dates
1334
1716
  });
1335
1717
  }
1336
1718
 
1719
+ const PREWARM_MARGIN = "600px 0px";
1720
+
1721
+ function useNearViewport() {
1722
+ const [element, setElement] = useState(null), [near, setNear] = useState(!1);
1723
+ return useEffect(() => {
1724
+ if (element === null) return;
1725
+ if (typeof IntersectionObserver > "u") {
1726
+ setNear(!0);
1727
+ return;
1728
+ }
1729
+ const observer = new IntersectionObserver(entries => {
1730
+ setNear(entries.at(-1)?.isIntersecting ?? !1);
1731
+ }, {
1732
+ rootMargin: PREWARM_MARGIN
1733
+ });
1734
+ return observer.observe(element), () => observer.disconnect();
1735
+ }, [ element ]), {
1736
+ ref: setElement,
1737
+ near: near
1738
+ };
1739
+ }
1740
+
1337
1741
  function RefChips({refs: refs, max: max = 2}) {
1338
1742
  if (refs.length === 0) return null;
1339
1743
  const shown = refs.slice(0, max), overflow = refs.length - shown.length;
@@ -1355,15 +1759,17 @@ function RefChips({refs: refs, max: max = 2}) {
1355
1759
 
1356
1760
  const EMPTY_NOTE = "No tasks match — adjust the filters, or start a workflow", UNGROUPED_TITLE = "Workflows without Studio documents";
1357
1761
 
1358
- 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}) {
1359
1763
  return groups.withDocuments.length === 0 && groups.withoutDocuments.length === 0 ? /* @__PURE__ */ jsx(MutedNote, {
1360
1764
  text: EMPTY_NOTE
1361
1765
  }) : /* @__PURE__ */ jsxs(Stack, {
1362
1766
  gap: 2,
1363
1767
  children: [ groups.withDocuments.map(group => /* @__PURE__ */ jsx(CollapsibleBand, {
1364
1768
  background: !0,
1769
+ onToggle: () => bands.toggle(group.key, GROUP_DEFAULT_OPEN),
1770
+ open: bands.isOpen(group.key, GROUP_DEFAULT_OPEN),
1365
1771
  sticky: !0,
1366
- title: extractDocumentId(group.document.id),
1772
+ title: toBareId(group.document.id),
1367
1773
  header: /* @__PURE__ */ jsx(Flex, {
1368
1774
  align: "center",
1369
1775
  flex: "none",
@@ -1376,6 +1782,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1376
1782
  })
1377
1783
  }),
1378
1784
  children: /* @__PURE__ */ jsx(InstanceBands, {
1785
+ bands: bands,
1786
+ instanceDefaultOpen: instanceDefaultOpen,
1379
1787
  instances: group.instances,
1380
1788
  onOpenTask: onOpenTask,
1381
1789
  onOpenWorkflow: onOpenWorkflow,
@@ -1383,6 +1791,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1383
1791
  })
1384
1792
  }, group.key)), groups.withoutDocuments.length > 0 ? /* @__PURE__ */ jsx(CollapsibleBand, {
1385
1793
  background: !0,
1794
+ onToggle: () => bands.toggle(UNGROUPED_BAND_KEY, GROUP_DEFAULT_OPEN),
1795
+ open: bands.isOpen(UNGROUPED_BAND_KEY, GROUP_DEFAULT_OPEN),
1386
1796
  sticky: !0,
1387
1797
  title: UNGROUPED_TITLE,
1388
1798
  header: /* @__PURE__ */ jsx(Text, {
@@ -1391,6 +1801,8 @@ function TaskGroupList({groups: groups, onOpenTask: onOpenTask, onOpenWorkflow:
1391
1801
  children: UNGROUPED_TITLE
1392
1802
  }),
1393
1803
  children: /* @__PURE__ */ jsx(InstanceBands, {
1804
+ bands: bands,
1805
+ instanceDefaultOpen: instanceDefaultOpen,
1394
1806
  instances: groups.withoutDocuments,
1395
1807
  onOpenTask: onOpenTask,
1396
1808
  onOpenWorkflow: onOpenWorkflow,
@@ -1408,12 +1820,14 @@ function targetOf(row) {
1408
1820
  };
1409
1821
  }
1410
1822
 
1411
- 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}) {
1412
1824
  /* @__PURE__ */
1413
1825
  return jsx(Stack, {
1414
1826
  gap: 1,
1415
1827
  children: instances.map(band => /* @__PURE__ */ jsx(InstanceBand, {
1416
1828
  band: band,
1829
+ bands: bands,
1830
+ instanceDefaultOpen: instanceDefaultOpen,
1417
1831
  onOpenTask: onOpenTask,
1418
1832
  onOpenWorkflow: onOpenWorkflow,
1419
1833
  showTerminalActions: showTerminalActions
@@ -1421,10 +1835,12 @@ function InstanceBands({instances: instances, onOpenTask: onOpenTask, onOpenWork
1421
1835
  });
1422
1836
  }
1423
1837
 
1424
- 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}) {
1425
1839
  const {resolvePathFromState: resolvePathFromState} = useRouter();
1426
1840
  /* @__PURE__ */
1427
1841
  return jsx(CollapsibleBand, {
1842
+ onToggle: () => bands.toggle(band.instanceId, instanceDefaultOpen),
1843
+ open: bands.isOpen(band.instanceId, instanceDefaultOpen),
1428
1844
  title: band.title,
1429
1845
  header: /* @__PURE__ */ jsxs(Fragment, {
1430
1846
  children: [
@@ -1486,26 +1902,86 @@ function TaskRows({rows: rows, onOpenTask: onOpenTask, showTerminalActions: show
1486
1902
  }
1487
1903
 
1488
1904
  function TaskActivityRow({row: row, onOpen: onOpen, showTerminalActions: showTerminalActions}) {
1489
- const entry = useWorkflowInstanceEntry(row.instanceId), render = taskRowRender(row, entry);
1905
+ const {ref: ref, near: near} = useNearViewport(), entry = useWorkflowInstanceEntry(row.instanceId, {
1906
+ evaluate: near
1907
+ }), render = taskRowRender(row, entry);
1490
1908
  /* @__PURE__ */
1491
- return jsx(StaleLock, {
1492
- stale: render.stale,
1493
- children: /* @__PURE__ */ jsx(ActivityRow, {
1494
- assignState: render.assignState,
1495
- assigneeIds: render.assigneeIds,
1496
- dateControl: /* @__PURE__ */ jsx(ActivityDateControl, {
1497
- dates: render.dates,
1909
+ return jsx(Box, {
1910
+ ref: ref,
1911
+ children: /* @__PURE__ */ jsx(StaleLock, {
1912
+ stale: render.stale,
1913
+ children: /* @__PURE__ */ jsx(ActivityRow, {
1914
+ assignState: render.assignState,
1915
+ assigneeIds: render.assigneeIds,
1916
+ dateControl: /* @__PURE__ */ jsx(ActivityDateControl, {
1917
+ dates: render.dates,
1918
+ instanceId: row.instanceId,
1919
+ state: render.dateState
1920
+ }),
1921
+ face: render.face,
1498
1922
  instanceId: row.instanceId,
1499
- state: render.dateState
1500
- }),
1501
- face: render.face,
1502
- instanceId: row.instanceId,
1503
- onOpen: onOpen,
1504
- terminalActions: showTerminalActions ? render.terminalActions : []
1923
+ onOpen: onOpen,
1924
+ terminalActions: showTerminalActions ? render.terminalActions : []
1925
+ })
1505
1926
  })
1506
1927
  });
1507
1928
  }
1508
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
+
1509
1985
  const INSTANCE_CAP = 200, NEWEST_INSTANCES = {
1510
1986
  includeCompleted: !0,
1511
1987
  limit: INSTANCE_CAP
@@ -1528,18 +2004,21 @@ function useToolInstances() {
1528
2004
  }, [ instances, loading, unreadable ]);
1529
2005
  }
1530
2006
 
1531
- function TasksTab({filterSlot: filterSlot, instances: instances, loading: loading, identity: identity, mineOnly: mineOnly, onOpenTask: onOpenTask, onOpenWorkflow: onOpenWorkflow, truncated: truncated, unreadable: unreadable}) {
1532
- 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({
1533
2012
  instances: instances,
1534
2013
  identity: identity
1535
- }), [ 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(() => {
1536
2015
  const filtered = applyTaskFilters({
1537
- groups: groups,
2016
+ groups: listed,
1538
2017
  filters: filters,
1539
2018
  now: /* @__PURE__ */ new Date
1540
2019
  });
1541
2020
  return mineOnly ? onlyMyTasks(filtered) : filtered;
1542
- }, [ groups, filters, mineOnly ]);
2021
+ }, [ listed, filters, mineOnly ]);
1543
2022
  return loading ?
1544
2023
  /* @__PURE__ */ jsx(Box, {
1545
2024
  paddingLeft: 2,
@@ -1560,8 +2039,13 @@ function TasksTab({filterSlot: filterSlot, instances: instances, loading: loadin
1560
2039
  /* @__PURE__ */ jsx(UnreadableDocsNote, {
1561
2040
  unreadable: unreadable
1562
2041
  }),
2042
+ /* @__PURE__ */ jsx(OrphanedWorkflowsNote, {
2043
+ orphans: orphans
2044
+ }),
1563
2045
  /* @__PURE__ */ jsx(TaskGroupList, {
2046
+ bands: bands,
1564
2047
  groups: visible,
2048
+ instanceDefaultOpen: instanceBandDefaultOpen(segment),
1565
2049
  onOpenTask: onOpenTask,
1566
2050
  onOpenWorkflow: onOpenWorkflow,
1567
2051
  showTerminalActions: mineOnly
@@ -1602,7 +2086,12 @@ function carryingSearchParams(state, searchParams) {
1602
2086
  }
1603
2087
 
1604
2088
  function WorkflowsToolRoot() {
1605
- 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;
1606
2095
  return instanceId ? /* @__PURE__ */ jsx(WorkflowInstanceDetail, {
1607
2096
  instanceId: instanceId,
1608
2097
  onBack: () => router.navigate(carryingSearchParams({}, router.state._searchParams))
@@ -1616,7 +2105,7 @@ function WorkflowsToolRoot() {
1616
2105
  }
1617
2106
 
1618
2107
  function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1619
- 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 => {
1620
2109
  if (typeof target != "string") return onOpenInstance(target);
1621
2110
  const held = [ ...instances.inFlight, ...instances.settled ].find(i => i._id === target);
1622
2111
  onOpenInstance(held ?? target);
@@ -1660,7 +2149,12 @@ function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1660
2149
  /* @__PURE__ */ jsx(TabSwitch, {
1661
2150
  ariaControls: "workflows-tool-panel",
1662
2151
  idPrefix: "workflows-tool-tab",
1663
- onSelect: setTab,
2152
+ onSelect: next => {
2153
+ next !== tab && telemetry.log(WorkflowToolOpened, {
2154
+ tab: next,
2155
+ via: "tab-switch"
2156
+ }), setTab(next);
2157
+ },
1664
2158
  options: TOOL_TABS,
1665
2159
  selected: tab
1666
2160
  }),
@@ -1691,9 +2185,9 @@ function WorkflowsHome({onOpenInstance: onOpenInstance}) {
1691
2185
  identity: identity,
1692
2186
  instances: [ ...instances.inFlight, ...instances.settled ],
1693
2187
  loading: instances.loading,
1694
- mineOnly: tab === "for-me",
1695
2188
  onOpenTask: setOpenTask,
1696
2189
  onOpenWorkflow: openInstance,
2190
+ segment: tab,
1697
2191
  truncated: instances.truncated,
1698
2192
  unreadable: instances.unreadable
1699
2193
  })