Drop workflow kinds, deliver reviews when done, rename/delete workflows

This commit is contained in:
Ian Gulliver
2026-10-20 20:09:38 -07:00
parent 51df9adf61
commit 35f59246f7
17 changed files with 671 additions and 531 deletions
+1 -1
View File
@@ -139,7 +139,7 @@ private struct ReviewWorkflowMenu: View {
.help("The review workflow whose reviewers review every snapshot of this project's tasks")
.task {
do {
reviewWorkflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .review }.map(\.name)
reviewWorkflows = try await store.api.client.listWorkflows().ok.body.json.map(\.name)
} catch {
reviewWorkflows = []
}
+1 -1
View File
@@ -159,7 +159,7 @@ final class SwitcherModel {
self.anchor = anchor
Task {
do {
workflows = try await store.api.client.listWorkflows().ok.body.json.filter { $0.kind == .task }
workflows = try await store.api.client.listWorkflows().ok.body.json
} catch {
overlays.showAlert(errorText(error))
}
+42 -30
View File
@@ -92,6 +92,28 @@ private func newStage(_ root: StepNode, _ model: String) -> StepNode {
return s
}
@MainActor
private func stepGraph(_ root: StepNode) -> Flowchart {
var chart = Flowchart(direction: .td)
func walk(_ node: StepNode, _ before: [String]) -> [String] {
switch node.kind {
case .order:
return node.children.reduce(before) { walk($1, $0) }
case .parallel:
return node.children.flatMap { walk($0, before) }
case .stage:
let id = "n\(chart.order.count)"
chart.node(id, label: node.name.isEmpty ? " " : node.name, shape: .box)
for b in before {
chart.edges.append(FlowEdge(from: b, to: id, label: nil, style: .solid, arrow: true))
}
return [id]
}
}
_ = walk(root, [])
return chart
}
private struct StepLine: Identifiable {
let node: StepNode
let guide: String
@@ -118,7 +140,6 @@ struct WorkflowEditor: View {
@ViewState private var loaded: WorkflowDefinition?
@ViewState private var name = ""
@ViewState private var description = ""
@ViewState private var review = false
@ViewState private var failure = ""
@ViewState private var saving = false
@@ -139,30 +160,21 @@ struct WorkflowEditor: View {
}
}
.buttonStyle(WebButtonStyle(primary: true))
.disabled(saving || !changed || name.trimmingCharacters(in: .whitespaces).isEmpty)
.disabled(saving || !changed || target.isEmpty)
}
.padding(.top, 16)
if let root {
if original == nil {
TextField("Name", text: $name)
.modifier(FieldStyle())
.padding(.top, 12)
} else {
Text(name)
.font(Fonts.font(13, weight: 700))
.textSelection(.enabled)
.padding(.top, 12)
}
TextField("Name", text: $name)
.modifier(FieldStyle())
.padding(.top, 12)
TextField("Description", text: $description, axis: .vertical)
.modifier(FieldStyle())
.padding(.top, 8)
Picker("", selection: $review) {
Text("Runs on a brief, each stage its own task").tag(false)
Text("Reviews snapshots, each stage a reviewer").tag(true)
if !root.stageNames().isEmpty {
FlowchartCanvas(layout: layoutFlowchart(stepGraph(root)))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 12)
}
.pickerStyle(.radioGroup)
.labelsHidden()
.padding(.top, 8)
if !failure.isEmpty {
Text(failure)
.foregroundStyle(Palette.danger)
@@ -197,11 +209,15 @@ struct WorkflowEditor: View {
}
private var current: WorkflowDefinition? {
root.map { WorkflowDefinition(description: description.isEmpty ? nil : description, kind: review ? .review : nil, steps: $0.children.map(\.step)) }
root.map { WorkflowDefinition(description: description.isEmpty ? nil : description, steps: $0.children.map(\.step)) }
}
private var target: String {
name.trimmingCharacters(in: .whitespaces)
}
private var changed: Bool {
original == nil || current != loaded
original == nil || current != loaded || target != original
}
private func load() async {
@@ -215,7 +231,6 @@ struct WorkflowEditor: View {
let def = try await store.api.client.getWorkflow(path: .init(name: original), headers: .init(accept: [.init(contentType: .json)])).ok.body.json
name = original
description = def.description ?? ""
review = def.kind == .review
root = StepNode(kind: .order, children: def.steps.map { StepNode($0) })
loaded = current
} catch {
@@ -229,14 +244,9 @@ struct WorkflowEditor: View {
}
saving = true
defer { saving = false }
let target = name.trimmingCharacters(in: .whitespaces)
do {
_ = try await store.api.client.putWorkflow(path: .init(name: target), body: .json(def)).ok.body.json
failure = ""
loaded = def
if original == nil {
close()
}
_ = try await store.api.client.putWorkflow(path: .init(name: target), query: .init(from: original), body: .json(def)).ok.body.json
close()
} catch {
failure = errorText(error)
}
@@ -338,10 +348,12 @@ private struct StepRow: View {
Button("Ungroup") { ungroup() }
}
Divider()
let siblings = node.parent?.children ?? []
let i = siblings.firstIndex { $0 === node }
Button("Move up") { move(-1) }
.disabled(node.index == 0)
.disabled(i == nil || i == 0)
Button("Move down") { move(1) }
.disabled(node.index == node.parent!.children.count - 1)
.disabled(i == nil || i == siblings.count - 1)
Divider()
Button("Remove") { node.detach() }
} label: {
+24 -13
View File
@@ -115,13 +115,8 @@ private struct WorkflowRow: View {
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(workflow.name)
.font(Fonts.font(13, weight: 700))
if workflow.kind == .review {
Text("reviews snapshots").font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
Text(workflow.name)
.font(Fonts.font(13, weight: 700))
if !workflow.description.isEmpty {
Text(workflow.description)
.foregroundStyle(Palette.muted)
@@ -135,13 +130,18 @@ private struct WorkflowRow: View {
}
.buttonStyle(SmallLinkButtonStyle())
.help("See and change this workflow's steps and prompts")
if workflow.kind == .task {
Button("run") {
run()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Open the Lead tab with a request to run this workflow in its composer")
Button("run") {
run()
}
.buttonStyle(SmallLinkButtonStyle())
.help("Open the Lead tab with a request to run this workflow in its composer")
Button("delete") {
Task {
await delete()
}
}
.buttonStyle(SmallLinkButtonStyle())
.help("Delete this workflow; tasks already running it keep their copy")
}
.padding(.vertical, 6)
.overlay(alignment: .top) {
@@ -149,6 +149,17 @@ private struct WorkflowRow: View {
}
}
private func delete() async {
guard await overlays.showConfirm(["Delete the workflow \"\(workflow.name)\"?"], action: "Delete", danger: true) else {
return
}
do {
_ = try await store.api.client.deleteWorkflow(path: .init(name: workflow.name)).noContent
} catch {
overlays.showAlert(errorText(error))
}
}
private func run() {
let key = "message:lead"
let ask = "Run the \"\(workflow.name)\" workflow: "