feat(relay): add transaction mode to requests (#49)

* feat(ui): initialize scroll masks onmount

* feat(relay): add transaction mode to requests

* test: set sqlite as default connection

* test(relay): add missing test

* chore: clearer access to implementation

* style: apply rector

* style: apply php style fixes

* test: ts type fixes
This commit is contained in:
Mazen Touati
2026-01-26 01:03:55 +01:00
committed by GitHub
parent 9118ad6d20
commit aa99eacd2c
26 changed files with 881 additions and 269 deletions

View File

@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--reka-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--reka-popover-content-transform-origin) rounded-md border p-2.5 shadow-md outline-hidden',
props.class,
)
"

View File

@@ -3,37 +3,11 @@
* @component RequestBuilderEndpoint
* @description The endpoint input and method selector for the request builder.
*/
import { AppButton } from '@/components/base/button';
import {
AppDropdownMenu,
AppDropdownMenuContent,
AppDropdownMenuGroup,
AppDropdownMenuItem,
AppDropdownMenuLabel,
AppDropdownMenuTrigger,
} from '@/components/base/dropdown-menu';
import { AppInput } from '@/components/base/input';
import {
AppSelect,
AppSelectContent,
AppSelectGroup,
AppSelectItem,
AppSelectLabel,
AppSelectSeparator,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { type RouteDefinition } from '@/interfaces/routes/routes';
import { useConfigStore, useRequestsHistoryStore, useRequestStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import { buildShareableUrl, encodeShareablePayload } from '@/utils/shareableLinks';
import { cn } from '@/utils/ui';
import { CodeXml, CornerDownLeftIcon, Link2, SparklesIcon } from 'lucide-vue-next';
import { computed, type HTMLAttributes, ref } from 'vue';
import { toast } from 'vue-sonner';
import CurlExportDialog from './CurlExportDialog.vue';
import ShareableLinkDialog from './ShareableLinkDialog.vue';
import { type HTMLAttributes } from 'vue';
import RequestBuilderEndpointInput from './RequestBuilderEndpointInput.vue';
import RequestBuilderMethodSelector from './RequestBuilderMethodSelector.vue';
import RequestBuilderOptionsMenu from './RequestBuilderOptionsMenu.vue';
/*
* Types & Interfaces.
@@ -48,240 +22,16 @@ export interface AppRequestBuilderEndpointProps {
*/
const props = defineProps<AppRequestBuilderEndpointProps>();
/*
* Stores.
*/
const requestStore = useRequestStore();
const configStore = useConfigStore();
const historyStore = useRequestsHistoryStore();
/*
* State.
*/
const showCurlDialog = ref(false);
const curlCommand = ref('');
const hasSpecialAuth = ref(false);
const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
const showShareableLinkDialog = ref(false);
const shareableLink = ref('');
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const endpoint = computed({
get: () => pendingRequestData.value?.endpoint ?? '',
set: (value: string) => requestStore.updateRequestEndpoint(value),
});
const method = computed({
get: () => pendingRequestData.value?.method?.toUpperCase() ?? 'GET',
set: (value: string) => requestStore.updateRequestMethod(value.toUpperCase()),
});
const currentRouteSupportedMethods = computed(() => {
const supportedRoutes = requestStore.pendingRequestData?.supportedRoutes;
const supportedMethods =
supportedRoutes?.map((route: RouteDefinition) => route.method) ?? [];
return availableMethods.filter((method: string) => supportedMethods.includes(method));
});
const currentRouteUnsupportedMethods = computed(() => {
return availableMethods.filter(
(method: string) => !currentRouteSupportedMethods.value.includes(method),
);
});
/*
* Route segment selection.
*/
const { handleClick: autoSelectRouteVariableSegmentWhenApplicable } =
useRouteSegmentSelection({ endpoint });
/*
* Actions.
*/
const executeCurrentRequest = async function () {
if (!requestStore.pendingRequestData) {
return;
}
await requestStore.executeCurrentRequest();
};
/**
* Executes request of Enter key is pressed.
*/
const executeCurrentRequestWhenEnterIsPressed = (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
executeCurrentRequest();
};
/**
* Generates and displays cURL command for the current request.
*/
const populateCurlCommandExporterDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
const result = generateCurlCommand(
requestStore.pendingRequestData,
configStore.apiUrl,
);
curlCommand.value = result.command;
hasSpecialAuth.value = result.hasSpecialAuth;
showCurlDialog.value = true;
};
/**
* Generates and shows shareable link dialog for the current request state.
*/
const openShareableLinkDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
try {
const lastLog = historyStore.lastLog;
const response = lastLog?.response;
const applicationKey = configStore.activeApplication ?? undefined;
const encodedPayload = encodeShareablePayload(
requestStore.pendingRequestData,
response,
lastLog ?? undefined,
applicationKey,
);
shareableLink.value = buildShareableUrl(configStore.appBasePath, encodedPayload);
showShareableLinkDialog.value = true;
} catch (error) {
console.error('Failed to generate shareable link:', error);
toast.error('Failed to generate shareable link', {
description: 'An unexpected error occurred.',
});
}
};
</script>
<template>
<div :class="cn('flex', props.class)" data-testid="request-builder-endpoint">
<AppSelect v-model="method">
<AppSelectTrigger
variant="toolbar"
class="h-full w-[95px] border-r pr-1.5 pl-5 text-xs"
>
<AppSelectValue :placeholder="method ? '' : 'Select a Method'">
{{ method || 'Select a Method' }}
</AppSelectValue>
</AppSelectTrigger>
<AppSelectContent :align-offset="2">
<AppSelectGroup v-if="currentRouteSupportedMethods.length">
<AppSelectLabel>Supported</AppSelectLabel>
<AppSelectItem
v-for="supportedMethod in currentRouteSupportedMethods"
:key="supportedMethod"
:value="supportedMethod"
>
{{ supportedMethod }}
</AppSelectItem>
</AppSelectGroup>
<AppSelectGroup v-if="currentRouteUnsupportedMethods.length !== 0">
<template v-if="currentRouteSupportedMethods.length">
<AppSelectSeparator />
<AppSelectLabel>Other</AppSelectLabel>
</template>
<AppSelectItem
v-for="unsupportedMethod in currentRouteUnsupportedMethods"
:key="unsupportedMethod"
:value="unsupportedMethod"
>
{{ unsupportedMethod }}
</AppSelectItem>
</AppSelectGroup>
</AppSelectContent>
</AppSelect>
<div class="flex flex-1 items-center">
<AppInput
ref="inputRef"
v-model="endpoint"
variant="toolbar"
class="h-full flex-1 text-xs"
placeholder="<endpoint>"
data-testid="endpoint-input"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>
<div class="flex gap-2 pr-2">
<AppButton
size="xs"
:disabled="!pendingRequestData || pendingRequestData?.isProcessing"
class="gap-0"
@click="executeCurrentRequest"
>
Send (
<CornerDownLeftIcon class="size-3 px-0" />
)
</AppButton>
<AppDropdownMenu>
<AppDropdownMenuTrigger as-child>
<AppButton
variant="outline"
size="xs"
:disabled="!pendingRequestData"
data-testid="request-options-button"
title="Request Options"
>
<SparklesIcon class="size-4" />
</AppButton>
</AppDropdownMenuTrigger>
<AppDropdownMenuContent align="end" class="w-48">
<AppDropdownMenuLabel>Export</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="export-curl-option"
@select="populateCurlCommandExporterDialog"
>
<CodeXml class="mr-2 size-2" />
<span>Export to cURL</span>
</AppDropdownMenuItem>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="copy-shareable-link-option"
@select="openShareableLinkDialog"
>
<Link2 class="mr-2 size-2" />
<span>Copy Shareable Link</span>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
</AppDropdownMenuContent>
</AppDropdownMenu>
</div>
</div>
<RequestBuilderMethodSelector />
<RequestBuilderEndpointInput>
<template #options-menu>
<RequestBuilderOptionsMenu />
</template>
</RequestBuilderEndpointInput>
</div>
<CurlExportDialog
v-model:open="showCurlDialog"
:command="curlCommand"
:has-special-auth="hasSpecialAuth"
/>
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
</template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
/**
* @component RequestBuilderEndpointInput
* @description The endpoint input field and send button for the request builder.
*/
import { AppButton } from '@/components/base/button';
import { AppInput } from '@/components/base/input';
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { useRequestStore } from '@/stores';
import { CornerDownLeftIcon } from 'lucide-vue-next';
import { computed } from 'vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const endpoint = computed({
get: () => pendingRequestData.value?.endpoint ?? '',
set: (value: string) => requestStore.updateRequestEndpoint(value),
});
const { handleClick: autoSelectRouteVariableSegmentWhenApplicable } =
useRouteSegmentSelection({ endpoint });
const executeCurrentRequest = async function () {
if (!requestStore.pendingRequestData) {
return;
}
await requestStore.executeCurrentRequest();
};
const executeCurrentRequestWhenEnterIsPressed = (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
executeCurrentRequest();
};
</script>
<template>
<div class="flex flex-1 items-center">
<AppInput
v-model="endpoint"
variant="toolbar"
class="h-full flex-1 text-xs"
placeholder="<endpoint>"
data-testid="endpoint-input"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>
<div class="flex gap-2 pr-2">
<AppButton
size="xs"
:disabled="!pendingRequestData || pendingRequestData?.isProcessing"
class="gap-0"
@click="executeCurrentRequest"
>
Send (
<CornerDownLeftIcon class="size-3 px-0" />
)
</AppButton>
<slot name="options-menu" />
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* @component RequestBuilderMethodSelector
* @description HTTP method selector for the request builder, with route support awareness.
*/
import {
AppSelect,
AppSelectContent,
AppSelectGroup,
AppSelectItem,
AppSelectLabel,
AppSelectSeparator,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { type RouteDefinition } from '@/interfaces/routes/routes';
import { useRequestStore } from '@/stores';
import { computed } from 'vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
/*
* State.
*/
const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
/*
* Computed & Methods.
*/
const method = computed({
get: () => requestStore.pendingRequestData?.method?.toUpperCase() ?? 'GET',
set: (value: string) => requestStore.updateRequestMethod(value.toUpperCase()),
});
const currentRouteSupportedMethods = computed(() => {
const supportedRoutes = requestStore.pendingRequestData?.supportedRoutes;
const supportedMethods =
supportedRoutes?.map((route: RouteDefinition) => route.method) ?? [];
return availableMethods.filter((m: string) => supportedMethods.includes(m));
});
const currentRouteUnsupportedMethods = computed(() => {
return availableMethods.filter(
(m: string) => !currentRouteSupportedMethods.value.includes(m),
);
});
</script>
<template>
<AppSelect v-model="method">
<AppSelectTrigger
variant="toolbar"
class="h-full w-[95px] border-r pr-1.5 pl-5 text-xs"
>
<AppSelectValue :placeholder="method ? '' : 'Select a Method'">
{{ method || 'Select a Method' }}
</AppSelectValue>
</AppSelectTrigger>
<AppSelectContent :align-offset="2">
<AppSelectGroup v-if="currentRouteSupportedMethods.length">
<AppSelectLabel>Supported</AppSelectLabel>
<AppSelectItem
v-for="supportedMethod in currentRouteSupportedMethods"
:key="supportedMethod"
:value="supportedMethod"
>
{{ supportedMethod }}
</AppSelectItem>
</AppSelectGroup>
<AppSelectGroup v-if="currentRouteUnsupportedMethods.length !== 0">
<template v-if="currentRouteSupportedMethods.length">
<AppSelectSeparator />
<AppSelectLabel>Other</AppSelectLabel>
</template>
<AppSelectItem
v-for="unsupportedMethod in currentRouteUnsupportedMethods"
:key="unsupportedMethod"
:value="unsupportedMethod"
>
{{ unsupportedMethod }}
</AppSelectItem>
</AppSelectGroup>
</AppSelectContent>
</AppSelect>
</template>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
/**
* @component RequestBuilderOptionsMenu
* @description Dropdown menu for request options like Transaction Mode, cURL export, and shareable links.
*/
import { AppButton } from '@/components/base/button';
import {
AppDropdownMenu,
AppDropdownMenuContent,
AppDropdownMenuGroup,
AppDropdownMenuItem,
AppDropdownMenuLabel,
AppDropdownMenuSeparator,
AppDropdownMenuTrigger,
} from '@/components/base/dropdown-menu';
import {
AppPopover,
AppPopoverContent,
AppPopoverTrigger,
} from '@/components/base/popover';
import { AppSwitch } from '@/components/base/switch';
import { useConfigStore, useRequestsHistoryStore, useRequestStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import { buildShareableUrl, encodeShareablePayload } from '@/utils/shareableLinks';
import { CircleHelp, CodeXml, Link2, SparklesIcon } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import CurlExportDialog from './CurlExportDialog.vue';
import ShareableLinkDialog from './ShareableLinkDialog.vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
const configStore = useConfigStore();
const historyStore = useRequestsHistoryStore();
/*
* State.
*/
const showCurlDialog = ref(false);
const curlCommand = ref('');
const hasSpecialAuth = ref(false);
const showShareableLinkDialog = ref(false);
const shareableLink = ref('');
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const transactionMode = computed({
get: () => pendingRequestData.value?.transactionMode ?? false,
set: (value: boolean) => requestStore.updateTransactionMode(value),
});
/**
* Generates and displays cURL command for the current request.
*/
const populateCurlCommandExporterDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
const result = generateCurlCommand(
requestStore.pendingRequestData,
configStore.apiUrl,
);
curlCommand.value = result.command;
hasSpecialAuth.value = result.hasSpecialAuth;
showCurlDialog.value = true;
};
/**
* Generates and shows shareable link dialog for the current request state.
*/
const openShareableLinkDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
try {
const lastLog = historyStore.lastLog;
const response = lastLog?.response;
const applicationKey = configStore.activeApplication ?? undefined;
const encodedPayload = encodeShareablePayload(
requestStore.pendingRequestData,
response,
lastLog ?? undefined,
applicationKey,
);
shareableLink.value = buildShareableUrl(configStore.appBasePath, encodedPayload);
showShareableLinkDialog.value = true;
} catch (error) {
console.error('Failed to generate shareable link:', error);
toast.error('Failed to generate shareable link', {
description: 'An unexpected error occurred.',
});
}
};
</script>
<template>
<AppDropdownMenu>
<AppDropdownMenuTrigger as-child>
<AppButton
variant="outline"
size="xs"
:disabled="!pendingRequestData"
data-testid="request-options-button"
title="Request Options"
>
<SparklesIcon class="size-4" />
</AppButton>
</AppDropdownMenuTrigger>
<AppDropdownMenuContent align="end" class="w-48">
<AppDropdownMenuLabel>Options</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="transaction-mode-option"
@select.prevent
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-1.5 font-normal">
<span>Transaction Mode</span>
<AppPopover>
<AppPopoverTrigger
ref="transaction-mode-option"
as-child
@click.stop.prevent
>
<CircleHelp
class="h-3.5 w-3.5 cursor-help text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
/>
</AppPopoverTrigger>
<AppPopoverContent
class="w-80"
side="right"
:side-offset="5"
>
<div class="space-y-2">
<h4 class="text-sm leading-none font-medium">
Transaction Mode
</h4>
<p
class="text-muted-foreground text-xs leading-relaxed"
>
Executes the request within a database
transaction that is automatically rolled back
after completion. This allows you to test
operations without affecting your persistent
data.
</p>
</div>
</AppPopoverContent>
</AppPopover>
</div>
<AppSwitch
v-model="transactionMode"
:variant="{ type: 'compact', default: 'default' }"
class="ml-2"
@click.stop
/>
</div>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
<AppDropdownMenuSeparator />
<AppDropdownMenuLabel>Export</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="export-curl-option"
@select="populateCurlCommandExporterDialog"
>
<CodeXml class="mr-2 size-4" />
<span>Export to cURL</span>
</AppDropdownMenuItem>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="copy-shareable-link-option"
@select="openShareableLinkDialog"
>
<Link2 class="mr-2 size-4" />
<span>Copy Shareable Link</span>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
</AppDropdownMenuContent>
</AppDropdownMenu>
<CurlExportDialog
v-model:open="showCurlDialog"
:command="curlCommand"
:has-special-auth="hasSpecialAuth"
/>
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
</template>

