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:
parent
9b3fa813f6
commit
153049f29f
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue