@revenexx/studio-integrations 1.4.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +297 -0
  2. package/README.md +2 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -6
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +134 -131
  6. package/dist/runtime/components/Integrations/CredentialLogo.vue +1 -1
  7. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +1 -1
  8. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +6 -6
  9. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +2 -0
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue +90 -11
  11. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +2 -0
  12. package/dist/runtime/components/Integrations/GroupFrameView.vue +121 -121
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue +20 -5
  14. package/dist/runtime/components/Integrations/NodeInspector.vue +44 -41
  15. package/dist/runtime/components/Integrations/NodePalette.vue +52 -37
  16. package/dist/runtime/components/Integrations/RunActionsMenu.vue +6 -5
  17. package/dist/runtime/components/Integrations/RunDetailPanel.d.vue.ts +7 -0
  18. package/dist/runtime/components/Integrations/RunDetailPanel.vue +14 -2
  19. package/dist/runtime/components/Integrations/RunDetailPanel.vue.d.ts +7 -0
  20. package/dist/runtime/components/Integrations/RunsTable.vue +72 -70
  21. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +20 -6
  22. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -0
  23. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +10 -6
  24. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +0 -34
  25. package/dist/runtime/components/Integrations/WorkflowEditor.vue +226 -277
  26. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +0 -34
  27. package/dist/runtime/components/Integrations/WorkflowExportDialog.d.vue.ts +6 -0
  28. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue +71 -0
  29. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue.d.ts +6 -0
  30. package/dist/runtime/components/Integrations/WorkflowImportFileStep.d.vue.ts +8 -0
  31. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue +95 -0
  32. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue.d.ts +8 -0
  33. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +5 -4
  34. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +0 -7
  35. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +2 -2
  36. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +2 -3
  37. package/dist/runtime/components/Integrations/WorkflowsTable.vue +14 -8
  38. package/dist/runtime/composables/useIntegrationsNodes.js +4 -1
  39. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +3 -1
  40. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -22
  41. package/dist/runtime/composables/useNow.d.ts +17 -0
  42. package/dist/runtime/composables/useNow.js +16 -0
  43. package/dist/runtime/composables/useWorkflowFile.d.ts +21 -0
  44. package/dist/runtime/composables/useWorkflowFile.js +63 -0
  45. package/dist/runtime/data/expressionEditor.d.ts +11 -1
  46. package/dist/runtime/data/expressionEditor.js +2 -1
  47. package/dist/runtime/data/nodeIcons.d.ts +14 -0
  48. package/dist/runtime/data/nodeIcons.js +57 -14
  49. package/dist/runtime/data/nodeRunData.js +1 -1
  50. package/dist/runtime/data/payloadForm.js +1 -1
  51. package/dist/runtime/i18n/messages/de.json +47 -11
  52. package/dist/runtime/i18n/messages/en.json +47 -11
  53. package/dist/runtime/pages/integrations/audit.vue +3 -3
  54. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +2 -2
  55. package/dist/runtime/pages/integrations/credentials.vue +17 -5
  56. package/dist/runtime/pages/integrations/index.vue +5 -10
  57. package/dist/runtime/pages/integrations/schedules.vue +24 -13
  58. package/dist/runtime/pages/integrations/templates/index.vue +40 -40
  59. package/dist/runtime/pages/integrations/workflows/[id].vue +324 -324
  60. package/dist/runtime/types/integrations.d.ts +13 -0
  61. package/dist/runtime/utils/cockpitSources.d.ts +68 -1
  62. package/dist/runtime/utils/credentialState.d.ts +15 -7
  63. package/dist/runtime/utils/credentialState.js +4 -12
  64. package/dist/runtime/utils/localize.d.ts +12 -0
  65. package/dist/runtime/utils/localize.js +1 -1
  66. package/dist/runtime/utils/paletteOrder.d.ts +29 -0
  67. package/dist/runtime/utils/paletteOrder.js +46 -0
  68. package/dist/runtime/utils/relativeTime.d.ts +29 -0
  69. package/dist/runtime/utils/relativeTime.js +23 -0
  70. package/dist/runtime/utils/runFeedQuery.d.ts +12 -6
  71. package/dist/runtime/utils/runFeedQuery.js +3 -18
  72. package/dist/runtime/utils/settingValue.d.ts +33 -0
  73. package/dist/runtime/utils/settingValue.js +8 -0
  74. package/dist/runtime/utils/statusStyle.d.ts +8 -8
  75. package/dist/runtime/utils/statusStyle.js +4 -1
  76. package/dist/runtime/utils/workflowFile.d.ts +119 -0
  77. package/dist/runtime/utils/workflowFile.js +192 -0
  78. package/package.json +10 -5
  79. package/dist/runtime/utils/errorPathAdvice.d.ts +0 -60
  80. package/dist/runtime/utils/errorPathAdvice.js +0 -19
