feat: persist UI state (#32)
* feat: persist UI state * test: fix var declaration * test: increate e2e timeout sometimes there might be a network latency to load CDN assets like the fonts. Let's give the tests a maximum of 1 minute to fully run.
This commit is contained in:
59
package-lock.json
generated
59
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
@@ -273,6 +274,7 @@
|
||||
"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",
|
||||
@@ -287,6 +289,7 @@
|
||||
"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",
|
||||
@@ -309,6 +312,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -318,6 +322,7 @@
|
||||
"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",
|
||||
@@ -413,6 +418,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -459,6 +465,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1283,7 +1290,8 @@
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.1",
|
||||
@@ -2363,6 +2371,7 @@
|
||||
"integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2415,6 +2424,7 @@
|
||||
"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",
|
||||
@@ -2774,6 +2784,7 @@
|
||||
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -2868,6 +2879,7 @@
|
||||
"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",
|
||||
@@ -3033,6 +3045,7 @@
|
||||
"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",
|
||||
@@ -3372,6 +3385,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3975,6 +3989,7 @@
|
||||
"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",
|
||||
@@ -4866,6 +4881,7 @@
|
||||
"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",
|
||||
@@ -4922,6 +4938,7 @@
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -5110,6 +5127,7 @@
|
||||
"integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.4.0",
|
||||
"globals": "^13.24.0",
|
||||
@@ -7973,6 +7991,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -7989,6 +8008,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pinia-plugin-persistedstate": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.7.1.tgz",
|
||||
"integrity": "sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"@pinia/nuxt": ">=0.10.0",
|
||||
"pinia": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"@pinia/nuxt": {
|
||||
"optional": true
|
||||
},
|
||||
"pinia": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
@@ -8065,6 +8109,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8104,6 +8149,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -8133,6 +8179,7 @@
|
||||
"integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"prettier": ">=2.0",
|
||||
"typescript": ">=2.9",
|
||||
@@ -9527,7 +9574,8 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
||||
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
@@ -9647,6 +9695,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9936,6 +9985,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -10125,6 +10175,7 @@
|
||||
"integrity": "sha512-oLnWs9Hak/LOlKjeSpOwD6JMks8BeICEdYMJBf6P4Lac/pO9tKiv/XhXnAM7nNfSkZahjlCZu9sS50zL8fSnsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -10252,6 +10303,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10265,6 +10317,7 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -10357,6 +10410,7 @@
|
||||
"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",
|
||||
@@ -10519,6 +10573,7 @@
|
||||
"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"
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createApp } from 'vue';
|
||||
* Application Components & Configuration.
|
||||
*/
|
||||
|
||||
import { createPersistedState } from 'pinia-plugin-persistedstate';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
@@ -39,8 +40,14 @@ import router from './router';
|
||||
const app = createApp(App);
|
||||
|
||||
// Configure application plugins
|
||||
app.use(createPinia()); // State management store
|
||||
app.use(router); // Client-side routing
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
pinia.use(
|
||||
createPersistedState({
|
||||
key: (id: string) => `nimbus:${id}`,
|
||||
}),
|
||||
);
|
||||
app.use(router);
|
||||
|
||||
// Mount the application to the DOM
|
||||
app.mount('#app');
|
||||
|
||||
@@ -32,10 +32,12 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
freeFormTypes?: boolean;
|
||||
class?: HTMLAttributes['class'];
|
||||
persistenceKey?: string;
|
||||
}>(),
|
||||
{
|
||||
freeFormTypes: false,
|
||||
class: undefined,
|
||||
persistenceKey: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -63,7 +65,7 @@ const {
|
||||
toggleAllParametersEnabledState,
|
||||
triggerParameterDeletion,
|
||||
deleteAllParameters,
|
||||
} = useKeyValueParameters(modelRef);
|
||||
} = useKeyValueParameters(modelRef, props.persistenceKey);
|
||||
|
||||
const { openCommand, closeCommand } = useValueGeneratorStore();
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
RequestHeaders,
|
||||
RequestParameters,
|
||||
} from '@/components/domain/Client/Request';
|
||||
import { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
const tab = useStorage(uniquePersistenceKey('request-builder-tab'), 'body');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,9 +25,10 @@ import {
|
||||
>
|
||||
<RequestBuilderEndpoint class="h-toolbar border-b" />
|
||||
<AppTabs
|
||||
default-value="body"
|
||||
:default-value="tab"
|
||||
class="mt-0 flex flex-1 flex-col overflow-hidden"
|
||||
data-testid="app-tabs-container"
|
||||
@update:model-value="tab = $event as string"
|
||||
>
|
||||
<div class="bg-subtle-background border-b">
|
||||
<AppTabsList class="h-toolbar px-panel rounded-none">
|
||||
|
||||
@@ -65,15 +65,16 @@ const syncHeadersWithPendingRequest = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const initializeHeaders = (previousPendingData: PendingRequest | null = null) => {
|
||||
const previousHeaders = previousPendingData?.headers ?? [];
|
||||
const previousHeaderKeys = previousHeaders.map((header: RequestHeader) => header.key);
|
||||
const enrichWithGlobalHeaders = (pendingRequest: PendingRequest | null) => {
|
||||
const currentHeaders = pendingRequest?.headers ?? [];
|
||||
|
||||
const currentHeaderKeys = currentHeaders.map((header: RequestHeader) => header.key);
|
||||
|
||||
const missingGlobalHeaders = globalHeaders.filter(
|
||||
(header: RequestHeader) => !previousHeaderKeys.includes(header.key),
|
||||
(header: RequestHeader) => !currentHeaderKeys.includes(header.key),
|
||||
);
|
||||
|
||||
headers.value = [...missingGlobalHeaders, ...previousHeaders];
|
||||
headers.value = [...missingGlobalHeaders, ...currentHeaders];
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -93,7 +94,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
initializeHeaders(oldValue);
|
||||
enrichWithGlobalHeaders(oldValue);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
@@ -113,11 +114,15 @@ onBeforeMount(() => {
|
||||
}),
|
||||
);
|
||||
|
||||
initializeHeaders();
|
||||
enrichWithGlobalHeaders(pendingRequestData.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSubHeader class="border-b">Request Headers</PanelSubHeader>
|
||||
<KeyValueParametersBuilder ref="parametersBuilder" v-model="headersAsParameters" />
|
||||
<KeyValueParametersBuilder
|
||||
ref="parametersBuilder"
|
||||
v-model="headersAsParameters"
|
||||
persistence-key="pending-request-headers"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -86,5 +86,9 @@ watch(
|
||||
<CopyButton :on-click="copyPreview" :copied="previewCopied" />
|
||||
</div>
|
||||
</div>
|
||||
<KeyValueParametersBuilder v-model="parameters" class="flex-1" />
|
||||
<KeyValueParametersBuilder
|
||||
v-model="parameters"
|
||||
class="flex-1"
|
||||
persistence-key="pending-request-parameters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -4,9 +4,10 @@ import CopyButton from '@/components/common/CopyButton.vue';
|
||||
import KeyValueDisplayList from '@/components/common/KeyValueDisplayList/KeyValueDisplayList.vue';
|
||||
import PanelSubHeader from '@/components/layout/PanelSubHeader/PanelSubHeader.vue';
|
||||
import { ResponseCookie } from '@/interfaces/http';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useClipboard, useStorage } from '@vueuse/core';
|
||||
import { LockIcon, LockOpenIcon } from 'lucide-vue-next';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface ResponseCookiesProps {
|
||||
cookies: ResponseCookie[];
|
||||
@@ -20,7 +21,10 @@ interface NormalizeCookieShape {
|
||||
|
||||
const props = defineProps<ResponseCookiesProps>();
|
||||
|
||||
const decryptedCookies = ref(false);
|
||||
const decryptedCookies = useStorage(
|
||||
uniquePersistenceKey('response-viewer-cookies-decrypted'),
|
||||
false,
|
||||
);
|
||||
|
||||
defineSlots<{
|
||||
value: (props: { item: ResponseCookie }) => string | number | boolean;
|
||||
|
||||
@@ -10,6 +10,8 @@ import ResponseCookies from '@/components/domain/Client/Response/ResponseCookies
|
||||
import ResponseHeaders from '@/components/domain/Client/Response/ResponseHeaders/ResponseHeaders.vue';
|
||||
import { STATUS } from '@/interfaces/http';
|
||||
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
|
||||
import { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import ResponseDumpAndDie from './ResponseBody/ResponseDumpAndDie.vue';
|
||||
|
||||
@@ -17,6 +19,8 @@ const historyStore = useRequestsHistoryStore();
|
||||
const requestStore = useRequestStore();
|
||||
const lastLog = computed(() => historyStore.lastLog);
|
||||
const pendingRequestData = computed(() => requestStore.pendingRequestData);
|
||||
|
||||
const tab = useStorage(uniquePersistenceKey('response-viewer-tab'), 'response');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,7 +29,11 @@ const pendingRequestData = computed(() => requestStore.pendingRequestData);
|
||||
v-if="pendingRequestData?.isProcessing"
|
||||
class="bg-background absolute top-0 left-0 z-[100] h-full w-full animate-pulse opacity-75"
|
||||
/>
|
||||
<AppTabs default-value="response" class="mt-0 flex h-full flex-col overflow-auto">
|
||||
<AppTabs
|
||||
:default-value="tab"
|
||||
class="mt-0 flex h-full flex-col overflow-auto"
|
||||
@update:model-value="tab = $event as string"
|
||||
>
|
||||
<div class="bg-subtle-background border-b">
|
||||
<AppTabsList class="h-toolbar px-panel rounded-none">
|
||||
<AppTabsTrigger value="response" label="Response" />
|
||||
|
||||
@@ -14,7 +14,9 @@ import RouteExplorerVersionSelector from '@/components/domain/RoutesExplorer/Rou
|
||||
import RoutesList from '@/components/domain/RoutesExplorer/RoutesList/RoutesList.vue';
|
||||
import { RouteDefinition, RoutesGroup } from '@/interfaces/routes/routes';
|
||||
import { useConfigStore } from '@/stores';
|
||||
import { computed, ref } from 'vue';
|
||||
import { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
|
||||
/*
|
||||
* Props.
|
||||
@@ -28,7 +30,7 @@ const props = defineProps<{
|
||||
* State.
|
||||
*/
|
||||
|
||||
const search = ref('');
|
||||
const search = useStorage(uniquePersistenceKey('routes-explorer-search-keyword'), '');
|
||||
|
||||
const versions = computed(() => Object.keys(props.routes || []));
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
AppSidebarMenuItem,
|
||||
AppSidebarMenuSub,
|
||||
} from '@/components/base/sidebar';
|
||||
import { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { ChevronRight, Folder } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -17,12 +19,19 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = useStorage(
|
||||
uniquePersistenceKey(`routes-explorer-resource-${props.resource}-expanded`),
|
||||
false,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSidebarMenuItem>
|
||||
<AppCollapsible
|
||||
class="group/collapsible [&[data-state=open]>button>svg:first-child]:rotate-90"
|
||||
:default-open="isOpen"
|
||||
@update:open="isOpen = $event"
|
||||
>
|
||||
<AppCollapsibleTrigger as-child>
|
||||
<AppSidebarMenuButton>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { keyValueParametersConfig } from '@/config';
|
||||
import { ExtendedParameter, ParametersExternalContract } from '@/interfaces/ui';
|
||||
import { useCounter, watchDebounced } from '@vueuse/core';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Manages key-value parameter state.
|
||||
*/
|
||||
export function useKeyValueParameters(model: Ref<ParametersExternalContract[]>) {
|
||||
export function useKeyValueParameters(
|
||||
model: Ref<ParametersExternalContract[]>,
|
||||
persistenceKey?: string,
|
||||
) {
|
||||
const { count: nextParameterId, inc: incrementParametersId } = useCounter();
|
||||
|
||||
const parameters = ref<ExtendedParameter[]>([]);
|
||||
const parameters: RemovableRef<ExtendedParameter[]> | Ref<ExtendedParameter[]> =
|
||||
persistenceKey ? useStorage(uniquePersistenceKey(persistenceKey), []) : ref([]);
|
||||
|
||||
const isUpdatingFromParentModel = ref(false);
|
||||
|
||||
const createParameterSkeleton = (id: number): ExtendedParameter => ({
|
||||
@@ -228,10 +235,13 @@ export function useKeyValueParameters(model: Ref<ParametersExternalContract[]>)
|
||||
},
|
||||
);
|
||||
|
||||
// Initialize parameters from parent model and ensure at least one empty parameter exists
|
||||
// Initialize parameters from parent model
|
||||
onBeforeMount(() => {
|
||||
updateParametersFromParentModel();
|
||||
addNewEmptyParameter();
|
||||
|
||||
if (parameters.value.length === 0) {
|
||||
addNewEmptyParameter();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
@@ -14,265 +14,275 @@ import { computed, Ref, ref } from 'vue';
|
||||
* Handles all aspects of request construction including method,
|
||||
* endpoint, headers, body, query parameters, and authorization.
|
||||
*/
|
||||
export const useRequestBuilderStore = defineStore('_requestBuilder', () => {
|
||||
/*
|
||||
* Stores.
|
||||
*/
|
||||
export const useRequestBuilderStore = defineStore(
|
||||
'_requestBuilder',
|
||||
() => {
|
||||
/*
|
||||
* Stores.
|
||||
*/
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const configStore = useConfigStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const configStore = useConfigStore();
|
||||
|
||||
/*
|
||||
* State.
|
||||
*/
|
||||
/*
|
||||
* State.
|
||||
*/
|
||||
|
||||
const pendingRequestData: Ref<PendingRequest | null> = ref<PendingRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/*
|
||||
* Computed.
|
||||
*/
|
||||
|
||||
const hasActiveRequest = computed(() => pendingRequestData.value !== null);
|
||||
|
||||
/*
|
||||
* Request Building Actions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Switches to the route definition for the specified method on the current endpoint.
|
||||
*
|
||||
* Updates schema and payload type based on the route definition for the given method.
|
||||
* If no route definition exists for this method on this endpoint, defaults to empty payload.
|
||||
*/
|
||||
const switchToRouteDefinitionOf = (method: string, requestData: PendingRequest) => {
|
||||
// Find route definition for this method within the same endpoint
|
||||
const targetRoute = requestData.supportedRoutes.find(
|
||||
(route: RouteDefinition) => route.method.toUpperCase() === method,
|
||||
const pendingRequestData: Ref<PendingRequest | null> = ref<PendingRequest | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!targetRoute) {
|
||||
// For methods not defined for this endpoint, default to empty payload and schema
|
||||
requestData.payloadType = RequestBodyTypeEnum.EMPTY;
|
||||
requestData.schema = {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
};
|
||||
/*
|
||||
* Computed.
|
||||
*/
|
||||
|
||||
return;
|
||||
}
|
||||
const hasActiveRequest = computed(() => pendingRequestData.value !== null);
|
||||
|
||||
// Use schema from the route definition for this method
|
||||
requestData.payloadType = getDefaultPayloadTypeForRoute(targetRoute);
|
||||
requestData.schema = targetRoute.schema;
|
||||
};
|
||||
/*
|
||||
* Request Building Actions.
|
||||
*/
|
||||
|
||||
const getAuthorizationForNewRequest = function (): AuthorizationContract {
|
||||
if (pendingRequestData.value !== null) {
|
||||
// Re-use the same authorization from last request if exists.
|
||||
return pendingRequestData.value.authorization;
|
||||
}
|
||||
/**
|
||||
* Switches to the route definition for the specified method on the current endpoint.
|
||||
*
|
||||
* Updates schema and payload type based on the route definition for the given method.
|
||||
* If no route definition exists for this method on this endpoint, defaults to empty payload.
|
||||
*/
|
||||
const switchToRouteDefinitionOf = (
|
||||
method: string,
|
||||
requestData: PendingRequest,
|
||||
) => {
|
||||
// Find route definition for this method within the same endpoint
|
||||
const targetRoute = requestData.supportedRoutes.find(
|
||||
(route: RouteDefinition) => route.method.toUpperCase() === method,
|
||||
);
|
||||
|
||||
// Otherwise, use default authorization from settings.
|
||||
if (!targetRoute) {
|
||||
// For methods not defined for this endpoint, default to empty payload and schema
|
||||
requestData.payloadType = RequestBodyTypeEnum.EMPTY;
|
||||
requestData.schema = {
|
||||
shape: {
|
||||
'x-name': 'root',
|
||||
'x-required': false,
|
||||
},
|
||||
extractionErrors: null,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Use schema from the route definition for this method
|
||||
requestData.payloadType = getDefaultPayloadTypeForRoute(targetRoute);
|
||||
requestData.schema = targetRoute.schema;
|
||||
};
|
||||
|
||||
const getAuthorizationForNewRequest = function (): AuthorizationContract {
|
||||
if (pendingRequestData.value !== null) {
|
||||
// Re-use the same authorization from last request if exists.
|
||||
return pendingRequestData.value.authorization;
|
||||
}
|
||||
|
||||
// Otherwise, use default authorization from settings.
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.CurrentUser
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.CurrentUser,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.Impersonate
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Impersonate,
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.Bearer
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Bearer,
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.Basic
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Basic,
|
||||
value: { username: '', password: '' },
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.CurrentUser
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.CurrentUser,
|
||||
type: AuthorizationType.None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.Impersonate
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Impersonate,
|
||||
value: 1,
|
||||
};
|
||||
}
|
||||
const getDefaultPayload = function (route: RouteDefinition): RequestBodyTypeEnum {
|
||||
if (settingsStore.preferences.defaultRequestBodyType === -1) {
|
||||
return getDefaultPayloadTypeForRoute(route);
|
||||
}
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType ===
|
||||
AuthorizationType.Bearer
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Bearer,
|
||||
value: '',
|
||||
};
|
||||
}
|
||||
return settingsStore.preferences.defaultRequestBodyType;
|
||||
};
|
||||
|
||||
if (
|
||||
settingsStore.preferences.defaultAuthorizationType === AuthorizationType.Basic
|
||||
) {
|
||||
return {
|
||||
type: AuthorizationType.Basic,
|
||||
value: { username: '', password: '' },
|
||||
/**
|
||||
* Initializes new pending request with a default state.
|
||||
*/
|
||||
const initializeRequest = (
|
||||
route: RouteDefinition,
|
||||
availableRoutesForEndpoint: RouteDefinition[],
|
||||
) => {
|
||||
pendingRequestData.value = {
|
||||
method: route.method,
|
||||
endpoint: route.endpoint,
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: getDefaultPayload(route),
|
||||
schema: route.schema,
|
||||
queryParameters: [],
|
||||
authorization: getAuthorizationForNewRequest(),
|
||||
supportedRoutes: availableRoutesForEndpoint,
|
||||
routeDefinition: route,
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the HTTP method for the current request.
|
||||
*
|
||||
* Handles method changes by updating schema and payload type based on
|
||||
* available route definitions, falling back to empty payload for unsupported methods.
|
||||
*/
|
||||
const updateRequestMethod = (method: string) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
|
||||
if (normalizedMethod === pendingRequestData.value.method) {
|
||||
return; // <- same method, no need to update.
|
||||
}
|
||||
|
||||
pendingRequestData.value.method = normalizedMethod;
|
||||
|
||||
// Switch to the route definition for this method if applicable.
|
||||
switchToRouteDefinitionOf(normalizedMethod, pendingRequestData.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the endpoint URL for the current request.
|
||||
*/
|
||||
const updateRequestEndpoint = (endpoint: string) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.endpoint = endpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the headers array for the current request.
|
||||
*/
|
||||
const updateRequestHeaders = (headers: Array<RequestHeader>) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.headers = headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the request body for the current request.
|
||||
*/
|
||||
const updateRequestBody = (body: PendingRequest['body']) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.body = body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the query parameters for the current request.
|
||||
*/
|
||||
const updateQueryParameters = (parameters: ParametersExternalContract[]) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.queryParameters = parameters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the authorization configuration for the current request.
|
||||
*/
|
||||
const updateAuthorization = (authorization: AuthorizationContract) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.authorization = authorization;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the current request to null state.
|
||||
*/
|
||||
const resetRequest = () => {
|
||||
pendingRequestData.value = null;
|
||||
};
|
||||
|
||||
/*
|
||||
* Helper functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds complete request URL with query parameters.
|
||||
*
|
||||
* Constructs the full URL by combining base URL, endpoint, and
|
||||
* enabled query parameters for the current request.
|
||||
*/
|
||||
const getRequestUrl = (request: PendingRequest): string => {
|
||||
return buildRequestUrl(
|
||||
configStore.apiUrl,
|
||||
request.endpoint,
|
||||
request.queryParameters,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
type: AuthorizationType.None,
|
||||
// State
|
||||
pendingRequestData,
|
||||
|
||||
// Computed
|
||||
hasActiveRequest,
|
||||
|
||||
// Actions
|
||||
initializeRequest,
|
||||
updateRequestMethod,
|
||||
updateRequestEndpoint,
|
||||
updateRequestHeaders,
|
||||
updateRequestBody,
|
||||
updateQueryParameters,
|
||||
updateAuthorization,
|
||||
resetRequest,
|
||||
getRequestUrl,
|
||||
};
|
||||
};
|
||||
|
||||
const getDefaultPayload = function (route: RouteDefinition): RequestBodyTypeEnum {
|
||||
if (settingsStore.preferences.defaultRequestBodyType === -1) {
|
||||
return getDefaultPayloadTypeForRoute(route);
|
||||
}
|
||||
|
||||
return settingsStore.preferences.defaultRequestBodyType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes new pending request with a default state.
|
||||
*/
|
||||
const initializeRequest = (
|
||||
route: RouteDefinition,
|
||||
availableRoutesForEndpoint: RouteDefinition[],
|
||||
) => {
|
||||
pendingRequestData.value = {
|
||||
method: route.method,
|
||||
endpoint: route.endpoint,
|
||||
headers: [],
|
||||
body: {},
|
||||
payloadType: getDefaultPayload(route),
|
||||
schema: route.schema,
|
||||
queryParameters: [],
|
||||
authorization: getAuthorizationForNewRequest(),
|
||||
supportedRoutes: availableRoutesForEndpoint,
|
||||
routeDefinition: route,
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the HTTP method for the current request.
|
||||
*
|
||||
* Handles method changes by updating schema and payload type based on
|
||||
* available route definitions, falling back to empty payload for unsupported methods.
|
||||
*/
|
||||
const updateRequestMethod = (method: string) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
|
||||
if (normalizedMethod === pendingRequestData.value.method) {
|
||||
return; // <- same method, no need to update.
|
||||
}
|
||||
|
||||
pendingRequestData.value.method = normalizedMethod;
|
||||
|
||||
// Switch to the route definition for this method if applicable.
|
||||
switchToRouteDefinitionOf(normalizedMethod, pendingRequestData.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the endpoint URL for the current request.
|
||||
*/
|
||||
const updateRequestEndpoint = (endpoint: string) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.endpoint = endpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the headers array for the current request.
|
||||
*/
|
||||
const updateRequestHeaders = (headers: Array<RequestHeader>) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.headers = headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the request body for the current request.
|
||||
*/
|
||||
const updateRequestBody = (body: PendingRequest['body']) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.body = body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the query parameters for the current request.
|
||||
*/
|
||||
const updateQueryParameters = (parameters: ParametersExternalContract[]) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.queryParameters = parameters;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the authorization configuration for the current request.
|
||||
*/
|
||||
const updateAuthorization = (authorization: AuthorizationContract) => {
|
||||
if (!pendingRequestData.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestData.value.authorization = authorization;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets the current request to null state.
|
||||
*/
|
||||
const resetRequest = () => {
|
||||
pendingRequestData.value = null;
|
||||
};
|
||||
|
||||
/*
|
||||
* Helper functions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Builds complete request URL with query parameters.
|
||||
*
|
||||
* Constructs the full URL by combining base URL, endpoint, and
|
||||
* enabled query parameters for the current request.
|
||||
*/
|
||||
const getRequestUrl = (request: PendingRequest): string => {
|
||||
return buildRequestUrl(
|
||||
configStore.apiUrl,
|
||||
request.endpoint,
|
||||
request.queryParameters,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
pendingRequestData,
|
||||
|
||||
// Computed
|
||||
hasActiveRequest,
|
||||
|
||||
// Actions
|
||||
initializeRequest,
|
||||
updateRequestMethod,
|
||||
updateRequestEndpoint,
|
||||
updateRequestHeaders,
|
||||
updateRequestBody,
|
||||
updateQueryParameters,
|
||||
updateAuthorization,
|
||||
resetRequest,
|
||||
getRequestUrl,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,49 +3,55 @@ import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from '../core/useSettingsStore';
|
||||
|
||||
export const useRequestsHistoryStore = defineStore('requestHistory', () => {
|
||||
/*
|
||||
* Stores & dependencies.
|
||||
*/
|
||||
const settingsStore = useSettingsStore();
|
||||
export const useRequestsHistoryStore = defineStore(
|
||||
'requestHistory',
|
||||
() => {
|
||||
/*
|
||||
* Stores & dependencies.
|
||||
*/
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
// State
|
||||
const logs = ref<RequestLog[]>([]);
|
||||
|
||||
// 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);
|
||||
|
||||
// Actions
|
||||
const addLog = (log: RequestLog) => {
|
||||
logs.value.push(log);
|
||||
|
||||
// Maintain max logs limit
|
||||
if (logs.value.length > maxLogs.value) {
|
||||
logs.value = logs.value.slice(-maxLogs.value);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
logs,
|
||||
maxLogs,
|
||||
const logs = ref<RequestLog[]>([]);
|
||||
|
||||
// Getters
|
||||
allLogs,
|
||||
lastLog,
|
||||
totalRequests,
|
||||
// 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);
|
||||
|
||||
// Actions
|
||||
addLog,
|
||||
clearLogs,
|
||||
};
|
||||
});
|
||||
const addLog = (log: RequestLog) => {
|
||||
logs.value.push(log);
|
||||
|
||||
// Maintain max logs limit
|
||||
if (logs.value.length > maxLogs.value) {
|
||||
logs.value = logs.value.slice(-maxLogs.value);
|
||||
}
|
||||
};
|
||||
|
||||
const clearLogs = () => {
|
||||
logs.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
logs,
|
||||
maxLogs,
|
||||
|
||||
// Getters
|
||||
allLogs,
|
||||
lastLog,
|
||||
totalRequests,
|
||||
|
||||
// Actions
|
||||
addLog,
|
||||
clearLogs,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: true,
|
||||
},
|
||||
);
|
||||
|
||||
125
resources/js/tests/utils/stores/uniquePersistenceKeys.test.ts
Normal file
125
resources/js/tests/utils/stores/uniquePersistenceKeys.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { MockInstance } from '@vitest/spy';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('uniquePersistenceKey', () => {
|
||||
let uniquePersistenceKey: (key: string) => string;
|
||||
let consoleWarnSpy: MockInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset modules to clear the internal keys array
|
||||
vi.resetModules();
|
||||
|
||||
// Re-import the module to get a fresh instance
|
||||
const module = await import('@/utils/stores');
|
||||
uniquePersistenceKey = module.uniquePersistenceKey;
|
||||
|
||||
vi.clearAllMocks();
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return a key with nimbus prefix', () => {
|
||||
const result = uniquePersistenceKey('testKey');
|
||||
expect(result).toBe('nimbus:testKey');
|
||||
});
|
||||
|
||||
it('should allow multiple different keys', () => {
|
||||
const result1 = uniquePersistenceKey('key1');
|
||||
const result2 = uniquePersistenceKey('key2');
|
||||
|
||||
expect(result1).toBe('nimbus:key1');
|
||||
expect(result2).toBe('nimbus:key2');
|
||||
});
|
||||
|
||||
it('should handle duplicate keys by appending -duplicate', () => {
|
||||
const result1 = uniquePersistenceKey('duplicate');
|
||||
const result2 = uniquePersistenceKey('duplicate');
|
||||
|
||||
expect(result1).toBe('nimbus:duplicate');
|
||||
expect(result2).toBe('nimbus:duplicate-duplicate');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
"Key duplicate must be unique. 'duplicate-duplicate' will be used instead.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple levels of duplication', () => {
|
||||
const result1 = uniquePersistenceKey('test');
|
||||
const result2 = uniquePersistenceKey('test');
|
||||
const result3 = uniquePersistenceKey('test');
|
||||
|
||||
expect(result1).toBe('nimbus:test');
|
||||
expect(result2).toBe('nimbus:test-duplicate');
|
||||
expect(result3).toBe('nimbus:test-duplicate-duplicate');
|
||||
});
|
||||
|
||||
it('should warn when duplicate key is detected', () => {
|
||||
uniquePersistenceKey('myKey');
|
||||
uniquePersistenceKey('myKey');
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPersistentKeys', () => {
|
||||
let uniquePersistenceKey: (key: string) => string;
|
||||
let clearPersistentKeys: () => void;
|
||||
let localStorageRemoveSpy: MockInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Reset modules to clear the internal keys array
|
||||
vi.resetModules();
|
||||
|
||||
// Re-import the module to get a fresh instance
|
||||
const module = await import('@/utils/stores');
|
||||
uniquePersistenceKey = module.uniquePersistenceKey;
|
||||
clearPersistentKeys = module.clearPersistentKeys;
|
||||
|
||||
localStorageRemoveSpy = vi.fn();
|
||||
|
||||
global.localStorage = {
|
||||
// @ts-expect-error it is a mock.
|
||||
removeItem: localStorageRemoveSpy,
|
||||
setItem: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
key: vi.fn(),
|
||||
length: 0,
|
||||
};
|
||||
});
|
||||
|
||||
it('should remove all registered keys from localStorage', () => {
|
||||
uniquePersistenceKey('key1');
|
||||
uniquePersistenceKey('key2');
|
||||
uniquePersistenceKey('key3');
|
||||
|
||||
clearPersistentKeys();
|
||||
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledWith('nimbus:key1');
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledWith('nimbus:key2');
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledWith('nimbus:key3');
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not attempt to remove keys if none were registered', () => {
|
||||
clearPersistentKeys();
|
||||
|
||||
expect(localStorageRemoveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove duplicate keys with their modified names', () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
uniquePersistenceKey('duplicate');
|
||||
uniquePersistenceKey('duplicate');
|
||||
|
||||
clearPersistentKeys();
|
||||
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledWith('nimbus:duplicate');
|
||||
expect(localStorageRemoveSpy).toHaveBeenCalledWith('nimbus:duplicate-duplicate');
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -25,3 +25,5 @@ export {
|
||||
export { calculateScrollToElement, getScrollBounds } from './scroll';
|
||||
|
||||
export { cn } from './ui';
|
||||
|
||||
export { clearPersistentKeys, uniquePersistenceKey } from './stores';
|
||||
|
||||
5
resources/js/utils/stores/index.ts
Normal file
5
resources/js/utils/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Stores utility functions
|
||||
*/
|
||||
|
||||
export { clearPersistentKeys, uniquePersistenceKey } from './uniquePersistenceKey';
|
||||
23
resources/js/utils/stores/uniquePersistenceKey.ts
Normal file
23
resources/js/utils/stores/uniquePersistenceKey.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const keys: string[] = [];
|
||||
|
||||
const render = (key: string) => `nimbus:${key}`;
|
||||
|
||||
export const uniquePersistenceKey = (key: string): string => {
|
||||
if (keys.includes(key)) {
|
||||
const newKey = key + '-duplicate';
|
||||
|
||||
console.warn(`Key ${key} must be unique. '${newKey}' will be used instead.`);
|
||||
|
||||
return uniquePersistenceKey(newKey);
|
||||
}
|
||||
|
||||
keys.push(key);
|
||||
|
||||
return render(key);
|
||||
};
|
||||
|
||||
export const clearPersistentKeys = () => {
|
||||
keys.forEach((key: string) => {
|
||||
window.localStorage.removeItem(render(key));
|
||||
});
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 60_000,
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
@@ -28,7 +29,7 @@ export default defineConfig({
|
||||
baseURL: "http://127.0.0.1:8000",
|
||||
trace: "retain-on-failure",
|
||||
ignoreHTTPSErrors: true,
|
||||
navigationTimeout: 60000,
|
||||
navigationTimeout: 60_000,
|
||||
screenshot: "only-on-failure",
|
||||
testIdAttribute: "data-testid",
|
||||
video: "retain-on-failure",
|
||||
|
||||
@@ -62,6 +62,7 @@ test("Dump and Die visualization sanity checklist", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "dd" }).click();
|
||||
await page.getByRole("button", { name: "GET /" }).click();
|
||||
await page.getByRole("tab", { name: "Headers" }).click();
|
||||
await page.getByRole("button", { name: "Add" }).click();
|
||||
const headerKey = page.getByTestId("kv-key").nth(3);
|
||||
await headerKey.fill("x-index");
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ test('Clicking around', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Authorization' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Please log in first' })).toBeVisible();
|
||||
await page.getByRole('tab', { name: 'Headers' }).click();
|
||||
await expect(page.getByTestId('kv-value').nth(2)).toBeVisible();
|
||||
await expect(page.getByTestId('kv-value').nth(3)).toBeEmpty();
|
||||
await expect(page.getByTestId('response-status-text')).toContainText('No request yet');
|
||||
await page.getByRole('button', { name: 'authentication' }).click();
|
||||
await expect(page.getByRole('button', { name: 'GET /show-logged-in-user' })).toBeVisible();
|
||||
|
||||
152
tests/E2E/tests/state-persistence.spec.ts
Normal file
152
tests/E2E/tests/state-persistence.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("State Persistence sanity checklist", async ({ page }) => {
|
||||
// Note: Generated with Playwright codegen.
|
||||
|
||||
await page.goto("http://127.0.0.1:8000/demo/");
|
||||
|
||||
await page.getByRole("button", { name: "inline-validation" }).click();
|
||||
await page.getByRole("button", { name: "shapes" }).click();
|
||||
await page.getByRole("button", { name: "POST /simple" }).click();
|
||||
await page.getByText('{ "name": "<placeholder>", "').click();
|
||||
await page.getByRole("button", { name: "Auto Fill" }).click();
|
||||
await page.getByRole("tab", { name: "Parameters" }).click();
|
||||
await page.getByRole("textbox", { name: "Key" }).click();
|
||||
await page.getByRole("textbox", { name: "Key" }).fill("key");
|
||||
await page.getByRole("textbox", { name: "Key" }).press("Tab");
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("value-example");
|
||||
await page.getByRole("tab", { name: "Authorization" }).click();
|
||||
await page.getByRole("tab", { name: "Headers" }).click();
|
||||
await page.getByRole("button", { name: "Add" }).click();
|
||||
await page.getByTestId("kv-key").nth(3).click();
|
||||
await page.getByTestId("kv-key").nth(3).fill("x-new");
|
||||
await page.getByTestId("kv-key").nth(3).press("Tab");
|
||||
await page.getByTestId("kv-value").nth(3).fill("x-value");
|
||||
await page.getByRole("button", { name: "Send ( )" }).click();
|
||||
|
||||
await page
|
||||
.getByTestId("response-content")
|
||||
.getByRole("tab", { name: "Headers" })
|
||||
.click();
|
||||
await page.getByRole("tab", { name: "Cookies" }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId("response-content").getByRole("tablist"))
|
||||
.toMatchAriaSnapshot(`
|
||||
- tablist:
|
||||
- tab "Response"
|
||||
- tab "Headers"
|
||||
- tab "Cookies" [selected]
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId("app-tabs-container")).toMatchAriaSnapshot(`
|
||||
- tablist:
|
||||
- tab "Parameters"
|
||||
- tab "Body"
|
||||
- tab "Authorization"
|
||||
- tab "Headers" [selected]
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId("kv-value").nth(3)).toHaveValue("x-value");
|
||||
|
||||
await expect(page.locator("#reka-splitter-panel-v-6")).toMatchAriaSnapshot(`
|
||||
- list:
|
||||
- listitem:
|
||||
- button "authentication":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "dd":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "inline-validation" [expanded]:
|
||||
- img
|
||||
- img
|
||||
- list:
|
||||
- button "POST /complex"
|
||||
- button "POST /conditional"
|
||||
- button "POST /enum"
|
||||
- button "POST /simple"
|
||||
- listitem:
|
||||
- button "request-parameters":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "responses":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "segments":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "shapes" [expanded]:
|
||||
- img
|
||||
- img
|
||||
- list:
|
||||
- button "POST /array-of-primitives"
|
||||
- button "POST /nested-object"
|
||||
- listitem:
|
||||
- button "spatie-data":
|
||||
- img
|
||||
- img
|
||||
- listitem:
|
||||
- button "verbs":
|
||||
- img
|
||||
- img
|
||||
`);
|
||||
|
||||
await page.getByRole("tab", { name: "Authorization" }).click();
|
||||
|
||||
await page
|
||||
.getByTestId("response-content")
|
||||
.getByRole("tab", { name: "Headers" })
|
||||
.click();
|
||||
|
||||
await page.getByRole("tab", { name: "Response" }).click();
|
||||
|
||||
await page
|
||||
.getByTestId("response-content")
|
||||
.getByRole("tab", { name: "Headers" })
|
||||
.click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Type to search..." }).click();
|
||||
|
||||
await page.getByRole("textbox", { name: "Type to search..." }).fill("user");
|
||||
|
||||
await page.getByRole("button", { name: "authentication" }).click();
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.locator("#reka-splitter-panel-v-6")).toMatchAriaSnapshot(`
|
||||
- text: Routes
|
||||
- list:
|
||||
- listitem:
|
||||
- button "authentication" [expanded]:
|
||||
- img
|
||||
- img
|
||||
- list:
|
||||
- button "GET /show-logged-in-user"
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId("response-content")).toMatchAriaSnapshot(`
|
||||
- tablist:
|
||||
- tab "Response"
|
||||
- tab "Headers" [selected]
|
||||
- tab "Cookies"
|
||||
`);
|
||||
|
||||
await expect(page.getByTestId("app-tabs-container")).toMatchAriaSnapshot(`
|
||||
- tablist:
|
||||
- tab "Parameters"
|
||||
- tab "Body"
|
||||
- tab "Authorization" [selected]
|
||||
- tab "Headers"
|
||||
`);
|
||||
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Type to search..." }),
|
||||
).toHaveValue("user");
|
||||
});
|
||||
Reference in New Issue
Block a user