@typeonce/effect-machine-devtools 0.27.0 → 0.28.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.
@@ -5,16 +5,8 @@ import type {
5
5
  State as VisualizationState,
6
6
  Transition as VisualizationTransition
7
7
  } from "../../MachineDocument.js"
8
- import { type InputField, projectInputSchema } from "./input-form.js"
9
8
  import { stateLabel, triggerLabel } from "./visualizer-model.js"
10
9
 
11
- export interface ChartField {
12
- readonly key: string
13
- readonly label: string
14
- readonly type: string
15
- readonly required: boolean
16
- }
17
-
18
10
  export interface ChartActivity {
19
11
  readonly id: string
20
12
  readonly kind: VisualizationActivity["type"]
@@ -29,7 +21,6 @@ export interface ChartNode {
29
21
  readonly children: ReadonlyArray<string>
30
22
  readonly active: boolean
31
23
  readonly initial: boolean
32
- readonly fields: ReadonlyArray<ChartField>
33
24
  readonly activities: ReadonlyArray<ChartActivity>
34
25
  }
35
26
 
@@ -69,53 +60,6 @@ export interface ChartModel {
69
60
  readonly initials: ReadonlyArray<ChartInitial>
70
61
  }
71
62
 
72
- const literalType = (value: string | number | boolean | null): string =>
73
- typeof value === "string" ? JSON.stringify(value) : String(value)
74
-
75
- const fieldType = (field: InputField): string => {
76
- switch (field._tag) {
77
- case "String":
78
- return field.format ?? "string"
79
- case "Number":
80
- return field.integer ? "integer" : "number"
81
- case "Boolean":
82
- return "boolean"
83
- case "Enum":
84
- return field.values.map(literalType).join(" | ")
85
- case "Literal":
86
- return literalType(field.value)
87
- case "Object":
88
- return "object"
89
- case "Array":
90
- return `${fieldType(field.item)}[]`
91
- case "Union":
92
- return field.alternatives.map(fieldType).join(" | ")
93
- case "Unsupported":
94
- return "unknown"
95
- }
96
- }
97
-
98
- const stateFields = (state: VisualizationState): ReadonlyArray<ChartField> => {
99
- if (state.valueSchema === null) return []
100
- const projected = projectInputSchema(state.valueSchema)
101
- if (projected._tag !== "Object") {
102
- return [{
103
- key: "value",
104
- label: projected.title ?? "value",
105
- type: fieldType(projected),
106
- required: true
107
- }]
108
- }
109
- return projected.fields
110
- .filter(({ key }) => key !== "_tag")
111
- .map(({ field, key, required }) => ({
112
- key,
113
- label: field.title ?? key,
114
- type: fieldType(field),
115
- required
116
- }))
117
- }
118
-
119
63
  const activityLabel = (activity: VisualizationActivity): string => {
120
64
  switch (activity.type) {
121
65
  case "machine":
@@ -177,7 +121,6 @@ export const makeChartModel = (document: VisualizationDocument): ChartModel => {
177
121
  children: [...state.children],
178
122
  active: active.has(state.path),
179
123
  initial: initialPaths.has(state.path),
180
- fields: stateFields(state),
181
124
  activities: state.activityIds.flatMap((id): ReadonlyArray<ChartActivity> => {
182
125
  const activity = activities.get(id)
183
126
  return activity === undefined ? [] : [{ id, kind: activity.type, label: activityLabel(activity) }]
@@ -6,8 +6,7 @@ import {
6
6
  type LaidOutChart,
7
7
  type LaidOutChartNode,
8
8
  layoutChart,
9
- maxVisibleActivities,
10
- maxVisibleFields
9
+ maxVisibleActivities
11
10
  } from "./chart-layout.js"
12
11
  import { makeChartModel } from "./chart-model.js"
13
12
 
@@ -130,7 +129,7 @@ const statusLabel = (active: boolean, initial: boolean): string => {
130
129
  const status = (active: boolean, initial: boolean): HTMLSpanElement => {
131
130
  const dot = element(
132
131
  "span",
133
- `chart-state-status${active ? " is-active" : ""}${initial ? " is-initial" : ""}`
132
+ `chart-state-status${active ? " is-active" : ""}`
134
133
  )
135
134
  dot.dataset.initial = String(initial)
136
135
  dot.setAttribute("aria-label", statusLabel(active, initial))
@@ -147,26 +146,8 @@ const stateContent = (layout: LaidOutChartNode, stateStatus: HTMLElement): Docum
147
146
  heading.append(identity)
148
147
  fragment.append(heading)
149
148
 
150
- if (layout.node.fields.length > 0) {
151
- const fields = element("div", "chart-state-section")
152
- fields.append(element("div", "chart-section-label", "value"))
153
- layout.node.fields.slice(0, maxVisibleFields).forEach((field) => {
154
- const row = element("div", "chart-field-row")
155
- row.append(
156
- element("span", "chart-field-name", `${field.label}${field.required ? "" : "?"}`),
157
- element("span", "chart-field-type", field.type)
158
- )
159
- fields.append(row)
160
- })
161
- if (layout.node.fields.length > maxVisibleFields) {
162
- fields.append(more(layout.node.fields.length - maxVisibleFields))
163
- }
164
- fragment.append(fields)
165
- }
166
-
167
149
  if (layout.node.activities.length > 0) {
168
150
  const activities = element("div", "chart-state-section")
169
- activities.append(element("div", "chart-section-label", "invokes"))
170
151
  layout.node.activities.slice(0, maxVisibleActivities).forEach((activity) => {
171
152
  const row = element("div", `chart-activity-row chart-activity-${activity.kind}`)
172
153
  row.append(
@@ -314,7 +295,19 @@ const render = (
314
295
  const directionArrow = svgElement("path")
315
296
  directionArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z")
316
297
  directionMarker.append(directionArrow)
317
- definitions.append(marker, directionMarker)
298
+ const initialMarker = svgElement("marker")
299
+ initialMarker.id = "chart-initial-arrow"
300
+ initialMarker.setAttribute("viewBox", "0 0 8 8")
301
+ initialMarker.setAttribute("refX", "7")
302
+ initialMarker.setAttribute("refY", "4")
303
+ initialMarker.setAttribute("markerWidth", "7")
304
+ initialMarker.setAttribute("markerHeight", "7")
305
+ initialMarker.setAttribute("markerUnits", "userSpaceOnUse")
306
+ initialMarker.setAttribute("orient", "auto")
307
+ const initialArrow = svgElement("path")
308
+ initialArrow.setAttribute("d", "M 1 1 L 7 4 L 1 7 z")
309
+ initialMarker.append(initialArrow)
310
+ definitions.append(marker, directionMarker, initialMarker)
318
311
  svg.append(definitions)
319
312
  const nodesLayer = element("div", "chart-nodes")
320
313
  const labelsLayer = element("div", "chart-labels")
@@ -340,6 +333,7 @@ const render = (
340
333
  const transitionElements = new Map<string, Array<Element>>()
341
334
  const edgeElements = new Map<string, Array<Element>>()
342
335
  const transitionControls = new Map<string, Array<HTMLButtonElement>>()
336
+ const parentByState = new Map(layout.nodes.map(({ node }) => [node.path, node.parent]))
343
337
  const chartEdges = new Map(
344
338
  layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : [])
345
339
  )
@@ -429,13 +423,15 @@ const render = (
429
423
  }
430
424
 
431
425
  for (const laidOut of layout.edges) {
426
+ const parentChild = laidOut.kind === "transition" && laidOut.edge.target !== null &&
427
+ parentByState.get(laidOut.edge.target) === laidOut.edge.source
432
428
  const group = svgElement(
433
429
  "g",
434
430
  `chart-edge-group chart-edge-${laidOut.kind}${
435
431
  laidOut.kind === "transition"
436
432
  ? ` chart-transition-${laidOut.edge.kind} chart-edge-trigger-${laidOut.edge.trigger.type}${
437
433
  laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}`
438
- }`
434
+ }${parentChild ? " chart-edge-parent-child" : ""}`
439
435
  : ""
440
436
  }`
441
437
  )
@@ -444,10 +440,23 @@ const render = (
444
440
  const route = chartEdgePathData(laidOut.points)
445
441
  casing.setAttribute("d", route)
446
442
  visible.setAttribute("d", route)
447
- visible.setAttribute("marker-end", "url(#chart-arrow)")
443
+ visible.setAttribute(
444
+ "marker-end",
445
+ laidOut.kind === "initial" ? "url(#chart-initial-arrow)" : "url(#chart-arrow)"
446
+ )
448
447
  const hit = svgElement("path", "chart-edge-hit")
449
448
  hit.setAttribute("d", route)
450
449
  group.append(casing, visible)
450
+ if (parentChild) {
451
+ const origin = laidOut.points[0]
452
+ if (origin !== undefined) {
453
+ const sourceAnchor = svgElement("circle", "chart-edge-parent-origin")
454
+ sourceAnchor.setAttribute("cx", String(origin.x))
455
+ sourceAnchor.setAttribute("cy", String(origin.y))
456
+ sourceAnchor.setAttribute("r", "4")
457
+ group.append(sourceAnchor)
458
+ }
459
+ }
451
460
  if (laidOut.kind === "transition") {
452
461
  const cue = chartDirectionCue(laidOut.points)
453
462
  if (cue !== null) {
@@ -467,10 +476,11 @@ const render = (
467
476
  "button",
468
477
  `chart-edge-label chart-edge-label-${laidOut.edge.trigger.type}${
469
478
  laidOut.edge.activityKind === null ? "" : ` chart-edge-activity-${laidOut.edge.activityKind}`
470
- }`,
479
+ }${parentChild ? " chart-edge-label-parent-child" : ""}`,
471
480
  laidOut.edge.label
472
481
  )
473
482
  label.type = "button"
483
+ if (parentChild) label.title = "Transition declared by the parent state"
474
484
  label.dataset.transitionId = laidOut.edge.transitionId
475
485
  position(label, {
476
486
  x: laidOut.label.x - laidOut.labelWidth / 2,
@@ -0,0 +1,91 @@
1
+ import { Machine } from "@typeonce/effect-machine"
2
+ import { Effect, Schema } from "effect"
3
+
4
+ const ReviewState = Schema.TaggedUnion({
5
+ Review: { title: Schema.String },
6
+ ReviewFailed: { message: Schema.String },
7
+ Complete: { slug: Schema.String }
8
+ })
9
+
10
+ const ReviewStates = Machine.states({
11
+ Workflow: {
12
+ initial: "Review",
13
+ states: {
14
+ Review: {
15
+ schema: ReviewState.cases.Review,
16
+ initial: "Form",
17
+ states: {
18
+ Form: {},
19
+ Failed: ReviewState.cases.ReviewFailed
20
+ }
21
+ },
22
+ Saving: {},
23
+ Publishing: {},
24
+ Complete: ReviewState.cases.Complete
25
+ }
26
+ }
27
+ })
28
+
29
+ const ReviewEvents = Machine.events(
30
+ Schema.TaggedUnion({
31
+ Submit: { route: Schema.Literals(["save", "invalid"]) }
32
+ })
33
+ )
34
+
35
+ const saveReview: Effect.Effect<string, string> = Effect.succeed("deterministic-chart")
36
+ const publishReview = Effect.succeed("deterministic-chart")
37
+
38
+ export const hierarchyRoutingMachine = Machine.make({
39
+ id: "hierarchy-routing",
40
+ states: ReviewStates.states,
41
+ events: ReviewEvents,
42
+ initial: (to) =>
43
+ to.Workflow.initial.resolve(({ target }) =>
44
+ target.from((workflow) =>
45
+ workflow.Review.from({ title: "A deterministic chart" }, (review) => review.Form.from())
46
+ )
47
+ )
48
+ }).handle({
49
+ Workflow: {
50
+ states: {
51
+ Review: {
52
+ on: {
53
+ Submit: (to) =>
54
+ to.branches({
55
+ save: {
56
+ title: "Save the review",
57
+ target: to.branch.Workflow.Saving()
58
+ },
59
+ invalid: {
60
+ title: "Show validation failure",
61
+ target: to.local.Failed()
62
+ }
63
+ }).resolve(({ event, select }) =>
64
+ event.route === "save"
65
+ ? select.save.from()
66
+ : select.invalid.from({ message: "Add a title before continuing." })
67
+ )
68
+ },
69
+ states: {
70
+ Form: {},
71
+ Failed: {}
72
+ }
73
+ },
74
+ Saving: {
75
+ invoke: (from) =>
76
+ from.effect("save-review", () => saveReview).onDone((to) => to.branch.Workflow.Publishing()).onFailure((to) =>
77
+ to.branch.Workflow.Review.Failed().resolve(({ target }) =>
78
+ target.from({ message: "The review could not be saved." })
79
+ )
80
+ )
81
+ },
82
+ Publishing: {
83
+ invoke: (from) =>
84
+ from.effect("publish-review", () => publishReview).onDone((to) =>
85
+ to.branch.Workflow.Complete().resolve(({ output, target }) => target.from({ slug: output }))
86
+ )
87
+ },
88
+ Complete: {}
89
+ }
90
+ }
91
+ })
@@ -3,6 +3,19 @@ import { mountVisualizer } from "./visualizer.js"
3
3
 
4
4
  let selectedKey: string | undefined
5
5
 
6
+ export const machineIndexScrollLeft = (
7
+ scrollLeft: number,
8
+ viewportWidth: number,
9
+ selectedLeft: number,
10
+ selectedWidth: number
11
+ ): number => {
12
+ if (selectedLeft < scrollLeft) return selectedLeft
13
+ const selectedRight = selectedLeft + selectedWidth
14
+ return selectedRight > scrollLeft + viewportWidth
15
+ ? selectedRight - viewportWidth
16
+ : scrollLeft
17
+ }
18
+
6
19
  const createElement = <Tag extends keyof HTMLElementTagNameMap>(
7
20
  tag: Tag,
8
21
  className?: string,
@@ -36,6 +49,7 @@ const statusLabel = (result: MachineResult): string => {
36
49
  }
37
50
 
38
51
  export const mountMachineIndex = (root: HTMLElement, snapshot: RegistrySnapshot): void => {
52
+ const previousScrollLeft = root.querySelector<HTMLElement>(".machine-index")?.scrollLeft ?? 0
39
53
  const results = [...snapshot.results].sort((left, right) => resultLabel(left).localeCompare(resultLabel(right)))
40
54
  if (selectedKey === undefined || !results.some((result) => result.key === selectedKey)) {
41
55
  selectedKey = results[0]?.key
@@ -78,4 +92,13 @@ export const mountMachineIndex = (root: HTMLElement, snapshot: RegistrySnapshot)
78
92
 
79
93
  shell.append(index, view)
80
94
  root.replaceChildren(shell)
95
+ const selected = index.querySelector<HTMLElement>(".machine-row.is-selected")
96
+ index.scrollLeft = selected === null
97
+ ? previousScrollLeft
98
+ : machineIndexScrollLeft(
99
+ previousScrollLeft,
100
+ index.clientWidth,
101
+ selected.offsetLeft,
102
+ selected.offsetWidth
103
+ )
81
104
  }
@@ -0,0 +1,141 @@
1
+ import { Machine } from "@typeonce/effect-machine"
2
+ import { Effect, Schema } from "effect"
3
+
4
+ const ReplicationStates = Machine.states({
5
+ Connecting: {},
6
+ IdentifyingSource: {},
7
+ ReadingServerInfo: {},
8
+ ReadingSlot: {},
9
+ CreatingSlot: {},
10
+ CopyingSnapshot: {},
11
+ CatchingUp: {},
12
+ ApplyingChanges: {},
13
+ Ready: {},
14
+ SessionUnavailable: {},
15
+ Stopping: {},
16
+ Stopped: {},
17
+ Failed: {}
18
+ })
19
+
20
+ const ReplicationEvents = Machine.events(
21
+ Schema.TaggedUnion({
22
+ Retry: {},
23
+ SessionUnavailable: {},
24
+ StopRequested: {}
25
+ })
26
+ )
27
+
28
+ const operation = (): Effect.Effect<void, string> => Effect.succeed(undefined)
29
+
30
+ export const sharedTerminalRoutingMachine = Machine.make({
31
+ id: "shared-terminal-routing",
32
+ states: ReplicationStates.states,
33
+ events: ReplicationEvents,
34
+ initial: (to) => to.Connecting()
35
+ }).handle({
36
+ Connecting: {
37
+ invoke: (from) =>
38
+ from.effect("connect", operation)
39
+ .onDone((to) => to.full.IdentifyingSource())
40
+ .onFailure((to) => to.full.Failed()),
41
+ on: {
42
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
43
+ StopRequested: (to) => to.full.Stopping()
44
+ }
45
+ },
46
+ IdentifyingSource: {
47
+ invoke: (from) =>
48
+ from.effect("identify-source", operation)
49
+ .onDone((to) => to.full.ReadingServerInfo())
50
+ .onFailure((to) => to.full.Failed()),
51
+ on: {
52
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
53
+ StopRequested: (to) => to.full.Stopping()
54
+ }
55
+ },
56
+ ReadingServerInfo: {
57
+ invoke: (from) =>
58
+ from.effect("read-server-info", operation)
59
+ .onDone((to) => to.full.ReadingSlot())
60
+ .onFailure((to) => to.full.Failed()),
61
+ on: {
62
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
63
+ StopRequested: (to) => to.full.Stopping()
64
+ }
65
+ },
66
+ ReadingSlot: {
67
+ invoke: (from) =>
68
+ from.effect("read-slot", operation)
69
+ .onDone((to) => to.full.CreatingSlot())
70
+ .onFailure((to) => to.full.Failed()),
71
+ on: {
72
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
73
+ StopRequested: (to) => to.full.Stopping()
74
+ }
75
+ },
76
+ CreatingSlot: {
77
+ invoke: (from) =>
78
+ from.effect("create-slot", operation)
79
+ .onDone((to) => to.full.CopyingSnapshot())
80
+ .onFailure((to) => to.full.Failed()),
81
+ on: {
82
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
83
+ StopRequested: (to) => to.full.Stopping()
84
+ }
85
+ },
86
+ CopyingSnapshot: {
87
+ invoke: (from) =>
88
+ from.effect("copy-snapshot", operation)
89
+ .onDone((to) => to.full.CatchingUp())
90
+ .onFailure((to) => to.full.Failed()),
91
+ on: {
92
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
93
+ StopRequested: (to) => to.full.Stopping()
94
+ }
95
+ },
96
+ CatchingUp: {
97
+ invoke: (from) =>
98
+ from.effect("catch-up", operation)
99
+ .onDone((to) => to.full.ApplyingChanges())
100
+ .onFailure((to) => to.full.Failed()),
101
+ on: {
102
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
103
+ StopRequested: (to) => to.full.Stopping()
104
+ }
105
+ },
106
+ ApplyingChanges: {
107
+ invoke: (from) =>
108
+ from.effect("apply-changes", operation)
109
+ .onDone((to) => to.full.Ready())
110
+ .onFailure((to) => to.full.Failed()),
111
+ on: {
112
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
113
+ StopRequested: (to) => to.full.Stopping()
114
+ }
115
+ },
116
+ Ready: {
117
+ on: {
118
+ SessionUnavailable: (to) => to.full.SessionUnavailable(),
119
+ StopRequested: (to) => to.full.Stopping()
120
+ }
121
+ },
122
+ SessionUnavailable: {
123
+ on: {
124
+ Retry: (to) => to.full.Connecting(),
125
+ StopRequested: (to) => to.full.Stopping()
126
+ }
127
+ },
128
+ Stopping: {
129
+ invoke: (from) =>
130
+ from.effect("stop-session", operation)
131
+ .onDone((to) => to.full.Stopped())
132
+ .onFailure((to) => to.full.Failed())
133
+ },
134
+ Stopped: {},
135
+ Failed: {
136
+ on: {
137
+ Retry: (to) => to.full.Connecting(),
138
+ StopRequested: (to) => to.full.Stopping()
139
+ }
140
+ }
141
+ })