diff --git a/app/components/CollapsibleSection.vue b/app/components/CollapsibleSection.vue index 35faa43b1..e3392e6e0 100644 --- a/app/components/CollapsibleSection.vue +++ b/app/components/CollapsibleSection.vue @@ -15,7 +15,7 @@ const props = withDefaults(defineProps(), { headingLevel: 'h2', }) -const appSettings = useSettings() +const { localSettings } = useUserLocalSettings() const buttonId = `${props.id}-collapsible-button` const contentId = `${props.id}-collapsible-content` @@ -47,17 +47,16 @@ onMounted(() => { function toggle() { isOpen.value = !isOpen.value - const removed = appSettings.settings.value.sidebar.collapsed.filter(c => c !== props.id) + const removed = localSettings.value.sidebar.collapsed.filter(c => c !== props.id) if (isOpen.value) { - appSettings.settings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } else { removed.push(props.id) - appSettings.settings.value.sidebar.collapsed = removed + localSettings.value.sidebar.collapsed = removed } - document.documentElement.dataset.collapsed = - appSettings.settings.value.sidebar.collapsed.join(' ') + document.documentElement.dataset.collapsed = localSettings.value.sidebar.collapsed.join(' ') } const ariaLabel = computed(() => { diff --git a/app/components/Header/ConnectorModal.vue b/app/components/Header/ConnectorModal.vue index 445161dba..a55d2d53d 100644 --- a/app/components/Header/ConnectorModal.vue +++ b/app/components/Header/ConnectorModal.vue @@ -2,7 +2,7 @@ const { isConnected, isConnecting, npmUser, error, hasOperations, connect, disconnect } = useConnector() -const { settings } = useSettings() +const { localSettings } = useUserLocalSettings() const tokenInput = shallowRef('') const portInput = shallowRef('31415') @@ -67,7 +67,7 @@ function handleDisconnect() {
@@ -201,7 +201,7 @@ function handleDisconnect() {
diff --git a/app/components/Settings/AccentColorPicker.vue b/app/components/Settings/AccentColorPicker.vue index 39003365a..1210b616e 100644 --- a/app/components/Settings/AccentColorPicker.vue +++ b/app/components/Settings/AccentColorPicker.vue @@ -1,11 +1,9 @@ @@ -62,6 +63,41 @@ const setLocale: typeof setNuxti18nLocale = locale => {

{{ $t('settings.tagline') }}

+ + + +
+ + + + +
+
+
@@ -77,18 +113,33 @@ const setLocale: typeof setNuxti18nLocale = locale => { - + + + + @@ -118,7 +169,7 @@ const setLocale: typeof setNuxti18nLocale = locale => { @@ -128,7 +179,7 @@ const setLocale: typeof setNuxti18nLocale = locale => { @@ -138,7 +189,7 @@ const setLocale: typeof setNuxti18nLocale = locale => { @@ -164,7 +215,7 @@ const setLocale: typeof setNuxti18nLocale = locale => { { label: $t('settings.data_source.npm'), value: 'npm' }, { label: $t('settings.data_source.algolia'), value: 'algolia' }, ]" - v-model="settings.searchProvider" + v-model="preferences.searchProvider" block size="sm" class="max-w-48" @@ -184,7 +235,7 @@ const setLocale: typeof setNuxti18nLocale = locale => {

{{ - settings.searchProvider === 'algolia' + preferences.searchProvider === 'algolia' ? $t('settings.data_source.algolia_description') : $t('settings.data_source.npm_description') }} @@ -192,7 +243,7 @@ const setLocale: typeof setNuxti18nLocale = locale => { { + + diff --git a/app/plugins/i18n-loader.client.ts b/app/plugins/i18n-loader.client.ts index b34894396..9479bfa60 100644 --- a/app/plugins/i18n-loader.client.ts +++ b/app/plugins/i18n-loader.client.ts @@ -1,20 +1,18 @@ export default defineNuxtPlugin({ + name: 'i18n-loader', + dependsOn: ['preferences-sync'], enforce: 'post', env: { islands: false }, setup() { const { $i18n } = useNuxtApp() const { locale, locales, setLocale } = $i18n - const { settings } = useSettings() - const settingsLocale = settings.value.selectedLocale + const { preferences } = useUserPreferencesState() + const settingsLocale = preferences.value.selectedLocale - if ( - settingsLocale && - // Check if the value is a supported locale - locales.value.map(l => l.code).includes(settingsLocale) && - // Check if the value is not a current locale - settingsLocale !== locale.value - ) { - setLocale(settingsLocale) + const matchedLocale = locales.value.map(l => l.code).find(code => code === settingsLocale) + + if (matchedLocale && matchedLocale !== locale.value) { + setLocale(matchedLocale) } }, }) diff --git a/app/plugins/preferences-sync.client.ts b/app/plugins/preferences-sync.client.ts new file mode 100644 index 000000000..cf58122d6 --- /dev/null +++ b/app/plugins/preferences-sync.client.ts @@ -0,0 +1,13 @@ +export default defineNuxtPlugin({ + name: 'preferences-sync', + setup() { + const { initSync } = useInitUserPreferencesSync() + const { applyStoredColorMode } = useColorModePreference() + + // Apply stored color mode preference early (before components mount) + applyStoredColorMode() + + // Initialize server sync for authenticated users + initSync() + }, +}) diff --git a/app/utils/prehydrate.ts b/app/utils/prehydrate.ts index cc6f88e1d..5d3be6e78 100644 --- a/app/utils/prehydrate.ts +++ b/app/utils/prehydrate.ts @@ -15,18 +15,16 @@ export function initPreferencesOnPrehydrate() { // Valid package manager IDs const validPMs = new Set(['npm', 'pnpm', 'yarn', 'bun', 'deno', 'vlt']) - // Read settings from localStorage - const settings = JSON.parse( - localStorage.getItem('npmx-settings') || '{}', - ) as Partial + // Read user preferences from localStorage + const preferences = JSON.parse(localStorage.getItem('npmx-user-preferences') || '{}') - const accentColorId = settings.accentColorId + const accentColorId = preferences.accentColorId if (accentColorId && accentColorIds.has(accentColorId)) { document.documentElement.style.setProperty('--accent-color', `var(--swatch-${accentColorId})`) } // Apply background accent - const preferredBackgroundTheme = settings.preferredBackgroundTheme + const preferredBackgroundTheme = preferences.preferredBackgroundTheme if (preferredBackgroundTheme) { document.documentElement.dataset.bgTheme = preferredBackgroundTheme } @@ -52,6 +50,7 @@ export function initPreferencesOnPrehydrate() { // Set data attribute for CSS-based visibility document.documentElement.dataset.pm = pm - document.documentElement.dataset.collapsed = settings.sidebar?.collapsed?.join(' ') ?? '' + const sidebar = JSON.parse(localStorage.getItem('npmx-settings') || '{}') + document.documentElement.dataset.collapsed = sidebar.sidebar?.collapsed?.join(' ') ?? '' }) } diff --git a/app/utils/storage.ts b/app/utils/storage.ts new file mode 100644 index 000000000..9706226a3 --- /dev/null +++ b/app/utils/storage.ts @@ -0,0 +1,34 @@ +export interface StorageProvider { + get: () => T | null + set: (value: T) => void + remove: () => void +} + +export function createLocalStorageProvider(key: string): StorageProvider { + return { + get: () => { + if (import.meta.server) return null + try { + const stored = localStorage.getItem(key) + if (stored) { + return JSON.parse(stored) as T + } + } catch { + localStorage.removeItem(key) + } + return null + }, + set: (value: T) => { + if (import.meta.server) return + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Storage full or other error, fail silently + } + }, + remove: () => { + if (import.meta.server) return + localStorage.removeItem(key) + }, + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 96ba7e6f2..606e00d3d 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -108,7 +108,11 @@ "accent_colors": "Accent colors", "clear_accent": "Clear accent color", "translation_progress": "Translation progress", - "background_themes": "Background shade" + "background_themes": "Background shade", + "syncing": "Syncing...", + "synced": "Settings synced", + "sync_error": "Sync failed", + "sync_enabled": "Cloud sync enabled" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/i18n/schema.json b/i18n/schema.json index 10e36631b..bcab296c2 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -330,6 +330,18 @@ }, "background_themes": { "type": "string" + }, + "syncing": { + "type": "string" + }, + "synced": { + "type": "string" + }, + "sync_error": { + "type": "string" + }, + "sync_enabled": { + "type": "string" } }, "additionalProperties": false diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 407219967..cbc7a5676 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -107,7 +107,11 @@ "accent_colors": "Accent colors", "clear_accent": "Clear accent colour", "translation_progress": "Translation progress", - "background_themes": "Background shade" + "background_themes": "Background shade", + "syncing": "Syncing...", + "synced": "Settings synced", + "sync_error": "Sync failed", + "sync_enabled": "Cloud sync enabled" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index b8bb158d6..85721714b 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -107,7 +107,11 @@ "accent_colors": "Accent colors", "clear_accent": "Clear accent color", "translation_progress": "Translation progress", - "background_themes": "Background shade" + "background_themes": "Background shade", + "syncing": "Syncing...", + "synced": "Settings synced", + "sync_error": "Sync failed", + "sync_enabled": "Cloud sync enabled" }, "i18n": { "missing_keys": "{count} missing translation | {count} missing translations", diff --git a/modules/cache.ts b/modules/cache.ts index a2e7c3a41..bb7332a41 100644 --- a/modules/cache.ts +++ b/modules/cache.ts @@ -42,6 +42,9 @@ export default defineNuxtModule({ const env = process.env.VERCEL_ENV nitroConfig.storage.atproto = env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } + + nitroConfig.storage['user-preferences'] = + env === 'production' ? upstash : { driver: 'vercel-runtime-cache' } }) }, }) diff --git a/nuxt.config.ts b/nuxt.config.ts index e64c2a9ad..fa25c7f0a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -125,6 +125,7 @@ export default defineNuxtConfig({ // never cache '/api/auth/**': { isr: false, cache: false }, '/api/social/**': { isr: false, cache: false }, + '/api/user/**': { isr: false, cache: false }, '/api/opensearch/suggestions': { isr: { expiration: 60 * 60 * 24 /* one day */, @@ -210,6 +211,10 @@ export default defineNuxtConfig({ driver: 'fsLite', base: './.cache/atproto', }, + 'user-preferences': { + driver: 'fsLite', + base: './.cache/user-preferences', + }, }, typescript: { tsConfig: { diff --git a/server/api/user/preferences.get.ts b/server/api/user/preferences.get.ts new file mode 100644 index 000000000..3f660ff57 --- /dev/null +++ b/server/api/user/preferences.get.ts @@ -0,0 +1,15 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { DEFAULT_USER_PREFERENCES } from '#shared/schemas/userPreferences' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const preferences = await useUserPreferencesStore().get(session.output.did) + + return preferences ?? { ...DEFAULT_USER_PREFERENCES, updatedAt: new Date().toISOString() } +}) diff --git a/server/api/user/preferences.post.ts b/server/api/user/preferences.post.ts new file mode 100644 index 000000000..9fd1a91b5 --- /dev/null +++ b/server/api/user/preferences.post.ts @@ -0,0 +1 @@ +export { default } from './preferences.put' diff --git a/server/api/user/preferences.put.ts b/server/api/user/preferences.put.ts new file mode 100644 index 000000000..9ab6e8558 --- /dev/null +++ b/server/api/user/preferences.put.ts @@ -0,0 +1,20 @@ +import { safeParse } from 'valibot' +import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' +import { UserPreferencesSchema } from '#shared/schemas/userPreferences' +import { useUserPreferencesStore } from '#server/utils/preferences/user-preferences-store' + +export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { + const session = safeParse(PublicUserSessionSchema, serverSession.data.public) + if (!session.success) { + throw createError({ statusCode: 401, message: 'Unauthorized' }) + } + + const settings = safeParse(UserPreferencesSchema, await readBody(event)) + if (!settings.success) { + throw createError({ statusCode: 400, message: 'Invalid settings format' }) + } + + await useUserPreferencesStore().set(session.output.did, settings.output) + + return { success: true } +}) diff --git a/server/utils/preferences/user-preferences-store.ts b/server/utils/preferences/user-preferences-store.ts new file mode 100644 index 000000000..5fed6ff38 --- /dev/null +++ b/server/utils/preferences/user-preferences-store.ts @@ -0,0 +1,49 @@ +import type { UserPreferences } from '#shared/schemas/userPreferences' +import { + USER_PREFERENCES_STORAGE_BASE, + DEFAULT_USER_PREFERENCES, +} from '#shared/schemas/userPreferences' + +export class UserPreferencesStore { + private readonly storage = useStorage(USER_PREFERENCES_STORAGE_BASE) + + async get(did: string): Promise { + const result = await this.storage.getItem(did) + return result ?? null + } + + async set(did: string, preferences: UserPreferences): Promise { + const withTimestamp: UserPreferences = { + ...preferences, + updatedAt: new Date().toISOString(), + } + await this.storage.setItem(did, withTimestamp) + } + + async merge(did: string, partial: Partial): Promise { + const existing = await this.get(did) + const base = existing ?? { ...DEFAULT_USER_PREFERENCES } + + const merged: UserPreferences = { + ...base, + ...partial, + updatedAt: new Date().toISOString(), + } + + await this.set(did, merged) + return merged + } + + async delete(did: string): Promise { + await this.storage.removeItem(did) + } +} + +let storeInstance: UserPreferencesStore | null = null + +export function useUserPreferencesStore(): UserPreferencesStore { + if (!storeInstance) { + storeInstance = new UserPreferencesStore() + } + return storeInstance +} diff --git a/shared/schemas/userPreferences.ts b/shared/schemas/userPreferences.ts new file mode 100644 index 000000000..cfbd520d9 --- /dev/null +++ b/shared/schemas/userPreferences.ts @@ -0,0 +1,55 @@ +import { object, string, boolean, nullable, optional, picklist, type InferOutput } from 'valibot' +import { ACCENT_COLORS, BACKGROUND_THEMES } from '#shared/utils/constants' + +const AccentColorIdSchema = picklist(Object.keys(ACCENT_COLORS.light) as [string, ...string[]]) + +const BackgroundThemeIdSchema = picklist(Object.keys(BACKGROUND_THEMES) as [string, ...string[]]) + +const ColorModePreferenceSchema = picklist(['light', 'dark', 'system']) + +const SearchProviderSchema = picklist(['npm', 'algolia']) + +export const UserPreferencesSchema = object({ + /** Display dates as relative (e.g., "3 days ago") instead of absolute */ + relativeDates: optional(boolean()), + /** Include @types/* package in install command for packages without built-in types */ + includeTypesInInstall: optional(boolean()), + /** Accent color theme ID (e.g., "rose", "amber", "emerald") */ + accentColorId: optional(nullable(AccentColorIdSchema)), + /** Preferred background shade */ + preferredBackgroundTheme: optional(nullable(BackgroundThemeIdSchema)), + /** Hide platform-specific packages (e.g., @scope/pkg-linux-x64) from search results */ + hidePlatformPackages: optional(boolean()), + /** User-selected locale code (e.g., "en", "de", "ja") */ + selectedLocale: optional(nullable(string())), + /** Color mode preference: 'light', 'dark', or 'system' */ + colorModePreference: optional(nullable(ColorModePreferenceSchema)), + /** Search provider for package search: 'npm' or 'algolia' */ + searchProvider: optional(SearchProviderSchema), + /** Timestamp of last update (ISO 8601) - managed by server */ + updatedAt: optional(string()), +}) + +export type UserPreferences = InferOutput + +export type AccentColorId = keyof typeof ACCENT_COLORS.light +export type BackgroundThemeId = keyof typeof BACKGROUND_THEMES +export type ColorModePreference = 'light' | 'dark' | 'system' +export type SearchProvider = 'npm' | 'algolia' + +/** + * Default user preferences. + * Used when creating new user preferences or merging with partial updates. + */ +export const DEFAULT_USER_PREFERENCES: Required> = { + relativeDates: false, + includeTypesInInstall: true, + accentColorId: null, + preferredBackgroundTheme: null, + hidePlatformPackages: true, + selectedLocale: null, + colorModePreference: null, + searchProvider: 'algolia', +} + +export const USER_PREFERENCES_STORAGE_BASE = 'npmx-kv-user-preferences' diff --git a/test/nuxt/components/DateTime.spec.ts b/test/nuxt/components/DateTime.spec.ts index 1ae7c1644..6dd7b1a76 100644 --- a/test/nuxt/components/DateTime.spec.ts +++ b/test/nuxt/components/DateTime.spec.ts @@ -4,12 +4,8 @@ import DateTime from '~/components/DateTime.vue' // Mock the useRelativeDates composable const mockRelativeDates = shallowRef(false) -vi.mock('~/composables/useSettings', () => ({ +vi.mock('~/composables/userPreferences/useRelativeDates', () => ({ useRelativeDates: () => mockRelativeDates, - useSettings: () => ({ - settings: ref({ relativeDates: mockRelativeDates.value }), - }), - useAccentColor: () => ({}), })) describe('DateTime', () => { diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts index bf1450998..8a4000aef 100644 --- a/test/nuxt/components/HeaderConnectorModal.spec.ts +++ b/test/nuxt/components/HeaderConnectorModal.spec.ts @@ -101,7 +101,7 @@ function resetMockState() { error: null, lastExecutionTime: null, } - mockSettings.value.connector = { + mockUserLocalSettings.value.connector = { autoOpenURL: false, } } @@ -112,28 +112,21 @@ function simulateConnect() { mockState.value.avatar = 'https://example.com/avatar.png' } -const mockSettings = ref({ - relativeDates: false, - includeTypesInInstall: true, - accentColorId: null, - hidePlatformPackages: true, - selectedLocale: null, - preferredBackgroundTheme: null, - searchProvider: 'npm', - connector: { - autoOpenURL: false, - }, +const mockUserLocalSettings = ref({ sidebar: { collapsed: [], }, + connector: { + autoOpenURL: false, + }, }) mockNuxtImport('useConnector', () => { return createMockUseConnector }) -mockNuxtImport('useSettings', () => { - return () => ({ settings: mockSettings }) +mockNuxtImport('useUserLocalSettings', () => { + return () => ({ localSettings: mockUserLocalSettings }) }) mockNuxtImport('useSelectedPackageManager', () => { diff --git a/test/nuxt/components/compare/FacetRow.spec.ts b/test/nuxt/components/compare/FacetRow.spec.ts index cfa30adc7..29b0e2a37 100644 --- a/test/nuxt/components/compare/FacetRow.spec.ts +++ b/test/nuxt/components/compare/FacetRow.spec.ts @@ -3,13 +3,8 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import FacetRow from '~/components/Compare/FacetRow.vue' // Mock useRelativeDates for DateTime component -vi.mock('~/composables/useSettings', () => ({ +vi.mock('~/composables/userPreferences/useRelativeDates', () => ({ useRelativeDates: () => ref(false), - useSettings: () => ({ - settings: ref({ relativeDates: false }), - }), - useAccentColor: () => ({}), - initAccentOnPrehydrate: () => {}, })) describe('FacetRow', () => { diff --git a/test/nuxt/components/compare/PackageSelector.spec.ts b/test/nuxt/components/compare/PackageSelector.spec.ts index 54eece316..f182962b1 100644 --- a/test/nuxt/components/compare/PackageSelector.spec.ts +++ b/test/nuxt/components/compare/PackageSelector.spec.ts @@ -1,22 +1,66 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' -import { mountSuspended } from '@nuxt/test-utils/runtime' +import { ref, shallowRef } from 'vue' +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' +import type { NpmSearchResponse } from '#shared/types' import PackageSelector from '~/components/Compare/PackageSelector.vue' -const mockFetch = vi.fn() -vi.stubGlobal('$fetch', mockFetch) +const mockSearchData = shallowRef(null) + +type MinimalSearchObject = { + package: Pick +} + +function createSearchResponse(objects: readonly MinimalSearchObject[]): NpmSearchResponse { + return { + isStale: false, + objects: objects.map(({ package: pkg }) => ({ + package: { + name: pkg.name, + description: pkg.description, + version: '0.0.0', + date: '', + links: {}, + publisher: { username: '' }, + maintainers: [], + }, + score: { final: 0, detail: { quality: 0, popularity: 0, maintenance: 0 } }, + searchScore: 0, + })), + total: objects.length, + time: new Date().toISOString(), + } +} + +mockNuxtImport('useSearchProvider', () => { + return () => ({ + searchProvider: ref('npm'), + isAlgolia: ref(false), + toggle: vi.fn(), + }) +}) + +mockNuxtImport('useSearch', () => { + return () => ({ + data: mockSearchData, + status: ref('idle'), + isLoadingMore: ref(false), + hasMore: ref(false), + fetchMore: vi.fn(), + isRateLimited: ref(false), + suggestions: ref([]), + suggestionsLoading: ref(false), + packageAvailability: ref(null), + }) +}) describe('PackageSelector', () => { beforeEach(() => { - mockFetch.mockReset() - mockFetch.mockResolvedValue({ - objects: [ - { package: { name: 'lodash', description: 'Lodash modular utilities' } }, - { package: { name: 'underscore', description: 'JavaScript utility library' } }, - ], - total: 2, - time: new Date().toISOString(), - }) + const objects = [ + { package: { name: 'lodash', description: 'Lodash modular utilities' } }, + { package: { name: 'underscore', description: 'JavaScript utility library' } }, + ] + + mockSearchData.value = createSearchResponse(objects) }) describe('selected packages display', () => { diff --git a/test/nuxt/composables/use-install-command.spec.ts b/test/nuxt/composables/use-install-command.spec.ts index 579942794..eda09b08d 100644 --- a/test/nuxt/composables/use-install-command.spec.ts +++ b/test/nuxt/composables/use-install-command.spec.ts @@ -217,9 +217,9 @@ describe('useInstallCommand', () => { }) it('should only include main command when @types disabled via settings', () => { - // Get settings and disable includeTypesInInstall directly - const { settings } = useSettings() - settings.value.includeTypesInInstall = false + // Get preferences and disable includeTypesInInstall directly + const { preferences } = useUserPreferencesState() + preferences.value.includeTypesInInstall = false const { fullInstallCommand, showTypesInInstall } = useInstallCommand( 'express', diff --git a/test/nuxt/composables/use-package-list-preferences.spec.ts b/test/nuxt/composables/use-package-list-preferences.spec.ts new file mode 100644 index 000000000..0b4655bf6 --- /dev/null +++ b/test/nuxt/composables/use-package-list-preferences.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { defineComponent, onMounted } from 'vue' +import { mount } from '@vue/test-utils' +import { usePackageListPreferences } from '../../../app/composables/usePackageListPreferences' +import { DEFAULT_PREFERENCES } from '../../../shared/types/preferences' + +const STORAGE_KEY = 'npmx-list-prefs' + +function mountWithSetup(run: () => void) { + return mount( + defineComponent({ + name: 'TestHarness', + setup() { + run() + return () => null + }, + }), + { attachTo: document.body }, + ) +} + +function setLocalStorage(stored: Record) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) +} + +describe('usePackageListPreferences', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('initializes with default values when storage is empty', () => { + mountWithSetup(() => { + const { preferences } = usePackageListPreferences() + onMounted(() => { + expect(preferences.value).toEqual(DEFAULT_PREFERENCES) + }) + }) + }) + + it('loads and merges values from localStorage', () => { + mountWithSetup(() => { + const stored = { viewMode: 'table' } + setLocalStorage(stored) + const { preferences } = usePackageListPreferences() + onMounted(() => { + expect(preferences.value.viewMode).toBe('table') + expect(preferences.value.paginationMode).toBe(DEFAULT_PREFERENCES.paginationMode) + expect(preferences.value.pageSize).toBe(DEFAULT_PREFERENCES.pageSize) + expect(preferences.value.columns).toEqual(DEFAULT_PREFERENCES.columns) + }) + }) + }) + + it('handles array merging by replacement', () => { + mountWithSetup(() => { + const stored = { columns: [] } + setLocalStorage(stored) + const { preferences } = usePackageListPreferences() + onMounted(() => { + expect(preferences.value.columns).toEqual([]) + }) + }) + }) +}) diff --git a/test/nuxt/composables/use-preferences-provider.spec.ts b/test/nuxt/composables/use-preferences-provider.spec.ts deleted file mode 100644 index 21d06e9af..000000000 --- a/test/nuxt/composables/use-preferences-provider.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { defineComponent, onMounted } from 'vue' -import { mount } from '@vue/test-utils' -import { usePreferencesProvider } from '../../../app/composables/usePreferencesProvider' - -const STORAGE_KEY = 'npmx-list-prefs' - -function mountWithSetup(run: () => void) { - return mount( - defineComponent({ - name: 'TestHarness', - setup() { - run() - return () => null - }, - }), - { attachTo: document.body }, - ) -} - -function setLocalStorage(stored: Record) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)) -} - -describe('usePreferencesProvider', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('initializes with default values when storage is empty', () => { - mountWithSetup(() => { - const defaults = { theme: 'light', cols: ['name', 'version'] } - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value).toEqual(defaults) - }) - }) - }) - - it('loads values from localStorage', () => { - mountWithSetup(() => { - const defaults = { theme: 'light' } - const stored = { theme: 'dark' } - setLocalStorage(stored) - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value).toEqual(stored) - }) - }) - }) - - it('handles array merging by replacement', () => { - mountWithSetup(() => { - const defaults = { cols: ['name', 'version', 'date'] } - const stored = { cols: ['name', 'version'] } - setLocalStorage(stored) - const { data } = usePreferencesProvider(defaults) - onMounted(() => { - expect(data.value.cols).toEqual(['name', 'version']) - }) - }) - }) -})