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

Configure Feed

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

feat: @mentions and record detail sidebar (#142, #129) (#134)

scott b9554a50 88982f01

+705
+135
src/docs/mentions.ts
··· 1 + /** 2 + * @Mention Support — collaborator mentions in documents. 3 + * 4 + * Pure logic module: mention parsing, user matching, suggestion filtering. 5 + * TipTap extension and DOM rendering handled separately. 6 + */ 7 + 8 + export interface MentionUser { 9 + id: string; 10 + name: string; 11 + color: string; 12 + online: boolean; 13 + } 14 + 15 + export interface Mention { 16 + userId: string; 17 + userName: string; 18 + start: number; 19 + end: number; 20 + } 21 + 22 + /** 23 + * Parse @mentions from plain text. 24 + * Matches @word patterns (alphanumeric + underscores). 25 + * Multi-word mentions are handled by the TipTap extension node, not plain text. 26 + */ 27 + export function parseMentions(text: string): Mention[] { 28 + if (!text) return []; 29 + 30 + const mentions: Mention[] = []; 31 + const regex = /@(\w+)/g; 32 + let match; 33 + 34 + while ((match = regex.exec(text)) !== null) { 35 + mentions.push({ 36 + userId: '', 37 + userName: match[1].trim(), 38 + start: match.index, 39 + end: match.index + match[0].length, 40 + }); 41 + } 42 + 43 + return mentions; 44 + } 45 + 46 + /** 47 + * Filter users by a search query for the mention autocomplete. 48 + * Case-insensitive prefix and contains matching. 49 + */ 50 + export function filterMentionUsers( 51 + users: MentionUser[], 52 + query: string, 53 + ): MentionUser[] { 54 + if (!query) return users; 55 + 56 + const lower = query.toLowerCase(); 57 + 58 + // Prefix matches first, then contains matches 59 + const prefix: MentionUser[] = []; 60 + const contains: MentionUser[] = []; 61 + 62 + for (const user of users) { 63 + const nameLower = user.name.toLowerCase(); 64 + if (nameLower.startsWith(lower)) { 65 + prefix.push(user); 66 + } else if (nameLower.includes(lower)) { 67 + contains.push(user); 68 + } 69 + } 70 + 71 + return [...prefix, ...contains]; 72 + } 73 + 74 + /** 75 + * Resolve mention text to a user by name matching. 76 + */ 77 + export function resolveMention( 78 + userName: string, 79 + users: MentionUser[], 80 + ): MentionUser | null { 81 + if (!userName) return null; 82 + 83 + const lower = userName.toLowerCase(); 84 + 85 + // Exact match first 86 + const exact = users.find(u => u.name.toLowerCase() === lower); 87 + if (exact) return exact; 88 + 89 + // Prefix match 90 + const prefix = users.find(u => u.name.toLowerCase().startsWith(lower)); 91 + return prefix || null; 92 + } 93 + 94 + /** 95 + * Build a display label for a mention. 96 + */ 97 + export function mentionLabel(user: MentionUser): string { 98 + return `@${user.name}`; 99 + } 100 + 101 + /** 102 + * Extract unique mentioned user IDs from a list of mentions. 103 + */ 104 + export function uniqueMentionedUsers(mentions: Mention[]): string[] { 105 + const seen = new Set<string>(); 106 + const result: string[] = []; 107 + 108 + for (const m of mentions) { 109 + if (m.userId && !seen.has(m.userId)) { 110 + seen.add(m.userId); 111 + result.push(m.userId); 112 + } 113 + } 114 + 115 + return result; 116 + } 117 + 118 + /** 119 + * Check if a specific user is mentioned in the text. 120 + */ 121 + export function isUserMentioned( 122 + text: string, 123 + userName: string, 124 + ): boolean { 125 + if (!text || !userName) return false; 126 + const lower = text.toLowerCase(); 127 + return lower.includes(`@${userName.toLowerCase()}`); 128 + } 129 + 130 + /** 131 + * Count mentions in text. 132 + */ 133 + export function countMentions(text: string): number { 134 + return parseMentions(text).length; 135 + }
+155
src/sheets/record-sidebar.ts
··· 1 + /** 2 + * Record Detail Sidebar — expanded row view with all fields. 3 + * 4 + * Pure logic module: field extraction, navigation, field ordering. 5 + * DOM rendering handled in the sheets UI layer. 6 + */ 7 + 8 + export interface RecordField { 9 + columnIndex: number; 10 + columnName: string; 11 + value: unknown; 12 + formula: string; 13 + } 14 + 15 + export interface RecordDetail { 16 + rowIndex: number; 17 + fields: RecordField[]; 18 + } 19 + 20 + export interface SidebarState { 21 + open: boolean; 22 + rowIndex: number | null; 23 + /** Field indices to show (null = show all) */ 24 + visibleFields: number[] | null; 25 + } 26 + 27 + /** 28 + * Create initial sidebar state. 29 + */ 30 + export function createSidebarState(): SidebarState { 31 + return { 32 + open: false, 33 + rowIndex: null, 34 + visibleFields: null, 35 + }; 36 + } 37 + 38 + /** 39 + * Open the sidebar for a specific row. 40 + */ 41 + export function openSidebar( 42 + state: SidebarState, 43 + rowIndex: number, 44 + ): SidebarState { 45 + return { ...state, open: true, rowIndex }; 46 + } 47 + 48 + /** 49 + * Close the sidebar. 50 + */ 51 + export function closeSidebar(state: SidebarState): SidebarState { 52 + return { ...state, open: false, rowIndex: null }; 53 + } 54 + 55 + /** 56 + * Navigate to the next row. 57 + */ 58 + export function nextRecord( 59 + state: SidebarState, 60 + maxRow: number, 61 + ): SidebarState { 62 + if (!state.open || state.rowIndex === null) return state; 63 + const next = Math.min(state.rowIndex + 1, maxRow); 64 + return { ...state, rowIndex: next }; 65 + } 66 + 67 + /** 68 + * Navigate to the previous row. 69 + */ 70 + export function prevRecord(state: SidebarState): SidebarState { 71 + if (!state.open || state.rowIndex === null) return state; 72 + const prev = Math.max(state.rowIndex - 1, 1); 73 + return { ...state, rowIndex: prev }; 74 + } 75 + 76 + /** 77 + * Extract record fields from sheet data. 78 + */ 79 + export function extractRecordFields( 80 + rowIndex: number, 81 + colCount: number, 82 + cellValues: Map<string, unknown>, 83 + cellFormulas: Map<string, string>, 84 + colToLetter: (col: number) => string, 85 + colHeaders: string[], 86 + ): RecordDetail { 87 + const fields: RecordField[] = []; 88 + 89 + for (let c = 0; c < colCount; c++) { 90 + const cellId = `${colToLetter(c)}${rowIndex}`; 91 + fields.push({ 92 + columnIndex: c, 93 + columnName: colHeaders[c] || colToLetter(c), 94 + value: cellValues.get(cellId) ?? '', 95 + formula: cellFormulas.get(cellId) || '', 96 + }); 97 + } 98 + 99 + return { rowIndex, fields }; 100 + } 101 + 102 + /** 103 + * Filter record fields by visibility config. 104 + */ 105 + export function filterVisibleFields( 106 + record: RecordDetail, 107 + visibleFields: number[] | null, 108 + ): RecordField[] { 109 + if (!visibleFields) return record.fields; 110 + return record.fields.filter(f => visibleFields.includes(f.columnIndex)); 111 + } 112 + 113 + /** 114 + * Set which fields are visible in the sidebar. 115 + */ 116 + export function setVisibleFields( 117 + state: SidebarState, 118 + fields: number[] | null, 119 + ): SidebarState { 120 + return { ...state, visibleFields: fields }; 121 + } 122 + 123 + /** 124 + * Toggle a single field's visibility. 125 + */ 126 + export function toggleFieldVisibility( 127 + state: SidebarState, 128 + columnIndex: number, 129 + totalCols: number, 130 + ): SidebarState { 131 + // If showing all, create explicit list minus the toggled field 132 + if (!state.visibleFields) { 133 + const all = Array.from({ length: totalCols }, (_, i) => i); 134 + return { ...state, visibleFields: all.filter(i => i !== columnIndex) }; 135 + } 136 + 137 + if (state.visibleFields.includes(columnIndex)) { 138 + const filtered = state.visibleFields.filter(i => i !== columnIndex); 139 + // If all removed, reset to show all 140 + return { ...state, visibleFields: filtered.length > 0 ? filtered : null }; 141 + } 142 + 143 + return { ...state, visibleFields: [...state.visibleFields, columnIndex].sort((a, b) => a - b) }; 144 + } 145 + 146 + /** 147 + * Check if a field is currently visible. 148 + */ 149 + export function isFieldVisible( 150 + state: SidebarState, 151 + columnIndex: number, 152 + ): boolean { 153 + if (!state.visibleFields) return true; 154 + return state.visibleFields.includes(columnIndex); 155 + }
+190
tests/mentions.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + parseMentions, 4 + filterMentionUsers, 5 + resolveMention, 6 + mentionLabel, 7 + uniqueMentionedUsers, 8 + isUserMentioned, 9 + countMentions, 10 + type MentionUser, 11 + type Mention, 12 + } from '../src/docs/mentions.js'; 13 + 14 + const alice: MentionUser = { id: 'u1', name: 'Alice', color: '#f00', online: true }; 15 + const bob: MentionUser = { id: 'u2', name: 'Bob', color: '#0f0', online: false }; 16 + const alicia: MentionUser = { id: 'u3', name: 'Alicia', color: '#00f', online: true }; 17 + const charlie: MentionUser = { id: 'u4', name: 'Charlie Brown', color: '#ff0', online: true }; 18 + 19 + const users: MentionUser[] = [alice, bob, alicia, charlie]; 20 + 21 + describe('parseMentions', () => { 22 + it('parses single mention', () => { 23 + const result = parseMentions('Hello @Alice!'); 24 + expect(result).toHaveLength(1); 25 + expect(result[0].userName).toBe('Alice'); 26 + expect(result[0].start).toBe(6); 27 + expect(result[0].end).toBe(12); 28 + }); 29 + 30 + it('parses multiple mentions', () => { 31 + const result = parseMentions('@Alice and @Bob'); 32 + expect(result).toHaveLength(2); 33 + expect(result[0].userName).toBe('Alice'); 34 + expect(result[1].userName).toBe('Bob'); 35 + }); 36 + 37 + it('parses single-word mention ignoring trailing words', () => { 38 + const result = parseMentions('Hey @Charlie Brown!'); 39 + // Plain text parser captures single word after @; multi-word handled by TipTap node 40 + expect(result).toHaveLength(1); 41 + expect(result[0].userName).toBe('Charlie'); 42 + }); 43 + 44 + it('returns empty for no mentions', () => { 45 + expect(parseMentions('Hello world')).toEqual([]); 46 + }); 47 + 48 + it('returns empty for empty string', () => { 49 + expect(parseMentions('')).toEqual([]); 50 + }); 51 + 52 + it('returns empty for null-ish input', () => { 53 + expect(parseMentions(null as any)).toEqual([]); 54 + expect(parseMentions(undefined as any)).toEqual([]); 55 + }); 56 + 57 + it('handles single character username', () => { 58 + const result = parseMentions('@A'); 59 + expect(result).toHaveLength(1); 60 + expect(result[0].userName).toBe('A'); 61 + }); 62 + 63 + it('sets empty userId', () => { 64 + const result = parseMentions('@Alice'); 65 + expect(result[0].userId).toBe(''); 66 + }); 67 + }); 68 + 69 + describe('filterMentionUsers', () => { 70 + it('returns all users for empty query', () => { 71 + expect(filterMentionUsers(users, '')).toEqual(users); 72 + }); 73 + 74 + it('prefix matches come first', () => { 75 + const result = filterMentionUsers(users, 'ali'); 76 + expect(result[0].name).toBe('Alice'); 77 + expect(result[1].name).toBe('Alicia'); 78 + expect(result).toHaveLength(2); 79 + }); 80 + 81 + it('contains matches follow prefix matches', () => { 82 + const result = filterMentionUsers(users, 'ob'); 83 + expect(result).toHaveLength(1); 84 + expect(result[0].name).toBe('Bob'); 85 + }); 86 + 87 + it('is case insensitive', () => { 88 + const result = filterMentionUsers(users, 'ALICE'); 89 + expect(result).toHaveLength(1); 90 + expect(result[0].name).toBe('Alice'); 91 + }); 92 + 93 + it('returns empty for no matches', () => { 94 + expect(filterMentionUsers(users, 'xyz')).toEqual([]); 95 + }); 96 + }); 97 + 98 + describe('resolveMention', () => { 99 + it('exact match takes priority', () => { 100 + const result = resolveMention('Alice', users); 101 + expect(result).toBe(alice); 102 + }); 103 + 104 + it('case insensitive exact match', () => { 105 + const result = resolveMention('alice', users); 106 + expect(result).toBe(alice); 107 + }); 108 + 109 + it('falls back to prefix match', () => { 110 + const result = resolveMention('Char', users); 111 + expect(result).toBe(charlie); 112 + }); 113 + 114 + it('returns null for no match', () => { 115 + expect(resolveMention('Zara', users)).toBeNull(); 116 + }); 117 + 118 + it('returns null for empty string', () => { 119 + expect(resolveMention('', users)).toBeNull(); 120 + }); 121 + 122 + it('returns null for null-ish input', () => { 123 + expect(resolveMention(null as any, users)).toBeNull(); 124 + }); 125 + }); 126 + 127 + describe('mentionLabel', () => { 128 + it('prepends @ to name', () => { 129 + expect(mentionLabel(alice)).toBe('@Alice'); 130 + }); 131 + }); 132 + 133 + describe('uniqueMentionedUsers', () => { 134 + it('returns unique user IDs', () => { 135 + const mentions: Mention[] = [ 136 + { userId: 'u1', userName: 'Alice', start: 0, end: 6 }, 137 + { userId: 'u2', userName: 'Bob', start: 10, end: 14 }, 138 + { userId: 'u1', userName: 'Alice', start: 20, end: 26 }, 139 + ]; 140 + expect(uniqueMentionedUsers(mentions)).toEqual(['u1', 'u2']); 141 + }); 142 + 143 + it('skips mentions with empty userId', () => { 144 + const mentions: Mention[] = [ 145 + { userId: '', userName: 'Unknown', start: 0, end: 8 }, 146 + { userId: 'u1', userName: 'Alice', start: 10, end: 16 }, 147 + ]; 148 + expect(uniqueMentionedUsers(mentions)).toEqual(['u1']); 149 + }); 150 + 151 + it('returns empty for no mentions', () => { 152 + expect(uniqueMentionedUsers([])).toEqual([]); 153 + }); 154 + }); 155 + 156 + describe('isUserMentioned', () => { 157 + it('detects mentioned user', () => { 158 + expect(isUserMentioned('Hello @Alice!', 'Alice')).toBe(true); 159 + }); 160 + 161 + it('is case insensitive', () => { 162 + expect(isUserMentioned('Hello @alice!', 'Alice')).toBe(true); 163 + }); 164 + 165 + it('returns false when not mentioned', () => { 166 + expect(isUserMentioned('Hello @Bob!', 'Alice')).toBe(false); 167 + }); 168 + 169 + it('returns false for empty text', () => { 170 + expect(isUserMentioned('', 'Alice')).toBe(false); 171 + }); 172 + 173 + it('returns false for empty userName', () => { 174 + expect(isUserMentioned('Hello @Alice!', '')).toBe(false); 175 + }); 176 + }); 177 + 178 + describe('countMentions', () => { 179 + it('counts mentions in text', () => { 180 + expect(countMentions('@Alice and @Bob')).toBe(2); 181 + }); 182 + 183 + it('returns 0 for no mentions', () => { 184 + expect(countMentions('Hello world')).toBe(0); 185 + }); 186 + 187 + it('returns 0 for empty text', () => { 188 + expect(countMentions('')).toBe(0); 189 + }); 190 + });
+225
tests/record-sidebar.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createSidebarState, 4 + openSidebar, 5 + closeSidebar, 6 + nextRecord, 7 + prevRecord, 8 + extractRecordFields, 9 + filterVisibleFields, 10 + setVisibleFields, 11 + toggleFieldVisibility, 12 + isFieldVisible, 13 + type SidebarState, 14 + type RecordDetail, 15 + } from '../src/sheets/record-sidebar.js'; 16 + 17 + describe('createSidebarState', () => { 18 + it('returns closed state with null row', () => { 19 + const state = createSidebarState(); 20 + expect(state.open).toBe(false); 21 + expect(state.rowIndex).toBeNull(); 22 + expect(state.visibleFields).toBeNull(); 23 + }); 24 + }); 25 + 26 + describe('openSidebar', () => { 27 + it('opens sidebar for a given row', () => { 28 + const state = openSidebar(createSidebarState(), 5); 29 + expect(state.open).toBe(true); 30 + expect(state.rowIndex).toBe(5); 31 + }); 32 + 33 + it('preserves visibleFields', () => { 34 + const initial = setVisibleFields(createSidebarState(), [0, 1, 2]); 35 + const state = openSidebar(initial, 3); 36 + expect(state.visibleFields).toEqual([0, 1, 2]); 37 + }); 38 + }); 39 + 40 + describe('closeSidebar', () => { 41 + it('closes and clears row', () => { 42 + const opened = openSidebar(createSidebarState(), 5); 43 + const state = closeSidebar(opened); 44 + expect(state.open).toBe(false); 45 + expect(state.rowIndex).toBeNull(); 46 + }); 47 + }); 48 + 49 + describe('nextRecord', () => { 50 + it('advances to next row', () => { 51 + const state = openSidebar(createSidebarState(), 3); 52 + const next = nextRecord(state, 10); 53 + expect(next.rowIndex).toBe(4); 54 + }); 55 + 56 + it('does not exceed maxRow', () => { 57 + const state = openSidebar(createSidebarState(), 10); 58 + const next = nextRecord(state, 10); 59 + expect(next.rowIndex).toBe(10); 60 + }); 61 + 62 + it('returns same state if not open', () => { 63 + const state = createSidebarState(); 64 + expect(nextRecord(state, 10)).toBe(state); 65 + }); 66 + 67 + it('returns same state if rowIndex is null', () => { 68 + const state = { open: true, rowIndex: null, visibleFields: null } as SidebarState; 69 + expect(nextRecord(state, 10)).toBe(state); 70 + }); 71 + }); 72 + 73 + describe('prevRecord', () => { 74 + it('goes to previous row', () => { 75 + const state = openSidebar(createSidebarState(), 5); 76 + const prev = prevRecord(state); 77 + expect(prev.rowIndex).toBe(4); 78 + }); 79 + 80 + it('does not go below 1', () => { 81 + const state = openSidebar(createSidebarState(), 1); 82 + const prev = prevRecord(state); 83 + expect(prev.rowIndex).toBe(1); 84 + }); 85 + 86 + it('returns same state if not open', () => { 87 + const state = createSidebarState(); 88 + expect(prevRecord(state)).toBe(state); 89 + }); 90 + }); 91 + 92 + describe('extractRecordFields', () => { 93 + it('extracts fields from cell data', () => { 94 + const values = new Map<string, unknown>([ 95 + ['A1', 'Hello'], 96 + ['B1', 42], 97 + ]); 98 + const formulas = new Map<string, string>([ 99 + ['B1', '=6*7'], 100 + ]); 101 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 102 + const colHeaders = ['Name', 'Score']; 103 + 104 + const record = extractRecordFields(1, 2, values, formulas, colToLetter, colHeaders); 105 + 106 + expect(record.rowIndex).toBe(1); 107 + expect(record.fields).toHaveLength(2); 108 + expect(record.fields[0]).toEqual({ 109 + columnIndex: 0, 110 + columnName: 'Name', 111 + value: 'Hello', 112 + formula: '', 113 + }); 114 + expect(record.fields[1]).toEqual({ 115 + columnIndex: 1, 116 + columnName: 'Score', 117 + value: 42, 118 + formula: '=6*7', 119 + }); 120 + }); 121 + 122 + it('uses column letter when header is missing', () => { 123 + const values = new Map<string, unknown>(); 124 + const formulas = new Map<string, string>(); 125 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 126 + 127 + const record = extractRecordFields(1, 1, values, formulas, colToLetter, []); 128 + expect(record.fields[0].columnName).toBe('A'); 129 + }); 130 + 131 + it('defaults value to empty string when missing', () => { 132 + const values = new Map<string, unknown>(); 133 + const formulas = new Map<string, string>(); 134 + const colToLetter = (c: number) => String.fromCharCode(65 + c); 135 + 136 + const record = extractRecordFields(1, 1, values, formulas, colToLetter, ['Col']); 137 + expect(record.fields[0].value).toBe(''); 138 + }); 139 + }); 140 + 141 + describe('filterVisibleFields', () => { 142 + const record: RecordDetail = { 143 + rowIndex: 1, 144 + fields: [ 145 + { columnIndex: 0, columnName: 'A', value: 'a', formula: '' }, 146 + { columnIndex: 1, columnName: 'B', value: 'b', formula: '' }, 147 + { columnIndex: 2, columnName: 'C', value: 'c', formula: '' }, 148 + ], 149 + }; 150 + 151 + it('returns all fields when visibleFields is null', () => { 152 + expect(filterVisibleFields(record, null)).toEqual(record.fields); 153 + }); 154 + 155 + it('filters to specified columns', () => { 156 + const result = filterVisibleFields(record, [0, 2]); 157 + expect(result).toHaveLength(2); 158 + expect(result[0].columnName).toBe('A'); 159 + expect(result[1].columnName).toBe('C'); 160 + }); 161 + 162 + it('returns empty for empty visibility list', () => { 163 + expect(filterVisibleFields(record, [])).toEqual([]); 164 + }); 165 + }); 166 + 167 + describe('setVisibleFields', () => { 168 + it('sets visible fields', () => { 169 + const state = setVisibleFields(createSidebarState(), [1, 3]); 170 + expect(state.visibleFields).toEqual([1, 3]); 171 + }); 172 + 173 + it('can reset to null (show all)', () => { 174 + const state = setVisibleFields(createSidebarState(), null); 175 + expect(state.visibleFields).toBeNull(); 176 + }); 177 + }); 178 + 179 + describe('toggleFieldVisibility', () => { 180 + it('hides a field when all are visible', () => { 181 + const state = createSidebarState(); // visibleFields: null = all visible 182 + const toggled = toggleFieldVisibility(state, 1, 3); 183 + expect(toggled.visibleFields).toEqual([0, 2]); 184 + }); 185 + 186 + it('shows a hidden field', () => { 187 + const state = setVisibleFields(createSidebarState(), [0, 2]); 188 + const toggled = toggleFieldVisibility(state, 1, 3); 189 + expect(toggled.visibleFields).toEqual([0, 1, 2]); 190 + }); 191 + 192 + it('hides a visible field from explicit list', () => { 193 + const state = setVisibleFields(createSidebarState(), [0, 1, 2]); 194 + const toggled = toggleFieldVisibility(state, 1, 3); 195 + expect(toggled.visibleFields).toEqual([0, 2]); 196 + }); 197 + 198 + it('resets to null when all fields re-hidden results in empty', () => { 199 + const state = setVisibleFields(createSidebarState(), [1]); 200 + const toggled = toggleFieldVisibility(state, 1, 3); 201 + expect(toggled.visibleFields).toBeNull(); 202 + }); 203 + 204 + it('keeps sorted order when adding', () => { 205 + const state = setVisibleFields(createSidebarState(), [0, 3]); 206 + const toggled = toggleFieldVisibility(state, 1, 4); 207 + expect(toggled.visibleFields).toEqual([0, 1, 3]); 208 + }); 209 + }); 210 + 211 + describe('isFieldVisible', () => { 212 + it('returns true when visibleFields is null', () => { 213 + expect(isFieldVisible(createSidebarState(), 5)).toBe(true); 214 + }); 215 + 216 + it('returns true for included field', () => { 217 + const state = setVisibleFields(createSidebarState(), [0, 2, 4]); 218 + expect(isFieldVisible(state, 2)).toBe(true); 219 + }); 220 + 221 + it('returns false for excluded field', () => { 222 + const state = setVisibleFields(createSidebarState(), [0, 2, 4]); 223 + expect(isFieldVisible(state, 1)).toBe(false); 224 + }); 225 + });