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

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