feat(history): add history viewer and rewind (#38)

* feat(ui): add `input group` base component

* feat(history): add history viewer and rewind

* test: update selector snapshot

* test: add PW base page

* style: apply TS style fixes

* chore(history): request history wiki

* chore(history): remove unwanted symbol

* chore: fix type

* style: apply TS style fixes
This commit is contained in:
Mazen Touati
2026-01-17 20:50:00 +01:00
committed by GitHub
parent 7b0af6feff
commit e1b844cee0
63 changed files with 2804 additions and 892 deletions

View File

@@ -22,7 +22,7 @@ const forwardedProps = useForwardProps(delegatedProps);
<template>
<DropdownMenuLabel
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', props.class)"
:class="cn('px-2 py-1.5 text-xs', inset && 'pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { cn } from '@/utils';
import type { HTMLAttributes } from 'vue';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<div
data-slot="input-group"
role="group"
:class="
cn(
'group/input-group relative flex w-full items-center rounded-sm border border-zinc-200 outline-none dark:border-zinc-800 dark:bg-zinc-200/30 dark:dark:bg-zinc-800/30',
'h-9 min-w-0 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-zinc-950 dark:has-[[data-slot=input-group-control]:focus-visible]:ring-zinc-300',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { cn } from '@/utils';
import type { HTMLAttributes } from 'vue';
import type { InputGroupVariants } from '.';
import { inputGroupAddonVariants } from '.';
const props = withDefaults(
defineProps<{
align?: InputGroupVariants['align'];
class?: HTMLAttributes['class'];
}>(),
{
align: 'inline-start',
class: undefined,
},
);
function handleInputGroupAddonClick(e: MouseEvent) {
const currentTarget = e.currentTarget as HTMLElement | null;
const target = e.target as HTMLElement | null;
if (target && target.closest('button')) {
return;
}
if (currentTarget && currentTarget?.parentElement) {
currentTarget.parentElement?.querySelector('input')?.focus();
}
}
</script>
<template>
<div
role="group"
data-slot="input-group-addon"
:data-align="props.align"
:class="cn(inputGroupAddonVariants({ align: props.align }), props.class)"
@click="handleInputGroupAddonClick"
>
<slot />
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { AppButton } from '@/components/base/button';
import { cn } from '@/utils';
import type { InputGroupButtonProps } from '.';
import { inputGroupButtonVariants } from '.';
const props = withDefaults(defineProps<InputGroupButtonProps>(), {
size: 'xs',
variant: 'ghost',
});
</script>
<template>
<AppButton
:data-size="props.size"
:variant="props.variant"
:class="cn(inputGroupButtonVariants({ size: props.size }), props.class)"
>
<slot />
</AppButton>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { AppInput } from '@/components/base/input';
import { cn } from '@/utils';
import type { HTMLAttributes } from 'vue';
import { ref } from 'vue';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
const inputRef = ref<InstanceType<typeof AppInput> | null>(null);
defineExpose({
focus: () => {
// AppInput renders a native input element, so we access it via $el
const inputElement = inputRef.value?.$el as HTMLInputElement | undefined;
inputElement?.focus();
},
});
</script>
<template>
<AppInput
ref="inputRef"
data-slot="input-group-control"
:class="
cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-offset-transparent focus-visible:ring-0 focus-visible:ring-transparent dark:bg-transparent',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/utils';
import type { HTMLAttributes } from 'vue';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<span
:class="
cn(
'flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)
"
>
<slot />
</span>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { AppTextarea } from '@/components/base/textarea';
import { cn } from '@/utils';
import type { HTMLAttributes } from 'vue';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<AppTextarea
data-slot="input-group-control"
:class="
cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none ring-offset-transparent focus-visible:ring-0 focus-visible:ring-transparent dark:bg-transparent',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1,59 @@
import type { ButtonVariants } from '@/components/base/button';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { HTMLAttributes } from 'vue';
export { default as AppInputGroup } from './AppInputGroup.vue';
export { default as AppInputGroupAddon } from './AppInputGroupAddon.vue';
export { default as AppInputGroupButton } from './AppInputGroupButton.vue';
export { default as AppInputGroupInput } from './AppInputGroupInput.vue';
export { default as AppInputGroupText } from './AppInputGroupText.vue';
export { default as AppInputGroupTextarea } from './AppInputGroupTextarea.vue';
export const inputGroupAddonVariants = cva(
"text-zinc-500 flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50 dark:text-zinc-400",
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
);
export type InputGroupVariants = VariantProps<typeof inputGroupAddonVariants>;
export const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center',
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
);
export type InputGroupButtonVariants = VariantProps<typeof inputGroupButtonVariants>;
export interface InputGroupButtonProps {
variant?: ButtonVariants['variant'];
size?: InputGroupButtonVariants['size'];
class?: HTMLAttributes['class'];
}

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { cn } from '@/utils';
import { useVModel } from '@vueuse/core';
import type { HTMLAttributes } from 'vue';
const props = defineProps<{
class?: HTMLAttributes['class'];
defaultValue?: string | number;
modelValue?: string | number;
}>();
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void;
}>();
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="
cn(
'flex field-sizing-content min-h-16 w-full rounded-md border border-zinc-200 bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-zinc-500 focus-visible:border-zinc-950 focus-visible:ring-[3px] focus-visible:ring-zinc-950/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-red-500 aria-invalid:ring-red-500/20 md:text-sm dark:border-zinc-800 dark:bg-zinc-200/30 dark:dark:bg-zinc-800/30 dark:placeholder:text-zinc-400 dark:focus-visible:border-zinc-300 dark:focus-visible:ring-zinc-300/50 dark:aria-invalid:border-red-900 dark:aria-invalid:ring-red-500/40 dark:aria-invalid:ring-red-900/20 dark:dark:aria-invalid:ring-red-900/40',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as AppTextarea } from './AppTextarea.vue';

View File

@@ -11,7 +11,7 @@ import {
} from '@/components/base/select';
import { AppSwitch } from '@/components/base/switch';
import { useKeyValueParameters } from '@/composables/ui/useKeyValueParameters';
import { ExtendedParameter, ParametersExternalContract } from '@/interfaces/ui';
import { ParameterContract } from '@/interfaces/ui';
import { useValueGeneratorStore } from '@/stores';
import { cn } from '@/utils';
import {
@@ -25,37 +25,36 @@ import { computed, type HTMLAttributes, ref } from 'vue';
import AppTooltipWrapper from '../../base/tooltip/AppTooltipWrapper.vue';
/*
* Props.
* Props and Emits.
*/
const props = withDefaults(
defineProps<{
modelValue?: ParameterContract[];
freeFormTypes?: boolean;
class?: HTMLAttributes['class'];
persistenceKey?: string;
}>(),
{
modelValue: () => [],
freeFormTypes: false,
class: undefined,
persistenceKey: undefined,
},
);
/*
* Model.
*/
const model = defineModel<ParametersExternalContract[]>();
const modelRef = computed({
get: () => model.value ?? [],
set: value => (model.value = value),
});
const emit = defineEmits<{
'update:parameters': [parameters: ParameterContract[]];
}>();
/*
* Composables.
*/
const modelValueRef = computed(() => props.modelValue);
const handleParametersUpdate = (parameters: ParameterContract[]) => {
emit('update:parameters', parameters);
};
const {
parameters,
deletingAll,
@@ -65,7 +64,7 @@ const {
toggleAllParametersEnabledState,
triggerParameterDeletion,
deleteAllParameters,
} = useKeyValueParameters(modelRef, props.persistenceKey);
} = useKeyValueParameters(modelValueRef, handleParametersUpdate);
const { openCommand, closeCommand } = useValueGeneratorStore();
@@ -116,7 +115,7 @@ const handleDeleteParameter = (index: number) => {
* Computed Properties.
*/
const shouldShowGeneratorIcon = (index: number, parameter: ExtendedParameter) => {
const shouldShowGeneratorIcon = (index: number, parameter: ParameterContract) => {
return focusedInputIndex.value === index && parameter.enabled;
};
</script>
@@ -257,7 +256,7 @@ const shouldShowGeneratorIcon = (index: number, parameter: ExtendedParameter) =>
class="size-4"
:class="{
'text-rose-500 dark:text-rose-700':
isParameterMarkedForDeletion(parameter.id),
isParameterMarkedForDeletion(index),
}"
/>
</AppTooltipWrapper>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import KeyValueParametersBuilder from '@/components/common/KeyValueParameters/KeyValueParameters.vue';
import { ParametersExternalContract } from '@/interfaces/ui';
import { ParameterContract } from '@/interfaces/ui';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { nextTick, ref, watch } from 'vue';
/*
@@ -20,7 +21,7 @@ const emit = defineEmits(['update:modelValue']);
// A guard flag to prevent endless syncing looping between the component and parent as it will mutate its dependency.
const isPropagatingChangesToParent = ref(false);
const payload = ref<ParametersExternalContract[]>([]);
const payload = ref<ParameterContract[]>([]);
/*
* Watchers.
@@ -51,23 +52,17 @@ watch(
{ deep: true },
);
watch(
payload,
() => {
isPropagatingChangesToParent.value = true;
emit('update:modelValue', convertParametersArrayToFormData(payload.value));
},
{ deep: true },
);
const handlePayloadUpdate = (parameters: ParameterContract[]) => {
isPropagatingChangesToParent.value = true;
payload.value = parameters;
emit('update:modelValue', convertParametersArrayToFormData(parameters));
};
/*
* Actions.
*/
function convertParametersArrayToFormData(
parameters: ParametersExternalContract[],
): FormData {
function convertParametersArrayToFormData(parameters: ParameterContract[]): FormData {
const formData = new FormData();
for (const parameter of parameters) {
@@ -89,8 +84,8 @@ function convertParametersArrayToFormData(
return formData;
}
function convertFormDataToParametersArray(form: FormData): ParametersExternalContract[] {
const parameters: ParametersExternalContract[] = [];
function convertFormDataToParametersArray(form: FormData): ParameterContract[] {
const parameters: ParameterContract[] = [];
form.forEach((value: FormDataEntryValue, key: string) => {
if (value instanceof File) {
@@ -98,18 +93,20 @@ function convertFormDataToParametersArray(form: FormData): ParametersExternalCon
// Note: File uploads are not properly tested or verified.
// TODO [Feature] Properly support file uploads.
parameters.push({
type: 'file',
type: ParameterType.File,
key: key,
value: value.name,
enabled: true,
});
return;
}
parameters.push({
type: 'text',
type: ParameterType.Text,
key: key,
value: value,
enabled: true,
});
});
@@ -118,5 +115,9 @@ function convertFormDataToParametersArray(form: FormData): ParametersExternalCon
</script>
<template>
<KeyValueParametersBuilder v-model="payload" :free-form-types="true" />
<KeyValueParametersBuilder
:model-value="payload"
:free-form-types="true"
@update:parameters="handlePayloadUpdate"
/>
</template>

View File

@@ -23,7 +23,10 @@ const tab = useStorage(uniquePersistenceKey('request-builder-tab'), 'body');
class="relative flex h-full max-h-full flex-1 flex-col"
data-testid="request-builder-root"
>
<RequestBuilderEndpoint class="h-toolbar border-b" />
<RequestBuilderEndpoint
class="h-toolbar border-b"
data-testid="request-builder-endpoint"
/>
<AppTabs
:default-value="tab"
class="mt-0 flex flex-1 flex-col overflow-hidden"
@@ -41,24 +44,28 @@ const tab = useStorage(uniquePersistenceKey('request-builder-tab'), 'body');
<AppTabsContent
value="parameters"
class="mt-0 flex max-h-full min-h-0 flex-1 flex-col"
data-testid="request-parameters"
>
<RequestParameters />
</AppTabsContent>
<AppTabsContent
value="body"
class="mt-0 flex max-h-full min-h-0 flex-1 flex-col"
data-testid="request-body"
>
<RequestBody />
</AppTabsContent>
<AppTabsContent
value="authorization"
class="mt-0 flex max-h-full min-h-0 flex-1 flex-col"
data-testid="request-authorization"
>
<RequestAuthorization />
</AppTabsContent>
<AppTabsContent
value="headers"
class="mt-0 flex max-h-full min-h-0 flex-1 flex-col"
data-testid="request-headers"
>
<RequestHeaders />
</AppTabsContent>

View File

@@ -1,128 +0,0 @@
<script setup lang="ts">
import KeyValueParametersBuilder from '@/components/common/KeyValueParameters/KeyValueParameters.vue';
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
import {
GeneratorType,
PendingRequest,
RequestHeader,
SourceGlobalHeaders,
} from '@/interfaces/http';
import { ParametersExternalContract } from '@/interfaces/ui';
import { useConfigStore, useRequestStore, useValueGeneratorStore } from '@/stores';
import { computed, onBeforeMount, ref, watch } from 'vue';
const requestStore = useRequestStore();
const configStore = useConfigStore();
const valueGeneratorStore = useValueGeneratorStore();
const headers = ref<RequestHeader[]>([]);
const pendingRequestData = computed(() => requestStore.pendingRequestData);
let globalHeaders: RequestHeader[] = [];
/**
* Converts RequestHeader[] to ParameterContractShape[] for the KeyValueParameters component.
*/
const headersAsParameters = computed({
get: (): ParametersExternalContract[] => {
return headers.value.map((header: RequestHeader) => ({
type: 'text',
key: header.key,
value: String(header.value),
}));
},
set: (parameters: ParametersExternalContract[]) => {
headers.value = parameters.map(
(parameter: ParametersExternalContract): RequestHeader => ({
key: parameter.key,
value: parameter.value as string | number | boolean | null,
}),
);
},
});
const generateValue = (value: GeneratorType): string => {
switch (value) {
case GeneratorType.Uuid:
return valueGeneratorStore.generateValue('uuid') as string;
case GeneratorType.Email:
return valueGeneratorStore.generateValue('email') as string;
case GeneratorType.String:
return valueGeneratorStore.generateValue('word') as string;
default:
return valueGeneratorStore.generateValue('word') as string;
}
};
const syncHeadersWithPendingRequest = () => {
if (pendingRequestData.value === null) {
return;
}
requestStore.updateRequestHeaders(
headers.value.filter((header: RequestHeader) => header.value !== null),
);
};
const enrichWithGlobalHeaders = (pendingRequest: PendingRequest | null) => {
const currentHeaders = pendingRequest?.headers ?? [];
const currentHeaderKeys = currentHeaders.map((header: RequestHeader) => header.key);
const missingGlobalHeaders = globalHeaders.filter(
(header: RequestHeader) => !currentHeaderKeys.includes(header.key),
);
headers.value = [...missingGlobalHeaders, ...currentHeaders];
};
/*
* Watchers.
*/
watch(headers, () => syncHeadersWithPendingRequest(), { deep: true });
watch(
pendingRequestData,
(newValue, oldValue) => {
// Only reinitialize if endpoint actually changed
if (
newValue?.endpoint === oldValue?.endpoint &&
newValue?.method === oldValue?.method
) {
return;
}
enrichWithGlobalHeaders(oldValue);
},
{ deep: true },
);
/*
* Lifecycle.
*/
onBeforeMount(() => {
globalHeaders = configStore.headers.map(
(globalHeader: SourceGlobalHeaders): RequestHeader => ({
key: globalHeader.header,
value:
globalHeader.type === 'generator'
? generateValue(globalHeader.value as GeneratorType)
: globalHeader.value,
}),
);
enrichWithGlobalHeaders(pendingRequestData.value);
});
</script>
<template>
<PanelSubHeader class="border-b">Request Headers</PanelSubHeader>
<KeyValueParametersBuilder
ref="parametersBuilder"
v-model="headersAsParameters"
persistence-key="pending-request-headers"
/>
</template>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import KeyValueParametersBuilder from '@/components/common/KeyValueParameters/KeyValueParameters.vue';
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
import { GeneratorType, SourceGlobalHeaders } from '@/interfaces/http';
import { ParameterContract } from '@/interfaces/ui';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { useConfigStore, useRequestStore, useValueGeneratorStore } from '@/stores';
import { computed, onBeforeMount, Ref, ref } from 'vue';
const requestStore = useRequestStore();
const configStore = useConfigStore();
const valueGeneratorStore = useValueGeneratorStore();
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const globalHeaders: Ref<ParameterContract[]> = ref([]);
const generateValue = (value: GeneratorType): string => {
switch (value) {
case GeneratorType.Uuid:
return valueGeneratorStore.generateValue('uuid') as string;
case GeneratorType.Email:
return valueGeneratorStore.generateValue('email') as string;
case GeneratorType.String:
return valueGeneratorStore.generateValue('word') as string;
default:
return valueGeneratorStore.generateValue('word') as string;
}
};
const syncHeadersWithPendingRequest = (headers: ParameterContract[]) => {
requestStore.updateRequestHeaders(headers);
};
const currentRequestHeaders = computed<ParameterContract[]>(
() => pendingRequestData.value?.headers ?? [],
);
const effectiveHeaders = computed<ParameterContract[]>(() => {
const currentHeaders = currentRequestHeaders.value;
if (currentHeaders.length === 0) {
return globalHeaders.value;
}
// Don't mess up with the current headers if the pending request have them already.
// They can be coming from history re-wind or persisted state.
return currentHeaders;
});
const handleHeadersUpdate = (parameters: ParameterContract[]) => {
syncHeadersWithPendingRequest(parameters);
};
/*
* Lifecycle.
*/
onBeforeMount(() => {
globalHeaders.value = configStore.headers.map(
(globalHeader: SourceGlobalHeaders): ParameterContract => ({
type: ParameterType.Text,
key: globalHeader.header,
value:
globalHeader.type === 'generator'
? generateValue(globalHeader.value as GeneratorType)
: String(globalHeader.value),
enabled: true,
}),
);
});
</script>
<template>
<PanelSubHeader class="border-b">Request Headers</PanelSubHeader>
<KeyValueParametersBuilder
ref="parametersBuilder"
:model-value="effectiveHeaders"
@update:parameters="handleHeadersUpdate"
/>
</template>

View File

@@ -2,10 +2,10 @@
import CopyButton from '@/components/common/CopyButton.vue';
import KeyValueParametersBuilder from '@/components/common/KeyValueParameters/KeyValueParameters.vue';
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
import { ParametersExternalContract } from '@/interfaces/ui';
import { ParameterContract } from '@/interfaces/ui';
import { useRequestStore } from '@/stores';
import { useClipboard, watchDebounced } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { useClipboard } from '@vueuse/core';
import { computed } from 'vue';
/*
* Stores & dependencies.
@@ -14,64 +14,29 @@ import { computed, ref, watch } from 'vue';
const requestStore = useRequestStore();
const { copy, copied: previewCopied } = useClipboard();
/*
* State.
*/
const parameters = ref<ParametersExternalContract[]>([]);
const preview = ref<string>('');
/*
* Computed.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const currentRequestQueryParameters = computed<ParameterContract[]>(
() => pendingRequestData.value?.queryParameters ?? [],
);
const handleQueryParametersUpdate = (parameters: ParameterContract[]) => {
requestStore.updateQueryParameters(parameters);
};
const preview = computed(() =>
pendingRequestData.value ? requestStore.getRequestUrl(pendingRequestData.value) : '',
);
/*
* Actions.
*/
const copyPreview = () => copy(preview.value);
/*
* Watchers.
*/
watchDebounced(
parameters,
() => {
if (pendingRequestData.value === null) {
return;
}
requestStore.updateQueryParameters(parameters.value);
preview.value = requestStore.getRequestUrl(pendingRequestData.value);
},
{ deep: true, debounce: 200 },
);
watch(
() => pendingRequestData.value?.endpoint,
(newEndpoint, oldEndpoint) => {
if (newEndpoint === oldEndpoint) {
return;
}
if (!pendingRequestData.value) {
return;
}
parameters.value = pendingRequestData.value.queryParameters.map(
(parameter: ParametersExternalContract): ParametersExternalContract => ({
key: parameter.key,
value: parameter.value,
}),
);
},
{ immediate: true },
);
</script>
<template>
@@ -87,8 +52,8 @@ watch(
</div>
</div>
<KeyValueParametersBuilder
v-model="parameters"
:model-value="currentRequestQueryParameters"
class="flex-1"
persistence-key="pending-request-parameters"
@update:parameters="handleQueryParametersUpdate"
/>
</template>

View File

@@ -1,7 +1,7 @@
export { default as RequestHeaders } from '@/components/domain/Client/Request/RequestHeaders/RequestHeaders.vue';
export * from './RequestAuthorization';
export * from './RequestBody';
export { default as RequestBodyFormNone } from './RequestBody/RequestBodyFormNone.vue';
export { default as RequestBuilder } from './RequestBuilder.vue';
export { default as RequestBuilderEndpoint } from './RequestBuilderEndpoint.vue';
export { default as RequestHeaders } from './RequestHeader/RequestHeaders.vue';
export { default as RequestParameters } from './RequestParameters/RequestParameters.vue';

View File

@@ -122,7 +122,21 @@ watch(
try {
const newDump = JSON.parse(String(newValue)) as DumpSnapshot;
// Check if we already have this dump in our session history
const existingIndex = dumpSnapshots.value.findIndex(
dump => dump.id === newDump.id,
);
// If it exists, we just select it (likely a history rewind)
if (existingIndex !== -1) {
selectedDumpIndex.value = existingIndex;
return;
}
dumpSnapshots.value = [newDump, ...dumpSnapshots.value];
selectedDumpIndex.value = 0;
} catch (error) {
console.error('Failed to parse dump snapshot:', error);

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { AppDropdownMenuItem } from '@/components/base/dropdown-menu';
import StatusIndicator from '@/components/domain/Client/Response/ResponseStatus/StatusIndicator.vue';
import HttpVerbLabel from '@/components/domain/HttpVerbLabel/HttpVerbLabel.vue';
import { RequestLog } from '@/interfaces/history/logs';
import { Response, STATUS } from '@/interfaces/http';
import { useTimeAgo } from '@vueuse/core';
import prettyBytes from 'pretty-bytes';
import prettyMs from 'pretty-ms';
interface HistoryItemProps {
log: RequestLog & {
response: Response;
};
index: number;
}
const props = defineProps<HistoryItemProps>();
const emit = defineEmits<{
select: [index: number];
}>();
const timeToTimeAgo = (timestamp: number): string => {
const timeAgo = useTimeAgo(new Date(timestamp * 1000));
return timeAgo.value;
};
</script>
<template>
<AppDropdownMenuItem
class="p-panel p-panel flex cursor-pointer flex-col items-start transition-colors"
@select="emit('select', props.index)"
>
<div class="flex w-full gap-2">
<div class="flex flex-1 flex-col gap-2">
<div
class="flex w-full justify-between gap-1 leading-tight"
:title="props.log.request.endpoint"
>
<div class="flex-1 truncate">
<HttpVerbLabel
:method="props.log.request.method"
data-testid="history-item-method"
/>
<span class="ml-1 text-xs" data-testid="history-item-endpoint">
{{ props.log.request.endpoint }}
</span>
</div>
<StatusIndicator
:status="props.log.response.status ?? STATUS.EMPTY"
data-testid="history-item-status"
/>
</div>
<div class="text-xxs flex min-w-0 flex-1 items-center gap-2">
<span class="text-nowrap" data-testid="response-status-badge">
{{ props.log.response.statusCode }}
-
{{ props.log.response.statusText }}
</span>
<div class="w-8 border-b border-zinc-200"></div>
<div class="flex w-full items-center justify-between">
<div>
<span class="text-subtle whitespace-nowrap">
{{
prettyMs(props.log.durationInMs, {
compact: true,
})
}}
</span>
<span
v-if="props.log.response"
class="text-subtle text-xxs whitespace-nowrap"
>
&nbsp;/
{{ prettyBytes(props.log.response.sizeInBytes) }}
</span>
</div>
<small class="text-subtle text-xxs whitespace-nowrap">
{{ timeToTimeAgo(props.log.response.timestamp) }}
</small>
</div>
</div>
</div>
</div>
</AppDropdownMenuItem>
</template>

View File

@@ -0,0 +1,230 @@
<script setup lang="ts">
import { AppButton } from '@/components/base/button';
import {
AppDropdownMenu,
AppDropdownMenuContent,
AppDropdownMenuSeparator,
AppDropdownMenuTrigger,
} from '@/components/base/dropdown-menu';
import {
AppInputGroup,
AppInputGroupAddon,
AppInputGroupInput,
} from '@/components/base/input-group';
import { AppScrollArea } from '@/components/base/scroll-area';
import HistoryItem from '@/components/domain/Client/Response/ResponseStatus/History/HistoryItem.vue';
import { RequestLog } from '@/interfaces/history/logs';
import { Response } from '@/interfaces/http';
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
import { cn } from '@/utils/ui';
import { useTimeAgo } from '@vueuse/core';
import { HistoryIcon, Search, Trash2Icon } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
const requestStore = useRequestStore();
const historyStore = useRequestsHistoryStore();
const lastLog = computed(() => historyStore.lastLog);
const readableTime = computed(() => {
if (lastLog.value?.response === undefined) {
return '';
}
return timeToTimeAgo(lastLog.value.response.timestamp);
});
const isOpen = ref(false);
const searchQuery = ref('');
const searchInputRef = ref<HTMLInputElement | null>(null);
// Auto-focus search input when dropdown opens
watch(isOpen, async newValue => {
if (newValue) {
await nextTick();
searchInputRef.value?.focus();
} else {
// Clear search when dropdown closes
searchQuery.value = '';
}
});
const timeToTimeAgo = (timestamp: number): string => {
const timeAgo = useTimeAgo(new Date(timestamp * 1000));
return timeAgo.value;
};
const absoluteTime = computed(() => {
if (lastLog.value?.response === undefined) {
return '';
}
const timestamp = new Date(lastLog.value.response.timestamp * 1000);
return timestamp.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
});
const selectHistoryItem = (index: number) => {
const log = historyStore.allLogs[index];
if (!log) {
return;
}
historyStore.setActiveLog(index);
requestStore.restoreFromHistory(log.request);
};
const reversedLogs = computed(() => {
return [...historyStore.allLogs]
.filter(log => log.response !== undefined)
.reverse() as (RequestLog & { response: Response })[];
});
const filteredLogs = computed(() => {
if (!searchQuery.value.trim()) {
return reversedLogs.value;
}
const query = searchQuery.value.toLowerCase();
return reversedLogs.value.filter(log =>
log.request.endpoint.toLowerCase().includes(query),
);
});
const getOriginalIndex = (reversedIndex: number) => {
const logsWithResponse = historyStore.allLogs.filter(
log => log.response !== undefined,
);
const originalLog = logsWithResponse[logsWithResponse.length - 1 - reversedIndex];
return historyStore.allLogs.indexOf(originalLog);
};
/*
* Clear RequestHistory Logic.
*/
const isConfirmingClear = ref(false);
const clearHistoryTimeoutId = ref<number | null>(null);
const handleClearHistory = () => {
if (isConfirmingClear.value) {
historyStore.clearLogs();
resetClearConfirmation();
isOpen.value = false;
return;
}
isConfirmingClear.value = true;
if (clearHistoryTimeoutId.value) {
window.clearTimeout(clearHistoryTimeoutId.value);
}
clearHistoryTimeoutId.value = window.setTimeout(() => {
resetClearConfirmation();
}, 1000);
};
const resetClearConfirmation = () => {
isConfirmingClear.value = false;
if (clearHistoryTimeoutId.value) {
window.clearTimeout(clearHistoryTimeoutId.value);
clearHistoryTimeoutId.value = null;
}
};
</script>
<template>
<AppDropdownMenu v-if="reversedLogs.length" v-model:open="isOpen">
<AppDropdownMenuTrigger as-child>
<AppButton
variant="ghost"
size="xs"
class="rounded px-1 transition-colors hover:bg-zinc-100 focus:outline-none focus-visible:ring-0 dark:hover:bg-zinc-800 dark:focus-visible:ring-0"
data-testid="response-history-trigger"
>
<small class="text-subtle text-xs" :title="absoluteTime">
{{ readableTime }}
</small>
<HistoryIcon class="size-3" />
</AppButton>
</AppDropdownMenuTrigger>
<AppDropdownMenuContent align="end" class="w-sm p-0">
<AppScrollArea class="max-h-96 overflow-y-auto">
<div class="px-panel my-2 flex gap-2">
<AppButton
variant="outline"
size="xs"
class="h-sub-toolbar justify-start shadow-none transition-colors"
:class="
cn(
isConfirmingClear &&
'text-rose-500 hover:text-rose-600 dark:text-rose-400 dark:hover:text-rose-300',
)
"
data-testid="clear-history-button"
@click="handleClearHistory"
>
<Trash2Icon class="size-3" />
Clear History
</AppButton>
<AppInputGroup class="h-sub-toolbar">
<AppInputGroupInput
ref="searchInputRef"
v-model="searchQuery"
placeholder="Type to search"
data-testid="history-search-input"
/>
<AppInputGroupAddon>
<Search class="size-3" />
</AppInputGroupAddon>
</AppInputGroup>
</div>
<AppDropdownMenuSeparator />
<template v-if="filteredLogs.length">
<template
v-for="(log, index) in filteredLogs"
:key="log.request.endpoint + log.response.timestamp"
>
<HistoryItem
:log="log"
:index="getOriginalIndex(reversedLogs.indexOf(log))"
data-testid="history-item"
:data-endpoint="log.request.endpoint"
:data-method="log.request.method"
@select="selectHistoryItem"
/>
<AppDropdownMenuSeparator
v-if="index < filteredLogs.length - 1"
/>
</template>
</template>
<div
v-else
class="text-subtle flex flex-col items-center justify-center gap-2 py-8 text-center text-sm"
data-testid="history-empty-state"
>
<Search class="size-8 opacity-20" />
<p>No results found matching your keyword</p>
</div>
</AppScrollArea>
</AppDropdownMenuContent>
</AppDropdownMenu>
</template>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import { AppButton } from '@/components/base/button';
import RequestHistory from '@/components/domain/Client/Response/ResponseStatus/History/RequestHistory.vue';
import ResponseStatusCode from '@/components/domain/Client/Response/ResponseStatus/ResponseStatusCode.vue';
import { PendingRequest, STATUS } from '@/interfaces/http';
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
import { cn } from '@/utils/ui';
import { useTimeAgo } from '@vueuse/core';
import { RefreshCwOffIcon } from 'lucide-vue-next';
import prettyBytes from 'pretty-bytes';
import prettyMs from 'pretty-ms';
@@ -76,33 +76,6 @@ const duration = computed(() => {
);
});
const readableTime = computed(() => {
if (lastLog.value?.response === undefined) {
return '';
}
const timestamp = new Date(lastLog.value.response.timestamp * 1000);
const timeAgo = useTimeAgo(timestamp);
return timeAgo.value;
});
const absoluteTime = computed(() => {
if (lastLog.value?.response === undefined) {
return '';
}
const timestamp = new Date(lastLog.value.response.timestamp * 1000);
return timestamp.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
});
/*
* Actions.
*/
@@ -137,9 +110,7 @@ const cancelRequest = () => {
</div>
<div v-if="!pendingRequestData?.isProcessing" class="flex items-center">
<small class="text-subtle text-xs" :title="absoluteTime">
{{ readableTime }}
</small>
<RequestHistory />
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { httpClientConfig } from '@/config';
import { ParametersExternalContract } from '@/interfaces';
import { ParameterContract, RequestHeader } from '@/interfaces';
import {
HttpHeaders,
PendingRequest,
@@ -8,6 +8,7 @@ import {
} from '@/interfaces/http';
import { useConfigStore } from '@/stores';
import { convertPayloadToFormData, getStatusGroup } from '@/utils/http';
import { generateContentTypeHeader } from '@/utils/request/content-type-header-generator';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { readonly, ref } from 'vue';
@@ -32,8 +33,11 @@ export function useHttpClient() {
// Only append enabled parameters with non-empty keys to avoid malformed URLs
request.queryParameters
.filter((parameter: ParametersExternalContract) => parameter.key.trim())
.forEach((parameter: ParametersExternalContract) => {
.filter(
(parameter: ParameterContract) =>
parameter.enabled && parameter.key.trim(),
)
.forEach((parameter: ParameterContract) => {
url.searchParams.append(parameter.key, parameter.value);
});
@@ -41,10 +45,27 @@ export function useHttpClient() {
};
const createRelayPayload = (request: PendingRequest) => {
// Generate Content-Type header just before making the request
// This ensures the correct header is sent without persisting it in the store
const headersWithContentType = generateContentTypeHeader(
request.payloadType,
request.headers
.filter(
(parameter: ParameterContract) =>
parameter.enabled && parameter.key.trim() !== '',
)
.map(
(parameter): RequestHeader => ({
key: parameter.key,
value: parameter.value,
}),
),
);
return {
endpoint: buildRequestUrl(request),
method: request.method,
headers: request.headers,
headers: headersWithContentType,
authorization: request.authorization,
body: getMemoizedBody(request),
};

View File

@@ -5,50 +5,9 @@ import {
generateRandomPayload,
serializeSchemaPayload,
} from '@/utils/payload';
import { types, TypeShape } from '@/utils/request/content-type-header-generator';
import { computed, onMounted, ref, watch } from 'vue';
/*
* Types & interfaces.
*/
interface TypeShape {
id: RequestBodyTypeEnum;
label: string;
autoFillable: boolean;
mimeType: string | null;
}
/*
* Constants.
*/
const types: TypeShape[] = [
{
id: RequestBodyTypeEnum.EMPTY,
label: 'Empty',
autoFillable: false,
mimeType: null,
},
{
id: RequestBodyTypeEnum.JSON,
label: 'JSON',
autoFillable: true,
mimeType: 'application/json',
},
{
id: RequestBodyTypeEnum.PLAIN_TEXT,
label: 'Plain Text',
autoFillable: false,
mimeType: 'text/plain',
},
{
id: RequestBodyTypeEnum.FORM_DATA,
label: 'Form Data',
autoFillable: true,
mimeType: 'multipart/form-data',
},
];
export function useRequestBody() {
/*
* Stores & dependencies.
@@ -121,46 +80,6 @@ export function useRequestBody() {
return serializeSchemaPayload(placeholderPayload, payloadType.value);
};
/**
* Updates the Content-Type header based on the selected payload type.
*
* Automatically manages the Content-Type header by adding, updating, or
* removing it based on the payload type's associated MIME type.
*/
const updateContentTypeHeader = (newValue: RequestBodyTypeEnum) => {
if (pendingRequestData.value === null) {
return;
}
const contentTypeIndex = pendingRequestData.value.headers.findIndex(
(header: RequestHeader) => header.key === 'content-type',
);
const value =
types.find(contentType => contentType.id === newValue)?.mimeType ?? null;
if (contentTypeIndex !== -1) {
if (value === null) {
pendingRequestData.value.headers.splice(contentTypeIndex, 1);
return;
}
pendingRequestData.value.headers[contentTypeIndex].value = value;
return;
}
if (value === null) {
return;
}
pendingRequestData.value.headers.push({
key: 'content-type',
value: value,
});
};
/**
* Initializes payload type from existing Content-Type headers.
*
@@ -238,8 +157,6 @@ export function useRequestBody() {
// Meaning that each tab will have its state.
payload.value = generateCurrentPayload();
pendingRequestData.value.payloadType = newValue;
updateContentTypeHeader(newValue);
});
watch(
@@ -288,7 +205,6 @@ export function useRequestBody() {
// Actions
autofill,
generateCurrentPayload,
updateContentTypeHeader,
initializePayloadTypeFromHeaders,
// Constants

View File

@@ -1,95 +1,31 @@
import { keyValueParametersConfig } from '@/config';
import { ExtendedParameter, ParametersExternalContract } from '@/interfaces/ui';
import { uniquePersistenceKey } from '@/utils/stores';
import { useCounter, useStorage, watchDebounced } from '@vueuse/core';
import { RemovableRef } from '@vueuse/shared';
import { computed, onBeforeMount, reactive, ref, Ref } from 'vue';
import { ParameterContract } from '@/interfaces/ui';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { useCounter, watchDebounced } from '@vueuse/core';
import { computed, onBeforeMount, reactive, ref, Ref, watch } from 'vue';
/**
* Manages key-value parameter state.
* Manages key-value parameter state with unidirectional data flow.
*
* @param modelValue - The current parameters from the parent (read-only)
* @param onUpdate - Callback to notify parent of parameter changes
*/
export function useKeyValueParameters(
model: Ref<ParametersExternalContract[]>,
persistenceKey?: string,
modelValue: Ref<ParameterContract[]>,
onUpdate: (parameters: ParameterContract[]) => void,
) {
const { count: nextParameterId, inc: incrementParametersId } = useCounter();
const parameters: RemovableRef<ExtendedParameter[]> | Ref<ExtendedParameter[]> =
persistenceKey ? useStorage(uniquePersistenceKey(persistenceKey), []) : ref([]);
const parameters: Ref<ParameterContract[]> = ref([]);
const isUpdatingFromParentModel = ref(false);
const createParameterSkeleton = (id: number): ExtendedParameter => ({
type: 'text',
const createParameterSkeleton = (id: number): ParameterContract => ({
type: ParameterType.Text,
id,
key: '',
value: '',
enabled: true,
});
const convertExternalToInternal = (
external: ParametersExternalContract,
id: number,
): ExtendedParameter => ({
type: external.type ?? 'text',
id,
key: external.key,
value: String(external.value ?? ''),
enabled: true,
});
/**
* Converts internal UI parameter data back to external format.
*
* Removes UI-specific properties to provide clean data for external clients.
*/
const convertInternalToExternal = (
internal: ExtendedParameter,
): ParametersExternalContract => ({
type: internal.type,
key: internal.key,
value: internal.value,
});
/**
* Creates a Map for efficient parameter lookup by key.
*
* Used for O(1) duplicate detection instead of O(n²) nested loops.
*/
const createParameterKeyMap = (
parameters: ExtendedParameter[],
): Map<string, ExtendedParameter> => {
return new Map(parameters.map(parameter => [parameter.key, parameter]));
};
/**
* Finds duplicate parameters efficiently using Map lookup.
*
* Returns parameters from incoming array that have keys matching existing parameters.
*/
const findDuplicateParameters = (
existing: ExtendedParameter[],
incoming: ExtendedParameter[],
): ExtendedParameter[] => {
const existingKeyMap = createParameterKeyMap(existing);
return incoming.filter(param => existingKeyMap.has(param.key));
};
/**
* Removes parameters with duplicate keys from existing array.
*
* Filters out parameters whose keys exist in the duplicates array.
*/
const removeDuplicateParameters = (
existing: ExtendedParameter[],
duplicates: ExtendedParameter[],
): ExtendedParameter[] => {
const duplicateKeys = new Set(duplicates.map(param => param.key));
return existing.filter(param => !duplicateKeys.has(param.key));
};
/*
* Deletion state management.
*/
@@ -107,16 +43,16 @@ export function useKeyValueParameters(
* Initiates deletion confirmation for a parameter.
* Returns true if this is the confirmation click (second click).
*/
const initiateParameterDeletion = (parameterId: number): boolean => {
const state = deletionStatesForParameters.get(parameterId);
const initiateParameterDeletion = (identifier: number): boolean => {
const state = deletionStatesForParameters.get(identifier);
if (state?.deleting) {
clearParameterDeletionState(parameterId);
clearParameterDeletionState(identifier);
return true;
}
setParameterDeletionState(parameterId);
setParameterDeletionState(identifier);
return false;
};
@@ -218,16 +154,77 @@ export function useKeyValueParameters(
const deletingAll = computed(() => isBulkDeletionMarked());
// Sync changes back to parent model with debouncing to avoid excessive updates.
// Skip syncing when we are applying updates that originate from the parent model.
/**
* Reconciliation logic to update internal parameters from the parent modelValue.
*
* This replaces the current parameters with the ones from the modelValue,
* but tries to preserve existing IDs for keys that haven't changed to maintain reactivity/focus.
*/
const updateParametersFromParentModel = (): void => {
const incoming = modelValue.value ?? [];
// Map current parameters by id for reconciliation
const currentById = new Map(
parameters.value.map(parameter => [parameter.id, parameter]),
);
const nextParameters: ParameterContract[] = incoming.map(external => {
const existing = currentById.get(external.id);
if (existing) {
// Create a new object instead of mutating the existing one
// This prevents shared references between history and active state
return {
...existing,
id: external.id,
key: external.key,
value: external.value,
type: external.type,
enabled: external.enabled,
};
}
incrementParametersId();
return { id: nextParameterId.value, ...external };
});
// If internal state is empty, we must ensure at least one skeleton
if (nextParameters.length === 0) {
incrementParametersId();
nextParameters.push(createParameterSkeleton(nextParameterId.value));
}
// Only update if the resulting content is different from current internal state
// to avoid triggering redundant observers.
if (JSON.stringify(parameters.value) !== JSON.stringify(incoming)) {
parameters.value = nextParameters;
}
};
/**
* Notifies parent of parameter changes with deep cloned data.
* This prevents shared references and ensures unidirectional data flow.
*/
const notifyParentOfChanges = (): void => {
// Deep clone to prevent shared references
const clonedParameters = parameters.value.map(p => ({
id: p.id,
type: p.type,
key: p.key,
value: p.value,
enabled: p.enabled,
}));
onUpdate(clonedParameters);
};
// Watch for internal changes to notify parent
watchDebounced(
parameters,
() => {
if (isUpdatingFromParentModel.value) {
return;
}
syncParametersBackToModel();
notifyParentOfChanges();
},
{
deep: true,
@@ -235,6 +232,15 @@ export function useKeyValueParameters(
},
);
// Watch for external changes to sync from parent.
watch(
modelValue,
() => {
updateParametersFromParentModel();
},
{ deep: true },
);
// Initialize parameters from parent model
onBeforeMount(() => {
updateParametersFromParentModel();
@@ -244,81 +250,6 @@ export function useKeyValueParameters(
}
});
/*
* Actions.
*/
/**
* Expands minimal external parameters to full internal structure for command support.
*
* Bridges external client data with internal parameter state by adding UI-specific
* properties like IDs, enabled state, and deletion tracking.
*/
const expandExternalParameters = (
externalParameters: ParametersExternalContract[],
): ExtendedParameter[] => {
return externalParameters.map(
(externalEntity: ParametersExternalContract): ExtendedParameter => {
incrementParametersId();
return convertExternalToInternal(externalEntity, nextParameterId.value);
},
);
};
/**
* Merges new parameters with existing ones, removing duplicates
*/
const mergeParametersWithoutDuplicates = (
newParameters: ExtendedParameter[],
existingParameters: ExtendedParameter[],
): ExtendedParameter[] => {
const duplicates = findDuplicateParameters(existingParameters, newParameters);
const cleanedExisting = removeDuplicateParameters(existingParameters, duplicates);
return [...newParameters, ...cleanedExisting];
};
/**
* Syncs parameters from parent model back to the component while preserving existing parameters.
*
* Parent parameters override existing ones with matching keys to prevent duplicates,
* but we preserve user-added parameters that don't conflict.
*/
const updateParametersFromParentModel = (): void => {
if (!model.value?.length) {
return;
}
// Suppress outbound sync while we incorporate parent-provided parameters to prevent
// a feedback loop where our update triggers a debounced write back to the parent.
isUpdatingFromParentModel.value = true;
const newParameters = expandExternalParameters(model.value);
parameters.value = mergeParametersWithoutDuplicates(
newParameters,
parameters.value,
);
// Clear the suppression after the debounce window to ensure no stale writes occur.
window.setTimeout(() => {
isUpdatingFromParentModel.value = false;
}, keyValueParametersConfig.SYNC_DEBOUNCE_DELAY + 10);
};
/**
* Filters out empty keys and disabled items before syncing to the parent model.
*
* Ensures parent components receive only valid, enabled key-value pairs without UI-specific state.
*/
const syncParametersBackToModel = (): void => {
model.value = parameters.value
.filter(parameter => parameter.key !== '' && parameter.enabled)
.map(convertInternalToExternal);
};
/**
* Adds a new empty parameter to the list for user input.
*/
@@ -337,7 +268,7 @@ export function useKeyValueParameters(
const shouldEnableAll = areAllParametersDisabled.value;
parameters.value.forEach(
(parameter: ExtendedParameter) => (parameter.enabled = shouldEnableAll),
(parameter: ParameterContract) => (parameter.enabled = shouldEnableAll),
);
};
@@ -348,7 +279,7 @@ export function useKeyValueParameters(
* First click marks for deletion, second click removes immediately.
*/
const triggerParameterDeletion = (
parameters: ExtendedParameter[],
parameters: ParameterContract[],
index: number,
): void => {
const parameter = parameters[index];
@@ -357,7 +288,7 @@ export function useKeyValueParameters(
return;
}
const shouldDelete = initiateParameterDeletion(parameter.id);
const shouldDelete = initiateParameterDeletion(index);
if (!shouldDelete) {
return;

View File

@@ -1,5 +1,5 @@
/**
* History and logging interfaces and types
* RequestHistory and logging interfaces and types
*/
export type { RequestLog } from './logs';

View File

@@ -1,7 +1,6 @@
import { AuthorizationContract } from '@/interfaces/auth/authorization';
import { RequestHeader } from '@/interfaces/http';
import { RouteDefinition } from '@/interfaces/routes/routes';
import { ParametersExternalContract } from '@/interfaces/ui';
import { ParameterContract } from '@/interfaces/ui';
import type { JSONSchema7 } from 'json-schema';
export enum RequestBodyTypeEnum {
@@ -24,7 +23,7 @@ export interface PendingRequest {
endpoint: string;
/** HTTP headers to include with the request */
headers: RequestHeader[];
headers: ParameterContract[];
/**
* Request body data organized by HTTP method and payload type.
@@ -52,7 +51,7 @@ export interface PendingRequest {
};
/** Query parameters to append to the request URL */
queryParameters: ParametersExternalContract[];
queryParameters: ParameterContract[];
/** Currently selected payload type for the request body */
payloadType: RequestBodyTypeEnum;
@@ -109,8 +108,10 @@ export interface PendingRequest {
export interface Request {
method: string;
endpoint: string;
headers: RequestHeader[];
headers: ParameterContract[];
body: FormData | string | null;
queryParameters: ParametersExternalContract[];
queryParameters: ParameterContract[];
payloadType: RequestBodyTypeEnum;
authorization: AuthorizationContract;
routeDefinition: RouteDefinition;
}

View File

@@ -25,4 +25,6 @@ export type { RouteDefinition, RouteExtractorException, RoutesGroup } from './ro
export type { JSONSchema7 } from './schema';
export type { ExtendedParameter, ParametersExternalContract } from './ui';
export type { ParameterContract } from './ui';
export { ParameterType } from './ui';

View File

@@ -2,10 +2,9 @@
* UI-related interfaces and types
*/
export type {
ExtendedParameter,
ParametersExternalContract,
} from './key-value-parameters';
export type { ParameterContract } from './key-value-parameters';
export { ParameterType } from './key-value-parameters';
export type {
GeneratorCategory,

View File

@@ -1,19 +1,12 @@
/**
* The full parameter shape used internally with the UI details.
*/
export interface ExtendedParameter {
id: number;
type: 'text' | 'file'; // <- Make an Enum.
export interface ParameterContract {
id?: number;
type: ParameterType;
key: string;
value: string;
enabled: boolean;
}
/**
* A minimal shape used to communicate with external components.
*/
export interface ParametersExternalContract {
type?: 'text' | 'file'; // <- Form Input type.
key: string;
value: string;
export enum ParameterType {
Text = 'text',
File = 'file',
}

View File

@@ -1,8 +1,8 @@
import { AuthorizationContract } from '@/interfaces/auth/authorization';
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum, RequestHeader } from '@/interfaces/http';
import { PendingRequest, Request, RequestBodyTypeEnum } from '@/interfaces/http';
import { RouteDefinition } from '@/interfaces/routes/routes';
import { ParametersExternalContract } from '@/interfaces/ui';
import { ParameterContract } from '@/interfaces/ui';
import { useConfigStore, useSettingsStore } from '@/stores';
import { buildRequestUrl, getDefaultPayloadTypeForRoute } from '@/utils/request';
import { defineStore } from 'pinia';
@@ -140,14 +140,18 @@ export const useRequestBuilderStore = defineStore(
route: RouteDefinition,
availableRoutesForEndpoint: RouteDefinition[],
) => {
const currentHeaders = pendingRequestData.value?.headers ?? [];
const currentQueryParameters =
pendingRequestData.value?.queryParameters ?? [];
pendingRequestData.value = {
method: route.method,
endpoint: route.endpoint,
headers: [],
headers: currentHeaders,
body: {},
payloadType: getDefaultPayload(route),
schema: route.schema,
queryParameters: [],
queryParameters: currentQueryParameters,
authorization: getAuthorizationForNewRequest(),
supportedRoutes: availableRoutesForEndpoint,
routeDefinition: route,
@@ -194,7 +198,7 @@ export const useRequestBuilderStore = defineStore(
/**
* Updates the headers array for the current request.
*/
const updateRequestHeaders = (headers: Array<RequestHeader>) => {
const updateRequestHeaders = (headers: Array<ParameterContract>) => {
if (!pendingRequestData.value) {
return;
}
@@ -216,7 +220,7 @@ export const useRequestBuilderStore = defineStore(
/**
* Updates the query parameters for the current request.
*/
const updateQueryParameters = (parameters: ParametersExternalContract[]) => {
const updateQueryParameters = (parameters: ParameterContract[]) => {
if (!pendingRequestData.value) {
return;
}
@@ -256,10 +260,61 @@ export const useRequestBuilderStore = defineStore(
return buildRequestUrl(
configStore.apiUrl,
request.endpoint,
request.queryParameters,
request.queryParameters.filter(
(parameter: ParameterContract) =>
parameter.enabled && parameter.key.trim() !== '',
),
);
};
/**
* Restores the request builder state from a historical request.
*/
const restoreFromHistory = (historicalRequest: Request) => {
if (!pendingRequestData.value) {
return;
}
const method = historicalRequest.method.toUpperCase();
const payloadType = historicalRequest.payloadType;
// Try to find and sync the route definition
const matchingRoute = pendingRequestData.value.supportedRoutes.find(
route =>
route.method.toUpperCase() === method &&
route.endpoint === historicalRequest.endpoint,
);
pendingRequestData.value = {
...pendingRequestData.value,
method,
endpoint: historicalRequest.endpoint,
headers: historicalRequest.headers.map(h => ({ ...h })),
queryParameters: historicalRequest.queryParameters.map(p => ({ ...p })),
payloadType,
// Restore body into the correct slot with reactivity in mind
body: {
...pendingRequestData.value.body,
[method]: {
...(pendingRequestData.value.body[method] ?? {}),
[payloadType]: historicalRequest.body,
},
},
// Restore authorization
authorization: {
...historicalRequest.authorization,
},
// Sync route definition and schema if matching route found
...(matchingRoute
? {
routeDefinition: matchingRoute,
schema: matchingRoute.schema,
}
: {}),
wasExecuted: true,
};
};
return {
// State
pendingRequestData,
@@ -277,6 +332,7 @@ export const useRequestBuilderStore = defineStore(
updateAuthorization,
resetRequest,
getRequestUrl,
restoreFromHistory,
};
},
{

View File

@@ -19,6 +19,7 @@ export const useRequestExecutorStore = defineStore('_requestExecutor', () => {
/*
* Stores & dependencies.
*/
const historyStore = useRequestsHistoryStore();
const { executeRequest, cancelCurrentRequest } = useHttpClient();

View File

@@ -68,6 +68,7 @@ export const useRequestStore = defineStore('request', () => {
updateQueryParameters: builderStore.updateQueryParameters,
updateAuthorization: builderStore.updateAuthorization,
getRequestUrl: builderStore.getRequestUrl,
restoreFromHistory: builderStore.restoreFromHistory,
// Request Execution Actions (delegated to executor store)
executeCurrentRequest: () => {

View File

@@ -1,7 +1,7 @@
import { RequestLog } from '@/interfaces/history/logs';
import { useSettingsStore } from '@/stores';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { useSettingsStore } from '../core/useSettingsStore';
export const useRequestsHistoryStore = defineStore(
'requestHistory',
@@ -13,14 +13,20 @@ export const useRequestsHistoryStore = defineStore(
// State
const logs = ref<RequestLog[]>([]);
const activeLogIndex = ref<number | null>(null);
// Computed
const maxLogs = computed(() => settingsStore.preferences.maxHistoryLogs);
// Computed
const allLogs = computed(() => logs.value);
const lastLog = computed(() => logs.value[logs.value.length - 1] ?? null);
const totalRequests = computed(() => logs.value.length);
const lastLog = computed(() => {
if (activeLogIndex.value !== null && logs.value[activeLogIndex.value]) {
return logs.value[activeLogIndex.value];
}
return logs.value[logs.value.length - 1] ?? null;
});
// Actions
const addLog = (log: RequestLog) => {
@@ -30,6 +36,12 @@ export const useRequestsHistoryStore = defineStore(
if (logs.value.length > maxLogs.value) {
logs.value = logs.value.slice(-maxLogs.value);
}
activeLogIndex.value = null;
};
const setActiveLog = (index: number | null) => {
activeLogIndex.value = index;
};
const clearLogs = () => {
@@ -40,15 +52,16 @@ export const useRequestsHistoryStore = defineStore(
// State
logs,
maxLogs,
activeLogIndex,
// Getters
allLogs,
lastLog,
totalRequests,
// Actions
addLog,
clearLogs,
setActiveLog,
};
},
{

View File

@@ -1,6 +1,7 @@
import RequestHeaders from '@/components/domain/Client/Request/RequestHeader/RequestHeaders.vue';
import RequestHeaders from '@/components/domain/Client/Request/RequestHeaders/RequestHeaders.vue';
import { AuthorizationType } from '@/interfaces/generated';
import { GeneratorType, PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { ParameterContract, ParameterType } from '@/interfaces/ui';
import { renderWithProviders } from '@/tests/_utils/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, reactive, ref } from 'vue';
@@ -40,6 +41,7 @@ const setPendingRequest = (request: PendingRequest | null) => {
describe('RequestHeaders', () => {
beforeEach(() => {
vi.useFakeTimers();
generateValue.mockClear();
mockConfigStore.headers = [
@@ -80,104 +82,83 @@ describe('RequestHeaders', () => {
it('initializes headers with global defaults and syncs them to the store', async () => {
renderComponent();
await nextTick();
vi.advanceTimersByTime(310);
await nextTick();
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
expect.objectContaining({ key: 'X-Global', value: 'foo', enabled: true }),
expect.objectContaining({
key: 'X-Generated',
value: 'generated@example.com',
enabled: true,
}),
]),
);
expect(generateValue).toHaveBeenCalledWith('email');
});
it('reinitializes headers when the request method changes', async () => {
it('preserves headers and does not re-inject globals when the endpoint changes', async () => {
// 1. Initial render populates headers from globals
renderComponent();
await nextTick();
vi.advanceTimersByTime(310);
await nextTick();
mockRequestStore.updateRequestHeaders.mockClear();
const firstSyncCall = vi.mocked(mockRequestStore.updateRequestHeaders).mock
.calls[0][0];
// 2. Simulate the store being updated with these headers
setPendingRequest({
...mockRequestStore.pendingRequestData!,
method: 'PUT', // <- Different method that the original one.
headers: [],
headers: firstSyncCall,
});
await nextTick();
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
]),
);
});
it('reinitializes headers when the request endpoint changes', async () => {
renderComponent();
await nextTick();
mockRequestStore.updateRequestHeaders.mockClear();
// 3. Change the endpoint
setPendingRequest({
...mockRequestStore.pendingRequestData!,
endpoint: 'api/accounts',
headers: [],
endpoint: 'api/other-endpoint',
});
await nextTick();
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ key: 'X-Global', value: 'foo' }),
]),
);
});
it('does not reinitialize when method and endpoint stay the same', async () => {
renderComponent();
await nextTick();
mockRequestStore.updateRequestHeaders.mockClear();
setPendingRequest({
...mockRequestStore.pendingRequestData!,
headers: [],
});
vi.advanceTimersByTime(310);
await nextTick();
// Should not have triggered a new update because effectiveHeaders returned currentHeaders
expect(mockRequestStore.updateRequestHeaders).not.toHaveBeenCalled();
});
it('merges existing request headers with global ones when changing endpoints', async () => {
(mockRequestStore.pendingRequestData as PendingRequest).headers = [
{ key: 'X-Existing', value: '123' },
{ key: 'X-Global', value: 'custom' },
it('prefers existing store headers over global defaults', async () => {
const customHeaders: ParameterContract[] = [
{
key: 'X-Custom',
value: 'custom-value',
enabled: true,
id: 1,
type: ParameterType.Text,
},
];
renderComponent();
mockRequestStore.updateRequestHeaders.mockClear();
setPendingRequest({
...mockRequestStore.pendingRequestData!,
method: 'PUT', // <- Different method that the original one to re-trigger th.
headers: [],
headers: customHeaders,
});
renderComponent();
await nextTick();
vi.advanceTimersByTime(310);
await nextTick();
expect(mockRequestStore.updateRequestHeaders).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ key: 'X-Existing', value: '123' }),
expect.objectContaining({ key: 'X-Global', value: 'custom' }),
]),
// It should NOT have initialized with global headers
// It might sync back the custom headers if they were deep cloned internally
expect(mockRequestStore.updateRequestHeaders).not.toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ key: 'X-Global' })]),
);
});
});

View File

@@ -5,23 +5,23 @@ import { describe, expect, it, vi } from 'vitest';
vi.mock('@/components/domain/Client/Request', () => ({
RequestBuilderEndpoint: {
name: 'RequestBuilderEndpoint',
template: '<div data-testid="request-builder-endpoint">Endpoint</div>',
template: '<div>Endpoint</div>',
},
RequestParameters: {
name: 'RequestParameters',
template: '<div data-testid="request-parameters">Parameters Panel</div>',
template: '<div>Parameters Panel</div>',
},
RequestBody: {
name: 'RequestBody',
template: '<div data-testid="request-body">Body Panel</div>',
template: '<div>Body Panel</div>',
},
RequestAuthorization: {
name: 'RequestAuthorization',
template: '<div data-testid="request-authorization">Authorization Panel</div>',
template: '<div>Authorization Panel</div>',
},
RequestHeaders: {
name: 'RequestHeaders',
template: '<div data-testid="request-headers">Headers Panel</div>',
template: '<div>Headers Panel</div>',
},
}));

View File

@@ -163,6 +163,36 @@ describe('ResponseDumpAndDie', () => {
consoleError.mockRestore();
});
it('does not duplicate dump and selects existing one on history rewind', async () => {
const snapshot1 = createDumpSnapshot('1', 'First', '2024-01-01 12:00:00', [
createStringDump('first'),
]);
const snapshot2 = createDumpSnapshot('2', 'Second', '2024-01-01 12:01:00', [
createStringDump('second'),
]);
const { rerender } = renderWithProviders(ResponseDumpAndDie, {
props: { rawContent: JSON.stringify(snapshot1) },
});
await nextTick();
// Receive a second dump
rerender({ rawContent: JSON.stringify(snapshot2) });
await nextTick();
expect(screen.getByText(/1 \/ 2/)).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
// Simulate history rewind back to the first dump
rerender({ rawContent: JSON.stringify(snapshot1) });
await nextTick();
// Should still have total 2 dumps, not 3.
expect(screen.getByText(/2 \/ 2/)).toBeInTheDocument();
expect(screen.getByText('First')).toBeInTheDocument();
});
});
describe('Snapshot Management', () => {

View File

@@ -0,0 +1,66 @@
import HistoryItem from '@/components/domain/Client/Response/ResponseStatus/History/HistoryItem.vue';
import { AuthorizationType } from '@/interfaces/generated';
import { RequestLog } from '@/interfaces/history/logs';
import { RequestBodyTypeEnum, Response, STATUS } from '@/interfaces/http';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/components/base/dropdown-menu', () => ({
AppDropdownMenuItem: {
name: 'AppDropdownMenuItem',
template: '<div><slot /></div>',
},
}));
describe('HistoryItem', () => {
const mockLog: RequestLog & { response: Response } = {
durationInMs: 150,
isProcessing: false,
request: {
method: 'GET',
endpoint: '/api/test',
headers: [],
queryParameters: [],
body: null,
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: {
method: 'GET',
endpoint: '/api/test',
shortEndpoint: '/api/test',
schema: { shape: {}, extractionErrors: null },
},
},
response: {
status: STATUS.SUCCESS,
statusCode: 200,
statusText: 'OK',
timestamp: Math.floor(Date.now() / 1000) - 60, // 1 minute ago
body: '{}',
headers: [],
cookies: [],
sizeInBytes: 1024,
},
};
it('renders history item details correctly including relative timestamp', () => {
renderWithProviders(HistoryItem, {
props: {
log: mockLog,
index: 0,
},
});
expect(screen.getByTestId('history-item-method')).toHaveTextContent('GET');
expect(screen.getByTestId('history-item-endpoint')).toHaveTextContent(
'/api/test',
);
expect(screen.getByTestId('response-status-badge')).toHaveTextContent('200 - OK');
// Assert relative timestamp
const timestamp = screen.getByText(
(content, element) => element?.tagName === 'SMALL',
);
expect(timestamp.textContent).toMatch(/1 minute ago|1 min ago/);
});
});

View File

@@ -0,0 +1,221 @@
import RequestHistory from '@/components/domain/Client/Response/ResponseStatus/History/RequestHistory.vue';
import { AuthorizationType } from '@/interfaces/generated';
import { RequestLog } from '@/interfaces/history/logs';
import { Request, RequestBodyTypeEnum, STATUS } from '@/interfaces/http';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { fireEvent } from '@testing-library/vue';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { nextTick, Reactive, reactive } from 'vue';
const mockRequestStore: Reactive<{
restoreFromHistory: Mock<(request: Request) => void>;
}> = reactive({
restoreFromHistory: vi.fn(),
});
const mockRequestsHistoryStore: Reactive<{
lastLog: RequestLog | null;
allLogs: RequestLog[];
setActiveLog: Mock<(index: number) => void>;
clearLogs: Mock<() => void>;
}> = reactive({
lastLog: null,
allLogs: [],
setActiveLog: vi.fn(),
clearLogs: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
useRequestsHistoryStore: () => mockRequestsHistoryStore,
};
});
vi.mock('@/components/base/dropdown-menu', () => ({
AppDropdownMenu: {
name: 'AppDropdownMenu',
template: '<div><slot /></div>',
props: ['open'],
},
AppDropdownMenuTrigger: {
name: 'AppDropdownMenuTrigger',
template: '<div><slot /></div>',
},
AppDropdownMenuContent: {
name: 'AppDropdownMenuContent',
template: '<div data-testid="dropdown-content"><slot /></div>',
},
AppDropdownMenuSeparator: {
name: 'AppDropdownMenuSeparator',
template: '<hr />',
},
}));
vi.mock(
'@/components/domain/Client/Response/ResponseStatus/History/HistoryItem.vue',
() => ({
default: {
name: 'HistoryItem',
template:
'<div data-testid="history-item" @click="$emit(\'select\', index)">History Item {{ index }}</div>',
props: ['log', 'index'],
emits: ['select'],
},
}),
);
describe('RequestHistory', () => {
beforeEach(() => {
mockRequestsHistoryStore.lastLog = null;
mockRequestsHistoryStore.allLogs = [];
mockRequestsHistoryStore.setActiveLog.mockClear();
mockRequestsHistoryStore.clearLogs.mockClear();
mockRequestStore.restoreFromHistory.mockClear();
vi.useFakeTimers();
});
const createLog = (endpoint: string, timestamp: number): RequestLog => ({
durationInMs: 100,
isProcessing: false,
request: {
method: 'GET',
endpoint,
headers: [],
queryParameters: [],
body: null,
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: {
method: 'GET',
endpoint,
shortEndpoint: endpoint,
schema: { shape: {}, extractionErrors: null },
},
},
response: {
status: STATUS.SUCCESS,
statusCode: 200,
statusText: 'OK',
timestamp,
body: '{}',
headers: [],
cookies: [],
sizeInBytes: 10,
},
});
it('renders nothing when history is empty', () => {
renderWithProviders(RequestHistory);
expect(screen.queryByTestId('response-history-trigger')).not.toBeInTheDocument();
});
it('renders history trigger when logs exist', async () => {
const log = createLog('/test', 1000);
mockRequestsHistoryStore.allLogs = [log];
mockRequestsHistoryStore.lastLog = log;
renderWithProviders(RequestHistory);
await nextTick();
expect(screen.getByTestId('response-history-trigger')).toBeInTheDocument();
});
it('filters logs based on search query', async () => {
const log1 = createLog('/users', 1000);
const log2 = createLog('/posts', 2000);
mockRequestsHistoryStore.allLogs = [log1, log2];
mockRequestsHistoryStore.lastLog = log2;
renderWithProviders(RequestHistory);
await nextTick();
const searchInput = screen.getByTestId('history-search-input');
await fireEvent.update(searchInput, 'users');
const items = screen.getAllByTestId('history-item');
expect(items).toHaveLength(1);
expect(items[0].textContent).toContain('History Item 0');
});
it('restores request when a history item is selected', async () => {
const log = createLog('/test', 1000);
mockRequestsHistoryStore.allLogs = [log];
mockRequestsHistoryStore.lastLog = log;
renderWithProviders(RequestHistory);
await nextTick();
const item = screen.getByTestId('history-item');
await fireEvent.click(item);
expect(mockRequestsHistoryStore.setActiveLog).toHaveBeenCalledWith(0);
expect(mockRequestStore.restoreFromHistory).toHaveBeenCalledWith(log.request);
});
it('requires double click to clear history (confirmation logic)', async () => {
const log = createLog('/test', 1000);
mockRequestsHistoryStore.allLogs = [log];
mockRequestsHistoryStore.lastLog = log;
renderWithProviders(RequestHistory);
await nextTick();
const clearButton = screen.getByTestId('clear-history-button');
// First click
await fireEvent.click(clearButton);
expect(mockRequestsHistoryStore.clearLogs).not.toHaveBeenCalled();
expect(clearButton.className).toContain('text-rose-500');
// Second click
await fireEvent.click(clearButton);
expect(mockRequestsHistoryStore.clearLogs).toHaveBeenCalled();
});
it('resets clear history confirmation after timeout', async () => {
const log = createLog('/test', 1000);
mockRequestsHistoryStore.allLogs = [log];
mockRequestsHistoryStore.lastLog = log;
renderWithProviders(RequestHistory);
await nextTick();
const clearButton = screen.getByTestId('clear-history-button');
await fireEvent.click(clearButton);
expect(clearButton.className).toContain('text-rose-500');
vi.advanceTimersByTime(1100);
await nextTick();
expect(clearButton.className).not.toContain('text-rose-500');
await fireEvent.click(clearButton); // Should still be first click after reset
expect(mockRequestsHistoryStore.clearLogs).not.toHaveBeenCalled();
});
it('displays logs in reverse order and only if they have a response', async () => {
const log1 = createLog('/test1', 1000);
const log2 = createLog('/test2', 2000);
delete log2.response;
const log3 = createLog('/test3', 3000);
mockRequestsHistoryStore.allLogs = [log1, log2, log3];
mockRequestsHistoryStore.lastLog = log3;
renderWithProviders(RequestHistory);
await nextTick();
const items = screen.getAllByTestId('history-item');
expect(items).toHaveLength(2);
// Reversed order: log3 (index 2) then log1 (index 0)
expect(items[0].textContent).toContain('History Item 2');
expect(items[1].textContent).toContain('History Item 0');
});
});

View File

@@ -1,22 +1,30 @@
import ResponseStatus from '@/components/domain/Client/Response/ResponseStatus/ResponseStatus.vue';
import { STATUS } from '@/interfaces/http';
import { RequestLog } from '@/interfaces';
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, Request, RequestBodyTypeEnum, STATUS } from '@/interfaces/http';
import { renderWithProviders, screen } from '@/tests/_utils/test-utils';
import { fireEvent } from '@testing-library/vue';
import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { nextTick, Reactive, reactive } from 'vue';
const mockRequestStore: Reactive<{
pendingRequestData: object | null;
cancelCurrentRequest: MockedFunction<any>; // eslint-disable-line @typescript-eslint/no-explicit-any
pendingRequestData: Partial<PendingRequest> | null;
cancelCurrentRequest: Mock<() => void>;
restoreFromHistory: Mock<(request: Request) => void>;
}> = reactive({
pendingRequestData: null,
cancelCurrentRequest: vi.fn(),
restoreFromHistory: vi.fn(),
});
const mockRequestsHistoryStore: Reactive<{
lastLog: object | null;
lastLog: RequestLog | null;
allLogs: RequestLog[];
setActiveLog: Mock<(index: number) => void>;
}> = reactive({
lastLog: null,
allLogs: [],
setActiveLog: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
@@ -33,7 +41,10 @@ describe('ResponseStatus', () => {
beforeEach(() => {
mockRequestStore.pendingRequestData = null;
mockRequestsHistoryStore.lastLog = null;
mockRequestsHistoryStore.allLogs = [];
mockRequestStore.cancelCurrentRequest.mockClear();
mockRequestStore.restoreFromHistory.mockClear();
mockRequestsHistoryStore.setActiveLog.mockClear();
});
it('shows pending status and cancel option while processing', async () => {
@@ -70,13 +81,35 @@ describe('ResponseStatus', () => {
wasExecuted: true,
};
const mockRequest: Request = {
method: 'GET',
endpoint: '/api/test',
headers: [],
queryParameters: [],
body: null,
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: {
method: 'GET',
endpoint: '/api/test',
shortEndpoint: '/api/test',
schema: { shape: {}, extractionErrors: null },
},
};
mockRequestsHistoryStore.lastLog = {
durationInMs: 3000,
isProcessing: false,
request: mockRequest,
response: {
status: STATUS.SUCCESS,
statusCode: 201,
statusText: 'Created',
sizeInBytes: 4096,
timestamp: Math.floor(Date.now() / 1000),
body: '',
headers: [],
cookies: [],
},
};
@@ -100,8 +133,36 @@ describe('ResponseStatus', () => {
durationInMs: 0,
};
const mockRequest: Request = {
method: 'GET',
endpoint: '/api/test',
headers: [],
queryParameters: [],
body: null,
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: {
method: 'GET',
endpoint: '/api/test',
shortEndpoint: '/api/test',
schema: { shape: {}, extractionErrors: null },
},
};
mockRequestsHistoryStore.lastLog = {
response: { sizeInBytes: 12345, timestamp: Math.floor(Date.now() / 1000) },
durationInMs: 0,
isProcessing: false,
request: mockRequest,
response: {
status: STATUS.SUCCESS,
statusCode: 200,
statusText: 'OK',
body: '',
headers: [],
cookies: [],
sizeInBytes: 12345,
timestamp: Math.floor(Date.now() / 1000),
},
};
renderWithProviders(ResponseStatus);
@@ -111,27 +172,6 @@ describe('ResponseStatus', () => {
expect(screen.getByText(/0B/)).toBeInTheDocument();
});
it('shows relative timestamp when last log exists', async () => {
mockRequestStore.pendingRequestData = {
isProcessing: false,
durationInMs: 0,
wasExecuted: true,
};
mockRequestsHistoryStore.lastLog = {
response: { timestamp: Math.floor(Date.now() / 1000) },
};
renderWithProviders(ResponseStatus);
await nextTick();
const timestamp = screen.getByText(
(content, element) => element?.tagName === 'SMALL',
);
expect(timestamp.textContent?.length ?? 0).toBeGreaterThan(0);
});
it('cancels request when cancel button clicked', async () => {
mockRequestStore.pendingRequestData = { isProcessing: true, durationInMs: 0 };
@@ -143,4 +183,52 @@ describe('ResponseStatus', () => {
expect(mockRequestStore.cancelCurrentRequest).toHaveBeenCalled();
});
it('opens history dropdown and selects an item', async () => {
const log: RequestLog = {
durationInMs: 100,
isProcessing: false,
request: {
method: 'POST',
endpoint: 'test',
headers: [],
queryParameters: [],
body: null,
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: {
method: 'POST',
endpoint: 'test',
shortEndpoint: 'test',
schema: { shape: {}, extractionErrors: null },
},
},
response: {
status: STATUS.SUCCESS,
statusCode: 200,
statusText: 'OK',
timestamp: Math.floor(Date.now() / 1000),
body: '{}',
headers: [],
cookies: [],
sizeInBytes: 10,
},
};
mockRequestsHistoryStore.allLogs = [log];
mockRequestsHistoryStore.lastLog = log;
mockRequestStore.pendingRequestData = { wasExecuted: true };
renderWithProviders(ResponseStatus);
await nextTick();
const trigger = screen.getByTestId('response-history-trigger');
await fireEvent.click(trigger);
// We can't easily test Radix dropdown content with testing-library-vue without more setup,
// but we can verify the trigger is there and clickable.
// For a more thorough test, we would need to mock the dropdown portal or use Playwright.
expect(trigger).toBeInTheDocument();
});
});

View File

@@ -2,6 +2,7 @@ import { useHttpClient } from '@/composables/request/useHttpClient';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces';
import { AuthorizationType } from '@/interfaces/generated';
import { RelayProxyResponse } from '@/interfaces/http';
import { ParameterType } from '@/interfaces/ui';
import axios, { AxiosError } from 'axios';
import { describe, expect, it, Mocked, vi } from 'vitest';
@@ -41,6 +42,9 @@ const defaultPendingRequest = {
extractionErrors: null,
},
},
isProcessing: false,
wasExecuted: false,
durationInMs: 0,
};
describe('useHttpClient', () => {
@@ -60,9 +64,9 @@ describe('useHttpClient', () => {
type: AuthorizationType.None,
},
queryParameters: [
{ key: 'page', value: '1' },
{ key: 'limit', value: '10' },
{ key: ' ', value: 'empty key' },
{ key: 'page', value: '1', enabled: true, type: ParameterType.Text },
{ key: 'limit', value: '10', enabled: true, type: ParameterType.Text },
{ key: ' ', value: 'empty key', enabled: true, type: ParameterType.Text },
],
};
@@ -480,4 +484,96 @@ describe('useHttpClient', () => {
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
expect(formDataCall).toBeInstanceOf(FormData);
});
it('generates Content-Type header on-demand for JSON payload', async () => {
const { executeRequest } = useHttpClient();
const request: PendingRequest = {
...defaultPendingRequest,
method: 'POST',
payloadType: RequestBodyTypeEnum.JSON,
headers: [
{
key: 'Accept',
value: 'application/json',
enabled: true,
type: ParameterType.Text,
},
],
authorization: { type: AuthorizationType.None },
body: {
POST: {
json: JSON.stringify({ name: 'John' }),
},
},
};
mockedAxios.post.mockResolvedValue({
data: JSON.stringify({
statusCode: 200,
statusText: 'OK',
headers: [],
body: '{}',
cookies: [],
timestamp: Date.now(),
duration: 100,
}),
});
await executeRequest(request);
// Verify the FormData was created with headers including Content-Type
expect(mockedAxios.post).toHaveBeenCalled();
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
expect(formDataCall).toBeInstanceOf(FormData);
// The headers are nested in FormData as headers[0][key], headers[0][value], etc.
// We should have 2 headers: Accept and content-type
expect(formDataCall.get('headers[0][key]')).toBe('Accept');
expect(formDataCall.get('headers[0][value]')).toBe('application/json');
expect(formDataCall.get('headers[1][key]')).toBe('content-type');
expect(formDataCall.get('headers[1][value]')).toBe('application/json');
});
it('does not add Content-Type header for EMPTY payload type', async () => {
const { executeRequest } = useHttpClient();
const request: PendingRequest = {
...defaultPendingRequest,
method: 'POST',
payloadType: RequestBodyTypeEnum.EMPTY,
headers: [
{
key: 'Accept',
value: 'application/json',
enabled: true,
type: ParameterType.Text,
},
],
authorization: { type: AuthorizationType.None },
body: {},
};
mockedAxios.post.mockResolvedValue({
data: JSON.stringify({
statusCode: 200,
statusText: 'OK',
headers: [],
body: '{}',
cookies: [],
timestamp: Date.now(),
duration: 100,
}),
});
await executeRequest(request);
expect(mockedAxios.post).toHaveBeenCalled();
const formDataCall = mockedAxios.post.mock.calls[0][1] as FormData;
// Should only have the Accept header, no Content-Type
expect(formDataCall.get('headers[0][key]')).toBe('Accept');
expect(formDataCall.get('headers[0][value]')).toBe('application/json');
expect(formDataCall.get('headers[1][key]')).toBeNull(); // No second header
});
});

View File

@@ -1,5 +1,6 @@
import { useKeyValueParameters } from '@/composables/ui/useKeyValueParameters';
import { ParametersExternalContract } from '@/interfaces';
import { ParameterContract } from '@/interfaces';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick, Ref, ref } from 'vue';
@@ -12,13 +13,14 @@ vi.mock('@/config', () => ({
}));
describe('useKeyValueParameters', () => {
let model: Ref<ParametersExternalContract[]>;
let modelValue: Ref<ParameterContract[]>;
let onUpdateCallback: ReturnType<typeof vi.fn>;
let composable: ReturnType<typeof useKeyValueParameters>;
beforeEach(() => {
model = ref([]);
composable = useKeyValueParameters(model);
modelValue = ref([]);
onUpdateCallback = vi.fn();
composable = useKeyValueParameters(modelValue, onUpdateCallback);
});
describe('initialization', () => {
@@ -49,7 +51,7 @@ describe('useKeyValueParameters', () => {
const newParameter =
composable.parameters.value[composable.parameters.value.length - 1];
expect(newParameter).toMatchObject({
type: 'text',
type: ParameterType.Text,
key: '',
value: '',
enabled: true,
@@ -74,10 +76,22 @@ describe('useKeyValueParameters', () => {
expect(composable.areAllParametersDisabled.value).toBe(false);
});
it('should update parameters from parent model', () => {
model.value = [
{ key: 'param1', value: 'value1' },
{ key: 'param2', value: 'value2' },
it('should update parameters from parent modelValue', () => {
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'param1',
value: 'value1',
enabled: true,
},
{
id: 2,
type: ParameterType.Text,
key: 'param2',
value: 'value2',
enabled: true,
},
];
composable.updateParametersFromParentModel();
@@ -96,243 +110,254 @@ describe('useKeyValueParameters', () => {
composable.parameters.value[0].value = 'existing-value';
// Add external parameters with one duplicate
model.value = [
{ key: 'existing', value: 'new-value' }, // Duplicate
{ key: 'new', value: 'new-value' }, // New
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'existing',
value: 'new-value',
enabled: true,
}, // Duplicate
{
id: 2,
type: ParameterType.Text,
key: 'new',
value: 'new-value',
enabled: true,
},
];
composable.updateParametersFromParentModel();
// Should have 2 parameters: the new one and the existing one (updated)
// Should have 2 parameters: existing (updated) and new
expect(composable.parameters.value).toHaveLength(2);
const existingParam = composable.parameters.value.find(
p => p.key === 'existing',
);
const newParam = composable.parameters.value.find(p => p.key === 'new');
expect(existingParam?.value).toBe('new-value'); // Updated
expect(newParam?.key).toBe('new');
expect(existingParam).toMatchObject({
key: 'existing',
value: 'new-value',
});
});
});
describe('deletion management', () => {
it('should initiate parameter deletion on first click', () => {
composable.addNewEmptyParameter();
const index = 0;
const parameter = composable.parameters.value[0];
composable.triggerParameterDeletion(composable.parameters.value, index);
composable.triggerParameterDeletion(composable.parameters.value, 0);
expect(composable.isParameterMarkedForDeletion(parameter.id)).toBe(true);
expect(composable.isParameterMarkedForDeletion(index)).toBe(true);
});
it('should delete parameter on second click', () => {
composable.addNewEmptyParameter();
const initialLength = composable.parameters.value.length;
composable.addNewEmptyParameter();
const index = 0;
// First click - mark for deletion
composable.triggerParameterDeletion(composable.parameters.value, 0);
// Second click - actually delete
composable.triggerParameterDeletion(composable.parameters.value, 0);
expect(composable.parameters.value).toHaveLength(initialLength - 1);
});
it('should handle bulk deletion confirmation', async () => {
composable.addNewEmptyParameter();
composable.addNewEmptyParameter();
// First click - mark for bulk deletion
composable.deleteAllParameters();
expect(composable.deletingAll.value).toBe(true);
composable.triggerParameterDeletion(composable.parameters.value, index);
expect(composable.parameters.value).toHaveLength(2);
// Second click - actually delete all
composable.deleteAllParameters();
expect(composable.parameters.value).toHaveLength(0);
expect(composable.deletingAll.value).toBe(false);
// Second click - actually delete
composable.triggerParameterDeletion(composable.parameters.value, index);
expect(composable.parameters.value).toHaveLength(1);
});
it('should clear deletion states', () => {
composable.addNewEmptyParameter();
const parameter = composable.parameters.value[0];
composable.addNewEmptyParameter();
// Mark for deletion
composable.triggerParameterDeletion(composable.parameters.value, 0);
expect(composable.isParameterMarkedForDeletion(parameter.id)).toBe(true);
expect(composable.isParameterMarkedForDeletion(0)).toBe(true);
// Clear all states
composable.clearAllDeletionStates();
expect(composable.isParameterMarkedForDeletion(parameter.id)).toBe(false);
expect(composable.isParameterMarkedForDeletion(0)).toBe(false);
});
});
describe('computed properties', () => {
it('should correctly compute areAllParametersDisabled', () => {
// No parameters - should be true
expect(composable.areAllParametersDisabled.value).toBe(true);
// Add enabled parameter
it('should delete all parameters', () => {
composable.addNewEmptyParameter();
composable.addNewEmptyParameter();
expect(composable.areAllParametersDisabled.value).toBe(false);
// Disable the parameter
composable.parameters.value[0].enabled = false;
expect(composable.areAllParametersDisabled.value).toBe(true);
});
it('should correctly compute deletingAll', () => {
expect(composable.deletingAll.value).toBe(false);
// Start bulk deletion
// First click - mark for deletion
composable.deleteAllParameters();
expect(composable.deletingAll.value).toBe(true);
// Second click - actually delete
composable.deleteAllParameters();
expect(composable.parameters.value).toHaveLength(0);
});
});
describe('parameter conversion', () => {
it('should convert external to internal format correctly', () => {
model.value = [
{ key: 'test', value: 'value' },
// @ts-expect-error asserting edge case.
{ key: 'number', value: 123 },
];
describe('event-based updates', () => {
it('should call onUpdate callback when parameters change', async () => {
composable.addNewEmptyParameter();
composable.updateParametersFromParentModel();
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 50));
expect(composable.parameters.value[0]).toMatchObject({
key: 'test',
value: 'value',
type: 'text',
enabled: true,
});
expect(composable.parameters.value[1]).toMatchObject({
key: 'number',
value: '123', // Converted to string
type: 'text',
enabled: true,
});
expect(onUpdateCallback).toHaveBeenCalled();
const callArgs =
onUpdateCallback.mock.calls[onUpdateCallback.mock.calls.length - 1][0];
expect(callArgs).toBeInstanceOf(Array);
expect(callArgs.length).toBeGreaterThan(0);
});
it('should sync parameters back to model correctly', async () => {
it('should deep clone parameters when calling onUpdate', async () => {
composable.addNewEmptyParameter();
composable.parameters.value[0].key = 'test-key';
composable.parameters.value[0].value = 'test-value';
composable.parameters.value[0].enabled = true;
composable.parameters.value[0].key = 'test';
composable.parameters.value[0].value = 'value';
// Add another parameter but disabled
composable.addNewEmptyParameter();
composable.parameters.value[1].key = 'disabled-key';
composable.parameters.value[1].value = 'disabled-value';
composable.parameters.value[1].enabled = false;
// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 50));
// Add empty key parameter
composable.addNewEmptyParameter();
composable.parameters.value[2].key = '';
composable.parameters.value[2].value = 'empty-key-value';
composable.parameters.value[2].enabled = true;
const callArgs =
onUpdateCallback.mock.calls[onUpdateCallback.mock.calls.length - 1][0];
const emittedParam = callArgs[0];
// Trigger sync (this happens automatically with watchDebounced)
// We'll manually trigger it for testing
composable.parameters.value = [...composable.parameters.value];
// Verify it's a deep clone (not the same reference)
expect(emittedParam).not.toBe(composable.parameters.value[0]);
expect(emittedParam).toMatchObject({
key: 'test',
value: 'value',
});
// Mutate the emitted parameter
emittedParam.key = 'mutated';
// Original should be unchanged
expect(composable.parameters.value[0].key).toBe('test');
});
it('should update parameters when modelValue changes externally', async () => {
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'initial',
value: 'value',
enabled: true,
},
];
await nextTick();
// Only enabled parameters with non-empty keys should be synced
expect(model.value).toEqual([
expect(composable.parameters.value.length).toBeGreaterThanOrEqual(1);
expect(composable.parameters.value[0].key).toBe('initial');
// Change modelValue externally (must use same ID to preserve object)
modelValue.value = [
{
type: 'text', // <- added implicitly.
key: 'test-key',
value: 'test-value',
id: 1,
type: ParameterType.Text,
key: 'updated',
value: 'new-value',
enabled: true,
},
]);
];
await nextTick();
// The reconciliation logic preserves existing objects, so we need to check
// that the values were updated
expect(composable.parameters.value[0].key).toBe('updated');
expect(composable.parameters.value[0].value).toBe('new-value');
});
});
describe('edge cases', () => {
it('should handle deletion of non-existent parameter', () => {
composable.addNewEmptyParameter();
const parameters = composable.parameters.value;
composable.triggerParameterDeletion(composable.parameters.value, 999);
expect(composable.isParameterMarkedForDeletion(parameters[0].id)).toBe(false);
});
it('should handle empty model value', () => {
model.value = [];
composable.updateParametersFromParentModel();
// Should not add any parameters from empty model
expect(composable.parameters.value.length).toBeGreaterThanOrEqual(0);
});
it('should handle null model value', () => {
// @ts-expect-error asserting edge case.
model.value = null;
composable.updateParametersFromParentModel();
// Should not crash and should not add parameters
expect(composable.parameters.value.length).toBeGreaterThanOrEqual(0);
});
it('should handle parameters with special characters in keys', () => {
model.value = [
{ key: 'key with spaces', value: 'value1' },
{ key: 'key-with-dashes', value: 'value2' },
{ key: 'key_with_underscores', value: 'value3' },
{ key: 'key.with.dots', value: 'value4' },
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'key-with-dash',
value: 'value1',
enabled: true,
},
{
id: 2,
type: ParameterType.Text,
key: 'key_with_underscore',
value: 'value2',
enabled: true,
},
{
id: 3,
type: ParameterType.Text,
key: 'key.with.dot',
value: 'value3',
enabled: true,
},
{
id: 4,
type: ParameterType.Text,
key: 'key with space',
value: 'value4',
enabled: true,
},
];
composable.updateParametersFromParentModel();
expect(composable.parameters.value).toHaveLength(4);
expect(composable.parameters.value[0].key).toBe('key with spaces');
expect(composable.parameters.value[1].key).toBe('key-with-dashes');
expect(composable.parameters.value[0].key).toBe('key-with-dash');
expect(composable.parameters.value[1].key).toBe('key_with_underscore');
expect(composable.parameters.value[2].key).toBe('key.with.dot');
expect(composable.parameters.value[3].key).toBe('key with space');
});
it('should handle very long parameter values', () => {
const longValue = 'a'.repeat(10000);
model.value = [{ key: 'long-value', value: longValue }];
it('should handle empty string values', () => {
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'empty-value',
value: '',
enabled: true,
},
];
composable.updateParametersFromParentModel();
expect(composable.parameters.value[0].value).toBe(longValue);
expect(composable.parameters.value[0].value).toBe('');
});
});
describe('duplicate handling', () => {
it('should handle duplicate keys correctly', () => {
// Add initial parameter
composable.addNewEmptyParameter();
composable.parameters.value[0].key = 'duplicate';
composable.parameters.value[0].value = 'original';
// Add external parameters with duplicate key
model.value = [
{ key: 'duplicate', value: 'updated' },
{ key: 'unique', value: 'new' },
modelValue.value = [
{
id: 1,
type: ParameterType.Text,
key: 'duplicate',
value: 'value1',
enabled: true,
},
{
id: 2,
type: ParameterType.Text,
key: 'duplicate',
value: 'value2',
enabled: true,
},
];
composable.updateParametersFromParentModel();
// Should have 2 parameters: updated duplicate and new unique
// Should have 2 parameters: both duplicates
expect(composable.parameters.value).toHaveLength(2);
const duplicateParam = composable.parameters.value.find(
const duplicateParams = composable.parameters.value.filter(
p => p.key === 'duplicate',
);
const uniqueParam = composable.parameters.value.find(p => p.key === 'unique');
expect(duplicateParam?.value).toBe('updated');
expect(uniqueParam?.value).toBe('new');
expect(duplicateParams).toHaveLength(2);
expect(duplicateParams[0].value).toBe('value1');
expect(duplicateParams[1].value).toBe('value2');
});
});
});

View File

@@ -3,7 +3,7 @@ import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { effectScope, nextTick, reactive } from 'vue';
import { effectScope, reactive } from 'vue';
const requestStore = reactive<{
pendingRequestData: PendingRequest | null;
@@ -114,20 +114,6 @@ describe('useRequestBody', () => {
expect(payload).toBe('{"cached":true}');
});
it('updates content-type header when payload type changes', async () => {
const composable = runComposable();
composable.payloadType.value = RequestBodyTypeEnum.JSON;
await nextTick();
expect(
requestStore.pendingRequestData?.headers.find(
header => header.key === 'content-type',
)?.value,
).toBe('application/json');
});
it('autofills payload using random generator', () => {
const composable = runComposable();

View File

@@ -1,6 +1,6 @@
import { AuthorizationContract } from '@/interfaces';
import { AuthorizationContract, ParameterContract, ParameterType } from '@/interfaces';
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum, RequestHeader } from '@/interfaces/http';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { RouteDefinition } from '@/interfaces/routes';
import { useRequestBuilderStore } from '@/stores/request/useRequestBuilderStore';
import { createPinia, setActivePinia } from 'pinia';
@@ -110,13 +110,20 @@ describe('useRequestBuilderStore', () => {
store.initializeRequest(baseRoute, [baseRoute]);
const headers: RequestHeader[] = [
{ key: 'Content-Type', value: 'application/json' },
const headers: ParameterContract[] = [
{
type: ParameterType.Text,
key: 'Content-Type',
value: 'application/json',
enabled: true,
},
];
const body: PendingRequest['body'] = {
GET: { [RequestBodyTypeEnum.JSON]: '{}' },
};
const params = [{ key: 'page', value: '1' }];
const params: ParameterContract[] = [
{ type: ParameterType.Text, key: 'page', value: '1', enabled: true },
];
const auth: AuthorizationContract = {
type: AuthorizationType.Bearer,
value: 'token',
@@ -142,7 +149,9 @@ describe('useRequestBuilderStore', () => {
const pending = store.pendingRequestData as PendingRequest;
pending.queryParameters = [{ key: 'page', value: '1' }];
pending.queryParameters = [
{ type: ParameterType.Text, key: 'page', value: '1', enabled: true },
];
expect(store.getRequestUrl(pending)).toBe('https://api.example.com/users?page=1');
});
@@ -156,4 +165,132 @@ describe('useRequestBuilderStore', () => {
expect(store.pendingRequestData).toBeNull();
});
it('restores state from a historical request', () => {
const store = createStore();
store.initializeRequest(baseRoute, [baseRoute]);
const historicalRequest = {
method: 'POST',
endpoint: 'users',
headers: [
{
type: ParameterType.Text,
key: 'X-RequestHistory',
value: 'true',
enabled: true,
},
],
body: '{"restored": true}',
queryParameters: [
{
type: ParameterType.Text,
key: 'restored',
value: 'true',
enabled: true,
},
],
payloadType: RequestBodyTypeEnum.JSON,
authorization: { type: AuthorizationType.None },
routeDefinition: baseRoute,
};
// @ts-expect-error simplified for test
store.restoreFromHistory(historicalRequest);
const pending = store.pendingRequestData as PendingRequest;
expect(pending.method).toBe('POST');
expect(pending.endpoint).toBe('users');
expect(pending.headers).toEqual(historicalRequest.headers);
expect(pending.queryParameters).toEqual(historicalRequest.queryParameters);
expect(pending.payloadType).toBe(RequestBodyTypeEnum.JSON);
expect(pending.body.POST?.[RequestBodyTypeEnum.JSON]).toBe(
historicalRequest.body,
);
});
it('preserves existing headers when initializing a new request', () => {
const store = createStore();
// Initial request
store.initializeRequest(baseRoute, [baseRoute]);
const initialHeaders: ParameterContract[] = [
{ type: ParameterType.Text, key: 'X-Test', value: 'test', enabled: true },
];
store.updateRequestHeaders(initialHeaders);
// Initialize new request (endpoint switch)
const nextRoute: RouteDefinition = { ...baseRoute, endpoint: 'posts' };
store.initializeRequest(nextRoute, [nextRoute]);
const pending = store.pendingRequestData as PendingRequest;
expect(pending.endpoint).toBe('posts');
expect(pending.headers).toEqual(initialHeaders);
});
it('deep clones parameters when restoring from history to prevent shared references', () => {
const store = createStore();
store.initializeRequest(baseRoute, [baseRoute]);
const historicalRequest = {
method: 'GET',
endpoint: 'users',
headers: [
{
id: 1,
type: ParameterType.Text,
key: 'X-Custom',
value: 'header1',
enabled: true,
},
{
id: 2,
type: ParameterType.Text,
key: 'X-Disabled',
value: 'header2',
enabled: false,
},
],
body: null,
queryParameters: [
{
id: 3,
type: ParameterType.Text,
key: 'active',
value: 'yes',
enabled: true,
},
{
id: 4,
type: ParameterType.Text,
key: 'inactive',
value: 'no',
enabled: false,
},
],
payloadType: RequestBodyTypeEnum.EMPTY,
authorization: { type: AuthorizationType.None },
routeDefinition: baseRoute,
};
// @ts-expect-error simplified for test
store.restoreFromHistory(historicalRequest);
const pending = store.pendingRequestData as PendingRequest;
// Verify values are restored correctly
expect(pending.headers).toEqual(historicalRequest.headers);
expect(pending.queryParameters).toEqual(historicalRequest.queryParameters);
// Modify the restored parameters (simulating UI interaction)
pending.headers[1].enabled = true;
pending.queryParameters[1].enabled = true;
// Verify the original historical request objects are NOT modified (no shared references)
expect(historicalRequest.headers[1].enabled).toBe(false);
expect(historicalRequest.queryParameters[1].enabled).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { AuthorizationContract, RouteDefinition } from '@/interfaces';
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { useRequestStore } from '@/stores/request/useRequestStore';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive } from 'vue';
@@ -226,6 +227,7 @@ describe('useRequestStore', () => {
it('should delegate updateRequestHeaders to builder store', () => {
const headers = [
{
type: ParameterType.Text,
key: 'Content-Type',
value: 'application/json',
enabled: true,
@@ -246,7 +248,9 @@ describe('useRequestStore', () => {
});
it('should delegate updateQueryParameters to builder store', () => {
const params = [{ key: 'page', value: '1' }];
const params = [
{ type: ParameterType.Text, key: 'page', value: '1', enabled: true },
];
store.updateQueryParameters(params);
expect(mockBuilderStore.updateQueryParameters).toHaveBeenCalledWith(params);
});
@@ -373,11 +377,20 @@ describe('useRequestStore', () => {
});
it('should handle multiple request updates', () => {
const headers = [{ key: 'Authorization', value: 'Bearer token' }];
const headers = [
{
type: ParameterType.Text,
key: 'Authorization',
value: 'Bearer token',
enabled: true,
},
];
const body: PendingRequest['body'] = {
POST: { json: JSON.stringify({ name: 'test' }) },
};
const params = [{ key: 'page', value: '1' }];
const params = [
{ type: ParameterType.Text, key: 'page', value: '1', enabled: true },
];
const auth: AuthorizationContract = {
type: AuthorizationType.Bearer,
value: 'abc123',

View File

@@ -26,7 +26,6 @@ describe('useRequestsHistoryStore', () => {
expect(store.allLogs).toHaveLength(2);
expect(store.lastLog?.durationInMs).toBe(30);
expect(store.totalRequests).toBe(2);
});
it('clears logs when requested', () => {
@@ -41,4 +40,43 @@ describe('useRequestsHistoryStore', () => {
expect(store.allLogs).toEqual([]);
expect(store.lastLog).toBeNull();
});
it('can set and reset an active log index', () => {
const store = useRequestsHistoryStore();
/* eslint-disable @typescript-eslint/no-explicit-any */
const logs: RequestLog[] = [
{ durationInMs: 10, isProcessing: false, request: { method: 'GET' } as any },
{ durationInMs: 20, isProcessing: false, request: { method: 'POST' } as any },
];
/* eslint-enable @typescript-eslint/no-explicit-any */
logs.forEach(log => store.addLog(log));
expect(store.lastLog?.durationInMs).toBe(20);
store.setActiveLog(0);
expect(store.activeLogIndex).toBe(0);
expect(store.lastLog?.durationInMs).toBe(10);
store.setActiveLog(null);
expect(store.activeLogIndex).toBe(null);
expect(store.lastLog?.durationInMs).toBe(20);
});
it('resets activeLogIndex when a new log is added', () => {
const store = useRequestsHistoryStore();
/* eslint-disable @typescript-eslint/no-explicit-any */
store.addLog({ durationInMs: 10, isProcessing: false, request: {} as any });
store.setActiveLog(0);
expect(store.activeLogIndex).toBe(0);
store.addLog({ durationInMs: 20, isProcessing: false, request: {} as any });
/* eslint-enable @typescript-eslint/no-explicit-any */
expect(store.activeLogIndex).toBeNull();
expect(store.lastLog?.durationInMs).toBe(20);
});
});

View File

@@ -0,0 +1,162 @@
import { RequestBodyTypeEnum } from '@/interfaces/http';
import {
generateContentTypeHeader,
getMimeTypeForPayloadType,
types,
} from '@/utils/request/content-type-header-generator';
import { describe, expect, it } from 'vitest';
describe('content-type-header-generator', () => {
describe('types constant', () => {
it('exports all payload types with correct structure', () => {
expect(types).toHaveLength(4);
expect(types).toEqual([
{
id: RequestBodyTypeEnum.EMPTY,
label: 'Empty',
autoFillable: false,
mimeType: null,
},
{
id: RequestBodyTypeEnum.JSON,
label: 'JSON',
autoFillable: true,
mimeType: 'application/json',
},
{
id: RequestBodyTypeEnum.PLAIN_TEXT,
label: 'Plain Text',
autoFillable: false,
mimeType: 'text/plain',
},
{
id: RequestBodyTypeEnum.FORM_DATA,
label: 'Form Data',
autoFillable: true,
mimeType: 'multipart/form-data',
},
]);
});
});
describe('getMimeTypeForPayloadType', () => {
it('returns correct MIME type for JSON', () => {
expect(getMimeTypeForPayloadType(RequestBodyTypeEnum.JSON)).toBe(
'application/json',
);
});
it('returns correct MIME type for Plain Text', () => {
expect(getMimeTypeForPayloadType(RequestBodyTypeEnum.PLAIN_TEXT)).toBe(
'text/plain',
);
});
it('returns correct MIME type for Form Data', () => {
expect(getMimeTypeForPayloadType(RequestBodyTypeEnum.FORM_DATA)).toBe(
'multipart/form-data',
);
});
it('returns null for Empty payload type', () => {
expect(getMimeTypeForPayloadType(RequestBodyTypeEnum.EMPTY)).toBeNull();
});
});
describe('generateContentTypeHeader', () => {
it('adds Content-Type header when none exists', () => {
const headers = [{ key: 'Accept', value: 'application/json' }];
const result = generateContentTypeHeader(RequestBodyTypeEnum.JSON, headers);
expect(result).toEqual([
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'application/json' },
]);
});
it('updates existing Content-Type header', () => {
const headers = [
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'text/plain' },
];
const result = generateContentTypeHeader(RequestBodyTypeEnum.JSON, headers);
expect(result).toEqual([
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'application/json' },
]);
});
it('removes Content-Type header when payload type is EMPTY', () => {
const headers = [
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'application/json' },
];
const result = generateContentTypeHeader(RequestBodyTypeEnum.EMPTY, headers);
expect(result).toEqual([{ key: 'Accept', value: 'application/json' }]);
});
it('does not modify headers when EMPTY and no Content-Type exists', () => {
const headers = [{ key: 'Accept', value: 'application/json' }];
const result = generateContentTypeHeader(RequestBodyTypeEnum.EMPTY, headers);
expect(result).toEqual([{ key: 'Accept', value: 'application/json' }]);
});
it('handles case-insensitive Content-Type header matching', () => {
const headers = [
{ key: 'Accept', value: 'application/json' },
{ key: 'Content-Type', value: 'text/plain' },
];
const result = generateContentTypeHeader(RequestBodyTypeEnum.JSON, headers);
expect(result).toEqual([
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'application/json' },
]);
});
it('does not mutate the original headers array', () => {
const headers = [{ key: 'Accept', value: 'application/json' }];
const originalHeaders = [...headers];
generateContentTypeHeader(RequestBodyTypeEnum.JSON, headers);
expect(headers).toEqual(originalHeaders);
});
it('handles Form Data payload type', () => {
const headers = [{ key: 'Accept', value: 'application/json' }];
const result = generateContentTypeHeader(
RequestBodyTypeEnum.FORM_DATA,
headers,
);
expect(result).toEqual([
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'multipart/form-data' },
]);
});
it('handles Plain Text payload type', () => {
const headers = [{ key: 'Accept', value: 'application/json' }];
const result = generateContentTypeHeader(
RequestBodyTypeEnum.PLAIN_TEXT,
headers,
);
expect(result).toEqual([
{ key: 'Accept', value: 'application/json' },
{ key: 'content-type', value: 'text/plain' },
]);
});
});
});

View File

@@ -1,5 +1,6 @@
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { ParameterType } from '@/interfaces/ui';
import { generateCurlCommand } from '@/utils/request';
import { describe, expect, it } from 'vitest';
@@ -7,8 +8,18 @@ const requestBase: PendingRequest = {
method: 'POST',
endpoint: 'users',
headers: [
{ key: 'Authorization', value: 'Bearer token' },
{ key: 'Accept', value: 'application/json' },
{
key: 'Authorization',
value: 'Bearer token',
enabled: true,
type: ParameterType.Text,
},
{
key: 'Accept',
value: 'application/json',
enabled: true,
type: ParameterType.Text,
},
],
body: {
POST: {
@@ -20,7 +31,9 @@ const requestBase: PendingRequest = {
shape: {},
extractionErrors: null,
},
queryParameters: [{ key: 'page', value: '1' }],
queryParameters: [
{ key: 'page', value: '1', enabled: true, type: ParameterType.Text },
],
authorization: { type: AuthorizationType.Bearer, value: 'token' },
supportedRoutes: [],
routeDefinition: {

View File

@@ -0,0 +1,121 @@
import { RequestBodyTypeEnum, RequestHeader } from '@/interfaces/http';
/**
* Type shape for request body types with their associated metadata.
*/
export interface TypeShape {
id: RequestBodyTypeEnum;
label: string;
autoFillable: boolean;
mimeType: string | null;
}
/**
* Available request body types with their MIME type mappings.
*
* This constant defines the supported payload types and their characteristics:
* - id: The enum value identifying the type
* - label: Human-readable name for UI display
* - autoFillable: Whether the type supports schema-based auto-fill
* - mimeType: The Content-Type header value, or null if no header should be set
*/
export const types: TypeShape[] = [
{
id: RequestBodyTypeEnum.EMPTY,
label: 'Empty',
autoFillable: false,
mimeType: null,
},
{
id: RequestBodyTypeEnum.JSON,
label: 'JSON',
autoFillable: true,
mimeType: 'application/json',
},
{
id: RequestBodyTypeEnum.PLAIN_TEXT,
label: 'Plain Text',
autoFillable: false,
mimeType: 'text/plain',
},
{
id: RequestBodyTypeEnum.FORM_DATA,
label: 'Form Data',
autoFillable: true,
mimeType: 'multipart/form-data',
},
];
/**
* Gets the MIME type for a given payload type.
*
* @param payloadType - The request body type to look up
* @returns The MIME type string, or null if the type has no associated MIME type
*
* @example
* getMimeTypeForPayloadType(RequestBodyTypeEnum.JSON) // 'application/json'
* getMimeTypeForPayloadType(RequestBodyTypeEnum.EMPTY) // null
*/
export function getMimeTypeForPayloadType(
payloadType: RequestBodyTypeEnum,
): string | null {
return types.find(type => type.id === payloadType)?.mimeType ?? null;
}
/**
* Generates a new headers array with the appropriate Content-Type header.
*
* This function creates a new array of headers based on the payload type:
* - If the payload type has a MIME type, adds/updates the Content-Type header
* - If the payload type has no MIME type (e.g., EMPTY), removes any Content-Type header
* - Does NOT mutate the input headers array
*
* @param payloadType - The request body type to generate Content-Type for
* @param existingHeaders - The current headers array
* @returns A new headers array with Content-Type properly set
*
* @example
* const headers = [{ key: 'Accept', value: 'application/json' }];
* const newHeaders = generateContentTypeHeader(RequestBodyTypeEnum.JSON, headers);
* // Returns: [
* // { key: 'Accept', value: 'application/json' },
* // { key: 'content-type', value: 'application/json' }
* // ]
*/
export function generateContentTypeHeader(
payloadType: RequestBodyTypeEnum,
existingHeaders: RequestHeader[],
): RequestHeader[] {
const mimeType = getMimeTypeForPayloadType(payloadType);
// Find existing Content-Type header (case-insensitive)
const contentTypeIndex = existingHeaders.findIndex(
(header: RequestHeader) => header.key.toLowerCase() === 'content-type',
);
// If no MIME type for this payload type, remove Content-Type if it exists
if (mimeType === null) {
if (contentTypeIndex !== -1) {
// Return new array without the Content-Type header
return [
...existingHeaders.slice(0, contentTypeIndex),
...existingHeaders.slice(contentTypeIndex + 1),
];
}
// No Content-Type to remove, return as-is
return existingHeaders;
}
// If Content-Type exists, update it
if (contentTypeIndex !== -1) {
return [
...existingHeaders.slice(0, contentTypeIndex),
{ key: 'content-type', value: mimeType },
...existingHeaders.slice(contentTypeIndex + 1),
];
}
// Add new Content-Type header
return [...existingHeaders, { key: 'content-type', value: mimeType }];
}

View File

@@ -1,8 +1,10 @@
import { ParametersExternalContract } from '@/interfaces';
import { ParameterContract } from '@/interfaces';
import { AuthorizationContract } from '@/interfaces/auth/authorization';
import { AuthorizationType } from '@/interfaces/generated';
import { PendingRequest, RequestBodyTypeEnum, RequestHeader } from '@/interfaces/http';
import { PendingRequest, RequestBodyTypeEnum } from '@/interfaces/http';
import { ParameterType } from '@/interfaces/ui/key-value-parameters';
import { buildRequestUrl } from '@/utils';
import { getMimeTypeForPayloadType } from '@/utils/request/content-type-header-generator';
/**
* Result of cURL command generation.
@@ -46,7 +48,7 @@ export function generateCurlCommand(
}
function getEffectiveQueryParametersAndBodyValue(request: PendingRequest): {
queryParameters: ParametersExternalContract[];
queryParameters: ParameterContract[];
requestBody: FormData | string | null;
} {
const requestBody = getRequestEffectiveBody(request);
@@ -62,7 +64,7 @@ function getEffectiveQueryParametersAndBodyValue(request: PendingRequest): {
return {
queryParameters: [
...request.queryParameters,
...request.queryParameters.filter(isValidParameter),
...convertKeyValuePairsToQueryParameters(requestBodyKeyValuePairs),
],
requestBody: null,
@@ -97,14 +99,15 @@ function buildRequestHeaderParts(request: PendingRequest): string[] {
const headerParts = validHeaders.map(header => `-H "${header.key}: ${header.value}"`);
// Add Content-Type header for JSON payloads if not already present
if (request.payloadType === RequestBodyTypeEnum.JSON) {
// Add Content-Type header for payload types with MIME types if not already present
const mimeType = getMimeTypeForPayloadType(request.payloadType);
if (mimeType) {
const hasContentType = validHeaders.some(
header => header.key.toLowerCase() === 'content-type',
);
if (!hasContentType) {
headerParts.push(`-H "Content-Type: application/json"`);
headerParts.push(`-H "Content-Type: ${mimeType}"`);
}
}
@@ -128,9 +131,10 @@ function buildAuthorizationHeaderPart(
function convertKeyValuePairsToQueryParameters(
keyValuePairs: Record<string, string>,
): ParametersExternalContract[] {
): ParameterContract[] {
return Object.entries(keyValuePairs).map(([key, value]) => ({
type: 'text',
type: ParameterType.Text,
enabled: true,
key,
value,
}));
@@ -139,15 +143,15 @@ function convertKeyValuePairsToQueryParameters(
/**
* Filters headers to only include valid ones.
*/
function getValidHeaders(request: PendingRequest): RequestHeader[] {
return request.headers.filter(isValidHeader);
function getValidHeaders(request: PendingRequest): ParameterContract[] {
return request.headers.filter(isValidParameter);
}
/**
* Checks if a header is valid for inclusion.
* Checks if a parameter is valid for inclusion.
*/
function isValidHeader(header: RequestHeader): boolean {
return header.key.trim() !== '' && header.value !== null && header.value !== '';
function isValidParameter(parameter: ParameterContract): boolean {
return parameter.enabled && parameter.key.trim() !== '';
}
/**

View File

@@ -1,9 +1,9 @@
import { ParametersExternalContract } from '@/interfaces';
import { ParameterContract } from '@/interfaces';
/**
* Checks if a query parameter is valid for inclusion in URLs.
*/
export function isValidQueryParameter(parameter: ParametersExternalContract): boolean {
export function isValidQueryParameter(parameter: ParameterContract): boolean {
return parameter.key.trim() !== '';
}
@@ -16,7 +16,7 @@ export function isValidQueryParameter(parameter: ParametersExternalContract): bo
export function buildRequestUrl(
baseUrl: string,
endpoint: string,
queryParameters: ParametersExternalContract[],
queryParameters: ParameterContract[],
): string {
const url = new URL(`${baseUrl}/${endpoint}`);

View File

@@ -56,13 +56,19 @@ export function generateErrorRequestLog(
}
const pendingRequestToRequestLogEntry = function (request: PendingRequest): Request {
// Extract the memoized body for the current method and payload type
const methodBody = request.body[request.method] ?? null;
const currentBody = methodBody ? (methodBody[request.payloadType] ?? null) : null;
return {
method: request.method,
endpoint: request.endpoint,
headers: request.headers,
body: null, // The Body is handled separately in execution
queryParameters: request.queryParameters,
headers: [...request.headers],
body: currentBody,
queryParameters: [...request.queryParameters],
payloadType: request.payloadType,
authorization: { ...request.authorization },
routeDefinition: { ...request.routeDefinition },
};
};

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"strict": true,
"isolatedModules": true,
"target": "ESNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./js/*"]
},
"types": ["./types/global", "./types/vue-shims"]
},
"include": [
"js/**/*.ts",
"js/**/*.vue",
"types/**/*.d.ts"
]
}