import { Controller } from "@hotwired/stimulus"
/**
* WorkflowToolbarController
*
* Manages toolbar actions: save, publish, undo/redo, zoom, keyboard shortcuts,
* validation display, node deletion, and panel toggles.
* Communicates with the main designer controller via Stimulus outlet.
*/
class WorkflowToolbarController extends Controller {
static targets = [
"saveBtn",
"publishBtn",
"zoomLevel",
"workflowName",
"executionModeBadge",
"metadataForm",
"metadataStatus",
"metadataSaveBtn",
]
static outlets = ["workflow-designer"]
static values = {
saveUrl: String,
publishUrl: String,
updateMetadataUrl: String,
csrfToken: String,
}
_zoom = 1
connect() {
this._bindKeyboardShortcuts()
}
disconnect() {
this._unbindKeyboardShortcuts()
}
// --- Helpers to access designer state ---
get _designer() {
return this.hasWorkflowDesignerOutlet ? this.workflowDesignerOutlet : null
}
get _editor() {
return this._designer?.editor
}
// --- Save / Publish ---
async save(event) {
if (event) event.preventDefault()
const designer = this._designer
if (!designer) return false
const { definition, canvasLayout } = designer.exportWorkflow()
this._setBtnLoading(this.hasSaveBtnTarget ? this.saveBtnTarget : null, true)
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue,
},
body: JSON.stringify({
workflowId: designer.workflowIdValue || null,
versionId: designer.versionIdValue || null,
definition,
canvasLayout,
}),
})
const result = await response.json()
if (response.ok && result.success) {
if (result.workflowId) designer.workflowIdValue = result.workflowId
if (result.versionId) designer.versionIdValue = result.versionId
designer.markSaved()
this.showFlash('Workflow saved successfully', 'success')
return true
} else {
this.showFlash(result.reason || 'Failed to save workflow', 'danger')
return false
}
} catch (error) {
console.error('Save failed:', error)
this.showFlash('Error saving workflow', 'danger')
return false
} finally {
this._setBtnLoading(this.hasSaveBtnTarget ? this.saveBtnTarget : null, false)
}
}
async publish(event) {
if (event) event.preventDefault()
if (!confirm('Publish this workflow version? Running instances will continue on their current version.')) return
const saved = await this.save()
if (!saved) return
const designer = this._designer
if (!designer) return
this._setBtnLoading(this.hasPublishBtnTarget ? this.publishBtnTarget : null, true)
try {
const response = await fetch(this.publishUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue,
},
body: JSON.stringify({ versionId: designer.versionIdValue }),
})
const result = await response.json()
if (response.ok && result.success) {
this.showFlash('Workflow published as v' + (result.versionNumber || ''), 'success')
setTimeout(() => window.location.reload(), 1000)
} else {
const reason = result.reason || 'Failed to publish workflow'
if (reason.startsWith('Definition validation failed:')) {
const errorText = reason.replace('Definition validation failed: ', '')
const errors = errorText.split('; ').filter(e => e.trim())
designer.showValidationResults({ valid: false, errors, warnings: [] })
}
this.showFlash(reason, 'danger')
}
} catch (error) {
console.error('Publish failed:', error)
this.showFlash('Error publishing workflow', 'danger')
} finally {
this._setBtnLoading(this.hasPublishBtnTarget ? this.publishBtnTarget : null, false)
}
}
async saveMetadata(event) {
if (event) event.preventDefault()
if (!this.hasMetadataFormTarget || !this.hasUpdateMetadataUrlValue) return
this._setMetadataStatus('')
this._setBtnLoading(this.hasMetadataSaveBtnTarget ? this.metadataSaveBtnTarget : null, true)
try {
const formData = new FormData(this.metadataFormTarget)
const payload = Object.fromEntries(formData.entries())
const response = await fetch(this.updateMetadataUrlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfTokenValue,
},
body: JSON.stringify(payload),
})
const result = await response.json()
if (response.ok && result.success) {
this._updateMetadataDisplay(result.workflow || payload)
this._closeMetadataModal()
this.showFlash('Workflow details updated', 'success')
return
}
this._setMetadataStatus(result.reason || 'Failed to update workflow details')
} catch (error) {
console.error('Metadata update failed:', error)
this._setMetadataStatus('Error updating workflow details')
} finally {
this._setBtnLoading(this.hasMetadataSaveBtnTarget ? this.metadataSaveBtnTarget : null, false)
}
}
_setBtnLoading(btn, loading) {
if (!btn) return
if (loading) {
btn._origHTML = btn.innerHTML
btn.disabled = true
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Saving…'
} else {
btn.disabled = false
btn.innerHTML = btn._origHTML || btn.innerHTML
}
}
showFlash(message, type) {
const toast = document.createElement('div')
toast.className = `alert alert-${type} alert-dismissible fade show wf-toast`
toast.setAttribute('role', 'alert')
toast.setAttribute('aria-live', 'assertive')
toast.innerHTML = `${this._escapeHtml(message)}<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`
document.body.appendChild(toast)
setTimeout(() => toast.remove(), 4000)
}
_setMetadataStatus(message) {
if (!this.hasMetadataStatusTarget) return
this.metadataStatusTarget.textContent = message
this.metadataStatusTarget.classList.toggle('d-none', !message)
}
_closeMetadataModal() {
const modalElement = this.metadataFormTarget?.closest('.modal')
const modal = window.bootstrap?.Modal?.getInstance(modalElement)
if (modal) {
modal.hide()
}
}
_updateMetadataDisplay(workflow) {
if (this.hasWorkflowNameTarget && workflow.name) {
this.workflowNameTarget.textContent = workflow.name
}
if (!this.hasExecutionModeBadgeTarget) return
const executionMode = workflow.executionMode || workflow.execution_mode
if (executionMode === 'ephemeral') {
this.executionModeBadgeTarget.className = 'badge bg-info text-dark ms-2'
this.executionModeBadgeTarget.title = 'Ephemeral workflows run in-memory with no persistence. Async nodes (approvals, delays) are not supported.'
this.executionModeBadgeTarget.innerHTML = '<i class="bi bi-lightning-charge me-1"></i>Ephemeral'
} else {
this.executionModeBadgeTarget.className = 'badge bg-primary ms-2'
this.executionModeBadgeTarget.title = 'Durable workflows persist execution state and support async nodes like approvals and delays.'
this.executionModeBadgeTarget.innerHTML = '<i class="bi bi-database me-1"></i>Durable'
}
}
_escapeHtml(value) {
const element = document.createElement('div')
element.textContent = value
return element.innerHTML
}
// --- Undo / Redo ---
undo() {
const designer = this._designer
if (designer) designer.historyManager.undo(designer.editor)
}
redo() {
const designer = this._designer
if (designer) designer.historyManager.redo(designer.editor)
}
// --- Zoom ---
zoomIn() { this._setZoom(Math.min(this._zoom + 0.1, 2)) }
zoomOut() { this._setZoom(Math.max(this._zoom - 0.1, 0.3)) }
zoomReset() { this._setZoom(1) }
_setZoom(level) {
this._zoom = Math.round(level * 100) / 100
const editor = this._editor
if (editor) {
editor.zoom = this._zoom
editor.zoom_refresh()
}
if (this.hasZoomLevelTarget) {
this.zoomLevelTarget.textContent = `${Math.round(this._zoom * 100)}%`
}
}
// --- Keyboard Shortcuts ---
_bindKeyboardShortcuts() {
this._keyHandler = (event) => this._handleKeydown(event)
this._keyUpHandler = (event) => {
if (event.key === 'Shift' && this._designer) {
this._designer._shiftHeld = false
}
}
this._keyDownShiftHandler = (event) => {
if (event.key === 'Shift' && this._designer) {
this._designer._shiftHeld = true
}
}
document.addEventListener('keydown', this._keyHandler)
document.addEventListener('keydown', this._keyDownShiftHandler)
document.addEventListener('keyup', this._keyUpHandler)
}
_unbindKeyboardShortcuts() {
if (this._keyHandler) document.removeEventListener('keydown', this._keyHandler)
if (this._keyDownShiftHandler) document.removeEventListener('keydown', this._keyDownShiftHandler)
if (this._keyUpHandler) document.removeEventListener('keyup', this._keyUpHandler)
}
_handleKeydown(event) {
const tag = event.target.tagName
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return
const ctrl = event.ctrlKey || event.metaKey
if (ctrl && event.key === 's') {
event.preventDefault()
this.save()
} else if (ctrl && event.key === 'z' && !event.shiftKey) {
event.preventDefault()
this.undo()
} else if (ctrl && (event.key === 'y' || (event.key === 'z' && event.shiftKey))) {
event.preventDefault()
this.redo()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
const designer = this._designer
if (designer && designer.selectedNodes.size > 0) {
event.preventDefault()
this.deleteSelectedNodes()
}
}
}
deleteSelectedNodes() {
const designer = this._designer
if (!designer || designer.readonlyValue) return
designer.selectedNodes.forEach(nodeId => {
try { designer.editor.removeNodeId(`node-${nodeId}`) } catch (_) { /* node may already be gone */ }
})
designer.clearMultiSelect()
}
// --- Validation ---
validateWorkflow() {
const designer = this._designer
if (!designer) return
const result = designer.validationService.validate(designer.editor.export())
designer.showValidationResults(result)
return result
}
// --- Panel Toggles ---
togglePalette() {
const designer = this._designer
if (designer?.hasNodePaletteTarget) {
designer.nodePaletteTarget.classList.toggle('wf-panel-collapsed')
}
}
toggleConfig() {
const designer = this._designer
if (designer?.hasNodeConfigTarget) {
designer.nodeConfigTarget.classList.toggle('wf-panel-collapsed')
}
}
}
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["workflow-toolbar"] = WorkflowToolbarController;