@revenexx/studio-integrations 0.6.0 → 1.0.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 +643 -0
- package/README.md +31 -0
- package/dist/module.d.mts +17 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +12 -3
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +32 -28
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +45 -38
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
- package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
- package/dist/runtime/components/Integrations/ExpressionInput.vue +21 -15
- package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
- package/dist/runtime/components/Integrations/Layout.vue +28 -3
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +30 -16
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
- package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -8
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +45 -42
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/NodeInspector.vue +315 -221
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue +37 -10
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
- package/dist/runtime/components/Integrations/NodePalette.vue +40 -35
- package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
- package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
- package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
- package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
- package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
- package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
- package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
- package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
- package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
- package/dist/runtime/components/Integrations/StickyNoteView.vue +82 -34
- package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
- package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
- package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
- package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue +18 -8
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +22 -12
- package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +41 -39
- package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +131 -87
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +27 -24
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +76 -46
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +55 -43
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +55 -43
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -13
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +79 -39
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
- package/dist/runtime/composables/integrationsApi.js +4 -2
- package/dist/runtime/composables/useCredentialTypes.js +4 -2
- package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
- package/dist/runtime/composables/useIntegrationsCredentials.js +8 -6
- package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
- package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
- package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
- package/dist/runtime/composables/useIntegrationsNodes.js +14 -2
- package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
- package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
- package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
- package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
- package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
- package/dist/runtime/composables/useIntegrationsT.js +9 -0
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
- package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
- package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
- package/dist/runtime/composables/useIntegrationsTriggers.js +21 -8
- package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
- package/dist/runtime/composables/useIntegrationsWorkflows.js +30 -7
- package/dist/runtime/composables/useNodeConfigResolve.js +4 -2
- package/dist/runtime/data/integrationNodeCatalog.d.ts +63 -1
- package/dist/runtime/data/integrationNodeCatalog.js +22 -4
- package/dist/runtime/data/integrationTemplates.d.ts +9 -0
- package/dist/runtime/data/integrationTemplates.js +7 -1
- package/dist/runtime/data/nodeInputData.d.ts +58 -1
- package/dist/runtime/data/nodeInputData.js +30 -5
- package/dist/runtime/data/payloadForm.js +2 -1
- package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
- package/dist/runtime/data/triggerGlyphs.js +11 -0
- package/dist/runtime/data/workflowIconCatalog.js +2 -1
- package/dist/runtime/i18n/messages/de.json +1224 -0
- package/dist/runtime/i18n/messages/en.json +1224 -0
- package/dist/runtime/i18n/translate.d.ts +83 -0
- package/dist/runtime/i18n/translate.js +41 -0
- package/dist/runtime/pages/integrations/audit.vue +52 -34
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
- package/dist/runtime/pages/integrations/credentials.vue +73 -52
- package/dist/runtime/pages/integrations/index.vue +66 -73
- package/dist/runtime/pages/integrations/runs.vue +33 -21
- package/dist/runtime/pages/integrations/schedules.vue +49 -28
- package/dist/runtime/pages/integrations/secrets.vue +34 -34
- package/dist/runtime/pages/integrations/templates/[slug].vue +89 -72
- package/dist/runtime/pages/integrations/templates/index.vue +35 -27
- package/dist/runtime/pages/integrations/workflows/[id].vue +351 -197
- package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
- package/dist/runtime/plugins/locale.d.ts +10 -0
- package/dist/runtime/plugins/locale.js +6 -0
- package/dist/runtime/plugins/realtime.client.d.ts +32 -0
- package/dist/runtime/plugins/realtime.client.js +4 -0
- package/dist/runtime/types/integrations.d.ts +25 -1
- package/dist/runtime/utils/cockpitSources.d.ts +22 -3
- package/dist/runtime/utils/credentialState.d.ts +30 -2
- package/dist/runtime/utils/credentialState.js +26 -11
- package/dist/runtime/utils/localize.d.ts +52 -5
- package/dist/runtime/utils/localize.js +47 -9
- package/dist/runtime/utils/pollBudget.d.ts +72 -0
- package/dist/runtime/utils/pollBudget.js +17 -0
- package/dist/runtime/utils/schedule.d.ts +0 -25
- package/dist/runtime/utils/schedule.js +37 -25
- package/dist/runtime/utils/servedPaths.d.ts +22 -0
- package/dist/runtime/utils/servedPaths.js +3 -0
- package/dist/runtime/utils/statusStyle.d.ts +26 -2
- package/dist/runtime/utils/statusStyle.js +9 -1
- package/dist/runtime/utils/validationFindings.d.ts +2 -2
- package/dist/runtime/utils/validationFindings.js +4 -3
- package/dist/types.d.mts +2 -6
- package/package.json +3 -2
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
3
3
|
import { Badge } from "@revenexx/studio";
|
|
4
|
-
import { statusClass } from "../../utils/statusStyle";
|
|
4
|
+
import { RUN_STATUS_WORDS, normalisedRunStatus, runStatusLabel, statusClass } from "../../utils/statusStyle";
|
|
5
|
+
import { TRIGGER_TYPE_WORDS, triggerTypeLabel } from "../../data/integrationNodeCatalog";
|
|
5
6
|
const props = defineProps({
|
|
6
7
|
runs: { type: Array, required: true },
|
|
7
8
|
loading: { type: Boolean, required: false, default: false },
|
|
8
|
-
emptyText: { type: String, required: false
|
|
9
|
+
emptyText: { type: String, required: false },
|
|
9
10
|
pagination: { type: Boolean, required: false, default: true },
|
|
10
11
|
showWorkflow: { type: Boolean, required: false, default: true }
|
|
11
12
|
});
|
|
12
13
|
const emit = defineEmits(["changed"]);
|
|
14
|
+
const t = useIntegrationsT();
|
|
13
15
|
const router = useRouter();
|
|
16
|
+
const emptyText = computed(() => props.emptyText ?? t("integration.runs.table.empty"));
|
|
14
17
|
const now = ref(Date.now());
|
|
15
18
|
let tick = null;
|
|
16
19
|
onMounted(() => {
|
|
@@ -31,10 +34,10 @@ function formatRelative(iso) {
|
|
|
31
34
|
if (!iso) return "\u2014";
|
|
32
35
|
const ms = Date.now() - new Date(iso).getTime();
|
|
33
36
|
if (Number.isNaN(ms)) return "\u2014";
|
|
34
|
-
if (ms < 6e4) return
|
|
35
|
-
if (ms < 36e5) return
|
|
36
|
-
if (ms < 864e5) return
|
|
37
|
-
return
|
|
37
|
+
if (ms < 6e4) return t("integration.common.time.secondsAgo", Math.max(1, Math.floor(ms / 1e3)));
|
|
38
|
+
if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
|
|
39
|
+
if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
|
|
40
|
+
return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
|
|
38
41
|
}
|
|
39
42
|
function formatDuration(ms) {
|
|
40
43
|
if (ms == null) return "\u2014";
|
|
@@ -49,16 +52,35 @@ function liveDuration(run) {
|
|
|
49
52
|
const end = run.finishedAt ? Date.parse(run.finishedAt) : now.value;
|
|
50
53
|
return end - start;
|
|
51
54
|
}
|
|
55
|
+
const filterTabs = computed(() => props.pagination ? [
|
|
56
|
+
{
|
|
57
|
+
key: "status",
|
|
58
|
+
label: t("integration.runs.table.column.status"),
|
|
59
|
+
getter: (r) => normalisedRunStatus(r.status),
|
|
60
|
+
options: RUN_STATUS_WORDS.map((v) => ({ value: v, label: runStatusLabel(v) }))
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "trigger",
|
|
64
|
+
label: t("integration.runs.table.column.trigger"),
|
|
65
|
+
getter: (r) => r.triggerType ?? "",
|
|
66
|
+
options: TRIGGER_TYPE_WORDS.map((v) => ({ value: v, label: triggerTypeLabel(v) }))
|
|
67
|
+
}
|
|
68
|
+
] : void 0);
|
|
52
69
|
const columns = computed(() => [
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
// Sorted and searched by the word on screen, not by the raw field: a column
|
|
71
|
+
// with no `searchGetter` is searched through its `sortGetter`, so a German
|
|
72
|
+
// reader typing the status they can see has to find the row that shows it.
|
|
73
|
+
{ key: "status", label: t("integration.runs.table.column.status"), width: "100px", sortable: true, sortGetter: (r) => runStatusLabel(r.status) },
|
|
74
|
+
...props.showWorkflow ? [{ key: "workflow", label: t("integration.runs.table.column.workflow"), sortable: true, sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
|
|
55
75
|
// Fixed floor wide enough for a full UUID, flexible above it — header and
|
|
56
76
|
// body rows are separate grids, so a content-sized track (max-content)
|
|
57
77
|
// would misalign them. The flex share also lets the table fill the width.
|
|
58
|
-
{ key: "id", label: "
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{ key: "
|
|
78
|
+
{ key: "id", label: t("integration.runs.table.column.id"), width: "minmax(280px,1fr)", searchGetter: (r) => `${r.temporalRunId ?? ""} ${r.id}` },
|
|
79
|
+
// Same rule as the status column above: the word on screen is what sorts and
|
|
80
|
+
// what a search has to match.
|
|
81
|
+
{ key: "trigger", label: t("integration.runs.table.column.trigger"), width: "120px", sortable: true, sortGetter: (r) => triggerTypeLabel(r.triggerType) },
|
|
82
|
+
{ key: "started", label: t("integration.runs.table.column.started"), width: "120px", sortable: true, sortGetter: (r) => r.startedAt },
|
|
83
|
+
{ key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: true, sortGetter: (r) => r.durationMs ?? -1 },
|
|
62
84
|
{ key: "actions", label: "", width: "52px" }
|
|
63
85
|
]);
|
|
64
86
|
</script>
|
|
@@ -69,8 +91,8 @@ const columns = computed(() => [
|
|
|
69
91
|
:columns="columns"
|
|
70
92
|
:row-key="(r) => r.id"
|
|
71
93
|
:default-sort="{ column: 'started', direction: 'desc' }"
|
|
72
|
-
:search="{ placeholder: '
|
|
73
|
-
:filter="
|
|
94
|
+
:search="{ placeholder: t('integration.runs.table.search.placeholder') }"
|
|
95
|
+
:filter-tabs="filterTabs"
|
|
74
96
|
:loading="loading"
|
|
75
97
|
:pagination="pagination"
|
|
76
98
|
:empty-text="emptyText"
|
|
@@ -78,7 +100,7 @@ const columns = computed(() => [
|
|
|
78
100
|
>
|
|
79
101
|
<template #cell-status="{ row: run }">
|
|
80
102
|
<Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(run.status)]">
|
|
81
|
-
{{ run.status }}
|
|
103
|
+
{{ runStatusLabel(run.status) }}
|
|
82
104
|
</Badge>
|
|
83
105
|
</template>
|
|
84
106
|
|
|
@@ -93,7 +115,7 @@ const columns = computed(() => [
|
|
|
93
115
|
|
|
94
116
|
<template #cell-trigger="{ row: run }">
|
|
95
117
|
<span class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground">
|
|
96
|
-
{{ run.triggerType }}
|
|
118
|
+
{{ triggerTypeLabel(run.triggerType) }}
|
|
97
119
|
</span>
|
|
98
120
|
</template>
|
|
99
121
|
|
|
@@ -14,7 +14,6 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
|
|
|
14
14
|
onChanged?: (() => any) | undefined;
|
|
15
15
|
}>, {
|
|
16
16
|
loading: boolean;
|
|
17
|
-
emptyText: string;
|
|
18
17
|
pagination: boolean;
|
|
19
18
|
showWorkflow: boolean;
|
|
20
19
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
@@ -5,6 +5,7 @@ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader,
|
|
|
5
5
|
import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
|
|
6
6
|
const open = defineModel("open", { type: Boolean, ...{ default: false } });
|
|
7
7
|
const emit = defineEmits(["created"]);
|
|
8
|
+
const t = useIntegrationsT();
|
|
8
9
|
const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
|
|
9
10
|
accounts: ref([]),
|
|
10
11
|
loading: ref(false),
|
|
@@ -30,7 +31,7 @@ async function importAccount(account) {
|
|
|
30
31
|
try {
|
|
31
32
|
const creds = await sftp.revealPassword(tenant, account.id);
|
|
32
33
|
if (!creds?.password) {
|
|
33
|
-
error.value = "
|
|
34
|
+
error.value = t("integration.credentials.sftpImport.error.readPassword");
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
37
|
const created = await create({
|
|
@@ -46,7 +47,7 @@ async function importAccount(account) {
|
|
|
46
47
|
emit("created", created.id);
|
|
47
48
|
open.value = false;
|
|
48
49
|
} catch (err) {
|
|
49
|
-
error.value = err instanceof Error ? err.message : "
|
|
50
|
+
error.value = err instanceof Error ? err.message : t("integration.credentials.sftpImport.error.importFailed");
|
|
50
51
|
} finally {
|
|
51
52
|
busyId.value = null;
|
|
52
53
|
}
|
|
@@ -57,9 +58,9 @@ async function importAccount(account) {
|
|
|
57
58
|
<Dialog v-model:open="open">
|
|
58
59
|
<DialogContent class="sm:max-w-md">
|
|
59
60
|
<DialogHeader>
|
|
60
|
-
<DialogTitle>
|
|
61
|
+
<DialogTitle>{{ t("integration.credentials.sftpImport.title") }}</DialogTitle>
|
|
61
62
|
<DialogDescription class="text-sm">
|
|
62
|
-
|
|
63
|
+
{{ t("integration.credentials.sftpImport.description") }}
|
|
63
64
|
</DialogDescription>
|
|
64
65
|
</DialogHeader>
|
|
65
66
|
|
|
@@ -67,7 +68,7 @@ async function importAccount(account) {
|
|
|
67
68
|
<div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
|
|
68
69
|
</div>
|
|
69
70
|
<p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
|
|
70
|
-
|
|
71
|
+
{{ t("integration.credentials.sftpImport.empty") }}
|
|
71
72
|
</p>
|
|
72
73
|
<div v-else class="max-h-72 space-y-1 overflow-y-auto">
|
|
73
74
|
<button
|
|
@@ -82,7 +83,7 @@ async function importAccount(account) {
|
|
|
82
83
|
<span class="block truncate font-mono text-sm">{{ account.login_username }}</span>
|
|
83
84
|
<span class="block truncate text-xs text-muted-foreground">{{ account.home_directory ?? account.server ?? "" }}</span>
|
|
84
85
|
</span>
|
|
85
|
-
<Badge variant="outline" class="text-xs font-medium uppercase shrink-0">
|
|
86
|
+
<Badge variant="outline" class="text-xs font-medium uppercase shrink-0">{{ t("integration.credentials.sftpImport.badge.active") }}</Badge>
|
|
86
87
|
<Loader2 v-if="busyId === account.id" :size="14" class="shrink-0 animate-spin text-muted-foreground" />
|
|
87
88
|
</button>
|
|
88
89
|
</div>
|
|
@@ -90,7 +91,7 @@ async function importAccount(account) {
|
|
|
90
91
|
<p v-if="error" class="text-sm text-rose-600">{{ error }}</p>
|
|
91
92
|
|
|
92
93
|
<div class="flex justify-end">
|
|
93
|
-
<Button variant="outline" size="sm" class="h-8 text-sm" @click="open = false">
|
|
94
|
+
<Button variant="outline" size="sm" class="h-8 text-sm" @click="open = false">{{ t("integration.common.action.close") }}</Button>
|
|
94
95
|
</div>
|
|
95
96
|
</DialogContent>
|
|
96
97
|
</Dialog>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, Layers, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
|
|
3
3
|
import SidebarNav from "@revenexx/studio-shared/components/Shared/SidebarNav.vue";
|
|
4
|
+
const t = useIntegrationsT();
|
|
4
5
|
const { workflows } = useIntegrationsWorkflows();
|
|
5
6
|
const { runningCount } = useIntegrationsRuns();
|
|
6
7
|
const { activeCount: schedulesActive } = useIntegrationsSchedules();
|
|
@@ -10,25 +11,28 @@ const sections = computed(() => [
|
|
|
10
11
|
{
|
|
11
12
|
key: "pinned",
|
|
12
13
|
items: [
|
|
13
|
-
{ label: "
|
|
14
|
+
{ label: t("integration.nav.item.dashboard"), to: "/integrations", icon: Home, exact: true }
|
|
14
15
|
]
|
|
15
16
|
},
|
|
16
17
|
{
|
|
17
18
|
key: "integrations",
|
|
18
|
-
|
|
19
|
+
// A rail's section heading is a proper noun and stays English in every UI
|
|
20
|
+
// language. It is still a key, so the decision is written down where a
|
|
21
|
+
// translator will look for it rather than only in a review comment.
|
|
22
|
+
label: t("integration.nav.section.integrations"),
|
|
19
23
|
items: [
|
|
20
|
-
{ label: "
|
|
21
|
-
{ label: "
|
|
22
|
-
{ label: "
|
|
23
|
-
{ label: "
|
|
24
|
-
{ label: "
|
|
25
|
-
{ label: "
|
|
26
|
-
{ label: "
|
|
24
|
+
{ label: t("integration.nav.item.workflows"), to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
|
|
25
|
+
{ label: t("integration.nav.item.templates"), to: "/integrations/templates", icon: Layers },
|
|
26
|
+
{ label: t("integration.nav.item.runs"), to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
|
|
27
|
+
{ label: t("integration.nav.item.schedules"), to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
|
|
28
|
+
{ label: t("integration.nav.item.credentials"), to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
|
|
29
|
+
{ label: t("integration.nav.item.secrets"), to: "/integrations/secrets", icon: KeyRound, badge: secretsTotal.value || null },
|
|
30
|
+
{ label: t("integration.nav.item.audit"), to: "/integrations/audit", icon: ShieldCheck }
|
|
27
31
|
]
|
|
28
32
|
}
|
|
29
33
|
]);
|
|
30
34
|
</script>
|
|
31
35
|
|
|
32
36
|
<template>
|
|
33
|
-
<SidebarNav title="
|
|
37
|
+
<SidebarNav :title="t('integration.nav.title')" :icon="Workflow" :sections="sections" minimal />
|
|
34
38
|
</template>
|
|
@@ -11,13 +11,23 @@ import type { WorkflowSticky } from '../../types/integrations.js';
|
|
|
11
11
|
* - NodeToolbar on hover/selection: 7-color pastel palette + delete.
|
|
12
12
|
* - Hand-rolled bottom-right resize handle (no @vue-flow/node-resizer dep);
|
|
13
13
|
* size persists once on pointer-up.
|
|
14
|
+
*
|
|
15
|
+
* **Absence of the callbacks is what makes a note read-only** — the run replay and
|
|
16
|
+
* the history preview hand it none, and then it draws no toolbar, no resize handle
|
|
17
|
+
* and cannot be opened for typing (behaviour specs/sticky-notes.md AC-9). They used
|
|
18
|
+
* to be handed no-ops instead, which left every one of those affordances on screen
|
|
19
|
+
* doing nothing: a reader could not tell "not allowed here" from "broken" (PO-284).
|
|
20
|
+
* `GroupFrameView` gates its own toolbar on the same absence.
|
|
14
21
|
*/
|
|
15
22
|
export interface StickyNodeData {
|
|
16
23
|
sticky: WorkflowSticky;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Persist a partial change into the blob (editor-side, syncs + pushes).
|
|
26
|
+
* Absent on a read-only canvas — see the note above.
|
|
27
|
+
*/
|
|
28
|
+
onUpdate?: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
|
|
29
|
+
/** Remove this sticky from the blob (editor-side). Absent when read-only. */
|
|
30
|
+
onRemove?: () => void;
|
|
21
31
|
}
|
|
22
32
|
type __VLS_Props = {
|
|
23
33
|
id: string;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { computed, nextTick, ref, watch } from "vue";
|
|
3
3
|
import { Position, useVueFlow } from "@vue-flow/core";
|
|
4
4
|
import { NodeToolbar } from "@vue-flow/node-toolbar";
|
|
5
|
-
import {
|
|
5
|
+
import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
|
|
6
6
|
const props = defineProps({
|
|
7
7
|
id: { type: String, required: true },
|
|
8
8
|
data: { type: Object, required: true },
|
|
@@ -10,20 +10,22 @@ const props = defineProps({
|
|
|
10
10
|
});
|
|
11
11
|
defineOptions({ inheritAttrs: false });
|
|
12
12
|
defineEmits(["updateNodeInternals"]);
|
|
13
|
+
const t = useIntegrationsT();
|
|
13
14
|
const flow = useVueFlow();
|
|
14
15
|
const STICKY_COLORS = [
|
|
15
|
-
{ bg: "#fef3c7", border: "#fde68a",
|
|
16
|
-
{ bg: "#ffedd5", border: "#fed7aa",
|
|
17
|
-
{ bg: "#ffe4e6", border: "#fecdd3",
|
|
18
|
-
{ bg: "#dcfce7", border: "#bbf7d0",
|
|
19
|
-
{ bg: "#dbeafe", border: "#bfdbfe",
|
|
20
|
-
{ bg: "#ede9fe", border: "#ddd6fe",
|
|
21
|
-
{ bg: "#f5f5f4", border: "#e7e5e4",
|
|
16
|
+
{ bg: "#fef3c7", border: "#fde68a", nameKey: "integration.canvas.color.amber" },
|
|
17
|
+
{ bg: "#ffedd5", border: "#fed7aa", nameKey: "integration.canvas.color.orange" },
|
|
18
|
+
{ bg: "#ffe4e6", border: "#fecdd3", nameKey: "integration.canvas.color.rose" },
|
|
19
|
+
{ bg: "#dcfce7", border: "#bbf7d0", nameKey: "integration.canvas.color.green" },
|
|
20
|
+
{ bg: "#dbeafe", border: "#bfdbfe", nameKey: "integration.canvas.color.blue" },
|
|
21
|
+
{ bg: "#ede9fe", border: "#ddd6fe", nameKey: "integration.canvas.color.violet" },
|
|
22
|
+
{ bg: "#f5f5f4", border: "#e7e5e4", nameKey: "integration.canvas.color.gray" }
|
|
22
23
|
];
|
|
23
24
|
const color = computed(() => STICKY_COLORS[props.data.sticky.color ?? 0] ?? STICKY_COLORS[0]);
|
|
25
|
+
const editable = computed(() => !!props.data.onUpdate && !!props.data.onRemove);
|
|
24
26
|
function pickColor(i) {
|
|
25
27
|
if ((props.data.sticky.color ?? 0) === i) return;
|
|
26
|
-
props.data.onUpdate({ color: i });
|
|
28
|
+
props.data.onUpdate?.({ color: i });
|
|
27
29
|
}
|
|
28
30
|
const DEFAULT_WIDTH = 240;
|
|
29
31
|
const DEFAULT_HEIGHT = 160;
|
|
@@ -54,13 +56,18 @@ function onResizeMove(e) {
|
|
|
54
56
|
function onResizeEnd() {
|
|
55
57
|
if (!resizing.value) return;
|
|
56
58
|
resizing.value = false;
|
|
57
|
-
props.data.onUpdate({ size: { width: width.value, height: height.value } });
|
|
59
|
+
props.data.onUpdate?.({ size: { width: width.value, height: height.value } });
|
|
58
60
|
}
|
|
59
61
|
const editing = ref(false);
|
|
60
62
|
const draft = ref("");
|
|
61
63
|
const textareaEl = ref(null);
|
|
64
|
+
function onDoubleClick(event) {
|
|
65
|
+
if (!editable.value) return;
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
startEdit();
|
|
68
|
+
}
|
|
62
69
|
function startEdit() {
|
|
63
|
-
if (editing.value) return;
|
|
70
|
+
if (!editable.value || editing.value) return;
|
|
64
71
|
draft.value = props.data.sticky.content;
|
|
65
72
|
editing.value = true;
|
|
66
73
|
nextTick(() => textareaEl.value?.focus());
|
|
@@ -69,7 +76,7 @@ function commitEdit() {
|
|
|
69
76
|
if (!editing.value) return;
|
|
70
77
|
editing.value = false;
|
|
71
78
|
if (draft.value !== props.data.sticky.content) {
|
|
72
|
-
props.data.onUpdate({ content: draft.value });
|
|
79
|
+
props.data.onUpdate?.({ content: draft.value });
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
82
|
function onTextareaKeydown(e) {
|
|
@@ -91,26 +98,60 @@ function onHoverLeave() {
|
|
|
91
98
|
hovered.value = false;
|
|
92
99
|
}, 250);
|
|
93
100
|
}
|
|
94
|
-
const toolbarVisible = computed(() => !editing.value && (hovered.value || !!props.selected));
|
|
101
|
+
const toolbarVisible = computed(() => editable.value && !editing.value && (hovered.value || !!props.selected));
|
|
95
102
|
function escapeHtml(s) {
|
|
96
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
103
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
97
104
|
}
|
|
98
105
|
function inline(s) {
|
|
99
106
|
return s.replace(/`([^`\n]+)`/g, '<code class="rounded bg-black/[0.07] px-1 py-px font-mono text-[0.85em]">$1</code>').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="font-medium underline decoration-current/40 underline-offset-2 hover:decoration-current">$1</a>').replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
|
100
107
|
}
|
|
108
|
+
const NOTE_HEADINGS = [
|
|
109
|
+
{ tag: "h2", cls: "m-0 text-base font-semibold" },
|
|
110
|
+
{ tag: "h3", cls: "m-0 text-sm font-semibold" },
|
|
111
|
+
{ tag: "h4", cls: "m-0 text-sm font-medium" }
|
|
112
|
+
];
|
|
113
|
+
const HEADING_RE = /^(#{1,3})\s+(.+)$/;
|
|
114
|
+
const UL_RE = /^[-*]\s+(.+)$/;
|
|
115
|
+
const OL_RE = /^(\d+)\.\s+(.+)$/;
|
|
116
|
+
function renderList(tag, items) {
|
|
117
|
+
const li = items.map(({ marker, text }) => {
|
|
118
|
+
const numeric = tag === "ol" ? " tabular-nums" : "";
|
|
119
|
+
const hidden = tag === "ul" ? ' aria-hidden="true"' : "";
|
|
120
|
+
const ordinal = tag === "ol" ? ` value="${Number.parseInt(marker, 10)}"` : "";
|
|
121
|
+
return `<li${ordinal} class="flex gap-1.5"><span${hidden} class="select-none${numeric}">${marker}</span><span class="min-w-0">${text}</span></li>`;
|
|
122
|
+
}).join("");
|
|
123
|
+
return `<${tag} role="list" class="m-0 list-none p-0 pl-1">${li}</${tag}>`;
|
|
124
|
+
}
|
|
101
125
|
function renderMarkdownLite(src) {
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
const lines = escapeHtml(src).split("\n");
|
|
127
|
+
const out = [];
|
|
128
|
+
let i = 0;
|
|
129
|
+
while (i < lines.length) {
|
|
130
|
+
const line = lines[i] ?? "";
|
|
131
|
+
const h = HEADING_RE.exec(line);
|
|
104
132
|
if (h) {
|
|
105
|
-
const cls = h[1]
|
|
106
|
-
|
|
133
|
+
const { tag, cls } = NOTE_HEADINGS[(h[1] ?? "#").length - 1] ?? NOTE_HEADINGS[2];
|
|
134
|
+
out.push(`<${tag} class="${cls}">${inline(h[2] ?? "")}</${tag}>`);
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
107
137
|
}
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
const listRe = UL_RE.test(line) ? UL_RE : OL_RE.test(line) ? OL_RE : null;
|
|
139
|
+
if (listRe) {
|
|
140
|
+
const tag = listRe === UL_RE ? "ul" : "ol";
|
|
141
|
+
const items = [];
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
const m = listRe.exec(lines[i] ?? "");
|
|
144
|
+
if (!m) break;
|
|
145
|
+
items.push({ marker: tag === "ul" ? "\u2022" : `${m[1]}.`, text: inline(m[m.length - 1] ?? "") });
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
out.push(renderList(tag, items));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
out.push(line ? `<div>${inline(line)}</div>` : "<div> </div>");
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
return out.join("");
|
|
114
155
|
}
|
|
115
156
|
const rendered = computed(() => renderMarkdownLite(props.data.sticky.content));
|
|
116
157
|
function onViewMouseDown(e) {
|
|
@@ -133,17 +174,17 @@ function onViewMouseDown(e) {
|
|
|
133
174
|
class="size-5 rounded-full border transition hover:scale-110"
|
|
134
175
|
:class="(data.sticky.color ?? 0) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
|
|
135
176
|
:style="{ backgroundColor: c.bg, borderColor: c.border }"
|
|
136
|
-
:title="c.
|
|
177
|
+
:title="t(c.nameKey)"
|
|
137
178
|
@click="pickColor(i)"
|
|
138
179
|
/>
|
|
139
180
|
<div class="mx-0.5 h-4 w-px bg-black/10" />
|
|
140
181
|
<button
|
|
141
182
|
type="button"
|
|
142
183
|
class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
|
|
143
|
-
title="
|
|
144
|
-
@click="data.onRemove()"
|
|
184
|
+
:title="t('integration.canvas.sticky.action.delete')"
|
|
185
|
+
@click="data.onRemove?.()"
|
|
145
186
|
>
|
|
146
|
-
<
|
|
187
|
+
<TrashBin :size="13" />
|
|
147
188
|
</button>
|
|
148
189
|
</div>
|
|
149
190
|
</NodeToolbar>
|
|
@@ -163,7 +204,7 @@ function onViewMouseDown(e) {
|
|
|
163
204
|
}"
|
|
164
205
|
@mouseenter="onHoverEnter"
|
|
165
206
|
@mouseleave="onHoverLeave"
|
|
166
|
-
@dblclick
|
|
207
|
+
@dblclick="onDoubleClick"
|
|
167
208
|
>
|
|
168
209
|
<!-- View mode -->
|
|
169
210
|
<div
|
|
@@ -172,7 +213,11 @@ function onViewMouseDown(e) {
|
|
|
172
213
|
@mousedown="onViewMouseDown"
|
|
173
214
|
>
|
|
174
215
|
<div v-if="data.sticky.content" v-html="rendered" />
|
|
175
|
-
|
|
216
|
+
<!-- The hint is an instruction, not content, so it goes with the rest of the
|
|
217
|
+
affordances: on a read-only canvas it would name the one gesture
|
|
218
|
+
`startEdit` refuses, which is the false affordance AC-9 removes rather
|
|
219
|
+
than a smaller version of it. An empty note there simply reads empty. -->
|
|
220
|
+
<span v-else-if="editable" class="select-none text-stone-400">{{ t("integration.canvas.sticky.state.emptyHint") }}</span>
|
|
176
221
|
</div>
|
|
177
222
|
|
|
178
223
|
<!-- Edit mode — nodrag/nowheel so typing and scrolling never drag or zoom
|
|
@@ -181,9 +226,9 @@ function onViewMouseDown(e) {
|
|
|
181
226
|
v-else
|
|
182
227
|
ref="textareaEl"
|
|
183
228
|
v-model="draft"
|
|
184
|
-
aria-label="
|
|
229
|
+
:aria-label="t('integration.canvas.sticky.textarea.aria')"
|
|
185
230
|
class="nodrag nowheel h-full w-full resize-none bg-transparent px-3 py-2 text-sm leading-snug text-stone-700 outline-none placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-stone-500/40"
|
|
186
|
-
placeholder="
|
|
231
|
+
:placeholder="t('integration.canvas.sticky.textarea.placeholder')"
|
|
187
232
|
spellcheck="false"
|
|
188
233
|
@blur="commitEdit"
|
|
189
234
|
@keydown="onTextareaKeydown"
|
|
@@ -191,10 +236,13 @@ function onViewMouseDown(e) {
|
|
|
191
236
|
@dblclick.stop
|
|
192
237
|
/>
|
|
193
238
|
|
|
194
|
-
<!-- Bottom-right resize handle (hand-rolled; min 150x80)
|
|
239
|
+
<!-- Bottom-right resize handle (hand-rolled; min 150x80). Gone on a
|
|
240
|
+
read-only canvas: a handle that cannot resize is the same lie as a bin
|
|
241
|
+
that cannot delete. -->
|
|
195
242
|
<div
|
|
243
|
+
v-if="editable"
|
|
196
244
|
class="nodrag absolute bottom-0 right-0 flex size-4 cursor-se-resize items-end justify-end p-0.5 text-stone-400/80"
|
|
197
|
-
title="
|
|
245
|
+
:title="t('integration.canvas.sticky.action.resize')"
|
|
198
246
|
@pointerdown="onResizeStart"
|
|
199
247
|
@pointermove="onResizeMove"
|
|
200
248
|
@pointerup="onResizeEnd"
|
|
@@ -11,13 +11,23 @@ import type { WorkflowSticky } from '../../types/integrations.js';
|
|
|
11
11
|
* - NodeToolbar on hover/selection: 7-color pastel palette + delete.
|
|
12
12
|
* - Hand-rolled bottom-right resize handle (no @vue-flow/node-resizer dep);
|
|
13
13
|
* size persists once on pointer-up.
|
|
14
|
+
*
|
|
15
|
+
* **Absence of the callbacks is what makes a note read-only** — the run replay and
|
|
16
|
+
* the history preview hand it none, and then it draws no toolbar, no resize handle
|
|
17
|
+
* and cannot be opened for typing (behaviour specs/sticky-notes.md AC-9). They used
|
|
18
|
+
* to be handed no-ops instead, which left every one of those affordances on screen
|
|
19
|
+
* doing nothing: a reader could not tell "not allowed here" from "broken" (PO-284).
|
|
20
|
+
* `GroupFrameView` gates its own toolbar on the same absence.
|
|
14
21
|
*/
|
|
15
22
|
export interface StickyNodeData {
|
|
16
23
|
sticky: WorkflowSticky;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Persist a partial change into the blob (editor-side, syncs + pushes).
|
|
26
|
+
* Absent on a read-only canvas — see the note above.
|
|
27
|
+
*/
|
|
28
|
+
onUpdate?: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
|
|
29
|
+
/** Remove this sticky from the blob (editor-side). Absent when read-only. */
|
|
30
|
+
onRemove?: () => void;
|
|
21
31
|
}
|
|
22
32
|
type __VLS_Props = {
|
|
23
33
|
id: string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One shortcut into a surface the *host* provides — Import & Export, the SFTP
|
|
3
|
+
* server, the event stream, inbound webhooks. None of the four is a page of this
|
|
4
|
+
* module: they are the cockpit's, and this card is the studio's landing page
|
|
5
|
+
* pointing at them.
|
|
6
|
+
*
|
|
7
|
+
* So the card has two states, and which one it is in is not its own decision:
|
|
8
|
+
*
|
|
9
|
+
* - **`available`** — the host has the surface. The card is a link and behaves
|
|
10
|
+
* exactly as it did before PO-223.
|
|
11
|
+
* - **not available** — the card stays on screen, looks the same, and is a button
|
|
12
|
+
* that explains where the surface lives instead of leading into a route the host
|
|
13
|
+
* does not serve. Before PO-223 it was a link into nothing: in the playground
|
|
14
|
+
* and in the standalone dev studio all four led to a 404.
|
|
15
|
+
*
|
|
16
|
+
* **Staying on screen is the point**, and it is the call the managed credential
|
|
17
|
+
* sources already made (`CredentialRevenexxSource.vue`, PO-222): a control that
|
|
18
|
+
* vanishes leaves nothing to read the explanation from, and the reader cannot tell
|
|
19
|
+
* a broken feature from one that belongs somewhere else.
|
|
20
|
+
*
|
|
21
|
+
* **No `aria-label`, on purpose** — like `TemplateCard`, the card takes its name
|
|
22
|
+
* from what it shows. The state is carried by the *role*: "Import & Export,
|
|
23
|
+
* button" instead of "link" is what says to a screen reader that this does not
|
|
24
|
+
* navigate, and it says it without announcing something no sighted reader can see
|
|
25
|
+
* (`specs/accessibility.md` AC-1 names the on-screen text as the source of a name).
|
|
26
|
+
*
|
|
27
|
+
* Who decides `available`, and why it is the router rather than a guess about the
|
|
28
|
+
* host, is `docs/host-surfaces.md`.
|
|
29
|
+
*
|
|
30
|
+
* The two branches draw the same body twice rather than sharing it: `as-child`
|
|
31
|
+
* wants a real element as its child, and the alternatives — a wrapper element
|
|
32
|
+
* around the card, or a second component for eight lines — cost either the grid
|
|
33
|
+
* layout or a file. **Keep the two bodies identical.**
|
|
34
|
+
*/
|
|
35
|
+
import type { Component } from 'vue';
|
|
36
|
+
type __VLS_Props = {
|
|
37
|
+
/** What the surface is called, in the reader's language. */
|
|
38
|
+
label: string;
|
|
39
|
+
/** One line on what it is for. Shown in both states. */
|
|
40
|
+
hint: string;
|
|
41
|
+
icon: Component;
|
|
42
|
+
/** Where the surface lives in a host that has it. */
|
|
43
|
+
to: string;
|
|
44
|
+
/** Whether this host has it. See the comment above; no default on purpose. */
|
|
45
|
+
available: boolean;
|
|
46
|
+
/** What the popover says when it does not. */
|
|
47
|
+
note: string;
|
|
48
|
+
};
|
|
49
|
+
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
50
|
+
export default _default;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ArrowRight } from "#solar-icons";
|
|
3
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@revenexx/studio";
|
|
4
|
+
defineProps({
|
|
5
|
+
label: { type: String, required: true },
|
|
6
|
+
hint: { type: String, required: true },
|
|
7
|
+
icon: { type: null, required: true },
|
|
8
|
+
to: { type: String, required: true },
|
|
9
|
+
available: { type: Boolean, required: true },
|
|
10
|
+
note: { type: String, required: true }
|
|
11
|
+
});
|
|
12
|
+
const CARD_CLASS = "group rounded-lg border border-black/[0.06] bg-white p-3 transition hover:border-black/20";
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<NuxtLink v-if="available" :to="to" :class="CARD_CLASS">
|
|
17
|
+
<div class="flex items-center gap-2">
|
|
18
|
+
<span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
|
|
19
|
+
<component :is="icon" :size="12" />
|
|
20
|
+
</span>
|
|
21
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
|
|
22
|
+
<ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
|
23
|
+
</div>
|
|
24
|
+
<p class="mt-2 text-sm text-muted-foreground">
|
|
25
|
+
{{ hint }}
|
|
26
|
+
</p>
|
|
27
|
+
</NuxtLink>
|
|
28
|
+
|
|
29
|
+
<Popover v-else>
|
|
30
|
+
<PopoverTrigger as-child>
|
|
31
|
+
<!-- No `id` here: `PopoverTrigger` puts reka-ui's generated `triggerId` on
|
|
32
|
+
its child and `PopoverContentImpl` points the opened dialog's
|
|
33
|
+
`aria-labelledby` at exactly that id, so the popover is named by this
|
|
34
|
+
card. Ours would replace it (reka's `Slot` merges with the child's
|
|
35
|
+
props winning) and leave the dialog naming nothing. -->
|
|
36
|
+
<button type="button" :class="[CARD_CLASS, 'w-full text-left']">
|
|
37
|
+
<div class="flex items-center gap-2">
|
|
38
|
+
<span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
|
|
39
|
+
<component :is="icon" :size="12" />
|
|
40
|
+
</span>
|
|
41
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
|
|
42
|
+
<ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
|
43
|
+
</div>
|
|
44
|
+
<p class="mt-2 text-sm text-muted-foreground">
|
|
45
|
+
{{ hint }}
|
|
46
|
+
</p>
|
|
47
|
+
</button>
|
|
48
|
+
</PopoverTrigger>
|
|
49
|
+
<PopoverContent align="start" class="w-72 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
|
|
50
|
+
<p class="text-sm text-muted-foreground">
|
|
51
|
+
{{ note }}
|
|
52
|
+
</p>
|
|
53
|
+
</PopoverContent>
|
|
54
|
+
</Popover>
|
|
55
|
+
</template>
|