fix(ui): restore completion flash for argument actions

Argument-form submits remount the dashboard, so flash via a one-shot
binding-scoped pending result that ActionButton consumes after return.
This commit is contained in:
jamesread 2026-07-17 11:21:59 +01:00
parent 9b3fa813f6
commit 153049f29f
4 changed files with 214 additions and 2 deletions

View File

@ -36,7 +36,12 @@
<script setup>
import { buttonResults } from './stores/buttonResults'
import { rateLimits } from './stores/rateLimits'
import { bindingExecutionState, setBindingExecutionState } from './stores/bindingExecutionState'
import {
bindingExecutionState,
pendingBindingFlash,
consumePendingBindingFlash,
setBindingExecutionState
} from './stores/bindingExecutionState'
import { connectionState } from './stores/connectionState'
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
import { useRouter } from 'vue-router'
@ -90,6 +95,7 @@ const isComponentMounted = ref(true)
// Animation classes
const buttonClasses = ref([])
const flashedTrackingIds = new Set()
// Show navigate on start icons - defaults to true if not set
const showNavigateOnStartIcons = computed(() => {
@ -142,6 +148,18 @@ const executionIndicatorTitle = computed(() => {
return ''
})
function consumeAndFlashPendingResult () {
const id = bindingId.value
if (!id) {
return
}
const pending = consumePendingBindingFlash(id)
if (pending) {
onExecutionFinished(pending)
}
}
// Timestamps
const updateIterationTimestamp = ref(0)
@ -376,6 +394,20 @@ function onExecutionStarted(logEntry) {
}
function onExecutionFinished(logEntry) {
const trackingId = logEntry.executionTrackingId
if (trackingId) {
if (flashedTrackingIds.has(trackingId)) {
return
}
flashedTrackingIds.add(trackingId)
}
// Local no-arg watches and the binding-scoped pending flash can both
// observe the same finished execution; consume so only one path flashes.
if (bindingId.value) {
consumePendingBindingFlash(bindingId.value)
}
if (logEntry.timedOut) {
renderExecutionResult('action-timeout', 'Timed out')
} else if (logEntry.blocked) {
@ -383,7 +415,6 @@ function onExecutionFinished(logEntry) {
} else if (logEntry.exitCode !== 0) {
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
} else {
const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
renderExecutionResult('action-success', 'Success!')
}
}
@ -430,6 +461,17 @@ onMounted(() => {
},
{ deep: true }
)
// Binding-scoped flash survives argument-form navigation/remount (#920).
watch(
() => pendingBindingFlash[bindingId.value],
(pending) => {
if (pending) {
consumeAndFlashPendingResult()
}
},
{ immediate: true }
)
})
onUnmounted(() => {

View File

@ -2,10 +2,13 @@ import { reactive } from 'vue'
import { buttonResults } from './buttonResults.js'
const INDICATOR_SHOW_DELAY_MS = 1000
const PENDING_FLASH_TTL_MS = 5000
export const bindingExecutionState = reactive({})
export const pendingBindingFlash = reactive({})
const pendingShowTimers = {}
const pendingFlashExpireTimers = {}
function cancelPendingShowTimer (bindingId) {
const timer = pendingShowTimers[bindingId]
@ -76,4 +79,56 @@ export function applyExecutionFinishedBindingState (logEntry) {
cancelPendingShowTimer(logEntry.bindingId)
recomputeBindingExecutionState(logEntry.bindingId)
recordPendingBindingFlash(logEntry)
}
function cancelPendingFlashExpireTimer (bindingId) {
const timer = pendingFlashExpireTimers[bindingId]
if (timer != null) {
clearTimeout(timer)
delete pendingFlashExpireTimers[bindingId]
}
}
export function recordPendingBindingFlash (logEntry) {
if (!logEntry?.bindingId || !logEntry.executionFinished) {
return
}
const bindingId = logEntry.bindingId
cancelPendingFlashExpireTimer(bindingId)
pendingBindingFlash[bindingId] = {
executionTrackingId: logEntry.executionTrackingId,
timedOut: logEntry.timedOut,
blocked: logEntry.blocked,
exitCode: logEntry.exitCode,
datetimeStarted: logEntry.datetimeStarted,
datetimeFinished: logEntry.datetimeFinished,
recordedAt: Date.now()
}
pendingFlashExpireTimers[bindingId] = setTimeout(() => {
delete pendingFlashExpireTimers[bindingId]
const current = pendingBindingFlash[bindingId]
if (current?.executionTrackingId === logEntry.executionTrackingId) {
delete pendingBindingFlash[bindingId]
}
}, PENDING_FLASH_TTL_MS)
}
export function consumePendingBindingFlash (bindingId) {
if (!bindingId || pendingBindingFlash[bindingId] === undefined) {
return null
}
const result = pendingBindingFlash[bindingId]
cancelPendingFlashExpireTimer(bindingId)
delete pendingBindingFlash[bindingId]
if (result.recordedAt && (Date.now() - result.recordedAt) > PENDING_FLASH_TTL_MS) {
return null
}
return result
}

View File

@ -0,0 +1,95 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import { By, Condition } from 'selenium-webdriver'
import {
DEFAULT_UI_WAIT_MS,
getRootAndWait,
getActionButton,
takeScreenshotOnFailure,
waitForArgumentFormPage,
waitForArgumentFormReady,
waitForDashboardLoaded,
} from '../../lib/elements.js'
async function waitForStartButtonEnabled () {
await webdriver.wait(
new Condition('wait for Start button to be enabled', async () => {
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
return await submitButton.isEnabled()
}),
DEFAULT_UI_WAIT_MS
)
}
async function waitForActionSuccessFlash (actionTitle) {
await webdriver.wait(
new Condition(`wait for ${actionTitle} success flash`, async () => {
try {
const button = await getActionButton(webdriver, actionTitle)
const classAttr = await button.getAttribute('class')
return classAttr && classAttr.includes('action-success')
} catch (e) {
return false
}
}),
DEFAULT_UI_WAIT_MS
)
}
describe('config: argumentActionFlash', function () {
this.timeout(15000)
before(async function () {
await runner.start('argumentActionFlash')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('Action with arguments flashes success after returning to dashboard (#920)', async function () {
await getRootAndWait()
const argButton = await getActionButton(webdriver, 'Hello world')
await argButton.click()
await waitForArgumentFormPage()
await waitForArgumentFormReady()
await waitForStartButtonEnabled()
const submitButton = await webdriver.findElement(By.css('button[name="start"]'))
await submitButton.click()
await webdriver.wait(
new Condition('wait to leave argument form', async () => {
const url = await webdriver.getCurrentUrl()
return !url.includes('/argumentForm')
}),
DEFAULT_UI_WAIT_MS
)
await waitForDashboardLoaded()
await waitForActionSuccessFlash('Hello world')
const flashedButton = await getActionButton(webdriver, 'Hello world')
const classAttr = await flashedButton.getAttribute('class')
expect(classAttr).to.include('action-success')
})
it('Action without arguments still flashes success on the dashboard', async function () {
await getRootAndWait()
const simpleButton = await getActionButton(webdriver, 'Simple action')
await simpleButton.click()
await waitForActionSuccessFlash('Simple action')
const flashedButton = await getActionButton(webdriver, 'Simple action')
const classAttr = await flashedButton.getAttribute('class')
expect(classAttr).to.include('action-success')
})
})

View File

@ -0,0 +1,20 @@
---
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
actions:
- title: Hello world
shell: echo {{ message }}
icon: ping
arguments:
- name: message
description: The message you want to print out on the shell.
title: Your Message
default: Hello World
type: ascii_sentence
- title: Simple action
shell: echo Hi
icon: ping