View File

@@ -3,12 +3,14 @@
* @component ResponseViewerResponse
* @description Renders the successful response details, including body, headers, and cookies.
*/
import { AppBadge } from '@/components/base/badge';
import {
AppTabs,
AppTabsContent,
AppTabsList,
AppTabsTrigger,
} from '@/components/base/tabs';
import { AppTooltipWrapper } from '@/components/base/tooltip';
import ResponseBody from '@/components/domain/Client/Response/ResponseBody/ResponseBody.vue';
import ResponseDumpAndDie from '@/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.vue';
import ResponseCookies from '@/components/domain/Client/Response/ResponseCookies/ResponseCookies.vue';
@@ -18,6 +20,7 @@ import { STATUS } from '@/interfaces/http';
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
import { uniquePersistenceKey } from '@/utils/stores';
import { useStorage } from '@vueuse/core';
import { DatabaseBackupIcon } from 'lucide-vue-next';
import { computed } from 'vue';
/*
@@ -60,6 +63,10 @@ const {
const lastLog = computed(() => historyStore.lastLog);
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const showTransactionAlert = computed(() => {
return lastLog.value?.request.transactionMode;
});
const handleTabClick = (event: Event) => {
scrollTabIntoView(event.currentTarget as HTMLElement);
};
@@ -76,11 +83,11 @@ const handleTabClick = (event: Event) => {
class="mt-0 flex h-full flex-col overflow-auto"
@update:model-value="tab = $event as string"
>
<div class="bg-subtle border-b">
<div class="relative">
<div class="bg-subtle flex items-center justify-between border-b">
<div class="relative min-w-0 flex-1">
<div
ref="scrollContainer"
class="scrollbar-hide overflow-x-auto"
class="scrollbar-hide flex items-center justify-between overflow-x-auto"
style="scrollbar-width: none; -ms-overflow-style: none"
@scroll="updateScrollMasks"
>
@@ -113,6 +120,20 @@ const handleTabClick = (event: Event) => {
class="from-subtle via-subtle/80 pointer-events-none absolute top-0 right-0 bottom-0 w-8 bg-gradient-to-l to-transparent transition-opacity duration-200"
/>
</div>
<div class="pr-panel">
<AppTooltipWrapper
v-if="showTransactionAlert"
value="Changes were automatically rolled back for this request"
>
<div class="flex items-center">
<AppBadge variant="outline" class="gap-1">
<DatabaseBackupIcon class="size-3 min-w-3" />
Transaction Mode
</AppBadge>
</div>
</AppTooltipWrapper>
</div>
</div>
<AppTabsContent
value="response"

View File

@@ -141,9 +141,18 @@ export function useHttpClient(): UseHttpClientResult {
const payload = createRelayPayload(request);
const formData = convertPayloadToFormData(payload);
const headers: Record<string, string> = {
'Content-Type': 'multipart/form-data',
};
// Add transaction mode header if enabled
if (request.transactionMode) {
headers['X-Nimbus-Transaction-Mode'] = '1';
}
axios
.post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
headers,
// Prevent Axios from parsing JSON automatically as we are handling it manually.
transformResponse: response => response,
signal: abortController.value?.signal,

View File

@@ -172,6 +172,8 @@ export function useTabHorizontalScroll(
onMounted(() => {
cleanupScrollListeners = setupScrollListeners() ?? null;
debouncedUpdateMasks();
});
onUnmounted(() => {

View File

@@ -103,6 +103,9 @@ export interface PendingRequest {
/** Whether the request was executed at least once */
wasExecuted?: boolean;
/** Whether to execute the request in transaction mode (rollback on completion) */
transactionMode?: boolean;
}
export interface Request {
@@ -114,4 +117,5 @@ export interface Request {
payloadType: RequestBodyTypeEnum;
authorization: AuthorizationContract;
routeDefinition: RouteDefinition;
transactionMode?: boolean;
}

View File

@@ -174,6 +174,7 @@ export const useRequestBuilderStore = defineStore(
isProcessing: false,
wasExecuted: false,
durationInMs: 0,
transactionMode: false,
};
// Ensure global headers are synced for the fresh request
@@ -258,6 +259,17 @@ export const useRequestBuilderStore = defineStore(
pendingRequestData.value.authorization = authorization;
};
/**
* Updates the transaction mode for the current request.
*/
const updateTransactionMode = (transactionMode: boolean) => {
if (!pendingRequestData.value) {
return;
}
pendingRequestData.value.transactionMode = transactionMode;
};
/**
* Resets the current request to null state.
*/
@@ -370,6 +382,7 @@ export const useRequestBuilderStore = defineStore(
}
: {}),
wasExecuted: true,
transactionMode: pendingRequestData.value.transactionMode ?? false,
};
};
@@ -440,6 +453,7 @@ export const useRequestBuilderStore = defineStore(
isProcessing: false,
wasExecuted,
durationInMs: payload.durationInMs ?? 0,
transactionMode: false,
};
};
@@ -476,6 +490,7 @@ export const useRequestBuilderStore = defineStore(
updateRequestBody,
updateQueryParameters,
updateAuthorization,
updateTransactionMode,
resetRequest,
syncGlobalHeadersWhenApplicable,
getRequestUrl,

View File

@@ -67,6 +67,7 @@ export const useRequestStore = defineStore('request', () => {
updateRequestBody: builderStore.updateRequestBody,
updateQueryParameters: builderStore.updateQueryParameters,
updateAuthorization: builderStore.updateAuthorization,
updateTransactionMode: builderStore.updateTransactionMode,
getRequestUrl: builderStore.getRequestUrl,
restoreFromHistory: builderStore.restoreFromHistory,

View File

@@ -0,0 +1,93 @@
import RequestBuilderEndpointInput from '@/components/domain/Client/Request/RequestBuilderEndpointInput.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateRequestEndpoint: vi.fn(),
executeCurrentRequest: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
};
});
vi.mock('@/composables/request/useRouteSegmentSelection', () => ({
useRouteSegmentSelection: () => ({
handleClick: vi.fn(),
}),
}));
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderEndpointInput, {
...options,
global: {
plugins: [createPinia()],
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderEndpointInput', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
endpoint: '/users/1',
isProcessing: false,
} as unknown as PendingRequest;
});
it('renders the current endpoint', () => {
const wrapper = createWrapper();
const input = wrapper.find('[data-testid="endpoint-input"]');
expect((input.element as HTMLInputElement).value).toBe('/users/1');
});
it('updates the endpoint when input changes', async () => {
const wrapper = createWrapper();
const input = wrapper.find('[data-testid="endpoint-input"]');
await input.setValue('/users/2');
expect(mockRequestStore.updateRequestEndpoint).toHaveBeenCalledWith('/users/2');
});
it('executes the request when the send button is clicked', async () => {
const wrapper = createWrapper();
const button = wrapper.find('button');
await button.trigger('click');
expect(mockRequestStore.executeCurrentRequest).toHaveBeenCalled();
});
it('disables the send button when processing', () => {
if (mockRequestStore.pendingRequestData) {
mockRequestStore.pendingRequestData.isProcessing = true;
}
const wrapper = createWrapper();
const button = wrapper.find('button');
expect(button.attributes('disabled')).toBeDefined();
});
});

