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:
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -20,7 +20,7 @@
|
||||
/vendor export-ignore
|
||||
/vite.config.js export-ignore
|
||||
/components.json export-ignore
|
||||
/resources/tsconfig.json export-ignore
|
||||
/tsconfig.json export-ignore
|
||||
/package.json export-ignore
|
||||
/package-lock.json export-ignore
|
||||
/CONTRIBUTING.md export-ignore
|
||||
|
||||
42
package-lock.json
generated
42
package-lock.json
generated
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "nimbus",
|
||||
"version": "0.3.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -19,7 +20,7 @@
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
@@ -275,7 +276,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
@@ -290,7 +290,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.0.tgz",
|
||||
"integrity": "sha512-wZxW+9XDytH3SKvS8cQzMyQCaaazH8XL1EMHleHe00wVzsv7NBQKVW2yzEHrRhmM7ZOhVdItPbvlRBvMp9ej7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.35.0",
|
||||
@@ -313,7 +312,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
@@ -323,7 +321,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
|
||||
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.5.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -419,7 +416,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -466,7 +462,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1291,8 +1286,7 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.1",
|
||||
@@ -2372,7 +2366,6 @@
|
||||
"integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2425,7 +2418,6 @@
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
@@ -2785,7 +2777,6 @@
|
||||
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2880,7 +2871,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
@@ -3046,7 +3036,6 @@
|
||||
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "7.18.0",
|
||||
"@typescript-eslint/types": "7.18.0",
|
||||
@@ -3386,7 +3375,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3990,7 +3978,6 @@
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
@@ -4882,7 +4869,6 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -4939,7 +4925,6 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5128,7 +5113,6 @@
|
||||
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"globals": "^13.24.0",
|
||||
@@ -7992,7 +7976,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
|
||||
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.2"
|
||||
},
|
||||
@@ -8110,7 +8093,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8150,7 +8132,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -8180,7 +8161,6 @@
|
||||
"integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"prettier": ">=2.0",
|
||||
"typescript": ">=2.9",
|
||||
@@ -8650,9 +8630,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reka-ui": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.5.1.tgz",
|
||||
"integrity": "sha512-QJGB3q21wQ1Kw28HhhNDpjfFe8qpePX1gK4FTBRd68XTh9aEnhR5bTJnlV0jxi8FBPh0xivZBeNFUc3jiGx7mQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz",
|
||||
"integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
@@ -9575,8 +9555,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
||||
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -9696,7 +9675,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9986,7 +9964,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10176,7 +10153,6 @@
|
||||
"integrity": "sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -10304,7 +10280,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10318,7 +10293,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -10411,7 +10385,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
@@ -10574,7 +10547,6 @@
|
||||
"integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@volar/typescript": "2.4.15",
|
||||
"@vue/language-core": "2.2.12"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"style:check": "eslint --config tools/eslint/.eslintrc.cjs --ignore-path tools/eslint/.eslintignore resources/js --ext .js,.ts,.vue",
|
||||
"style:fix": "npm run style:check -- --fix",
|
||||
"type:check": "vue-tsc --noEmit --project ./resources/tsconfig.json",
|
||||
"type:check": "vue-tsc --noEmit --project tsconfig.json",
|
||||
"vite": "vite --config vite.config.js",
|
||||
"build": "npm run type:check && npm run vite -- build && cp -a ./resources/dist-static/. ./resources/dist",
|
||||
"build:dev": "npm run vite -- build --mode=development && cp -a ./resources/dist-static/. ./resources/dist",
|
||||
@@ -77,7 +77,7 @@
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tw-animate-css": "^1.3.8",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue-codemirror": "^6.1.1",
|
||||
|
||||
@@ -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>
|
||||
|
||||
34
resources/js/components/base/input-group/AppInputGroup.vue
Normal file
34
resources/js/components/base/input-group/AppInputGroup.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
59
resources/js/components/base/input-group/index.ts
Normal file
59
resources/js/components/base/input-group/index.ts
Normal 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'];
|
||||
}
|
||||
33
resources/js/components/base/textarea/AppTextarea.vue
Normal file
33
resources/js/components/base/textarea/AppTextarea.vue
Normal 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>
|
||||
1
resources/js/components/base/textarea/index.ts
Normal file
1
resources/js/components/base/textarea/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AppTextarea } from './AppTextarea.vue';
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
() => {
|
||||
const handlePayloadUpdate = (parameters: ParameterContract[]) => {
|
||||
isPropagatingChangesToParent.value = true;
|
||||
|
||||
emit('update:modelValue', convertParametersArrayToFormData(payload.value));
|
||||
},
|
||||
{ deep: 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
/
|
||||
{{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* History and logging interfaces and types
|
||||
* RequestHistory and logging interfaces and types
|
||||
*/
|
||||
|
||||
export type { RequestLog } from './logs';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useRequestExecutorStore = defineStore('_requestExecutor', () => {
|
||||
/*
|
||||
* Stores & dependencies.
|
||||
*/
|
||||
|
||||
const historyStore = useRequestsHistoryStore();
|
||||
const { executeRequest, cancelCurrentRequest } = useHttpClient();
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>',
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
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 deep clone parameters when calling onUpdate', async () => {
|
||||
composable.addNewEmptyParameter();
|
||||
composable.parameters.value[0].key = 'test';
|
||||
composable.parameters.value[0].value = 'value';
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const callArgs =
|
||||
onUpdateCallback.mock.calls[onUpdateCallback.mock.calls.length - 1][0];
|
||||
const emittedParam = callArgs[0];
|
||||
|
||||
// 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',
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
expect(composable.parameters.value[1]).toMatchObject({
|
||||
key: 'number',
|
||||
value: '123', // Converted to string
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sync parameters back to model correctly', async () => {
|
||||
composable.addNewEmptyParameter();
|
||||
composable.parameters.value[0].key = 'test-key';
|
||||
composable.parameters.value[0].value = 'test-value';
|
||||
composable.parameters.value[0].enabled = true;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
// Trigger sync (this happens automatically with watchDebounced)
|
||||
// We'll manually trigger it for testing
|
||||
composable.parameters.value = [...composable.parameters.value];
|
||||
},
|
||||
];
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
162
resources/js/tests/utils/content-type-header-generator.test.ts
Normal file
162
resources/js/tests/utils/content-type-header-generator.test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
121
resources/js/utils/request/content-type-header-generator.ts
Normal file
121
resources/js/utils/request/content-type-header-generator.ts
Normal 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 }];
|
||||
}
|
||||
@@ -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() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
111
tests/E2E/pages/BasePage.ts
Normal file
111
tests/E2E/pages/BasePage.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export class BasePage {
|
||||
constructor(public readonly page: Page) { }
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/demo');
|
||||
}
|
||||
|
||||
async executeRequest() {
|
||||
await this.page.getByTestId('endpoint-input').click();
|
||||
await this.page.waitForTimeout(300); // <- Wait for next tick.
|
||||
|
||||
await this.page.getByRole('button', { name: 'Send ( )' }).click();
|
||||
|
||||
await expect(this.page.getByTestId('response-status-text')).not.toContainText(
|
||||
'Pending',
|
||||
);
|
||||
}
|
||||
|
||||
async sendRequest(
|
||||
group: string | null,
|
||||
endpoint: string,
|
||||
options: { autoFill?: boolean; expectedStatus?: string } = {},
|
||||
) {
|
||||
const { autoFill = false } = options;
|
||||
|
||||
if (group) {
|
||||
await this.page.getByRole('button', { name: group }).click();
|
||||
}
|
||||
|
||||
await this.page.getByRole('button', { name: endpoint, exact: true }).click();
|
||||
|
||||
if (autoFill) {
|
||||
await this.page.getByRole('tab', { name: 'Body' }).click();
|
||||
await this.page.getByRole('button', { name: 'Auto Fill' }).click();
|
||||
}
|
||||
|
||||
await this.executeRequest();
|
||||
}
|
||||
|
||||
async addHeader(key: string, value: string, index: number = 0) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Headers' })
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByTestId('request-headers')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
const headerKey =
|
||||
index === 0
|
||||
? this.page.getByTestId('request-headers').getByTestId('kv-key').first()
|
||||
: this.page.getByTestId('request-headers').getByTestId('kv-key').nth(index);
|
||||
|
||||
const headerValue =
|
||||
index === 0
|
||||
? this.page.getByTestId('request-headers').getByTestId('kv-value').first()
|
||||
: this.page.getByTestId('request-headers').getByTestId('kv-value').nth(index);
|
||||
|
||||
await headerKey.fill(key);
|
||||
await headerValue.fill(value);
|
||||
|
||||
return { headerKey, headerValue };
|
||||
}
|
||||
|
||||
async addQueryParameter(key: string, value: string) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Parameters' })
|
||||
.click();
|
||||
|
||||
await this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
const paramKey = this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByTestId('kv-key')
|
||||
.first();
|
||||
|
||||
const paramValue = this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByTestId('kv-value')
|
||||
.first();
|
||||
|
||||
await paramKey.fill(key);
|
||||
await paramValue.fill(value);
|
||||
|
||||
return { paramKey, paramValue };
|
||||
}
|
||||
|
||||
async openHistory() {
|
||||
await this.page.getByTestId('response-history-trigger').click();
|
||||
}
|
||||
|
||||
async rewindToHistoryItem(searchText: string) {
|
||||
await this.openHistory();
|
||||
await this.page
|
||||
.getByTestId('history-item')
|
||||
.filter({ has: this.page.getByText(searchText) })
|
||||
.click();
|
||||
}
|
||||
|
||||
async searchHistory(query: string) {
|
||||
await this.page.getByTestId('history-search-input').fill(query);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ test("Dump and Die visualization sanity checklist", async ({ page }) => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator("#reka-collapsible-content-v-119"),
|
||||
page.locator("#reka-collapsible-content-v-126"),
|
||||
).toMatchAriaSnapshot(`- text: "0: \\"/\\" (1) 1: \\"\\\\\\" (1)"`);
|
||||
|
||||
// dump #3 (runtime object)
|
||||
|
||||
12
tests/E2E/tests/fixtures.ts
Normal file
12
tests/E2E/tests/fixtures.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { test as base } from '@playwright/test';
|
||||
import { BasePage } from '../pages/BasePage';
|
||||
|
||||
export const test = base.extend<{
|
||||
basePage: BasePage;
|
||||
}>({
|
||||
basePage: async ({ page }, use) => {
|
||||
await use(new BasePage(page));
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
372
tests/E2E/tests/history.spec.ts
Normal file
372
tests/E2E/tests/history.spec.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
|
||||
test.describe('Search Functionality', () => {
|
||||
test('filters history by endpoint', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Send 3 requests to different endpoints
|
||||
await basePage.sendRequest('authentication', 'GET /show-logged-in-user');
|
||||
await basePage.sendRequest('shapes', 'POST /nested-object', { autoFill: true });
|
||||
await basePage.sendRequest('request-parameters', 'GET /');
|
||||
|
||||
await basePage.openHistory();
|
||||
|
||||
// Assert - Verify all 3 items are visible
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(3);
|
||||
|
||||
// Act - Search for "nested"
|
||||
await basePage.searchHistory('nested');
|
||||
|
||||
// Assert - Only nested-object should be visible
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('history-item').first()).toHaveAttribute(
|
||||
'data-endpoint',
|
||||
/_demo\/shapes\/nested-object/,
|
||||
);
|
||||
|
||||
// Act - Search for "authentication"
|
||||
await basePage.searchHistory('authentication');
|
||||
|
||||
// Assert - Only show-logged-in-user should be visible
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(1);
|
||||
await expect(page.getByTestId('history-item').first()).toHaveAttribute(
|
||||
'data-endpoint',
|
||||
/_demo\/authentication\/show-logged-in-user/,
|
||||
);
|
||||
});
|
||||
|
||||
test('search is case-insensitive', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
await basePage.sendRequest('authentication', 'GET /show-logged-in-user');
|
||||
await basePage.openHistory();
|
||||
|
||||
// Act & Assert - Search with uppercase
|
||||
await basePage.searchHistory('AUTHENTICATION');
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(1);
|
||||
|
||||
// Act & Assert - Search with mixed case
|
||||
await basePage.searchHistory('AuThEnTiCaTiOn');
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('shows empty state when no matches found', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
await basePage.sendRequest('authentication', 'GET /show-logged-in-user');
|
||||
await basePage.openHistory();
|
||||
|
||||
// Act - Search for non-existent endpoint
|
||||
await basePage.searchHistory('nonexistent-endpoint');
|
||||
|
||||
// Assert - Should show empty state
|
||||
await expect(page.getByTestId('history-empty-state')).toBeVisible();
|
||||
await expect(page.getByTestId('history-empty-state')).toContainText(
|
||||
'No results found matching your keyword',
|
||||
);
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('clearing search shows all items', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
await basePage.sendRequest('authentication', 'GET /show-logged-in-user');
|
||||
await basePage.sendRequest('shapes', 'POST /nested-object', { autoFill: true });
|
||||
await basePage.openHistory();
|
||||
|
||||
// Act - Search for something
|
||||
await basePage.searchHistory('authentication');
|
||||
|
||||
// Assert
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(1);
|
||||
|
||||
// Act - Clear search
|
||||
await page.getByTestId('history-search-input').clear();
|
||||
|
||||
// Assert - All items should be visible again
|
||||
await expect(page.getByTestId('history-item')).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mutation Prevention', () => {
|
||||
test("rewinding doesn't mutate history - headers", async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Send first request with custom header
|
||||
await page.getByRole("button", { name: "authentication" }).click();
|
||||
await page.getByRole("button", { name: "GET /show-logged-in-user" }).click();
|
||||
|
||||
const { headerKey, headerValue } = await basePage.addHeader(
|
||||
"X-Test-Header",
|
||||
"original-value-1",
|
||||
2,
|
||||
);
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Act - Send second request with different header value
|
||||
await page.getByRole("button", { name: "shapes" }).click();
|
||||
await page.getByRole("button", { name: "POST /nested-object" }).click();
|
||||
|
||||
await headerKey.fill("X-Test-Header");
|
||||
await headerValue.fill("original-value-2");
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Act - Rewind to first request
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
|
||||
// Assert - Verify header is restored to original-value-1
|
||||
await expect(headerKey).toHaveValue("X-Test-Header");
|
||||
await expect(headerValue).toHaveValue("original-value-1");
|
||||
|
||||
// Act - Modify the header
|
||||
await headerValue.fill("modified-value");
|
||||
|
||||
// Act - Rewind to first request AGAIN
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
|
||||
// Assert - CRITICAL: Header should STILL be original-value-1, NOT modified-value
|
||||
await expect(headerValue).toHaveValue("original-value-1");
|
||||
});
|
||||
|
||||
test("rewinding doesn't mutate history - query parameters", async ({
|
||||
page,
|
||||
basePage,
|
||||
}) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Send first request with query parameter
|
||||
await page.getByRole("button", { name: "authentication" }).click();
|
||||
await page.getByRole("button", { name: "GET /show-logged-in-user" }).click();
|
||||
|
||||
const { paramKey, paramValue } = await basePage.addQueryParameter(
|
||||
"test-param",
|
||||
"original-param-1",
|
||||
);
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Act - Send second request with different parameter value
|
||||
await page.getByRole("button", { name: "shapes" }).click();
|
||||
await page.getByRole("button", { name: "POST /nested-object" }).click();
|
||||
|
||||
await paramKey.fill("test-param");
|
||||
await paramValue.fill("original-param-2");
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Act - Rewind to first request
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
|
||||
// Assert - Verify parameter is restored
|
||||
await expect(paramKey).toHaveValue("test-param");
|
||||
await expect(paramValue).toHaveValue("original-param-1");
|
||||
|
||||
// Act - Modify the parameter
|
||||
await paramValue.fill("modified-param");
|
||||
|
||||
// Act - Rewind to first request AGAIN
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
|
||||
// Assert - CRITICAL: Parameter should STILL be original-param-1, NOT modified-param
|
||||
await expect(paramValue).toHaveValue("original-param-1");
|
||||
});
|
||||
|
||||
test("multiple rewinds maintain independent state", async ({
|
||||
page,
|
||||
basePage,
|
||||
}) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
const requests = [
|
||||
{
|
||||
endpoint: "GET /show-logged-in-user",
|
||||
group: "authentication",
|
||||
headerValue: "request-1",
|
||||
autoFill: false,
|
||||
},
|
||||
{
|
||||
endpoint: "POST /nested-object",
|
||||
group: "shapes",
|
||||
headerValue: "request-2",
|
||||
autoFill: true,
|
||||
},
|
||||
{
|
||||
endpoint: "POST /array-of-primitives",
|
||||
group: null,
|
||||
headerValue: "request-3",
|
||||
autoFill: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Act - Send 3 requests with different headers
|
||||
for (const request of requests) {
|
||||
if (request.group) {
|
||||
await page.getByRole("button", { name: request.group }).click();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: request.endpoint }).click();
|
||||
|
||||
if (request.autoFill) {
|
||||
await page.getByRole("tab", { name: "Body" }).click();
|
||||
await page.getByRole("button", { name: "Auto Fill" }).click();
|
||||
}
|
||||
|
||||
const { headerValue } = await basePage.addHeader(
|
||||
"X-Request-ID",
|
||||
request.headerValue,
|
||||
);
|
||||
|
||||
await basePage.executeRequest();
|
||||
}
|
||||
|
||||
// Act & Assert - Rewind to request #1, modify header
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
await expect(
|
||||
page.getByTestId("request-headers").getByTestId("kv-value").first(),
|
||||
).toHaveValue("request-1");
|
||||
await page
|
||||
.getByTestId("request-headers")
|
||||
.getByTestId("kv-value")
|
||||
.first()
|
||||
.fill("modified-1");
|
||||
|
||||
// Act & Assert - Rewind to request #2, modify header
|
||||
await basePage.rewindToHistoryItem("nested-object");
|
||||
await expect(
|
||||
page.getByTestId("request-headers").getByTestId("kv-value").first(),
|
||||
).toHaveValue("request-2");
|
||||
await page
|
||||
.getByTestId("request-headers")
|
||||
.getByTestId("kv-value")
|
||||
.first()
|
||||
.fill("modified-2");
|
||||
|
||||
// Act & Assert - Rewind to request #1 AGAIN
|
||||
await basePage.rewindToHistoryItem("show-logged-in-user");
|
||||
await expect(
|
||||
page.getByTestId("request-headers").getByTestId("kv-value").first(),
|
||||
).toHaveValue("request-1");
|
||||
|
||||
// Act & Assert - Rewind to request #3
|
||||
await basePage.rewindToHistoryItem("array-of-primitives");
|
||||
await expect(
|
||||
page.getByTestId("request-headers").getByTestId("kv-value").first(),
|
||||
).toHaveValue("request-3");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rewind Functionality', () => {
|
||||
test('basic rewind flow', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Assert - Initial State
|
||||
await expect(page.getByTestId('response-empty')).toBeVisible();
|
||||
|
||||
// Act - Send first request (Show logged in user)
|
||||
await basePage.sendRequest('authentication', 'GET /show-logged-in-user');
|
||||
|
||||
// Act - Send second request (Nested object)
|
||||
await basePage.sendRequest('shapes', 'POST /nested-object', { autoFill: true });
|
||||
|
||||
// Assert
|
||||
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue('_demo/shapes/nested-object');
|
||||
|
||||
// Act - Open history and go back to first request
|
||||
await basePage.rewindToHistoryItem('show-logged-in-user');
|
||||
|
||||
// Assert - Verify Rewind
|
||||
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue(
|
||||
'_demo/authentication/show-logged-in-user',
|
||||
);
|
||||
await expect(page.getByTestId('request-builder-root')).toContainText('GET');
|
||||
await expect(page.getByTestId('response-status-badge')).toContainText('200 - OK');
|
||||
});
|
||||
|
||||
test('preserves request body when rewinding', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Send POST request with body
|
||||
await page.getByRole('button', { name: 'shapes' }).click();
|
||||
await page.getByRole('button', { name: 'POST /nested-object' }).click();
|
||||
await page.getByRole('button', { name: 'Auto Fill' }).click();
|
||||
|
||||
const bodyEditor = page.getByTestId('request-builder-root').locator('.cm-content');
|
||||
|
||||
// Assert - Verify body is filled
|
||||
await expect(bodyEditor).toContainText('"name"');
|
||||
|
||||
// Act - Send request and then another request
|
||||
await basePage.executeRequest();
|
||||
|
||||
await basePage.sendRequest(null, 'POST /array-of-primitives');
|
||||
|
||||
// Act - Rewind to POST request
|
||||
await basePage.rewindToHistoryItem('nested-object');
|
||||
|
||||
// Assert - Verify body is restored
|
||||
await expect(bodyEditor).toContainText('"name"');
|
||||
});
|
||||
|
||||
test('updates all UI elements on rewind', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Send first request with headers and params
|
||||
await page.getByRole('button', { name: 'authentication' }).click();
|
||||
await page.getByRole('button', { name: 'GET /show-logged-in-user' }).click();
|
||||
|
||||
await basePage.addHeader('X-Custom', 'header-value');
|
||||
|
||||
await basePage.addQueryParameter('query-key', 'query-value');
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Act - Send second request (different everything)
|
||||
await basePage.sendRequest('shapes', 'POST /nested-object', { autoFill: true });
|
||||
|
||||
// Act - Rewind to first request
|
||||
await basePage.rewindToHistoryItem('show-logged-in-user');
|
||||
|
||||
// Assert - Verify ALL elements are updated
|
||||
await expect(page.getByTestId('request-builder-root')).toContainText('GET');
|
||||
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue(
|
||||
'_demo/authentication/show-logged-in-user',
|
||||
);
|
||||
|
||||
await basePage.page.getByTestId('request-builder-root').getByRole('tab', { name: 'Headers' }).click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("request-headers")
|
||||
.getByTestId("kv-key")
|
||||
.first(),
|
||||
).toHaveValue("X-Custom");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("request-headers")
|
||||
.getByTestId("kv-value")
|
||||
.first(),
|
||||
).toHaveValue("header-value");
|
||||
|
||||
await page.getByRole('tab', { name: 'Parameters' }).click();
|
||||
|
||||
await expect(page.getByTestId('request-parameters').getByTestId('kv-key').first()).toHaveValue('query-key');
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("request-parameters")
|
||||
.getByTestId("kv-value")
|
||||
.first(),
|
||||
).toHaveValue("query-value");
|
||||
|
||||
await expect(page.getByTestId('response-status-badge')).toContainText('200 - OK');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ module.exports = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parser: '@typescript-eslint/parser',
|
||||
project: 'resources/tsconfig.json',
|
||||
project: 'tsconfig.json',
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./js/*"]
|
||||
"@/*": [
|
||||
"./resources/js/*"
|
||||
]
|
||||
},
|
||||
"types": ["./types/global", "./types/vue-shims"]
|
||||
"types": [
|
||||
"./resources/types/global",
|
||||
"./resources/types/vue-shims"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"js/**/*.ts",
|
||||
"js/**/*.vue",
|
||||
"types/**/*.d.ts"
|
||||
"resources/js/**/*.ts",
|
||||
"resources/js/**/*.vue",
|
||||
"resources/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '/resources/js'),
|
||||
'~': path.resolve(__dirname, '/resources/css'),
|
||||
'@': path.resolve(__dirname, 'resources/js'),
|
||||
'~': path.resolve(__dirname, 'resources/css'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
|
||||
@@ -18,6 +18,7 @@ This guide covers everything you need to know about using Nimbus to test and exp
|
||||
- [Response Viewer](#response-viewer)
|
||||
- [Cookie Inspection](#cookie-inspection)
|
||||
- [Dump and Die Responses](#dump-and-die-responses)
|
||||
- [Request History](#request-history)
|
||||
- [Authentication](#authentication)
|
||||
- [Session-Based Authentication](#session-based-authentication)
|
||||
- [Bearer Tokens](#bearer-tokens)
|
||||
@@ -206,6 +207,28 @@ When keep sending `dd` responses, the previous values can still be accessed via
|
||||
|
||||

|
||||
|
||||
#### Request History
|
||||
|
||||
Nimbus keeps a log of every request you send, allowing you to quickly "rewind" to a previous state.
|
||||
|
||||

|
||||
|
||||
**Features:**
|
||||
- **Automatic Logging**: Every execution is recorded with its timestamp, request details, and response status.
|
||||
- **Searchable Logs**: Use the search bar within the history dropdown to filter by endpoint path.
|
||||
- **Full Restoration**: Clicking any history item will restore the **entire Request Builder state**, including:
|
||||
- HTTP Method and Endpoint
|
||||
- Headers and Query Parameters
|
||||
- Request Body & Payload Type
|
||||
- Authentication settings
|
||||
- **Persistent Session**: Your history is preserved across page refreshes.
|
||||
|
||||
**How to use:**
|
||||
1. Click on the relative timestamp (e.g., "5 minutes ago") next to the response status badge.
|
||||
2. Browse or search through your previous requests.
|
||||
3. Click a log entry to restore its state into the Request Builder.
|
||||
4. Use the "Clear History" button to purge your session logs.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
BIN
wiki/user-guide/assets/history-viewer.png
Normal file
BIN
wiki/user-guide/assets/history-viewer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 253 KiB |
Reference in New Issue
Block a user