This commit is contained in:
2026-02-10 19:12:31 +03:00
parent 631126a85c
commit d9cc66e35c
11 changed files with 771 additions and 22 deletions

View File

@@ -1,10 +1,6 @@
<template>
<q-card class="s8n-calculator q-pa-md">
<q-card-section>
<div class="text-h6">Simple Calculator</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<div class="s8n-calculator q-pa-md">
<div class="q-gutter-md">
<q-select
outlined
v-model="inputs.operator"
@@ -49,19 +45,19 @@
Result: {{ outputs.result }} ({{ duration }}ms)
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
color="primary"
@click="execute('Calc')"
:loading="running"
size="lg"
icon="calculate"
label="Calculate"
/>
</q-card-actions>
</q-card>
<div class="row justify-end q-mt-md">
<q-btn
color="primary"
@click="execute('Calc')"
:loading="running"
size="lg"
icon="calculate"
label="Calculate"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">

View File

@@ -0,0 +1,301 @@
<template>
<div class="s8n-http-request q-pa-md">
<div class="q-gutter-md">
<!-- HTTP Method and URL -->
<div class="row q-gutter-md">
<q-select
outlined
v-model="inputs.method"
label="Method"
:options="httpMethods"
emit-value
map-options
class="col-3"
/>
<q-input
outlined
v-model="inputs.url"
label="URL"
placeholder="https://api.example.com/data"
class="col"
/>
</div>
<!-- Headers Section -->
<q-expansion-item
icon="mdi-format-list-bulleted"
label="Headers"
caption="Add custom HTTP headers"
>
<div class="q-gutter-sm q-pa-sm">
<div
v-for="([key, value], index) in Object.entries(inputs.headers)"
:key="index"
class="row q-gutter-sm items-center"
>
<q-input
outlined
:model-value="key"
@update:model-value="updateHeaderKey(index, $event as string)"
label="Header Name"
dense
class="col-4"
/>
<q-input
outlined
:model-value="value"
@update:model-value="updateHeaderValue(key, $event as string)"
label="Header Value"
dense
class="col"
/>
<q-btn icon="mdi-delete" color="negative" outline dense @click="removeHeader(key)" />
</div>
<q-btn icon="mdi-plus" label="Add Header" color="secondary" outline @click="addHeader" />
</div>
</q-expansion-item>
<!-- Body Section (only for POST, PUT, PATCH) -->
<q-expansion-item
v-if="showBodyInput"
icon="mdi-code-json"
label="Request Body"
caption="JSON body for POST/PUT/PATCH requests"
dense
default-opened
>
<div class="q-pa-sm">
<q-input
v-model="bodyText"
type="textarea"
outlined
label="JSON Body"
placeholder='{"key": "value"}'
rows="6"
:error="!isValidJson"
error-message="Invalid JSON"
/>
</div>
</q-expansion-item>
<!-- Result Section -->
<div class="result-section q-mt-md">
<q-banner v-if="error" class="bg-negative text-white" rounded>
{{ error }}
</q-banner>
<div v-else-if="outputs !== undefined">
<q-separator class="q-my-md" />
<div class="row items-center q-gutter-sm q-mb-sm">
<q-badge :color="getStatusColor(outputs.statusCode)" class="text-h6">
{{ outputs.statusCode }} {{ outputs.statusText }}
</q-badge>
<span class="text-caption">({{ outputs.duration }}ms)</span>
</div>
<!-- Response Headers -->
<q-expansion-item
v-if="outputs.headers && Object.keys(outputs.headers).length > 0"
icon="mdi-format-list-bulleted"
label="Response Headers"
dense
>
<q-list dense bordered class="rounded-borders">
<q-item v-for="(value, key) in outputs.headers" :key="key">
<q-item-section>
<q-item-label caption>{{ key }}</q-item-label>
<q-item-label>{{ value }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-expansion-item>
<!-- Response Body -->
<q-expansion-item
v-if="outputs.response"
icon="mdi-code-json"
label="Response Body"
dense
default-opened
>
<q-input
:model-value="formatResponse(outputs.response)"
type="textarea"
outlined
readonly
rows="8"
/>
</q-expansion-item>
</div>
</div>
<div class="row justify-end q-mt-md">
<q-btn
color="primary"
@click="execute('Execute')"
:loading="running"
size="lg"
icon="mdi-send"
label="Send Request"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { runtime } from '../components/s8n-runtime';
interface HttpRequestInputs {
method: string;
url: string;
headers: Record<string, string>;
body: object | null;
}
interface HttpRequestOutputs {
statusCode: number;
statusText: string;
response: string;
headers: Record<string, string>;
duration: number;
}
const httpMethods = [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'HEAD', value: 'HEAD' },
{ label: 'OPTIONS', value: 'OPTIONS' },
];
const bodyText = ref('');
const { execute, running, error, outputs, inputs } = runtime.createExecutor<
HttpRequestInputs,
HttpRequestOutputs
>('S8n.Components.Basics.HttpRequest', {
method: 'GET',
url: '',
headers: {},
body: null,
});
const showBodyInput = computed(() => {
const method = inputs.value.method.toUpperCase();
return ['POST', 'PUT', 'PATCH'].includes(method);
});
const isValidJson = computed(() => {
if (!bodyText.value || bodyText.value.trim() === '') {
return true;
}
try {
JSON.parse(bodyText.value);
return true;
} catch {
return false;
}
});
// Sync bodyText with inputs.body
watch(
() => inputs.value.body,
(newBody) => {
if (newBody) {
bodyText.value = JSON.stringify(newBody, null, 2);
} else {
bodyText.value = '';
}
},
{ immediate: true },
);
// Update inputs.body when bodyText changes (if valid JSON)
watch(bodyText, (newValue) => {
if (!newValue || newValue.trim() === '') {
inputs.value.body = null;
return;
}
try {
inputs.value.body = JSON.parse(newValue);
} catch {
// Invalid JSON, don't update
}
});
function addHeader() {
// Generate a unique key for the new header
const baseKey = 'new-header';
let counter = 1;
let newKey = baseKey;
while (newKey in inputs.value.headers) {
newKey = `${baseKey}-${counter}`;
counter++;
}
inputs.value.headers[newKey] = '';
}
function removeHeader(key: string) {
delete inputs.value.headers[key];
// Create a new object to trigger reactivity
inputs.value.headers = { ...inputs.value.headers };
}
function updateHeaderKey(oldIndex: number, newKey: string) {
const entries = Object.entries(inputs.value.headers);
if (oldIndex >= entries.length) return;
const entry = entries[oldIndex];
if (!entry) return;
const [currentKey, value] = entry;
// If the new key is empty or same as current, do nothing
if (!newKey.trim() || newKey === currentKey) return;
// Remove the old key and add with new key
delete inputs.value.headers[currentKey];
inputs.value.headers[newKey] = value;
// Create a new object to trigger reactivity
inputs.value.headers = { ...inputs.value.headers };
}
function updateHeaderValue(key: string, newValue: string) {
if (key in inputs.value.headers) {
inputs.value.headers[key] = newValue;
// Create a new object to trigger reactivity
inputs.value.headers = { ...inputs.value.headers };
}
}
function getStatusColor(statusCode: number): string {
if (statusCode >= 200 && statusCode < 300) return 'positive';
if (statusCode >= 300 && statusCode < 400) return 'info';
if (statusCode >= 400 && statusCode < 500) return 'warning';
if (statusCode >= 500) return 'negative';
return 'grey';
}
function formatResponse(response: string): string {
try {
const parsed = JSON.parse(response);
return JSON.stringify(parsed, null, 2);
} catch {
return response;
}
}
</script>
<style lang="scss" scoped>
.s8n-http-request {
max-width: 800px;
width: 100%;
.result-section {
min-height: 60px;
}
}
</style>

