瀏覽代碼

fix(ui): restore completion flash for argument actions (#1075)

James Read 1 月之前
父節點
當前提交
ecb0486074

+ 44 - 2
frontend/resources/vue/ActionButton.vue

@@ -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(() => {

+ 55 - 0
frontend/resources/vue/stores/bindingExecutionState.js

@@ -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
 }

+ 95 - 0
integration-tests/tests/argumentActionFlash/argumentActionFlash.mjs

@@ -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')
+  })
+})

+ 20 - 0
integration-tests/tests/argumentActionFlash/config.yaml

@@ -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