ArgumentForm.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. <template>
  2. <section id = "argument-popup">
  3. <div class="section-header">
  4. <h2>Start action: {{ title }}</h2>
  5. </div>
  6. <div class="section-content padding">
  7. <form @submit="handleSubmit">
  8. <template v-if="actionArguments.length > 0">
  9. <template v-for="arg in actionArguments" :key="arg.name">
  10. <label :for="arg.name">
  11. {{ formatLabel(arg.title) }}
  12. </label>
  13. <datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="`${arg.name}-choices`">
  14. <option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
  15. {{ suggestion }}
  16. </option>
  17. <option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
  18. {{ suggestion }}
  19. </option>
  20. </datalist>
  21. <select v-if="getInputComponent(arg) === 'select'" :id="arg.name" :name="arg.name" :value="getArgumentValue(arg)"
  22. :required="arg.required" @input="handleInput(arg, $event)" @change="handleChange(arg, $event)">
  23. <option v-for="choice in arg.choices" :key="choice.value" :value="choice.value">
  24. {{ choice.title || choice.value }}
  25. </option>
  26. </select>
  27. <component v-else :is="getInputComponent(arg)" :id="arg.name" :name="arg.name"
  28. :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
  29. :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
  30. :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? `${arg.name}-choices` : undefined"
  31. :type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
  32. :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
  33. :step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
  34. @input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
  35. <span class="argument-description" v-html="arg.description"></span>
  36. </template>
  37. </template>
  38. <template v-if="justificationRequired">
  39. <label for="justification">Justification:</label>
  40. <input id="justification" name="justification" type="text" v-model="justificationValue" required />
  41. </template>
  42. <div v-if="actionArguments.length === 0 && !justificationRequired">
  43. <p>No arguments required</p>
  44. </div>
  45. <div class="buttons">
  46. <button name="start" type="submit" :disabled="!formReady || (hasConfirmation && !confirmationChecked)">
  47. Start
  48. </button>
  49. <button name="cancel" type="button" @click="handleCancel">
  50. Cancel
  51. </button>
  52. </div>
  53. </form>
  54. </div>
  55. </section>
  56. </template>
  57. <script setup>
  58. import { ref, onMounted, onBeforeUnmount, onUnmounted, nextTick } from 'vue'
  59. import { useRouter } from 'vue-router'
  60. import { requestReconnectNow } from '../../../js/websocket.js'
  61. const router = useRouter()
  62. // Reactive data
  63. const dialog = ref(null)
  64. const title = ref('')
  65. const icon = ref('')
  66. //const arguments = ref([])
  67. const argValues = ref({})
  68. const confirmationChecked = ref(false)
  69. const hasConfirmation = ref(false)
  70. const formErrors = ref({})
  71. const actionArguments = ref([])
  72. const popupOnStart = ref('')
  73. const formReady = ref(false)
  74. const justificationRequired = ref(false)
  75. const justificationValue = ref('')
  76. let isComponentMounted = true
  77. // Computed properties
  78. const props = defineProps({
  79. bindingId: {
  80. type: String,
  81. required: true
  82. }
  83. })
  84. // Methods
  85. async function setup() {
  86. formReady.value = false
  87. document.body.removeAttribute('loaded-argument-form')
  88. try {
  89. const ret = await window.client.getActionBinding({
  90. bindingId: props.bindingId
  91. })
  92. const action = ret.action
  93. title.value = action.title
  94. icon.value = action.icon
  95. popupOnStart.value = action.popupOnStart || ''
  96. actionArguments.value = action.arguments || []
  97. justificationRequired.value = action.justification || false
  98. justificationValue.value = ''
  99. argValues.value = {}
  100. formErrors.value = {}
  101. confirmationChecked.value = false
  102. hasConfirmation.value = false
  103. // Initialize values from query params or defaults
  104. actionArguments.value.forEach(arg => {
  105. if (arg.type === 'confirmation') {
  106. hasConfirmation.value = true
  107. const paramValue = getQueryParamValue(arg.name)
  108. let checkedValue = false
  109. if (paramValue !== null) {
  110. checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
  111. } else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
  112. checkedValue = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
  113. }
  114. argValues.value[arg.name] = checkedValue
  115. confirmationChecked.value = checkedValue
  116. } else {
  117. const paramValue = getQueryParamValue(arg.name)
  118. if (arg.type === 'checkbox') {
  119. // For checkboxes, handle boolean default values properly
  120. if (paramValue !== null) {
  121. argValues.value[arg.name] = paramValue === '1' || paramValue === 'true' || paramValue === true
  122. } else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
  123. argValues.value[arg.name] = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
  124. } else {
  125. argValues.value[arg.name] = false
  126. }
  127. } else {
  128. argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
  129. }
  130. }
  131. })
  132. // Run initial validation on all fields after DOM is updated
  133. await nextTick()
  134. for (const arg of actionArguments.value) {
  135. if (arg.type && !arg.type.startsWith('regex:') && arg.type !== 'select' && arg.type !== '' && arg.type !== 'confirmation' && arg.type !== 'checkbox') {
  136. await validateArgument(arg, argValues.value[arg.name] || '')
  137. }
  138. }
  139. if (isComponentMounted) {
  140. formReady.value = true
  141. document.body.setAttribute('loaded-argument-form', props.bindingId)
  142. }
  143. } catch (err) {
  144. console.error('Failed to load argument form:', err)
  145. }
  146. }
  147. function getQueryParamValue(paramName) {
  148. const params = new URLSearchParams(window.location.search.substring(1))
  149. return params.get(paramName)
  150. }
  151. function formatLabel(title) {
  152. const lastChar = title.charAt(title.length - 1)
  153. if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
  154. return title
  155. }
  156. return title + ':'
  157. }
  158. function getInputComponent(arg) {
  159. if (arg.type === 'html') {
  160. return 'div'
  161. } else if (arg.type === 'raw_string_multiline') {
  162. return 'textarea'
  163. } else if (arg.choices && arg.choices.length > 0 && (arg.type === 'select' || arg.type === '')) {
  164. return 'select'
  165. } else {
  166. return 'input'
  167. }
  168. }
  169. function getInputType(arg) {
  170. if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
  171. return undefined
  172. }
  173. if (arg.type === 'confirmation') {
  174. return 'checkbox'
  175. }
  176. if (arg.type === 'ascii_identifier' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
  177. return 'text'
  178. }
  179. if (arg.type === 'datetime') {
  180. return 'datetime-local'
  181. }
  182. return arg.type
  183. }
  184. function getPattern(arg) {
  185. if (arg.type && arg.type.startsWith('regex:')) {
  186. return arg.type.replace('regex:', '')
  187. }
  188. return undefined
  189. }
  190. function getArgumentValue(arg) {
  191. if (arg.type === 'checkbox' || arg.type === 'confirmation') {
  192. return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true || argValues.value[arg.name] === 'true'
  193. }
  194. return argValues.value[arg.name] || ''
  195. }
  196. function handleInput(arg, event) {
  197. const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
  198. argValues.value[arg.name] = value
  199. updateUrlWithArg(arg.name, value)
  200. }
  201. function handleChange(arg, event) {
  202. if (arg.type === 'confirmation') {
  203. confirmationChecked.value = event.target.checked
  204. return
  205. }
  206. // Validate the input
  207. validateArgument(arg, event.target.value)
  208. }
  209. async function validateArgument(arg, value) {
  210. if (!arg.type || arg.type.startsWith('regex:')) {
  211. return
  212. }
  213. // Skip validation for datetime - backend will handle mangling values without seconds
  214. if (arg.type === 'datetime') {
  215. const inputElement = document.getElementById(arg.name)
  216. if (inputElement) {
  217. inputElement.setCustomValidity('')
  218. }
  219. delete formErrors.value[arg.name]
  220. return
  221. }
  222. // Skip validation for checkbox and confirmation - they're always valid
  223. if (arg.type === 'checkbox' || arg.type === 'confirmation') {
  224. const inputElement = document.getElementById(arg.name)
  225. if (inputElement) {
  226. inputElement.setCustomValidity('')
  227. }
  228. delete formErrors.value[arg.name]
  229. return
  230. }
  231. try {
  232. const validateArgumentTypeArgs = {
  233. value: value,
  234. type: arg.type,
  235. bindingId: props.bindingId,
  236. argumentName: arg.name
  237. }
  238. const validation = await window.client.validateArgumentType(validateArgumentTypeArgs)
  239. // Get the input element to set custom validity
  240. const inputElement = document.getElementById(arg.name)
  241. if (validation.valid) {
  242. delete formErrors.value[arg.name]
  243. // Clear custom validity message
  244. if (inputElement) {
  245. inputElement.setCustomValidity('')
  246. }
  247. } else {
  248. formErrors.value[arg.name] = validation.description
  249. // Set custom validity message
  250. if (inputElement) {
  251. inputElement.setCustomValidity(validation.description)
  252. }
  253. }
  254. } catch (err) {
  255. console.warn('Validation failed:', err)
  256. // On error, clear any custom validity
  257. const inputElement = document.getElementById(arg.name)
  258. if (inputElement) {
  259. inputElement.setCustomValidity('')
  260. }
  261. }
  262. }
  263. function updateUrlWithArg(name, value) {
  264. if (name && value !== undefined) {
  265. const url = new URL(window.location.href)
  266. // Don't add passwords to URL
  267. const arg = actionArguments.value.find(a => a.name === name)
  268. if (arg && arg.type === 'password') {
  269. return
  270. }
  271. url.searchParams.set(name, value)
  272. window.history.replaceState({}, '', url.toString())
  273. }
  274. }
  275. function shouldSendArgument(arg) {
  276. if (!arg.name) {
  277. return false
  278. }
  279. return arg.type !== 'html'
  280. }
  281. function formatArgumentValueForApi(arg, rawValue) {
  282. if (arg.type === 'checkbox' || arg.type === 'confirmation') {
  283. return rawValue === '1' || rawValue === true || rawValue === 'true' ? '1' : '0'
  284. }
  285. if (rawValue === true) {
  286. return '1'
  287. }
  288. if (rawValue === false) {
  289. return '0'
  290. }
  291. return rawValue ?? ''
  292. }
  293. function getArgumentValues() {
  294. const ret = []
  295. for (const arg of actionArguments.value) {
  296. if (!shouldSendArgument(arg)) {
  297. continue
  298. }
  299. ret.push({
  300. name: arg.name,
  301. value: formatArgumentValueForApi(arg, argValues.value[arg.name])
  302. })
  303. }
  304. return ret
  305. }
  306. function getUniqueId() {
  307. if (window.isSecureContext) {
  308. return window.crypto.randomUUID()
  309. } else {
  310. return Date.now().toString()
  311. }
  312. }
  313. function getBrowserSuggestions(arg) {
  314. if (!arg.suggestionsBrowserKey) {
  315. return []
  316. }
  317. try {
  318. const stored = localStorage.getItem(`olivetin-suggestions-${arg.suggestionsBrowserKey}`)
  319. if (stored) {
  320. const suggestions = JSON.parse(stored)
  321. return Array.isArray(suggestions) ? suggestions : []
  322. }
  323. } catch (err) {
  324. console.warn('Failed to load browser suggestions:', err)
  325. }
  326. return []
  327. }
  328. function saveBrowserSuggestions() {
  329. for (const arg of actionArguments.value) {
  330. if (arg.suggestionsBrowserKey) {
  331. const value = argValues.value[arg.name]
  332. // Only save non-empty values for non-checkbox/confirmation/password types
  333. if (value && value !== '' && arg.type !== 'checkbox' && arg.type !== 'confirmation' && arg.type !== 'password') {
  334. try {
  335. const key = `olivetin-suggestions-${arg.suggestionsBrowserKey}`
  336. const stored = localStorage.getItem(key)
  337. let suggestions = []
  338. if (stored) {
  339. suggestions = JSON.parse(stored)
  340. if (!Array.isArray(suggestions)) {
  341. suggestions = []
  342. }
  343. }
  344. // Add value if not already present
  345. if (!suggestions.includes(value)) {
  346. suggestions.unshift(value) // Add to beginning
  347. // Keep only the most recent 50 suggestions
  348. if (suggestions.length > 50) {
  349. suggestions = suggestions.slice(0, 50)
  350. }
  351. localStorage.setItem(key, JSON.stringify(suggestions))
  352. }
  353. } catch (err) {
  354. console.warn('Failed to save browser suggestions:', err)
  355. }
  356. }
  357. }
  358. }
  359. }
  360. async function startAction(actionArgs) {
  361. const startActionArgs = {
  362. bindingId: props.bindingId,
  363. arguments: actionArgs,
  364. uniqueTrackingId: getUniqueId()
  365. }
  366. if (justificationRequired.value) {
  367. startActionArgs.justification = justificationValue.value
  368. }
  369. try {
  370. requestReconnectNow()
  371. const response = await window.client.startAction(startActionArgs)
  372. console.log('Action started successfully with tracking ID:', response.executionTrackingId)
  373. return response
  374. } catch (err) {
  375. console.error('Failed to start action:', err)
  376. throw err
  377. }
  378. }
  379. async function handleSubmit(event) {
  380. event.preventDefault()
  381. if (!formReady.value) {
  382. return
  383. }
  384. if (popupOnStart.value === 'history') {
  385. router.push(`/action/${props.bindingId}`)
  386. return
  387. }
  388. // Set custom validity for required fields
  389. if (justificationRequired.value && (!justificationValue.value || justificationValue.value.trim() === '')) {
  390. const inputElement = document.getElementById('justification')
  391. if (inputElement) {
  392. inputElement.setCustomValidity('This field is required')
  393. }
  394. }
  395. for (const arg of actionArguments.value) {
  396. const value = argValues.value[arg.name]
  397. const inputElement = document.getElementById(arg.name)
  398. if (arg.required && (!value || value === '')) {
  399. formErrors.value[arg.name] = 'This field is required'
  400. // Set custom validity for required field validation
  401. if (inputElement) {
  402. inputElement.setCustomValidity('This field is required')
  403. }
  404. }
  405. }
  406. const form = event.target
  407. if (!form.checkValidity()) {
  408. console.log('argument form has elements that failed validation')
  409. return
  410. }
  411. const argvs = getArgumentValues()
  412. console.log('argument form has elements that passed validation')
  413. // Save values to localStorage for arguments with suggestionsBrowserKey
  414. saveBrowserSuggestions()
  415. try {
  416. const response = await startAction(argvs)
  417. if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
  418. router.push(`/logs/${response.executionTrackingId}`)
  419. } else {
  420. router.back()
  421. }
  422. } catch (err) {
  423. console.error('Failed to start action:', err)
  424. }
  425. }
  426. function handleCancel() {
  427. router.back()
  428. clearBookmark()
  429. }
  430. function clearBookmark() {
  431. window.history.replaceState({
  432. path: window.location.pathname
  433. }, '', window.location.pathname)
  434. }
  435. function show() {
  436. if (dialog.value) {
  437. dialog.value.showModal()
  438. }
  439. }
  440. function close() {
  441. if (dialog.value) {
  442. dialog.value.close()
  443. }
  444. }
  445. // Expose methods for parent components
  446. defineExpose({
  447. show,
  448. close
  449. })
  450. // Lifecycle
  451. onMounted(() => {
  452. setup()
  453. })
  454. onBeforeUnmount(() => {
  455. isComponentMounted = false
  456. })
  457. onUnmounted(() => {
  458. document.body.removeAttribute('loaded-argument-form')
  459. })
  460. </script>
  461. <style scoped>
  462. form {
  463. grid-template-columns: max-content auto auto;
  464. }
  465. .argument-description {
  466. font-size: 0.875rem;
  467. color: #666;
  468. margin-top: 0.25rem;
  469. }
  470. .buttons {
  471. display: flex;
  472. gap: 0.5rem;
  473. justify-content: flex-end;
  474. padding-top: 1rem;
  475. border-top: 1px solid #eee;
  476. }
  477. /* Checkbox specific styling */
  478. .argument-group input[type="checkbox"] {
  479. width: auto;
  480. margin-right: 0.5rem;
  481. }
  482. .argument-group input[type="checkbox"]+label {
  483. display: inline;
  484. font-weight: normal;
  485. }
  486. </style>