View File

@@ -0,0 +1,83 @@
import RequestBuilderMethodSelector from '@/components/domain/Client/Request/RequestBuilderMethodSelector.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateRequestMethod: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
};
});
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderMethodSelector, {
...options,
global: {
plugins: [createPinia()],
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderMethodSelector', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
method: 'GET',
supportedRoutes: [
{ method: 'GET', endpoint: '/test' },
{ method: 'POST', endpoint: '/test' },
],
} as unknown as PendingRequest;
});
it('renders the current method', () => {
const wrapper = createWrapper();
expect(wrapper.text()).toContain('GET');
});
it('updates the method when a new one is selected', async () => {
const wrapper = createWrapper();
// Simulate selection (depends on how AppSelect works, but we can verify the computed property)
// Since we are unit testing the component's internal logic:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.method = 'POST';
expect(mockRequestStore.updateRequestMethod).toHaveBeenCalledWith('POST');
});
it('identifies supported and unsupported methods correctly', () => {
const wrapper = createWrapper();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.currentRouteSupportedMethods).toContain('GET');
expect(vm.currentRouteSupportedMethods).toContain('POST');
expect(vm.currentRouteUnsupportedMethods).toContain('PUT');
expect(vm.currentRouteUnsupportedMethods).toContain('PATCH');
expect(vm.currentRouteUnsupportedMethods).toContain('DELETE');
});
});

