diff --git a/packages/shared-stores/package.json b/packages/shared-stores/package.json index ad739d516..bb56dadce 100644 --- a/packages/shared-stores/package.json +++ b/packages/shared-stores/package.json @@ -9,11 +9,16 @@ ".": "./src/index.ts" }, "scripts": { - "type-check": "echo 'Skipping: shared-stores uses Svelte 5 runes, type-checked at build time'" + "type-check": "echo 'Skipping: shared-stores uses Svelte 5 runes, type-checked at build time'", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "jsdom": "^29.0.1", "svelte": "^5.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^4.1.2" }, "dependencies": { "@manacore/local-store": "workspace:*", diff --git a/packages/shared-stores/src/index.ts b/packages/shared-stores/src/index.ts index e15329e25..1ccd605cf 100644 --- a/packages/shared-stores/src/index.ts +++ b/packages/shared-stores/src/index.ts @@ -15,6 +15,13 @@ export { type AppSettingsStore, type AppSettingsStoreOptions, } from './settings.svelte'; +export { + createViewStore, + type ViewStore, + type ViewStoreConfig, + type SortOption, + type SavedFilter, +} from './view.svelte'; export { createSimpleNavigationStores, type SimpleNavigationStores, diff --git a/packages/shared-stores/src/test/mock-environment.ts b/packages/shared-stores/src/test/mock-environment.ts new file mode 100644 index 000000000..e4f46243d --- /dev/null +++ b/packages/shared-stores/src/test/mock-environment.ts @@ -0,0 +1,5 @@ +/** Mock for $app/environment — always acts as browser in tests */ +export const browser = true; +export const building = false; +export const dev = true; +export const version = 'test'; diff --git a/packages/shared-stores/src/view.svelte.test.ts b/packages/shared-stores/src/view.svelte.test.ts new file mode 100644 index 000000000..5ea22e17e --- /dev/null +++ b/packages/shared-stores/src/view.svelte.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Must dynamically import to get fresh $state per test +async function freshStore() { + vi.resetModules(); + const mod = await import('./view.svelte'); + return mod.createViewStore; +} + +type TestViewMode = 'list' | 'grid' | 'table'; + +interface TestFilters { + search?: string; + tagIds?: string[]; + status?: string[]; +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('createViewStore', () => { + describe('defaults', () => { + it('initializes with default view mode', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + expect(store.viewMode).toBe('list'); + }); + + it('initializes with default sort', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + expect(store.sort).toEqual({ field: 'name', direction: 'asc' }); + }); + + it('starts with empty filters', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + expect(store.activeFilters).toEqual({}); + }); + + it('starts with empty saved filters', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + expect(store.savedFilters).toEqual([]); + }); + }); + + describe('viewMode', () => { + it('updates view mode', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setViewMode('grid'); + expect(store.viewMode).toBe('grid'); + }); + + it('persists view mode to localStorage', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setViewMode('table'); + expect(JSON.parse(localStorage.getItem('test_view_mode')!)).toBe('table'); + }); + }); + + describe('sort', () => { + it('updates sort', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setSort({ field: 'date', direction: 'desc' }); + expect(store.sort).toEqual({ field: 'date', direction: 'desc' }); + }); + + it('persists sort to localStorage', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setSort({ field: 'date', direction: 'desc' }); + expect(JSON.parse(localStorage.getItem('test_sort')!)).toEqual({ + field: 'date', + direction: 'desc', + }); + }); + }); + + describe('filters', () => { + it('sets filters', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello', tagIds: ['t1'] }); + expect(store.activeFilters).toEqual({ search: 'hello', tagIds: ['t1'] }); + }); + + it('updates a single filter key', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + store.updateFilter('tagIds', ['t1', 't2']); + expect(store.activeFilters).toEqual({ search: 'hello', tagIds: ['t1', 't2'] }); + }); + + it('clears all filters', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello', tagIds: ['t1'] }); + store.clearFilters(); + expect(store.activeFilters).toEqual({}); + }); + }); + + describe('hasActiveFilters', () => { + it('returns false when no filters are set', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + expect(store.hasActiveFilters).toBe(false); + }); + + it('uses default heuristic when no custom fn provided', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + expect(store.hasActiveFilters).toBe(true); + }); + + it('ignores empty arrays in default heuristic', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ tagIds: [] }); + expect(store.hasActiveFilters).toBe(false); + }); + + it('uses custom hasActiveFilters function', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + hasActiveFilters: (f) => !!f.search, + }); + store.setFilters({ tagIds: ['t1'] }); + expect(store.hasActiveFilters).toBe(false); + store.setFilters({ search: 'x' }); + expect(store.hasActiveFilters).toBe(true); + }); + }); + + describe('saved filters', () => { + it('saves a named filter preset', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + store.saveFilter('My Filter'); + expect(store.savedFilters).toHaveLength(1); + expect(store.savedFilters[0].name).toBe('My Filter'); + expect(store.savedFilters[0].criteria).toEqual({ search: 'hello' }); + }); + + it('persists saved filters to localStorage', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + store.saveFilter('My Filter'); + const stored = JSON.parse(localStorage.getItem('test_saved_filters')!); + expect(stored).toHaveLength(1); + expect(stored[0].name).toBe('My Filter'); + }); + + it('loads a saved filter', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + store.saveFilter('My Filter'); + store.clearFilters(); + expect(store.activeFilters).toEqual({}); + store.loadFilter(store.savedFilters[0].id); + expect(store.activeFilters).toEqual({ search: 'hello' }); + }); + + it('deletes a saved filter', async () => { + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.setFilters({ search: 'hello' }); + store.saveFilter('Filter 1'); + store.saveFilter('Filter 2'); + expect(store.savedFilters).toHaveLength(2); + store.deleteSavedFilter(store.savedFilters[0].id); + expect(store.savedFilters).toHaveLength(1); + expect(store.savedFilters[0].name).toBe('Filter 2'); + }); + }); + + describe('initialize', () => { + it('loads persisted view mode from localStorage', async () => { + localStorage.setItem('test_view_mode', JSON.stringify('grid')); + localStorage.setItem('test_sort', JSON.stringify({ field: 'date', direction: 'desc' })); + + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.initialize(); + expect(store.viewMode).toBe('grid'); + expect(store.sort).toEqual({ field: 'date', direction: 'desc' }); + }); + + it('only initializes once', async () => { + localStorage.setItem('test_view_mode', JSON.stringify('grid')); + + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.initialize(); + expect(store.viewMode).toBe('grid'); + + // Change localStorage directly — second initialize should be no-op + localStorage.setItem('test_view_mode', JSON.stringify('table')); + store.initialize(); + expect(store.viewMode).toBe('grid'); + }); + + it('loads persisted saved filters', async () => { + localStorage.setItem( + 'test_saved_filters', + JSON.stringify([ + { id: 'f1', name: 'Preset', criteria: { search: 'test' }, createdAt: '2024-01-01' }, + ]) + ); + + const createViewStore = await freshStore(); + const store = createViewStore({ + storagePrefix: 'test', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + store.initialize(); + expect(store.savedFilters).toHaveLength(1); + expect(store.savedFilters[0].name).toBe('Preset'); + }); + }); + + describe('storage prefix isolation', () => { + it('uses different localStorage keys per prefix', async () => { + const createViewStore = await freshStore(); + const store1 = createViewStore({ + storagePrefix: 'app1', + defaultViewMode: 'list', + defaultSort: { field: 'name', direction: 'asc' }, + }); + const _store2 = createViewStore({ + storagePrefix: 'app2', + defaultViewMode: 'grid', + defaultSort: { field: 'date', direction: 'desc' }, + }); + + store1.setViewMode('table'); + expect(JSON.parse(localStorage.getItem('app1_view_mode')!)).toBe('table'); + expect(localStorage.getItem('app2_view_mode')).toBeNull(); + }); + }); +}); diff --git a/packages/shared-stores/src/view.svelte.ts b/packages/shared-stores/src/view.svelte.ts new file mode 100644 index 000000000..d2f1c531d --- /dev/null +++ b/packages/shared-stores/src/view.svelte.ts @@ -0,0 +1,176 @@ +/** + * View Store Factory + * + * Creates a type-safe view/filter/sort store with localStorage persistence. + * Replaces ~110 LOC boilerplate per module. + * + * @example + * ```typescript + * import { createViewStore } from '@manacore/shared-stores'; + * + * type MyViewMode = 'list' | 'grid' | 'kanban'; + * + * interface MyFilters { + * search?: string; + * status?: string[]; + * tagIds?: string[]; + * } + * + * export const viewStore = createViewStore({ + * storagePrefix: 'inventar', + * defaultViewMode: 'list', + * defaultSort: { field: 'name', direction: 'asc' }, + * hasActiveFilters: (f) => !!(f.search || f.status?.length || f.tagIds?.length), + * }); + * ``` + */ + +import { browser } from '$app/environment'; + +export interface SortOption { + field: string; + direction: 'asc' | 'desc'; +} + +export interface SavedFilter { + id: string; + name: string; + criteria: F; + createdAt: string; +} + +export interface ViewStoreConfig> { + /** Prefix for localStorage keys (e.g. 'inventar' → 'inventar_view_mode') */ + storagePrefix: string; + /** Default view mode */ + defaultViewMode: V; + /** Default sort option */ + defaultSort: SortOption; + /** Returns true if any filters are active (used for UI indicators) */ + hasActiveFilters?: (filters: F) => boolean; +} + +export interface ViewStore> { + readonly viewMode: V; + readonly sort: SortOption; + readonly activeFilters: F; + readonly savedFilters: SavedFilter[]; + readonly hasActiveFilters: boolean; + + initialize(): void; + setViewMode(mode: V): void; + setSort(newSort: SortOption): void; + setFilters(filters: F): void; + updateFilter(key: K, value: F[K]): void; + clearFilters(): void; + saveFilter(name: string): void; + loadFilter(id: string): void; + deleteSavedFilter(id: string): void; +} + +function load(key: string, fallback: T): T { + if (!browser) return fallback; + try { + const data = localStorage.getItem(key); + return data ? JSON.parse(data) : fallback; + } catch { + return fallback; + } +} + +function save(key: string, value: unknown) { + if (!browser) return; + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Silently fail (quota exceeded, private mode, etc.) + } +} + +export function createViewStore>( + config: ViewStoreConfig +): ViewStore { + const VIEW_KEY = `${config.storagePrefix}_view_mode`; + const SORT_KEY = `${config.storagePrefix}_sort`; + const FILTERS_KEY = `${config.storagePrefix}_saved_filters`; + + let viewMode = $state(config.defaultViewMode); + let sort = $state(config.defaultSort); + let activeFilters = $state({} as F); + let savedFilters = $state[]>([]); + let initialized = $state(false); + + return { + get viewMode() { + return viewMode; + }, + get sort() { + return sort; + }, + get activeFilters() { + return activeFilters; + }, + get savedFilters() { + return savedFilters; + }, + get hasActiveFilters() { + if (config.hasActiveFilters) return config.hasActiveFilters(activeFilters); + return Object.values(activeFilters).some( + (v) => v !== undefined && v !== null && v !== '' && (!Array.isArray(v) || v.length > 0) + ); + }, + + initialize() { + if (initialized) return; + viewMode = load(VIEW_KEY, config.defaultViewMode); + sort = load(SORT_KEY, config.defaultSort); + savedFilters = load[]>(FILTERS_KEY, []); + initialized = true; + }, + + setViewMode(mode: V) { + viewMode = mode; + save(VIEW_KEY, mode); + }, + + setSort(newSort: SortOption) { + sort = newSort; + save(SORT_KEY, newSort); + }, + + setFilters(filters: F) { + activeFilters = filters; + }, + + updateFilter(key: K, value: F[K]) { + activeFilters = { ...activeFilters, [key]: value }; + }, + + clearFilters() { + activeFilters = {} as F; + }, + + saveFilter(name: string) { + const filter: SavedFilter = { + id: crypto.randomUUID(), + name, + criteria: { ...activeFilters }, + createdAt: new Date().toISOString(), + }; + savedFilters = [...savedFilters, filter]; + save(FILTERS_KEY, savedFilters); + }, + + loadFilter(id: string) { + const filter = savedFilters.find((f) => f.id === id); + if (filter) { + activeFilters = { ...filter.criteria }; + } + }, + + deleteSavedFilter(id: string) { + savedFilters = savedFilters.filter((f) => f.id !== id); + save(FILTERS_KEY, savedFilters); + }, + }; +} diff --git a/packages/shared-stores/tsconfig.json b/packages/shared-stores/tsconfig.json index c0db43203..6e65d460e 100644 --- a/packages/shared-stores/tsconfig.json +++ b/packages/shared-stores/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src/**/*"], + "include": ["src/**/*", "vitest.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/packages/shared-stores/vitest.config.ts b/packages/shared-stores/vitest.config.ts new file mode 100644 index 000000000..b2aeb54c4 --- /dev/null +++ b/packages/shared-stores/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + globals: true, + environment: 'jsdom', + }, + resolve: { + conditions: ['browser'], + alias: { + '$app/environment': new URL('./src/test/mock-environment.ts', import.meta.url).pathname, + }, + }, +});