fix(ui): restore completion flash for argument actions (#1075)
This commit is contained in:
commit
ecb0486074
|
|
@ -36,7 +36,12 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { buttonResults } from './stores/buttonResults'
|
import { buttonResults } from './stores/buttonResults'
|
||||||
import { rateLimits } from './stores/rateLimits'
|
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 { connectionState } from './stores/connectionState'
|
||||||
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
|
import { requestReconnectNow, applyExecutionLogEntry } from '../../js/websocket.js'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
@ -90,6 +95,7 @@ const isComponentMounted = ref(true)
|
||||||
|
|
||||||
// Animation classes
|
// Animation classes
|
||||||
const buttonClasses = ref([])
|
const buttonClasses = ref([])
|
||||||
|
const flashedTrackingIds = new Set()
|
||||||
|
|
||||||
// Show navigate on start icons - defaults to true if not set
|
// Show navigate on start icons - defaults to true if not set
|
||||||
const showNavigateOnStartIcons = computed(() => {
|
const showNavigateOnStartIcons = computed(() => {
|
||||||
|
|
@ -142,6 +148,18 @@ const executionIndicatorTitle = computed(() => {
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function consumeAndFlashPendingResult () {
|
||||||
|
const id = bindingId.value
|
||||||
|
if (!id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = consumePendingBindingFlash(id)
|
||||||
|
if (pending) {
|
||||||
|
onExecutionFinished(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
const updateIterationTimestamp = ref(0)
|
const updateIterationTimestamp = ref(0)
|
||||||
|
|
||||||
|
|
@ -376,6 +394,20 @@ function onExecutionStarted(logEntry) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onExecutionFinished(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) {
|
if (logEntry.timedOut) {
|
||||||
renderExecutionResult('action-timeout', 'Timed out')
|
renderExecutionResult('action-timeout', 'Timed out')
|
||||||
} else if (logEntry.blocked) {
|
} else if (logEntry.blocked) {
|
||||||
|
|
@ -383,7 +415,6 @@ function onExecutionFinished(logEntry) {
|
||||||
} else if (logEntry.exitCode !== 0) {
|
} else if (logEntry.exitCode !== 0) {
|
||||||
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
|
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
|
||||||
} else {
|
} else {
|
||||||
const ellapsed = Math.ceil(new Date(logEntry.datetimeFinished) - new Date(logEntry.datetimeStarted)) / 1000
|
|
||||||
renderExecutionResult('action-success', 'Success!')
|
renderExecutionResult('action-success', 'Success!')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +461,17 @@ onMounted(() => {
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Binding-scoped flash survives argument-form navigation/remount (#920).
|
||||||
|
watch(
|
||||||
|
() => pendingBindingFlash[bindingId.value],
|
||||||
|
(pending) => {
|
||||||
|
if (pending) {
|
||||||
|
consumeAndFlashPendingResult()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@ import { reactive } from 'vue'
|
||||||
import { buttonResults } from './buttonResults.js'
|
import { buttonResults } from './buttonResults.js'
|
||||||
|
|
||||||
const INDICATOR_SHOW_DELAY_MS = 1000
|
const INDICATOR_SHOW_DELAY_MS = 1000
|
||||||
|
const PENDING_FLASH_TTL_MS = 5000
|
||||||
|
|
||||||
export const bindingExecutionState = reactive({})
|
export const bindingExecutionState = reactive({})
|
||||||
|
export const pendingBindingFlash = reactive({})
|
||||||
|
|
||||||
const pendingShowTimers = {}
|
const pendingShowTimers = {}
|
||||||
|
const pendingFlashExpireTimers = {}
|
||||||
|
|
||||||
function cancelPendingShowTimer (bindingId) {
|
function cancelPendingShowTimer (bindingId) {
|
||||||
const timer = pendingShowTimers[bindingId]
|
const timer = pendingShowTimers[bindingId]
|
||||||
|
|
@ -76,4 +79,56 @@ export function applyExecutionFinishedBindingState (logEntry) {
|
||||||
|
|
||||||
cancelPendingShowTimer(logEntry.bindingId)
|
cancelPendingShowTimer(logEntry.bindingId)
|
||||||
recomputeBindingExecutionState(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