View File

@@ -0,0 +1,86 @@
import RequestBuilderOptionsMenu from '@/components/domain/Client/Request/RequestBuilderOptionsMenu.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateTransactionMode: vi.fn(),
});
const mockConfigStore = reactive({
apiUrl: 'http://localhost',
activeApplication: 'main',
appBasePath: '/nimbus',
});
const mockHistoryStore = reactive({
lastLog: null,
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
useConfigStore: () => mockConfigStore,
useRequestsHistoryStore: () => mockHistoryStore,
};
});
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderOptionsMenu, {
...options,
global: {
plugins: [createPinia()],
stubs: {
CurlExportDialog: true,
ShareableLinkDialog: true,
AppPopover: true,
AppPopoverContent: true,
AppPopoverTrigger: true,
},
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderOptionsMenu', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
transactionMode: false,
} as unknown as PendingRequest;
});
it('renders the options button', () => {
const wrapper = createWrapper();
expect(wrapper.find('[data-testid="request-options-button"]').exists()).toBe(
true,
);
});
it('updates transaction mode via computed property', async () => {
const wrapper = createWrapper();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.transactionMode = true;
expect(mockRequestStore.updateTransactionMode).toHaveBeenCalledWith(true);
});
});

View File

@@ -69,6 +69,7 @@ const pendingRequestToRequestLogEntry = function (request: PendingRequest): Requ
payloadType: request.payloadType,
authorization: { ...request.authorization },
routeDefinition: { ...request.routeDefinition },
transactionMode: request.transactionMode,
};
};