@@ -31,7 +31,9 @@ import {
31
31
  } from "@revenexx/studio";
32
32
  import { toast } from "../../utils/notify";
33
33
  import { apiRefusal, errorReason } from "../../utils/apiError";
34
- import { credentialState, relativeTime } from "../../utils/credentialState";
34
+ import { credentialState } from "../../utils/credentialState";
35
+ import { relativeTime } from "../../utils/relativeTime";
36
+ import { useNow } from "../../composables/useNow";
35
37
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
36
38
  import { resolveLocalized } from "../../utils/localize";
37
39
  const props = defineProps({
@@ -41,6 +43,7 @@ const props = defineProps({
41
43
  });
42
44
  const emit = defineEmits(["update:open", "changed"]);
43
45
  const t = useIntegrationsT();
46
+ const now = useNow();
44
47
  const { update, remove, test, oauthAuthorizeUrl, load } = useIntegrationsCredentials();
45
48
  const tab = ref("credentials");
46
49
  const tabs = computed(() => [
@@ -204,167 +207,167 @@ async function connect() {
204
207
  scrolling pane and the block below it are siblings, as they were when the
205
208
  pane was the whole tab, so nothing about the spacing around them moves. -->
206
209
  <template v-if="tab === 'credentials'">
207
- <div class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
208
- <!-- Connection state — the headline answer: are these credentials correct? -->
209
- <div class="rounded-lg border p-3" :class="state.toneClass">
210
- <div class="flex items-center gap-2">
211
- <CheckCircle2 v-if="state.key === 'connected'" :size="16" class="shrink-0" />
212
- <XCircle v-else-if="state.key === 'failed'" :size="16" class="shrink-0" />
213
- <span class="font-medium">{{ state.label }}</span>
214
- <span v-if="state.at" class="text-sm opacity-70">· verified {{ relativeTime(state.at) }}</span>
215
- <div class="flex-1" />
216
- <Button
217
- v-if="isOAuthAuthcode"
218
- size="sm"
219
- variant="outline"
220
- class="bg-white/60"
221
- :disabled="connecting"
222
- @click="connect"
223
- >
224
- <Loader2 v-if="connecting" :size="13" class="mr-1 animate-spin" />
225
- {{ t("integration.credentials.detail.action.reconnect") }}
226
- </Button>
227
- </div>
228
- <!-- The stored sentence, and only while nothing fresher is on screen. The
210
+ <div class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
211
+ <!-- Connection state — the headline answer: are these credentials correct? -->
212
+ <div class="rounded-lg border p-3" :class="state.toneClass">
213
+ <div class="flex items-center gap-2">
214
+ <CheckCircle2 v-if="state.key === 'connected'" :size="16" class="shrink-0" />
215
+ <XCircle v-else-if="state.key === 'failed'" :size="16" class="shrink-0" />
216
+ <span class="font-medium">{{ state.label }}</span>
217
+ <span v-if="state.at" class="text-sm opacity-70">· verified {{ relativeTime(state.at, now) }}</span>
218
+ <div class="flex-1" />
219
+ <Button
220
+ v-if="isOAuthAuthcode"
221
+ size="sm"
222
+ variant="outline"
223
+ class="bg-white/60"
224
+ :disabled="connecting"
225
+ @click="connect"
226
+ >
227
+ <Loader2 v-if="connecting" :size="13" class="mr-1 animate-spin" />
228
+ {{ t("integration.credentials.detail.action.reconnect") }}
229
+ </Button>
230
+ </div>
231
+ <!-- The stored sentence, and only while nothing fresher is on screen. The
229
232
  live box below shows the result of a test just run, and the reload that
230
233
  follows it puts the very same sentence here — so both would print it
231
234
  twice, and past the length the platform truncates at, twice differently.
232
235
  The freshly returned one wins, being whole (specs/credential-test.md
233
236
  AC-1). -->
234
- <p v-if="state.message && !liveTest" class="mt-1 text-sm break-words opacity-90">
235
- {{ state.message }}
236
- </p>
237
- <p v-else-if="state.key === 'untested'" class="mt-1 text-sm opacity-80">
238
- {{ t("integration.credentials.detail.state.untestedHint") }}
239
- </p>
240
- </div>
237
+ <p v-if="state.message && !liveTest" class="mt-1 text-sm break-words opacity-90">
238
+ {{ state.message }}
239
+ </p>
240
+ <p v-else-if="state.key === 'untested'" class="mt-1 text-sm opacity-80">
241
+ {{ t("integration.credentials.detail.state.untestedHint") }}
242
+ </p>
243
+ </div>
241
244
 
242
- <!-- Editable fields -->
243
- <div class="space-y-3">
244
- <div class="space-y-1.5">
245
- <Label for="detail-cred-name">{{ t("integration.credentials.shared.field.name.label") }}</Label>
246
- <Input
247
- id="detail-cred-name"
248
- v-model="form.name"
249
- class="bg-white"
250
- :aria-invalid="fieldErrors.name ? 'true' : void 0"
251
- :aria-describedby="fieldErrors.name ? 'detail-cred-name-error' : void 0"
252
- />
253
- <p v-if="fieldErrors.name" id="detail-cred-name-error" class="text-sm text-rose-700">
254
- {{ fieldErrors.name }}
255
- </p>
256
- </div>
245
+ <!-- Editable fields -->
246
+ <div class="space-y-3">
247
+ <div class="space-y-1.5">
248
+ <Label for="detail-cred-name">{{ t("integration.credentials.shared.field.name.label") }}</Label>
249
+ <Input
250
+ id="detail-cred-name"
251
+ v-model="form.name"
252
+ class="bg-white"
253
+ :aria-invalid="fieldErrors.name ? 'true' : void 0"
254
+ :aria-describedby="fieldErrors.name ? 'detail-cred-name-error' : void 0"
255
+ />
256
+ <p v-if="fieldErrors.name" id="detail-cred-name-error" class="text-sm text-rose-700">
257
+ {{ fieldErrors.name }}
258
+ </p>
259
+ </div>
257
260
 
258
- <!-- "Use revenexx …" source picker (SFTP / Mail / API key) — mounted
261
+ <!-- "Use revenexx …" source picker (SFTP / Mail / API key) — mounted
259
262
  only for the types it serves, which is tidiness rather than
260
263
  protection: it renders nothing for any other type anyway. It used
261
264
  to be protection, because the component resolved cockpit-only
262
265
  sources at setup and mounting it elsewhere took this form down;
263
266
  since PO-222 it asks instead (`utils/cockpitSources.ts`). -->
264
- <IntegrationsCredentialRevenexxSource
265
- v-if="hasRevenexxSource(type?.slug)"
266
- :type="type"
267
- @update:active="(v) => {
267
+ <IntegrationsCredentialRevenexxSource
268
+ v-if="hasRevenexxSource(type?.slug)"
269
+ :type="type"
270
+ @update:active="(v) => {
268
271
  sourceActive = v;
269
272
  clearRefusal();
270
273
  }"
271
- @apply="(c) => Object.assign(form.config, c)"
272
- />
274
+ @apply="(c) => Object.assign(form.config, c)"
275
+ />
273
276
 
274
- <!-- Each label names its own control (`for`/`id`): without it a
277
+ <!-- Each label names its own control (`for`/`id`): without it a
275
278
  screen reader reads the field as unlabelled, and the label text
276
279
  cannot be used to reach it at all. -->
277
- <div v-for="f in fieldsOf(type)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
278
- <Label :for="`detail-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
280
+ <div v-for="f in fieldsOf(type)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
281
+ <Label :for="`detail-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
279
282
 
280
- <Input
281
- v-if="f.type === 'secret'"
282
- :id="`detail-field-${f.key}`"
283
- :model-value="form.config[f.key] ?? ''"
284
- type="password"
285
- autocomplete="new-password"
286
- :placeholder="t('integration.credentials.shared.field.secret.placeholder')"
287
- class="bg-white"
288
- v-bind="configFieldAria(f.key)"
289
- @update:model-value="(v) => form.config[f.key] = v"
290
- />
291
- <Input
292
- v-else-if="f.type === 'number'"
293
- :id="`detail-field-${f.key}`"
294
- type="number"
295
- :model-value="form.config[f.key] ?? ''"
296
- class="bg-white"
297
- v-bind="configFieldAria(f.key)"
298
- @update:model-value="(v) => form.config[f.key] = v === '' ? void 0 : Number(v)"
299
- />
300
- <div v-else-if="f.type === 'boolean'" class="flex items-center gap-2 pt-1">
301
- <Switch :id="`detail-field-${f.key}`" :model-value="Boolean(form.config[f.key])" v-bind="configFieldAria(f.key)" @update:model-value="(v) => form.config[f.key] = v" />
302
- </div>
303
- <Select
304
- v-else-if="f.type === 'select'"
305
- :model-value="form.config[f.key] ?? ''"
306
- @update:model-value="(v) => form.config[f.key] = v"
307
- >
308
- <SelectTrigger :id="`detail-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
309
- <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
310
- </SelectTrigger>
311
- <SelectContent>
312
- <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
313
- {{ resolveLocalized(opt.label) }}
314
- </SelectItem>
315
- </SelectContent>
316
- </Select>
317
- <Input
318
- v-else
319
- :id="`detail-field-${f.key}`"
320
- :model-value="form.config[f.key] ?? ''"
321
- class="bg-white"
322
- v-bind="configFieldAria(f.key)"
323
- @update:model-value="(v) => form.config[f.key] = v"
324
- />
283
+ <Input
284
+ v-if="f.type === 'secret'"
285
+ :id="`detail-field-${f.key}`"
286
+ :model-value="form.config[f.key] ?? ''"
287
+ type="password"
288
+ autocomplete="new-password"
289
+ :placeholder="t('integration.credentials.shared.field.secret.placeholder')"
290
+ class="bg-white"
291
+ v-bind="configFieldAria(f.key)"
292
+ @update:model-value="(v) => form.config[f.key] = v"
293
+ />
294
+ <Input
295
+ v-else-if="f.type === 'number'"
296
+ :id="`detail-field-${f.key}`"
297
+ type="number"
298
+ :model-value="form.config[f.key] ?? ''"
299
+ class="bg-white"
300
+ v-bind="configFieldAria(f.key)"
301
+ @update:model-value="(v) => form.config[f.key] = v === '' ? void 0 : Number(v)"
302
+ />
303
+ <div v-else-if="f.type === 'boolean'" class="flex items-center gap-2 pt-1">
304
+ <Switch :id="`detail-field-${f.key}`" :model-value="Boolean(form.config[f.key])" v-bind="configFieldAria(f.key)" @update:model-value="(v) => form.config[f.key] = v" />
305
+ </div>
306
+ <Select
307
+ v-else-if="f.type === 'select'"
308
+ :model-value="form.config[f.key] ?? ''"
309
+ @update:model-value="(v) => form.config[f.key] = v"
310
+ >
311
+ <SelectTrigger :id="`detail-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
312
+ <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
313
+ </SelectTrigger>
314
+ <SelectContent>
315
+ <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
316
+ {{ resolveLocalized(opt.label) }}
317
+ </SelectItem>
318
+ </SelectContent>
319
+ </Select>
320
+ <Input
321
+ v-else
322
+ :id="`detail-field-${f.key}`"
323
+ :model-value="form.config[f.key] ?? ''"
324
+ class="bg-white"
325
+ v-bind="configFieldAria(f.key)"
326
+ @update:model-value="(v) => form.config[f.key] = v"
327
+ />
325
328
 
326
- <p v-if="fieldErrors[`config.${f.key}`]" :id="`detail-field-${f.key}-error`" class="text-sm text-rose-700">
327
- {{ fieldErrors[`config.${f.key}`] }}
328
- </p>
329
- </div>
329
+ <p v-if="fieldErrors[`config.${f.key}`]" :id="`detail-field-${f.key}-error`" class="text-sm text-rose-700">
330
+ {{ fieldErrors[`config.${f.key}`] }}
331
+ </p>
332
+ </div>
330
333
 
331
- <!-- Live test feedback (from the test the operator just ran) -->
332
- <div
333
- v-if="liveTest"
334
- class="rounded-md px-3 py-2 text-sm"
335
- :class="liveTest.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
336
- >
337
- {{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
338
- </div>
334
+ <!-- Live test feedback (from the test the operator just ran) -->
335
+ <div
336
+ v-if="liveTest"
337
+ class="rounded-md px-3 py-2 text-sm"
338
+ :class="liveTest.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
339
+ >
340
+ {{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
341
+ </div>
339
342
 
340
- </div>
343
+ </div>
341
344
 
342
- </div>
345
+ </div>
343
346
 
344
- <!-- Outside the scrolling pane on purpose, the way the create dialog has it:
347
+ <!-- Outside the scrolling pane on purpose, the way the create dialog has it:
345
348
  a refusal puts the operator in the first marked field, which scrolls that
346
349
  pane back to the top — and a summary rendered at the end of the form would
347
350
  be carried out of sight by the very focus meant to make the refusal
348
351
  visible. What is marked at a field is not repeated here; what has no field
349
352
  to be marked at would otherwise be silent. -->
350
- <div v-if="summaryReasons.length" role="alert" class="mt-3 space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
351
- <!-- Keyed by position: two undrawn keys can be refused with the same
353
+ <div v-if="summaryReasons.length" role="alert" class="mt-3 space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
354
+ <!-- Keyed by position: two undrawn keys can be refused with the same
352
355
  sentence, and a duplicate key is how one of them goes unrendered. -->
353
- <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
354
- </div>
356
+ <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
357
+ </div>
355
358
 
356
- <div class="flex items-center gap-2 pt-3">
357
- <div class="flex-1" />
358
- <Button variant="outline" :disabled="testing || saving" @click="runTest">
359
- <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
360
- <TestIcon v-else :size="14" class="mr-1" />
361
- {{ t("integration.credentials.shared.action.testConnection") }}
362
- </Button>
363
- <Button :disabled="saving || !form.name.trim()" @click="save">
364
- <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
365
- {{ t("integration.common.action.save") }}
366
- </Button>
367
- </div>
359
+ <div class="flex items-center gap-2 pt-3">
360
+ <div class="flex-1" />
361
+ <Button variant="outline" :disabled="testing || saving" @click="runTest">
362
+ <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
363
+ <TestIcon v-else :size="14" class="mr-1" />
364
+ {{ t("integration.credentials.shared.action.testConnection") }}
365
+ </Button>
366
+ <Button :disabled="saving || !form.name.trim()" @click="save">
367
+ <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
368
+ {{ t("integration.common.action.save") }}
369
+ </Button>
370
+ </div>
368
371
  </template>
369
372
 
370
373
  <!-- Tab 2: Details -->
@@ -5,7 +5,7 @@ const props = defineProps({
5
5
  type: { type: [Object, null], required: false },
6
6
  size: { type: Number, required: false }
7
7
  });
8
- const box = computed(() => (props.size ?? 16) + 16);
8
+ const box = computed(() => (props.size ?? 16) + 8);
9
9
  const glyph = computed(() => props.size ?? 16);
10
10
  const logoSrc = computed(() => {
11
11
  const imgs = props.type?.images;
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, reactive, ref, useId, watch } from "vue";
2
+ import { computed, ref, useId, watch } from "vue";
3
3
  import {
4
4
  AddCircle as Plus,
5
5
  CheckCircle as Check,
@@ -51,16 +51,16 @@ const filtered = computed(() => {
51
51
  {{ t("integration.credentials.typePicker.empty", { query }) }}
52
52
  </p>
53
53
  <button
54
- v-for="t in filtered"
55
- :key="t.slug"
54
+ v-for="type in filtered"
55
+ :key="type.slug"
56
56
  type="button"
57
57
  class="flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left transition hover:bg-muted/50"
58
- @click="emit('pick', t)"
58
+ @click="emit('pick', type)"
59
59
  >
60
- <IntegrationsCredentialLogo :type="t" :size="16" />
60
+ <IntegrationsCredentialLogo :type="type" :size="16" />
61
61
  <div class="min-w-0 flex-1 truncate text-sm">
62
- <span class="font-medium">{{ resolveLocalized(t.name) || t.slug }}</span>
63
- <span class="ml-1.5 text-muted-foreground">({{ authKindLabel(t.auth_kind) }})</span>
62
+ <span class="font-medium">{{ resolveLocalized(type.name) || type.slug }}</span>
63
+ <span class="ml-1.5 text-muted-foreground">({{ authKindLabel(type.auth_kind) }})</span>
64
64
  </div>
65
65
  </button>
66
66
  </div>
@@ -21,7 +21,9 @@ type __VLS_Props = {
21
21
  };
22
22
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
23
23
  "update:modelValue": (value: string) => any;
24
+ blur: () => any;
24
25
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
25
26
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
27
+ onBlur?: (() => any) | undefined;
26
28
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
27
29
  export default _default;
@@ -1,13 +1,15 @@
1
1
  <script setup>
2
2
  import { computed, inject, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
3
3
  import { Editor, EditorContent } from "@tiptap/vue-3";
4
- import { Node } from "@tiptap/core";
4
+ import { Mark, Node } from "@tiptap/core";
5
+ import { NodeSelection, TextSelection } from "@tiptap/pm/state";
6
+ import { isHistoryTransaction } from "@tiptap/pm/history";
5
7
  import Document from "@tiptap/extension-document";
6
8
  import Paragraph from "@tiptap/extension-paragraph";
7
9
  import Text from "@tiptap/extension-text";
8
10
  import History from "@tiptap/extension-history";
9
11
  import { resolveExpressionText } from "../../data/nodeRunData";
10
- import { EXPR_TOKEN_NODE, docToText, hasRawTokenText, textToDoc } from "../../data/expressionEditor";
12
+ import { EXPR_EDITING_MARK, EXPR_TOKEN_NODE, docToText, hasRawTokenText, textToDoc, textToInlineContent } from "../../data/expressionEditor";
11
13
  const props = defineProps({
12
14
  modelValue: { type: null, required: true },
13
15
  placeholder: { type: String, required: false },
@@ -17,7 +19,7 @@ const props = defineProps({
17
19
  label: { type: String, required: false },
18
20
  multiline: { type: Boolean, required: false }
19
21
  });
20
- const emit = defineEmits(["update:modelValue"]);
22
+ const emit = defineEmits(["update:modelValue", "blur"]);
21
23
  const t = useIntegrationsT();
22
24
  const text = computed(() => String(props.modelValue ?? ""));
23
25
  const OPEN = "${{ ";
@@ -68,19 +70,33 @@ const ExprToken = Node.create({
68
70
  return ["span", { "data-expr-token": String(node.attrs.token) }, String(node.attrs.token)];
69
71
  },
70
72
  addNodeView() {
71
- return ({ node }) => {
73
+ return ({ node, getPos }) => {
72
74
  const dom = document.createElement("span");
73
75
  dom.className = "expr-pill";
74
76
  dom.textContent = tokenLabel(String(node.attrs.token));
75
77
  dom.title = String(node.attrs.token);
78
+ dom.addEventListener("mousedown", (e) => {
79
+ if (e.button !== 0 || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
80
+ const pos = getPos();
81
+ if (pos !== void 0 && onPillMousedown(pos)) e.preventDefault();
82
+ });
76
83
  return { dom };
77
84
  };
78
85
  }
79
86
  });
87
+ const ExprEditing = Mark.create({
88
+ name: EXPR_EDITING_MARK,
89
+ inclusive: true,
90
+ // No `parseHTML`: the mark is only ever made by `openPill`. Parsed back, a copy
91
+ // of opened text would paste as text nobody opened, left unpilled.
92
+ renderHTML() {
93
+ return ["span", { "data-expr-editing": "", class: "expr-editing" }, 0];
94
+ }
95
+ });
80
96
  const lineMode = () => ({ multiline: props.multiline });
81
97
  function editableAttributes() {
82
98
  return {
83
- "role": "textbox",
99
+ role: "textbox",
84
100
  ...props.multiline ? { "aria-multiline": "true" } : {},
85
101
  ...props.label ? { "aria-label": props.label } : {}
86
102
  };
@@ -94,13 +110,66 @@ function endOfLine(line) {
94
110
  });
95
111
  return pos;
96
112
  }
113
+ function openPill(pos, node) {
114
+ if (node.type.name !== EXPR_TOKEN_NODE) return false;
115
+ const { state, dispatch } = editor.view;
116
+ const token = String(node.attrs.token);
117
+ const textNode = state.schema.text(token, [state.schema.marks[EXPR_EDITING_MARK].create()]);
118
+ const tr = state.tr.replaceWith(pos, pos + node.nodeSize, textNode);
119
+ tr.setSelection(TextSelection.create(tr.doc, pos + token.length));
120
+ dispatch(tr);
121
+ editor.commands.focus();
122
+ return true;
123
+ }
124
+ function onPillMousedown(pos) {
125
+ const view = editor.view;
126
+ const sel = view.state.selection;
127
+ if (sel instanceof NodeSelection && sel.from === pos) return openPill(pos, sel.node);
128
+ view.focus();
129
+ view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)));
130
+ return true;
131
+ }
132
+ function editingRanges() {
133
+ const ranges = [];
134
+ editor.state.doc.descendants((node, pos) => {
135
+ if (node.isText && node.marks.some((m) => m.type.name === EXPR_EDITING_MARK)) {
136
+ ranges.push({ from: pos, to: pos + node.nodeSize });
137
+ }
138
+ });
139
+ return ranges;
140
+ }
141
+ function closeEditing() {
142
+ if (editor.isDestroyed) return;
143
+ const ranges = editingRanges();
144
+ if (!ranges.length) return;
145
+ const { state, dispatch } = editor.view;
146
+ const tr = state.tr;
147
+ for (const r of ranges.reverse()) {
148
+ const raw = state.doc.textBetween(r.from, r.to);
149
+ tr.replaceWith(r.from, r.to, textToInlineContent(raw).map((n) => state.schema.nodeFromJSON(n)));
150
+ }
151
+ dispatch(tr);
152
+ }
97
153
  const editor = new Editor({
98
- extensions: [Document, Paragraph, Text, History, ExprToken],
154
+ extensions: [Document, Paragraph, Text, History, ExprToken, ExprEditing],
99
155
  content: textToDoc(text.value, lineMode()),
100
156
  editorProps: {
101
- // Single line: swallow Enter (paragraph splits would break the field). A
102
- // multiline field wants exactly that split, so it lets the key through.
103
- handleKeyDown: (_view, event) => !props.multiline && event.key === "Enter" && !event.isComposing,
157
+ // A selected pill is not replaced by what is typed: a click on it selects it
158
+ // (`onPillMousedown`), and a click into the field followed by typing used to
159
+ // put text beside the value, never over it. Deleting it is Backspace's job.
160
+ handleTextInput: (view, _from, _to, input) => {
161
+ const sel = view.state.selection;
162
+ if (!(sel instanceof NodeSelection) || sel.node.type.name !== EXPR_TOKEN_NODE) return false;
163
+ const tr = view.state.tr.insertText(input, sel.to);
164
+ view.dispatch(tr.setSelection(TextSelection.create(tr.doc, sel.to + input.length)));
165
+ return true;
166
+ },
167
+ handleKeyDown: (view, event) => {
168
+ if (event.isComposing || event.key !== "Enter") return false;
169
+ const sel = view.state.selection;
170
+ if (sel instanceof NodeSelection && openPill(sel.from, sel.node)) return true;
171
+ return !props.multiline;
172
+ },
104
173
  attributes: editableAttributes()
105
174
  },
106
175
  onUpdate({ editor: ed }) {
@@ -114,9 +183,19 @@ const editor = new Editor({
114
183
  }
115
184
  if (raw !== text.value) emit("update:modelValue", raw);
116
185
  },
186
+ onSelectionUpdate({ editor: ed, transaction }) {
187
+ if (isHistoryTransaction(transaction)) return;
188
+ const { from, to } = ed.state.selection;
189
+ const ranges = editingRanges();
190
+ if (ranges.length && !ranges.some((r) => from >= r.from && to <= r.to)) closeEditing();
191
+ },
117
192
  onFocus() {
118
193
  if (insertHandler) insertHandler.value = insertToken;
119
194
  if (hasSuggestions.value) open.value = true;
195
+ },
196
+ onBlur() {
197
+ closeEditing();
198
+ emit("blur");
120
199
  }
121
200
  });
122
201
  onBeforeUnmount(() => {
@@ -280,9 +359,9 @@ function insert(expr) {
280
359
  v-if="listOpen"
281
360
  :id="pickerId"
282
361
  role="group"
283
- @keydown="onPickerKeydown"
284
362
  :aria-label="t('integration.nodeConfig.expression.picker.aria')"
285
363
  class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
364
+ @keydown="onPickerKeydown"
286
365
  >
287
366
  <div class="shrink-0 border-b border-black/[0.06] p-1">
288
367
  <input
@@ -322,5 +401,5 @@ function insert(expr) {
322
401
  </template>
323
402
 
324
403
  <style>
325
- .expr-editor .ProseMirror{align-items:center;display:flex;height:100%;outline:none;overflow-x:auto;scrollbar-width:none}.expr-editor .ProseMirror p{line-height:30px;margin:0;white-space:pre}.expr-editor.expr-multiline{--expr-line:1.375rem;max-height:calc(var(--expr-line)*10 + .5rem);min-height:calc(var(--expr-line)*4 + .5rem);overflow-x:hidden;overflow-y:auto;padding-bottom:.25rem;padding-top:.25rem}.expr-editor.expr-multiline .ProseMirror{display:block;height:auto;overflow-x:visible}.expr-editor.expr-multiline .ProseMirror p{line-height:var(--expr-line);white-space:pre-wrap}.expr-pill{align-items:center;background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.25);border-radius:6px;color:#047857;display:inline-flex;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;height:20px;line-height:1;margin:0 2px;padding:0 7px;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.expr-pill.ProseMirror-selectednode{outline:2px solid rgba(16,185,129,.5)}
404
+ .expr-editor .ProseMirror{align-items:center;display:flex;height:100%;outline:none;overflow-x:auto;scrollbar-width:none}.expr-editor .ProseMirror p{line-height:30px;margin:0;white-space:pre}.expr-editor.expr-multiline{--expr-line:1.375rem;max-height:calc(var(--expr-line)*10 + .5rem);min-height:calc(var(--expr-line)*4 + .5rem);overflow-x:hidden;overflow-y:auto;padding-bottom:.25rem;padding-top:.25rem}.expr-editor.expr-multiline .ProseMirror{display:block;height:auto;overflow-x:visible}.expr-editor.expr-multiline .ProseMirror p{line-height:var(--expr-line);white-space:pre-wrap}.expr-pill{align-items:center;background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.25);border-radius:6px;color:#047857;cursor:text;display:inline-flex;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;height:20px;line-height:1;margin:0 2px;padding:0 7px;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.expr-editing{background:rgba(16,185,129,.08);border-radius:4px;color:#047857;font-family:ui-monospace,monospace}.expr-pill.ProseMirror-selectednode{outline:2px solid rgba(16,185,129,.5)}
326
405
  </style>
@@ -21,7 +21,9 @@ type __VLS_Props = {
21
21
  };
22
22
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
23
23
  "update:modelValue": (value: string) => any;
24
+ blur: () => any;
24
25
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
25
26
  "onUpdate:modelValue"?: ((value: string) => any) | undefined;
27
+ onBlur?: (() => any) | undefined;
26
28
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
27
29
  export default _default;