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:
@@ -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,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -172,6 +172,8 @@ export function useTabHorizontalScroll(
|
||||
|
||||
onMounted(() => {
|
||||
cleanupScrollListeners = setupScrollListeners() ?? null;
|
||||
|
||||
debouncedUpdateMasks();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@ const pendingRequestToRequestLogEntry = function (request: PendingRequest): Requ
|
||||
payloadType: request.payloadType,
|
||||
authorization: { ...request.authorization },
|
||||
routeDefinition: { ...request.routeDefinition },
|
||||
transactionMode: request.transactionMode,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user