ArgumentForm.vue 16 KB

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