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:
Mazen Touati
2026-01-11 01:32:57 +01:00
committed by GitHub
parent 8bdd510f17
commit 8780a79557
22 changed files with 748 additions and 313 deletions

59
package-lock.json generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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');

View File

@@ -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();

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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" />

View File

@@ -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 || []));

View File

@@ -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>

View File

@@ -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();
}
});
/*

View File

@@ -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,
},
);

View File

@@ -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,
},
);

View 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();
});
});

View File

@@ -25,3 +25,5 @@ export {
export { calculateScrollToElement, getScrollBounds } from './scroll';
export { cn } from './ui';
export { clearPersistentKeys, uniquePersistenceKey } from './stores';

View File

@@ -0,0 +1,5 @@
/**
* Stores utility functions
*/
export { clearPersistentKeys, uniquePersistenceKey } from './uniquePersistenceKey';

View 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));
});
};

View File

@@ -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",

View File

@@ -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");

View File

@@ -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();

View 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");
});