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:
@@ -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));
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user