Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(sheets): saved views with filter/sort/group presets (#130)

Persistent view presets for sheet configurations.

- Create, update, rename, delete, duplicate views
- View state: filters, sort, groupByCol, hiddenCols, columnOrder
- Serialize/deserialize for storage
- Active state detection and equality comparison
- Sort views by name or last updated
- 19 unit tests

+374
+182
src/sheets/saved-views.ts
··· 1 + /** 2 + * Saved Views — persistent filter/sort/group/hidden-column presets. 3 + * 4 + * Pure logic module: view definition, serialization, application. 5 + * Storage in Yjs handled by the sheets main module. 6 + */ 7 + 8 + export interface SavedView { 9 + id: string; 10 + name: string; 11 + createdAt: number; 12 + updatedAt: number; 13 + /** Filter state: column index → filter values */ 14 + filters: Record<number, string[]>; 15 + /** Sort state: column index and direction */ 16 + sort: { columnIndex: number; direction: 'asc' | 'desc' } | null; 17 + /** Group-by column index */ 18 + groupByCol: number | null; 19 + /** Set of hidden column indices */ 20 + hiddenCols: number[]; 21 + /** Column order (indices in display order) */ 22 + columnOrder: number[] | null; 23 + } 24 + 25 + let _viewCounter = 0; 26 + 27 + /** 28 + * Generate a unique view ID. 29 + */ 30 + export function generateViewId(): string { 31 + return `view-${Date.now()}-${++_viewCounter}`; 32 + } 33 + 34 + /** 35 + * Reset view counter (for testing). 36 + */ 37 + export function resetViewCounter(): void { 38 + _viewCounter = 0; 39 + } 40 + 41 + /** 42 + * Create a new saved view with the current sheet state. 43 + */ 44 + export function createView( 45 + name: string, 46 + state: Omit<SavedView, 'id' | 'name' | 'createdAt' | 'updatedAt'>, 47 + now = Date.now(), 48 + ): SavedView { 49 + return { 50 + id: generateViewId(), 51 + name, 52 + createdAt: now, 53 + updatedAt: now, 54 + ...state, 55 + }; 56 + } 57 + 58 + /** 59 + * Update an existing view with new state. 60 + */ 61 + export function updateView( 62 + view: SavedView, 63 + changes: Partial<Omit<SavedView, 'id' | 'createdAt'>>, 64 + now = Date.now(), 65 + ): SavedView { 66 + return { 67 + ...view, 68 + ...changes, 69 + updatedAt: now, 70 + }; 71 + } 72 + 73 + /** 74 + * Rename a view. 75 + */ 76 + export function renameView( 77 + view: SavedView, 78 + newName: string, 79 + now = Date.now(), 80 + ): SavedView { 81 + return { ...view, name: newName, updatedAt: now }; 82 + } 83 + 84 + /** 85 + * Delete a view from the list by ID. 86 + */ 87 + export function deleteView(views: SavedView[], viewId: string): SavedView[] { 88 + return views.filter(v => v.id !== viewId); 89 + } 90 + 91 + /** 92 + * Duplicate a view with a new name. 93 + */ 94 + export function duplicateView( 95 + view: SavedView, 96 + newName?: string, 97 + now = Date.now(), 98 + ): SavedView { 99 + return { 100 + ...view, 101 + id: generateViewId(), 102 + name: newName || `${view.name} (copy)`, 103 + createdAt: now, 104 + updatedAt: now, 105 + }; 106 + } 107 + 108 + /** 109 + * Serialize a view to a JSON-safe object. 110 + */ 111 + export function serializeView(view: SavedView): string { 112 + return JSON.stringify(view); 113 + } 114 + 115 + /** 116 + * Deserialize a view from JSON string. 117 + * Returns null if invalid. 118 + */ 119 + export function deserializeView(json: string): SavedView | null { 120 + try { 121 + const obj = JSON.parse(json); 122 + if (!obj || typeof obj !== 'object') return null; 123 + if (!obj.id || !obj.name) return null; 124 + return obj as SavedView; 125 + } catch { 126 + return null; 127 + } 128 + } 129 + 130 + /** 131 + * Check if a view has any active filters/sort/grouping. 132 + */ 133 + export function isViewActive(view: SavedView): boolean { 134 + const hasFilters = Object.keys(view.filters).length > 0; 135 + const hasSort = view.sort !== null; 136 + const hasGroup = view.groupByCol !== null; 137 + const hasHidden = view.hiddenCols.length > 0; 138 + const hasReorder = view.columnOrder !== null; 139 + return hasFilters || hasSort || hasGroup || hasHidden || hasReorder; 140 + } 141 + 142 + /** 143 + * Create an empty (default) view state. 144 + */ 145 + export function emptyViewState(): Omit<SavedView, 'id' | 'name' | 'createdAt' | 'updatedAt'> { 146 + return { 147 + filters: {}, 148 + sort: null, 149 + groupByCol: null, 150 + hiddenCols: [], 151 + columnOrder: null, 152 + }; 153 + } 154 + 155 + /** 156 + * Compare two views for equality (ignoring timestamps and ID). 157 + */ 158 + export function viewStatesEqual(a: SavedView, b: SavedView): boolean { 159 + return ( 160 + JSON.stringify(a.filters) === JSON.stringify(b.filters) && 161 + JSON.stringify(a.sort) === JSON.stringify(b.sort) && 162 + a.groupByCol === b.groupByCol && 163 + JSON.stringify(a.hiddenCols) === JSON.stringify(b.hiddenCols) && 164 + JSON.stringify(a.columnOrder) === JSON.stringify(b.columnOrder) 165 + ); 166 + } 167 + 168 + /** 169 + * Sort views by name or last updated. 170 + */ 171 + export function sortViews( 172 + views: SavedView[], 173 + by: 'name' | 'updated', 174 + ): SavedView[] { 175 + const sorted = [...views]; 176 + if (by === 'name') { 177 + sorted.sort((a, b) => a.name.localeCompare(b.name)); 178 + } else { 179 + sorted.sort((a, b) => b.updatedAt - a.updatedAt); 180 + } 181 + return sorted; 182 + }
+192
tests/saved-views.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + createView, 4 + updateView, 5 + renameView, 6 + deleteView, 7 + duplicateView, 8 + serializeView, 9 + deserializeView, 10 + isViewActive, 11 + emptyViewState, 12 + viewStatesEqual, 13 + sortViews, 14 + resetViewCounter, 15 + type SavedView, 16 + } from '../src/sheets/saved-views.js'; 17 + 18 + describe('Saved Views', () => { 19 + beforeEach(() => { 20 + resetViewCounter(); 21 + }); 22 + 23 + describe('createView', () => { 24 + it('creates a view with name and state', () => { 25 + const view = createView('My Filter', { 26 + filters: { 0: ['Active'] }, 27 + sort: { columnIndex: 1, direction: 'asc' }, 28 + groupByCol: null, 29 + hiddenCols: [], 30 + columnOrder: null, 31 + }, 5000); 32 + expect(view.name).toBe('My Filter'); 33 + expect(view.filters).toEqual({ 0: ['Active'] }); 34 + expect(view.sort).toEqual({ columnIndex: 1, direction: 'asc' }); 35 + expect(view.createdAt).toBe(5000); 36 + }); 37 + 38 + it('generates unique IDs', () => { 39 + const v1 = createView('A', emptyViewState(), 1000); 40 + const v2 = createView('B', emptyViewState(), 2000); 41 + expect(v1.id).not.toBe(v2.id); 42 + }); 43 + }); 44 + 45 + describe('updateView', () => { 46 + it('merges changes into view', () => { 47 + const view = createView('V1', emptyViewState(), 1000); 48 + const updated = updateView(view, { 49 + filters: { 2: ['High'] }, 50 + }, 2000); 51 + expect(updated.filters).toEqual({ 2: ['High'] }); 52 + expect(updated.updatedAt).toBe(2000); 53 + expect(updated.name).toBe('V1'); 54 + }); 55 + }); 56 + 57 + describe('renameView', () => { 58 + it('updates view name', () => { 59 + const view = createView('Old Name', emptyViewState(), 1000); 60 + const renamed = renameView(view, 'New Name', 2000); 61 + expect(renamed.name).toBe('New Name'); 62 + expect(renamed.updatedAt).toBe(2000); 63 + }); 64 + }); 65 + 66 + describe('deleteView', () => { 67 + it('removes view by ID', () => { 68 + const v1 = createView('A', emptyViewState()); 69 + const v2 = createView('B', emptyViewState()); 70 + const result = deleteView([v1, v2], v1.id); 71 + expect(result).toHaveLength(1); 72 + expect(result[0].id).toBe(v2.id); 73 + }); 74 + 75 + it('returns unchanged list for unknown ID', () => { 76 + const v1 = createView('A', emptyViewState()); 77 + expect(deleteView([v1], 'unknown')).toHaveLength(1); 78 + }); 79 + }); 80 + 81 + describe('duplicateView', () => { 82 + it('duplicates with new ID and name', () => { 83 + const original = createView('Original', { 84 + filters: { 0: ['X'] }, 85 + sort: null, 86 + groupByCol: 2, 87 + hiddenCols: [3], 88 + columnOrder: null, 89 + }, 1000); 90 + const dup = duplicateView(original, undefined, 2000); 91 + expect(dup.id).not.toBe(original.id); 92 + expect(dup.name).toBe('Original (copy)'); 93 + expect(dup.filters).toEqual({ 0: ['X'] }); 94 + expect(dup.groupByCol).toBe(2); 95 + }); 96 + 97 + it('uses custom name if provided', () => { 98 + const original = createView('V1', emptyViewState()); 99 + const dup = duplicateView(original, 'Custom Name'); 100 + expect(dup.name).toBe('Custom Name'); 101 + }); 102 + }); 103 + 104 + describe('serializeView / deserializeView', () => { 105 + it('round-trips a view', () => { 106 + const view = createView('Test', { 107 + filters: { 1: ['A', 'B'] }, 108 + sort: { columnIndex: 0, direction: 'desc' }, 109 + groupByCol: 3, 110 + hiddenCols: [4, 5], 111 + columnOrder: [0, 2, 1, 3], 112 + }, 1000); 113 + const json = serializeView(view); 114 + const restored = deserializeView(json); 115 + expect(restored).not.toBeNull(); 116 + expect(restored!.name).toBe('Test'); 117 + expect(restored!.filters).toEqual({ 1: ['A', 'B'] }); 118 + expect(restored!.hiddenCols).toEqual([4, 5]); 119 + }); 120 + 121 + it('returns null for invalid JSON', () => { 122 + expect(deserializeView('not json')).toBeNull(); 123 + expect(deserializeView('{}')).toBeNull(); 124 + expect(deserializeView('null')).toBeNull(); 125 + }); 126 + }); 127 + 128 + describe('isViewActive', () => { 129 + it('returns false for empty view', () => { 130 + const view = createView('Empty', emptyViewState()); 131 + expect(isViewActive(view)).toBe(false); 132 + }); 133 + 134 + it('returns true when filters set', () => { 135 + const view = createView('F', { ...emptyViewState(), filters: { 0: ['X'] } }); 136 + expect(isViewActive(view)).toBe(true); 137 + }); 138 + 139 + it('returns true when sort set', () => { 140 + const view = createView('S', { ...emptyViewState(), sort: { columnIndex: 0, direction: 'asc' } }); 141 + expect(isViewActive(view)).toBe(true); 142 + }); 143 + 144 + it('returns true when group set', () => { 145 + const view = createView('G', { ...emptyViewState(), groupByCol: 2 }); 146 + expect(isViewActive(view)).toBe(true); 147 + }); 148 + 149 + it('returns true when columns hidden', () => { 150 + const view = createView('H', { ...emptyViewState(), hiddenCols: [3] }); 151 + expect(isViewActive(view)).toBe(true); 152 + }); 153 + }); 154 + 155 + describe('viewStatesEqual', () => { 156 + it('returns true for identical states', () => { 157 + const v1 = createView('A', { filters: { 0: ['X'] }, sort: null, groupByCol: null, hiddenCols: [], columnOrder: null }); 158 + const v2 = createView('B', { filters: { 0: ['X'] }, sort: null, groupByCol: null, hiddenCols: [], columnOrder: null }); 159 + expect(viewStatesEqual(v1, v2)).toBe(true); 160 + }); 161 + 162 + it('returns false for different states', () => { 163 + const v1 = createView('A', { ...emptyViewState(), filters: { 0: ['X'] } }); 164 + const v2 = createView('B', { ...emptyViewState(), filters: { 0: ['Y'] } }); 165 + expect(viewStatesEqual(v1, v2)).toBe(false); 166 + }); 167 + }); 168 + 169 + describe('sortViews', () => { 170 + it('sorts by name', () => { 171 + const views = [ 172 + createView('Zeta', emptyViewState()), 173 + createView('Alpha', emptyViewState()), 174 + createView('Mid', emptyViewState()), 175 + ]; 176 + const sorted = sortViews(views, 'name'); 177 + expect(sorted[0].name).toBe('Alpha'); 178 + expect(sorted[2].name).toBe('Zeta'); 179 + }); 180 + 181 + it('sorts by updated (newest first)', () => { 182 + const views = [ 183 + createView('Old', emptyViewState(), 1000), 184 + createView('New', emptyViewState(), 3000), 185 + createView('Mid', emptyViewState(), 2000), 186 + ]; 187 + const sorted = sortViews(views, 'updated'); 188 + expect(sorted[0].name).toBe('New'); 189 + expect(sorted[2].name).toBe('Old'); 190 + }); 191 + }); 192 + });