@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.
- package/CHANGELOG.md +297 -0
- package/README.md +2 -1
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -6
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +134 -131
- package/dist/runtime/components/Integrations/CredentialLogo.vue +1 -1
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +1 -1
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +6 -6
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +2 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +90 -11
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +2 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +121 -121
- package/dist/runtime/components/Integrations/NodeConfigField.vue +20 -5
- package/dist/runtime/components/Integrations/NodeInspector.vue +44 -41
- package/dist/runtime/components/Integrations/NodePalette.vue +52 -37
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +6 -5
- package/dist/runtime/components/Integrations/RunDetailPanel.d.vue.ts +7 -0
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +14 -2
- package/dist/runtime/components/Integrations/RunDetailPanel.vue.d.ts +7 -0
- package/dist/runtime/components/Integrations/RunsTable.vue +72 -70
- package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +20 -6
- package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -0
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +10 -6
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +0 -34
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +226 -277
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +0 -34
- package/dist/runtime/components/Integrations/WorkflowExportDialog.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowExportDialog.vue +71 -0
- package/dist/runtime/components/Integrations/WorkflowExportDialog.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowImportFileStep.d.vue.ts +8 -0
- package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue +95 -0
- package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue.d.ts +8 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +5 -4
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +0 -7
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +2 -2
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +2 -3
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +14 -8
- package/dist/runtime/composables/useIntegrationsNodes.js +4 -1
- package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +3 -1
- package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -22
- package/dist/runtime/composables/useNow.d.ts +17 -0
- package/dist/runtime/composables/useNow.js +16 -0
- package/dist/runtime/composables/useWorkflowFile.d.ts +21 -0
- package/dist/runtime/composables/useWorkflowFile.js +63 -0
- package/dist/runtime/data/expressionEditor.d.ts +11 -1
- package/dist/runtime/data/expressionEditor.js +2 -1
- package/dist/runtime/data/nodeIcons.d.ts +14 -0
- package/dist/runtime/data/nodeIcons.js +57 -14
- package/dist/runtime/data/nodeRunData.js +1 -1
- package/dist/runtime/data/payloadForm.js +1 -1
- package/dist/runtime/i18n/messages/de.json +47 -11
- package/dist/runtime/i18n/messages/en.json +47 -11
- package/dist/runtime/pages/integrations/audit.vue +3 -3
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +2 -2
- package/dist/runtime/pages/integrations/credentials.vue +17 -5
- package/dist/runtime/pages/integrations/index.vue +5 -10
- package/dist/runtime/pages/integrations/schedules.vue +24 -13
- package/dist/runtime/pages/integrations/templates/index.vue +40 -40
- package/dist/runtime/pages/integrations/workflows/[id].vue +324 -324
- package/dist/runtime/types/integrations.d.ts +13 -0
- package/dist/runtime/utils/cockpitSources.d.ts +68 -1
- package/dist/runtime/utils/credentialState.d.ts +15 -7
- package/dist/runtime/utils/credentialState.js +4 -12
- package/dist/runtime/utils/localize.d.ts +12 -0
- package/dist/runtime/utils/localize.js +1 -1
- package/dist/runtime/utils/paletteOrder.d.ts +29 -0
- package/dist/runtime/utils/paletteOrder.js +46 -0
- package/dist/runtime/utils/relativeTime.d.ts +29 -0
- package/dist/runtime/utils/relativeTime.js +23 -0
- package/dist/runtime/utils/runFeedQuery.d.ts +12 -6
- package/dist/runtime/utils/runFeedQuery.js +3 -18
- package/dist/runtime/utils/settingValue.d.ts +33 -0
- package/dist/runtime/utils/settingValue.js +8 -0
- package/dist/runtime/utils/statusStyle.d.ts +8 -8
- package/dist/runtime/utils/statusStyle.js +4 -1
- package/dist/runtime/utils/workflowFile.d.ts +119 -0
- package/dist/runtime/utils/workflowFile.js +192 -0
- package/package.json +10 -5
- package/dist/runtime/utils/errorPathAdvice.d.ts +0 -60
- 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
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
274
|
+
@apply="(c) => Object.assign(form.config, c)"
|
|
275
|
+
/>
|
|
273
276
|
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
343
|
+
</div>
|
|
341
344
|
|
|
342
|
-
|
|
345
|
+
</div>
|
|
343
346
|
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
-
|
|
354
|
-
|
|
356
|
+
<p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
|
|
357
|
+
</div>
|
|
355
358
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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) +
|
|
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;
|
|
@@ -51,16 +51,16 @@ const filtered = computed(() => {
|
|
|
51
51
|
{{ t("integration.credentials.typePicker.empty", { query }) }}
|
|
52
52
|
</p>
|
|
53
53
|
<button
|
|
54
|
-
v-for="
|
|
55
|
-
:key="
|
|
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',
|
|
58
|
+
@click="emit('pick', type)"
|
|
59
59
|
>
|
|
60
|
-
<IntegrationsCredentialLogo :type="
|
|
60
|
+
<IntegrationsCredentialLogo :type="type" :size="16" />
|
|
61
61
|
<div class="min-w-0 flex-1 truncate text-sm">
|
|
62
|
-
<span class="font-medium">{{ resolveLocalized(
|
|
63
|
-
<span class="ml-1.5 text-muted-foreground">({{ authKindLabel(
|
|
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
|
-
|
|
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
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
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;
|