View File

@@ -29,6 +29,18 @@ import { ref } from 'vue';
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
const linksList: EssentialLinkProps[] = [
{
title: 'Home',
caption: 'Main page',
icon: 'home',
link: '/',
},
{
title: 'Playground',
caption: 'S8n Components',
icon: 'mdi-cube-outline',
link: '/#/playground',
},
{
title: 'Docs',
caption: 'quasar.dev',

View File

@@ -5,5 +5,5 @@
</template>
<script setup lang="ts">
import ComponentCalculator from 'components/ComponentCalculator.vue';
import ComponentCalculator from 'src/components_s8n/ComponentCalculator.vue';
</script>

View File

@@ -0,0 +1,136 @@
<template>
<q-page class="playground-page">
<div class="row q-pa-md q-gutter-md">
<!-- Component Selection Sidebar -->
<div class="col-12 col-md-3">
<q-card>
<q-card-section>
<div class="text-h6">Components</div>
<div class="text-caption text-grey">Select a component to interact with</div>
</q-card-section>
<q-list separator>
<q-item
v-for="component in availableComponents"
:key="component.code"
clickable
:active="selectedComponent === component.code"
@click="selectedComponent = component.code"
active-class="bg-primary text-white"
>
<q-item-section avatar>
<q-icon :name="component.icon" />
</q-item-section>
<q-item-section>
<q-item-label>{{ component.name }}</q-item-label>
<q-item-label caption :class="selectedComponent === component.code ? 'text-white' : 'text-grey'">
{{ component.description }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card>
<!-- Component Info Card -->
<q-card class="q-mt-md" v-if="currentComponentInfo">
<q-card-section>
<div class="text-subtitle2">Component Info</div>
</q-card-section>
<q-card-section>
<div class="text-caption">
<strong>Code:</strong> {{ currentComponentInfo.code }}
</div>
<div class="text-caption">
<strong>Class:</strong> {{ currentComponentInfo.className }}
</div>
<div class="text-caption">
<strong>Method:</strong> {{ currentComponentInfo.method }}
</div>
</q-card-section>
</q-card>
</div>
<!-- Main Component Area -->
<div class="col-12 col-md">
<q-card class="full-height">
<q-card-section>
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
<div class="text-caption text-grey" v-if="currentComponentInfo">
{{ currentComponentInfo.description }}
</div>
</q-card-section>
<q-card-section class="q-pa-md">
<component
:is="currentComponentComponent"
v-if="currentComponentComponent"
/>
<div v-else class="text-center text-grey q-pa-xl">
<q-icon name="mdi-cube-outline" size="64px" class="q-mb-md" />
<div class="text-h6">Welcome to S8n Playground</div>
<div class="text-body1">
Select a component from the sidebar to start interacting with it.
</div>
</div>
</q-card-section>
</q-card>
</div>
</div>
</q-page>
</template>
<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from 'vue';
interface ComponentInfo {
code: string;
name: string;
description: string;
icon: string;
className: string;
method: string;
component: ReturnType<typeof defineAsyncComponent>;
}
const selectedComponent = ref<string>('');
// Define available components
const availableComponents: ComponentInfo[] = [
{
code: 'calculator',
name: 'Calculator',
description: 'Perform mathematical operations on numbers',
icon: 'mdi-calculator',
className: 'S8n.Components.Basics.Calculator',
method: 'Calc',
component: defineAsyncComponent(() => import('../components_s8n/ComponentCalculator.vue')),
},
{
code: 'httprequest',
name: 'HTTP Request',
description: 'Make HTTP requests to any URL',
icon: 'mdi-web',
className: 'S8n.Components.Basics.HttpRequest',
method: 'Execute',
component: defineAsyncComponent(() => import('../components_s8n/ComponentHttpRequest.vue')),
},
];
const currentComponentInfo = computed(() => {
return availableComponents.find(c => c.code === selectedComponent.value);
});
const currentComponentComponent = computed(() => {
return currentComponentInfo.value?.component;
});
</script>
<style lang="scss" scoped>
.playground-page {
min-height: calc(100vh - 50px);
.full-height {
min-height: calc(100vh - 100px);
}
}
</style>

View File

@@ -4,9 +4,11 @@ const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
children: [
{ path: '', component: () => import('pages/IndexPage.vue') },
{ path: 'playground', component: () => import('pages/PlaygroundPage.vue') },
],
},
// Always leave this as last one,
// but you can also remove it
{