feat(shared-stores): add createViewStore factory for view/filter/sort

Generic factory that eliminates ~110 LOC boilerplate per module for
view mode, sort, filters, and saved filter presets with localStorage
persistence. Includes 23 tests covering all store operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 14:25:31 +02:00
parent de8335277a
commit 934f3337e3
7 changed files with 548 additions and 3 deletions

View file

@ -9,11 +9,16 @@
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"scripts": { "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": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"jsdom": "^29.0.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"typescript": "^5.0.0" "typescript": "^5.0.0",
"vitest": "^4.1.2"
}, },
"dependencies": { "dependencies": {
"@manacore/local-store": "workspace:*", "@manacore/local-store": "workspace:*",

View file

@ -15,6 +15,13 @@ export {
type AppSettingsStore, type AppSettingsStore,
type AppSettingsStoreOptions, type AppSettingsStoreOptions,
} from './settings.svelte'; } from './settings.svelte';
export {
createViewStore,
type ViewStore,
type ViewStoreConfig,
type SortOption,
type SavedFilter,
} from './view.svelte';
export { export {
createSimpleNavigationStores, createSimpleNavigationStores,
type SimpleNavigationStores, type SimpleNavigationStores,

View file

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

View file

@ -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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
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<TestViewMode, TestFilters>({
storagePrefix: 'app1',
defaultViewMode: 'list',
defaultSort: { field: 'name', direction: 'asc' },
});
const _store2 = createViewStore<TestViewMode, TestFilters>({
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();
});
});
});

View file

@ -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<MyViewMode, MyFilters>({
* 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<F> {
id: string;
name: string;
criteria: F;
createdAt: string;
}
export interface ViewStoreConfig<V extends string, F extends Record<string, unknown>> {
/** 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<V extends string, F extends Record<string, unknown>> {
readonly viewMode: V;
readonly sort: SortOption;
readonly activeFilters: F;
readonly savedFilters: SavedFilter<F>[];
readonly hasActiveFilters: boolean;
initialize(): void;
setViewMode(mode: V): void;
setSort(newSort: SortOption): void;
setFilters(filters: F): void;
updateFilter<K extends keyof F>(key: K, value: F[K]): void;
clearFilters(): void;
saveFilter(name: string): void;
loadFilter(id: string): void;
deleteSavedFilter(id: string): void;
}
function load<T>(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<V extends string, F extends Record<string, unknown>>(
config: ViewStoreConfig<V, F>
): ViewStore<V, F> {
const VIEW_KEY = `${config.storagePrefix}_view_mode`;
const SORT_KEY = `${config.storagePrefix}_sort`;
const FILTERS_KEY = `${config.storagePrefix}_saved_filters`;
let viewMode = $state<V>(config.defaultViewMode);
let sort = $state<SortOption>(config.defaultSort);
let activeFilters = $state<F>({} as F);
let savedFilters = $state<SavedFilter<F>[]>([]);
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<V>(VIEW_KEY, config.defaultViewMode);
sort = load<SortOption>(SORT_KEY, config.defaultSort);
savedFilters = load<SavedFilter<F>[]>(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<K extends keyof F>(key: K, value: F[K]) {
activeFilters = { ...activeFilters, [key]: value };
},
clearFilters() {
activeFilters = {} as F;
},
saveFilter(name: string) {
const filter: SavedFilter<F> = {
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);
},
};
}

View file

@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src"
}, },
"include": ["src/**/*"], "include": ["src/**/*", "vitest.config.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View file

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