ArgumentForm.vue 17 KB

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