diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 98f638b..7a3cf75 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,9 @@ "allow": [ "Bash(npm test)", "Bash(export PATH=\"/c/Users/aaron/AppData/Local/mise/shims:$PATH\")", - "WebFetch(domain:ehire-cms.webflow.io)" + "WebFetch(domain:ehire-cms.webflow.io)", + "Bash(curl -s --max-time 15 \"http://115.70.110.212:81/swagger/v1/swagger.json\")", + "Bash(curl:*)" ] } } diff --git a/changelog/v0.6.0.md b/changelog/v0.6.0.md index 02d4fb7..9477203 100644 --- a/changelog/v0.6.0.md +++ b/changelog/v0.6.0.md @@ -22,4 +22,4 @@ - Fixed document viewer page mode content collapsing to a short height instead of filling the viewport - Fixed white bars appearing on sides of document in experimental viewer with page view mode - Moved viewer settings (Styled/Page toggles) from dropdown menu to inline toggle buttons in the top bar -- Styled viewer strips inline font declarations from SEC HTML for consistent app typography and larger text +- Moved viewer settings (Styled/Page toggles) from dropdown menu to inline toggle buttons in the top bar diff --git a/changelog/v0.7.0.md b/changelog/v0.7.0.md new file mode 100644 index 0000000..bb36398 --- /dev/null +++ b/changelog/v0.7.0.md @@ -0,0 +1,12 @@ +# v0.7.0 + +## New Features +- Tabbed query interface: keep multiple queries open simultaneously with VS Code-style tabs +- Open in New Tab option in query three-dot menu +- Favorites system: right-click any filing row to add/remove favorites +- Favorites tab shows bookmarked filings with cached results and background refresh +- Right-click context menu on table rows: Copy Cell, Copy Row, Add/Remove Favorites + +## Improvements +- Tabs persist across page reloads via Strings API; queries lazy-load on first tab switch +- In-flight queries cancelled on tab switch to prevent results landing in wrong tab diff --git a/changelog/v0.8.0.md b/changelog/v0.8.0.md new file mode 100644 index 0000000..89d3735 --- /dev/null +++ b/changelog/v0.8.0.md @@ -0,0 +1,16 @@ +# v0.8.0 + +## New Features +- CodeMirror 6 query editor with PgSQL syntax highlighting and dark theme +- Autocomplete dropdown with grouped Filing Fields (from API) and SQL Keywords +- Dynamic field autocomplete: type suggestions inside {Label:Type} syntax, modifier suggestions after | +- Favorites field type with |modifier syntax: {favs:Favorites|array} injects favorite IDs into queries +- Modifiers: csv, array, pgarray, first, last — format values for different SQL patterns +- Modifiers now work on Tags and Favorites (e.g., {Tags:Tags|pgarray} → array['a','b']) + +## Improvements +- Query parameter {Label:Type:Default} blocks highlighted in teal in the code editor +- Styled viewer strips inline font declarations from SEC HTML for consistent app typography and larger text +- Help page updated with Favorites field type and modifiers reference +- Tag chips in dynamic parameters now show visible X buttons for individual removal +- Preview button in query toolbar: hold to see compiled query with values replaced diff --git a/package-lock.json b/package-lock.json index 2dfd379..285db7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,13 @@ "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", "@auth0/auth0-angular": "^2.3.0", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@ng-icons/core": "^33.0.0", "@ng-icons/lucide": "^33.0.0", "@spartan-ng/brain": "^0.0.1-alpha.584", @@ -23,6 +30,7 @@ "ag-grid-community": "^35.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", "tailwind-merge": "^3.3.1", @@ -2633,6 +2641,101 @@ "dev": true, "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.1", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz", + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.5", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz", + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz", + "integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.40.0.tgz", + "integrity": "sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -4168,6 +4271,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", + "integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz", + "integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", @@ -4283,6 +4410,12 @@ "win32" ] }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -11467,6 +11600,21 @@ "dev": true, "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -11851,6 +11999,12 @@ "node": ">= 6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -21277,6 +21431,12 @@ "webpack": "^5.0.0" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/stylehacks": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz", @@ -23035,6 +23195,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 018ddc3..9980bc8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,13 @@ "@angular/platform-browser": "^21.0.0", "@angular/router": "^21.0.0", "@auth0/auth0-angular": "^2.3.0", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", "@ng-icons/core": "^33.0.0", "@ng-icons/lucide": "^33.0.0", "@spartan-ng/brain": "^0.0.1-alpha.584", @@ -43,6 +50,7 @@ "ag-grid-community": "^35.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "ngx-extended-pdf-viewer": "^25.6.4", "rxjs": "~7.8.0", "tailwind-merge": "^3.3.1", diff --git a/src/app/components/code-editor/code-editor.component.ts b/src/app/components/code-editor/code-editor.component.ts new file mode 100644 index 0000000..ebf6d82 --- /dev/null +++ b/src/app/components/code-editor/code-editor.component.ts @@ -0,0 +1,129 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + NgZone, + afterNextRender, + effect, + inject, + input, + output, + viewChild, + DestroyRef, +} from '@angular/core'; +import { EditorState, Extension } from '@codemirror/state'; +import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { sql, PostgreSQL } from '@codemirror/lang-sql'; + +import { darkTheme, highlightStyle } from './editor-theme'; +import { parameterHighlighter } from './query-language'; + +@Component({ + selector: 'app-code-editor', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: `
`, + styles: ` + :host { + display: block; + overflow: hidden; + } + .editor-host { + height: 100%; + } + .editor-host .cm-editor { + height: 100%; + } + .editor-host .cm-editor.cm-focused { + outline: none; + } + `, +}) +export class CodeEditorComponent { + private readonly zone = inject(NgZone); + private readonly destroyRef = inject(DestroyRef); + + readonly value = input(''); + readonly placeholder = input(''); + readonly extensions = input([]); + + readonly valueChange = output(); + readonly keyboardShortcut = output(); + + private readonly editorHost = viewChild.required>('editorHost'); + private view: EditorView | null = null; + private isExternalUpdate = false; + + constructor() { + afterNextRender(() => { + this.createEditor(); + }); + + // Sync external value changes into the editor + effect(() => { + const val = this.value(); + const view = this.view; + if (!view) return; + const current = view.state.doc.toString(); + if (val !== current) { + this.isExternalUpdate = true; + view.dispatch({ + changes: { from: 0, to: current.length, insert: val }, + }); + this.isExternalUpdate = false; + } + }); + + this.destroyRef.onDestroy(() => { + this.view?.destroy(); + this.view = null; + }); + } + + private createEditor() { + const host = this.editorHost().nativeElement; + + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged && !this.isExternalUpdate) { + const text = update.state.doc.toString(); + this.zone.run(() => this.valueChange.emit(text)); + } + }); + + // Ctrl+Enter / Cmd+Enter shortcut + const searchKeymap = keymap.of([ + { + key: 'Ctrl-Enter', + mac: 'Cmd-Enter', + run: () => { + this.zone.run(() => this.keyboardShortcut.emit('ctrl-enter')); + return true; + }, + }, + ]); + + const state = EditorState.create({ + doc: this.value(), + extensions: [ + searchKeymap, + history(), + keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]), + closeBrackets(), + sql({ dialect: PostgreSQL }), + darkTheme, + highlightStyle, + parameterHighlighter, + cmPlaceholder(this.placeholder()), + updateListener, + EditorView.lineWrapping, + ...this.extensions(), + ], + }); + + this.zone.runOutsideAngular(() => { + this.view = new EditorView({ state, parent: host }); + }); + } +} diff --git a/src/app/components/code-editor/editor-theme.ts b/src/app/components/code-editor/editor-theme.ts new file mode 100644 index 0000000..518b94b --- /dev/null +++ b/src/app/components/code-editor/editor-theme.ts @@ -0,0 +1,81 @@ +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; + +/** Dark theme matching the app's VS Code-like color scheme */ +export const darkTheme = EditorView.theme( + { + '&': { + backgroundColor: '#1e1e1e', + color: '#d4d4d4', + fontFamily: + "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace", + fontSize: '13px', + }, + '.cm-content': { + caretColor: '#d4d4d4', + padding: '8px 12px', + minHeight: '64px', + }, + '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#d4d4d4' }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { + backgroundColor: '#264f78', + }, + '.cm-gutters': { display: 'none' }, + '.cm-activeLine': { backgroundColor: 'transparent' }, + '&.cm-focused .cm-activeLine': { backgroundColor: '#2a2a2a' }, + '.cm-activeLineGutter': { backgroundColor: 'transparent' }, + '.cm-scroller': { overflow: 'auto' }, + + /* Parameter syntax {Label:Type:Default} */ + '.cm-parameter-syntax': { color: '#4ec9b0', fontWeight: 'bold' }, + + /* Autocomplete tooltip */ + '.cm-tooltip': { + backgroundColor: '#252526', + border: '1px solid #3c3c3c', + color: '#d4d4d4', + }, + '.cm-tooltip-autocomplete': { + '& > ul': { fontFamily: 'inherit', fontSize: '12px' }, + '& > ul > li': { padding: '2px 8px' }, + '& > ul > li[aria-selected]': { backgroundColor: '#04395e', color: '#ffffff' }, + }, + '.cm-tooltip-autocomplete .cm-completionIcon': { + display: 'none', + }, + '.cm-completionLabel': { color: '#d4d4d4' }, + '.cm-completionDetail': { color: '#858585', fontStyle: 'italic', marginLeft: '8px' }, + '.cm-completionMatchedText': { color: '#4ec9b0', textDecoration: 'none' }, + + /* Section headers in autocomplete */ + '.cm-completionSection': { + color: '#858585', + fontSize: '10px', + textTransform: 'uppercase', + letterSpacing: '0.5px', + padding: '4px 8px 2px', + borderBottom: '1px solid #3c3c3c', + }, + + /* Placeholder */ + '.cm-placeholder': { color: '#6e6e6e', fontStyle: 'normal' }, + }, + { dark: true }, +); + +/** SQL-like syntax highlighting colors */ +export const highlightStyle = syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.keyword, color: '#569cd6' }, + { tag: tags.string, color: '#ce9178' }, + { tag: tags.number, color: '#b5cea8' }, + { tag: tags.operator, color: '#d4d4d4' }, + { tag: tags.comment, color: '#6a9955' }, + { tag: [tags.function(tags.variableName), tags.standard(tags.variableName)], color: '#dcdcaa' }, + { tag: tags.typeName, color: '#4ec9b0' }, + { tag: tags.bool, color: '#569cd6' }, + { tag: tags.null, color: '#569cd6' }, + { tag: tags.punctuation, color: '#d4d4d4' }, + ]), +); diff --git a/src/app/components/code-editor/index.ts b/src/app/components/code-editor/index.ts new file mode 100644 index 0000000..37d31d2 --- /dev/null +++ b/src/app/components/code-editor/index.ts @@ -0,0 +1,4 @@ +export * from './code-editor.component'; +export * from './query-language'; +export * from './query-autocomplete'; +export * from './editor-theme'; diff --git a/src/app/components/code-editor/query-autocomplete.ts b/src/app/components/code-editor/query-autocomplete.ts new file mode 100644 index 0000000..27f0d22 --- /dev/null +++ b/src/app/components/code-editor/query-autocomplete.ts @@ -0,0 +1,246 @@ +import { + autocompletion, + CompletionContext, + CompletionResult, + Completion, + CompletionSection, +} from '@codemirror/autocomplete'; +import { Extension } from '@codemirror/state'; +import { FilingService } from '../../core/services/filing.service'; +import { firstValueFrom } from 'rxjs'; + +// --- Sections for grouped autocomplete --- + +const filingFieldsSection: CompletionSection = { name: 'Filing Fields', rank: 0 }; +const sqlKeywordsSection: CompletionSection = { name: 'SQL Keywords', rank: 1 }; +const fieldTypesSection: CompletionSection = { name: 'Field Types', rank: 0 }; +const modifiersSection: CompletionSection = { name: 'Modifiers', rank: 0 }; + +// --- Static data --- + +const SQL_KEYWORDS = [ + 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'ILIKE', + 'ORDER BY', 'ORDER', 'BY', 'LIMIT', 'BETWEEN', 'DESC', 'ASC', + 'IS', 'NULL', 'AS', 'LEFT', 'RIGHT', 'JOIN', 'ON', 'INNER', 'OUTER', + 'GROUP BY', 'GROUP', 'HAVING', 'DISTINCT', 'UNION', 'EXCEPT', + 'EXISTS', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'TRUE', 'FALSE', + 'ARRAY', 'CAST', +]; + +const FIELD_TYPES = [ + { label: 'StringInput', detail: 'Free text input' }, + { label: 'NumberInput', detail: 'Numeric input' }, + { label: 'FormTypes', detail: 'SEC form type dropdown' }, + { label: 'Tags', detail: 'Tag multi-select' }, + { label: 'Favorites', detail: 'Inject favorite IDs' }, +]; + +const MODIFIERS = [ + { label: 'csv', detail: 'Comma-separated values' }, + { label: 'array', detail: 'Parenthesized list for IN operator' }, + { label: 'pgarray', detail: "PostgreSQL array literal: array['a','b']" }, + { label: 'first', detail: 'First item only' }, + { label: 'last', detail: 'Last item only' }, +]; + +// --- API cache --- + +let cachedApiResults: Completion[] | null = null; +let cacheTimestamp = 0; +const CACHE_TTL = 30_000; // 30 seconds + +async function fetchApiCompletions( + filingService: FilingService, + word: string, +): Promise { + const now = Date.now(); + if (cachedApiResults && now - cacheTimestamp < CACHE_TTL) { + return cachedApiResults; + } + + try { + const results = await firstValueFrom(filingService.autocomplete(word)); + const completions: Completion[] = results.map((r) => ({ + label: r.word, + detail: r.description ?? undefined, + section: filingFieldsSection, + boost: 1, + })); + + // Deduplicate by label + const seen = new Set(); + const unique = completions.filter((c) => { + if (seen.has(c.label)) return false; + seen.add(c.label); + return true; + }); + + cachedApiResults = unique; + cacheTimestamp = now; + return unique; + } catch { + return cachedApiResults ?? []; + } +} + +// --- Context detection --- + +interface BraceContext { + /** Which segment the cursor is in: 'label' | 'type' | 'default' | 'modifier' */ + segment: 'label' | 'type' | 'default' | 'modifier'; + /** The text typed so far in this segment */ + typed: string; + /** Start position of the typed text in the document */ + from: number; +} + +/** + * Detect if the cursor is inside {Label:Type:Default|modifier} syntax. + * Returns context info or null if outside braces. + */ +function getBraceContext(doc: string, pos: number): BraceContext | null { + // Scan backwards to find the opening brace + let braceStart = -1; + for (let i = pos - 1; i >= 0; i--) { + if (doc[i] === '}') return null; // hit a close brace first → not inside + if (doc[i] === '{' && (i === 0 || doc[i - 1] !== '\\')) { + braceStart = i; + break; + } + } + if (braceStart === -1) return null; + + // Check there's no close brace between braceStart and pos + const inside = doc.substring(braceStart + 1, pos); + + // Determine segment by counting colons and pipes + const colonCount = (inside.match(/:/g) || []).length; + const hasPipe = inside.includes('|'); + + if (hasPipe) { + // After the pipe → modifier segment + const pipeIdx = inside.lastIndexOf('|'); + const typed = inside.substring(pipeIdx + 1); + return { segment: 'modifier', typed, from: braceStart + 1 + pipeIdx + 1 }; + } + + if (colonCount === 0) { + // Label segment + return { segment: 'label', typed: inside, from: braceStart + 1 }; + } + + if (colonCount === 1) { + // Type segment (after first colon) + const colonIdx = inside.indexOf(':'); + const typed = inside.substring(colonIdx + 1); + return { segment: 'type', typed, from: braceStart + 1 + colonIdx + 1 }; + } + + // colonCount >= 2 → default segment (or modifier if pipe present, handled above) + // Check for pipe in the last part + const lastColonIdx = inside.indexOf(':', inside.indexOf(':') + 1); + const afterLastColon = inside.substring(lastColonIdx + 1); + if (afterLastColon.includes('|')) { + const pipeIdx = afterLastColon.lastIndexOf('|'); + const typed = afterLastColon.substring(pipeIdx + 1); + return { + segment: 'modifier', + typed, + from: braceStart + 1 + lastColonIdx + 1 + pipeIdx + 1, + }; + } + + return { + segment: 'default', + typed: afterLastColon, + from: braceStart + 1 + lastColonIdx + 1, + }; +} + +// --- Main completion source --- + +async function queryCompletionSource( + context: CompletionContext, + filingService: FilingService, +): Promise { + const doc = context.state.doc.toString(); + const pos = context.pos; + + // Check if inside {braces} + const braceCtx = getBraceContext(doc, pos); + + if (braceCtx) { + if (braceCtx.segment === 'type') { + // Suggest field types + const word = braceCtx.typed.trim(); + const options: Completion[] = FIELD_TYPES + .filter((ft) => !word || ft.label.toLowerCase().startsWith(word.toLowerCase())) + .map((ft) => ({ + label: ft.label, + detail: ft.detail, + section: fieldTypesSection, + })); + if (options.length === 0) return null; + return { from: braceCtx.from, options }; + } + + if (braceCtx.segment === 'modifier') { + const word = braceCtx.typed.trim(); + const options: Completion[] = MODIFIERS + .filter((m) => !word || m.label.startsWith(word.toLowerCase())) + .map((m) => ({ + label: m.label, + detail: m.detail, + section: modifiersSection, + })); + if (options.length === 0) return null; + return { from: braceCtx.from, options }; + } + + // Label or default segment → no autocomplete + return null; + } + + // Outside braces: SQL keywords + filing fields from API + const wordMatch = context.matchBefore(/\w+/); + if (!wordMatch && !context.explicit) return null; + const from = wordMatch?.from ?? pos; + const word = wordMatch?.text ?? ''; + + // Build SQL keyword completions + const sqlOptions: Completion[] = SQL_KEYWORDS + .filter((kw) => !word || kw.toLowerCase().startsWith(word.toLowerCase())) + .map((kw) => ({ + label: kw.toLowerCase(), + detail: 'keyword', + section: sqlKeywordsSection, + apply: kw.toLowerCase(), + })); + + // Fetch API completions (debounced/cached) + let apiOptions: Completion[] = []; + if (word.length >= 1 || context.explicit) { + const allApi = await fetchApiCompletions(filingService, word); + apiOptions = allApi.filter( + (c) => !word || c.label.toLowerCase().startsWith(word.toLowerCase()), + ); + } + + const options = [...apiOptions, ...sqlOptions]; + if (options.length === 0) return null; + + return { from, options }; +} + +/** + * Create a CodeMirror autocomplete extension for the query editor. + */ +export function createQueryAutocomplete(filingService: FilingService): Extension { + return autocompletion({ + override: [ + (context: CompletionContext) => queryCompletionSource(context, filingService), + ], + activateOnTyping: true, + defaultKeymap: true, + }); +} diff --git a/src/app/components/code-editor/query-language.ts b/src/app/components/code-editor/query-language.ts new file mode 100644 index 0000000..519afba --- /dev/null +++ b/src/app/components/code-editor/query-language.ts @@ -0,0 +1,42 @@ +import { + ViewPlugin, + Decoration, + DecorationSet, + EditorView, + ViewUpdate, +} from '@codemirror/view'; +import { RangeSetBuilder } from '@codemirror/state'; + +const parameterMark = Decoration.mark({ class: 'cm-parameter-syntax' }); + +/** + * ViewPlugin that highlights {Label:Type:Default|modifier} parameter syntax + * with a distinct teal color. Uses the same regex pattern as QueryParserService. + */ +export const parameterHighlighter = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.view); + } + } + }, + { decorations: (v) => v.decorations }, +); + +function buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const doc = view.state.doc.toString(); + const regex = /(? + `, +}) +export class FavoritesInputComponent { + private readonly favoritesService = inject(FavoritesService); + + readonly value = input(''); + readonly defaultValue = input(''); + readonly modifier = input(''); + readonly valueChange = output(); + + readonly placeholder = computed(() => { + if (!this.favoritesService.loaded()) return 'Loading...'; + if (!this.favoritesService.hasFavorites()) return 'No favorites'; + return ''; + }); + + readonly displayText = computed(() => { + const ids = this.favoritesService.favoriteIds(); + if (ids.length === 0) return ''; + + switch (this.modifier()) { + case 'array': + return `(${ids.join(',')})`; + case 'pgarray': + return `array[${ids.join(',')}]`; + case 'first': + return String(ids[0]); + case 'last': + return String(ids[ids.length - 1]); + case 'csv': + default: + return ids.join(','); + } + }); +} diff --git a/src/app/components/query-builder/inputs/tags-input.component.ts b/src/app/components/query-builder/inputs/tags-input.component.ts index 20a0739..e9d4664 100644 --- a/src/app/components/query-builder/inputs/tags-input.component.ts +++ b/src/app/components/query-builder/inputs/tags-input.component.ts @@ -38,10 +38,11 @@ import { TagsService } from '../../../core/services/tags.service'; {{ tag }} } diff --git a/src/app/components/query-builder/query-parameters.component.ts b/src/app/components/query-builder/query-parameters.component.ts index 4325fd7..eae4b73 100644 --- a/src/app/components/query-builder/query-parameters.component.ts +++ b/src/app/components/query-builder/query-parameters.component.ts @@ -16,6 +16,7 @@ import { StringInputComponent } from './inputs/string-input.component'; import { NumberInputComponent } from './inputs/number-input.component'; import { FormTypesInputComponent } from './inputs/form-types-input.component'; import { TagsInputComponent } from './inputs/tags-input.component'; +import { FavoritesInputComponent } from './inputs/favorites-input.component'; /** * Component for displaying inline parameter inputs for parameterized queries. @@ -32,6 +33,7 @@ import { TagsInputComponent } from './inputs/tags-input.component'; NumberInputComponent, FormTypesInputComponent, TagsInputComponent, + FavoritesInputComponent, ], providers: [ provideIcons({ lucideRotateCcw, lucideSearch, lucideLoader2 }), @@ -77,6 +79,14 @@ import { TagsInputComponent } from './inputs/tags-input.component'; (valueChange)="onValueChange(param.index, $event)" /> } + @case ('Favorites') { + + } @default { { expect(types).toContain('NumberInput'); expect(types).toContain('FormTypes'); expect(types).toContain('Tags'); - expect(types).toHaveLength(4); + expect(types).toContain('Favorites'); + expect(types).toHaveLength(5); }); }); diff --git a/src/app/core/services/component-registry.service.ts b/src/app/core/services/component-registry.service.ts index 27a5a9c..a3824d9 100644 --- a/src/app/core/services/component-registry.service.ts +++ b/src/app/core/services/component-registry.service.ts @@ -64,6 +64,13 @@ const DEFAULT_COMPONENT_CONFIGS: Record [] as string[], }, + + Favorites: { + type: 'Favorites', + displayName: 'Favorites', + compile: (value) => String(value ?? ''), + getDefaultValue: () => [] as string[], + }, }; /** diff --git a/src/app/core/services/favorites.service.ts b/src/app/core/services/favorites.service.ts new file mode 100644 index 0000000..065425f --- /dev/null +++ b/src/app/core/services/favorites.service.ts @@ -0,0 +1,60 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { catchError, of, Subject, debounceTime, switchMap, take } from 'rxjs'; +import { StringsService } from './strings.service'; + +const STORAGE_KEY = 'filing-favorites'; + +@Injectable({ providedIn: 'root' }) +export class FavoritesService { + private readonly stringsService = inject(StringsService); + private readonly saveSubject = new Subject(); + + readonly favoriteIds = signal([]); + readonly loaded = signal(false); + readonly favoriteIdSet = computed(() => new Set(this.favoriteIds())); + readonly hasFavorites = computed(() => this.favoriteIds().length > 0); + + constructor() { + this.load(); + + // Debounced persistence + this.saveSubject.pipe( + debounceTime(500), + switchMap(() => + this.stringsService.setJson(STORAGE_KEY, this.favoriteIds()).pipe( + catchError(err => { + console.error('Failed to save favorites', err); + return of(null); + }), + ), + ), + ).subscribe(); + } + + private load() { + this.stringsService.getJson(STORAGE_KEY).pipe( + catchError(() => of(null)), + take(1), + ).subscribe(ids => { + if (Array.isArray(ids)) { + this.favoriteIds.set(ids); + } + this.loaded.set(true); + }); + } + + isFavorite(id: number): boolean { + return this.favoriteIdSet().has(id); + } + + addFavorite(id: number) { + if (this.isFavorite(id)) return; + this.favoriteIds.update(ids => [...ids, id]); + this.saveSubject.next(); + } + + removeFavorite(id: number) { + this.favoriteIds.update(ids => ids.filter(i => i !== id)); + this.saveSubject.next(); + } +} diff --git a/src/app/core/services/query-compiler.service.ts b/src/app/core/services/query-compiler.service.ts index 476d343..d1ba314 100644 --- a/src/app/core/services/query-compiler.service.ts +++ b/src/app/core/services/query-compiler.service.ts @@ -2,9 +2,9 @@ import { inject, Injectable } from '@angular/core'; import { ParsedQuery, QueryParameter, - RenderComponentType, } from '../models/query-parameter.model'; import { QueryParserService } from './query-parser.service'; +import { FavoritesService } from './favorites.service'; /** * Result of compiling a query. @@ -29,6 +29,7 @@ export interface CompileResult { }) export class QueryCompilerService { private readonly parserService = inject(QueryParserService); + private readonly favoritesService = inject(FavoritesService); /** * Compile a parameterized query into an executable query string. @@ -57,6 +58,16 @@ export class QueryCompilerService { }; } + // Check if Favorites parameters are used and service is loaded + const hasFavorites = parsed.parameters.some(p => p.componentType === 'Favorites'); + if (hasFavorites && !this.favoritesService.loaded()) { + return { + compiledQuery: query, + success: false, + errors: ['Favorites are still loading. Please wait...'], + }; + } + const errors: string[] = []; let compiledQuery = query; @@ -79,7 +90,7 @@ export class QueryCompilerService { } // Compile the value based on component type - const compiledValue = this.compileValue(param.componentType, effectiveValue); + const compiledValue = this.compileValue(param, effectiveValue); // Replace in query compiledQuery = @@ -99,28 +110,88 @@ export class QueryCompilerService { } /** - * Compile a value based on its component type. + * Compile a value based on its component type, then apply any modifier. */ - private compileValue(componentType: RenderComponentType, value: string | number | string[]): string { - switch (componentType) { - case 'StringInput': - return this.compileStringValue(value as string); - + private compileValue(param: QueryParameter, value: string | number | string[]): string { + // Get the raw items for this type + let items: string[]; + + switch (param.componentType) { + case 'Favorites': + items = this.favoritesService.favoriteIds().map(String); + break; + case 'Tags': + items = this.normalizeToArray(value); + break; case 'NumberInput': - return this.compileNumberValue(value as number | string); - + items = [this.compileNumberValue(value as number | string)]; + break; + case 'StringInput': case 'FormTypes': - return this.compileStringValue(value as string); - + default: + items = [String(value ?? '')]; + break; + } + + // If there's a modifier, use it to format the output + if (param.modifier) { + return this.applyModifier(items, param.modifier, param.componentType); + } + + // No modifier — use the original type-specific formatting + switch (param.componentType) { + case 'StringInput': + case 'FormTypes': + return this.compileStringValue(items[0]); + case 'NumberInput': + return items[0]; case 'Tags': return this.compileTagsValue(value as string[] | string); - + case 'Favorites': + return items.join(','); default: - // Fallback to string compilation - return this.compileStringValue(String(value)); + return this.compileStringValue(items[0]); } } + /** + * Apply a modifier to format an array of items. + * Numbers stay unquoted, strings get single-quoted. + */ + private applyModifier(items: string[], modifier: string, componentType: string): string { + const isNumeric = componentType === 'Favorites' || componentType === 'NumberInput'; + + const formatItem = (item: string) => { + if (isNumeric) return item; + const escaped = item.replace(/'/g, "''"); + return `'${escaped}'`; + }; + + switch (modifier) { + case 'csv': + return items.map(formatItem).join(','); + case 'array': + return `(${items.map(formatItem).join(',')})`; + case 'pgarray': + return `array[${items.map(formatItem).join(',')}]`; + case 'first': + return items.length > 0 ? formatItem(items[0]) : (isNumeric ? '0' : "''"); + case 'last': + return items.length > 0 ? formatItem(items[items.length - 1]) : (isNumeric ? '0' : "''"); + default: + return items.map(formatItem).join(','); + } + } + + /** + * Normalize a value to a string array (for Tags and similar list types). + */ + private normalizeToArray(value: string | number | string[]): string[] { + if (Array.isArray(value)) return value.map(String); + if (typeof value === 'string') return value.split(',').map(s => s.trim()).filter(s => s); + return [String(value)]; + } + /** * Compile a string value - wraps in single quotes with proper escaping. */ @@ -176,6 +247,8 @@ export class QueryCompilerService { return 0; case 'Tags': return []; + case 'Favorites': + return []; case 'StringInput': case 'FormTypes': default: diff --git a/src/app/core/services/query-parser.service.ts b/src/app/core/services/query-parser.service.ts index d42ff62..77c0fc7 100644 --- a/src/app/core/services/query-parser.service.ts +++ b/src/app/core/services/query-parser.service.ts @@ -27,6 +27,15 @@ export class QueryParserService { 'NumberInput', 'FormTypes', 'Tags', + 'Favorites', + ]); + + private readonly validModifiers: Set = new Set([ + 'csv', + 'array', + 'pgarray', + 'first', + 'last', ]); // Regex to match parameter syntax: {Label:RenderComponent:DefaultValue} @@ -113,11 +122,22 @@ export class QueryParserService { }; } - // Extract component type (optional, defaults to StringInput) + // Extract component type and modifier. + // Handle {label:Type|modifier} where pipe is part of the type segment (no default value). let componentType: RenderComponentType = 'StringInput'; + let modifier: string | undefined; + if (parts.length > 1 && parts[1]?.trim()) { - const typeStr = parts[1].trim(); - if (!this.validComponentTypes.has(typeStr)) { + let typeStr = parts[1].trim(); + + // Check for |modifier attached to the type (no default value case) + const pipeIdx = typeStr.indexOf('|'); + if (pipeIdx >= 0) { + modifier = typeStr.substring(pipeIdx + 1).trim(); + typeStr = typeStr.substring(0, pipeIdx).trim(); + } + + if (typeStr && !this.validComponentTypes.has(typeStr)) { return { error: { message: `Invalid component type: ${typeStr}. Valid types are: ${[...this.validComponentTypes].join(', ')}`, @@ -126,15 +146,37 @@ export class QueryParserService { }, }; } - componentType = typeStr as RenderComponentType; + if (typeStr) { + componentType = typeStr as RenderComponentType; + } } // Extract default value (optional) // Join remaining parts back together in case default value contained colons - const defaultValue = parts.length > 2 - ? parts.slice(2).join(':').trim() + let defaultValue = parts.length > 2 + ? parts.slice(2).join(':').trim() : ''; + // Extract modifier from |modifier in the default value (e.g., {label:Type:default|csv}) + if (!modifier) { + const pipeIndex = defaultValue.lastIndexOf('|'); + if (pipeIndex >= 0) { + modifier = defaultValue.substring(pipeIndex + 1).trim(); + defaultValue = defaultValue.substring(0, pipeIndex).trim(); + } + } + + // Validate modifier if present + if (modifier && !this.validModifiers.has(modifier)) { + return { + error: { + message: `Invalid modifier: ${modifier}. Valid modifiers are: ${[...this.validModifiers].join(', ')}`, + startIndex, + endIndex, + }, + }; + } + return { parameter: { label, @@ -144,6 +186,7 @@ export class QueryParserService { rawMatch, startIndex, endIndex, + modifier, }, }; } diff --git a/src/app/core/services/saved-queries.service.ts b/src/app/core/services/saved-queries.service.ts index 9e99aa5..03524ac 100644 --- a/src/app/core/services/saved-queries.service.ts +++ b/src/app/core/services/saved-queries.service.ts @@ -212,6 +212,7 @@ export class SavedQueriesService { case 'NumberInput': return 0; case 'Tags': + case 'Favorites': return []; default: return ''; diff --git a/src/app/core/services/tab-manager.service.ts b/src/app/core/services/tab-manager.service.ts new file mode 100644 index 0000000..5d94352 --- /dev/null +++ b/src/app/core/services/tab-manager.service.ts @@ -0,0 +1,188 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { catchError, of, Subject, debounceTime, switchMap, take } from 'rxjs'; +import { StringsService } from './strings.service'; +import { Filing } from '../models/filing.model'; + +const STORAGE_KEY = 'filing-tabs'; + +export interface TabEntry { + queryGuid: string; +} + +export interface TabRuntimeState { + results: Filing[]; + loading: boolean; + hasSearched: boolean; + error: string | null; +} + +interface PersistedTabState { + tabs: TabEntry[]; + activeIndex: number; +} + +function defaultRuntime(): TabRuntimeState { + return { results: [], loading: false, hasSearched: false, error: null }; +} + +@Injectable({ providedIn: 'root' }) +export class TabManagerService { + private readonly stringsService = inject(StringsService); + private readonly saveSubject = new Subject(); + private readonly runtimeMap = new Map(); + + readonly tabs = signal([]); + readonly activeIndex = signal(0); // -1 = favorites tab + readonly loaded = signal(false); + + readonly activeTab = computed(() => { + const idx = this.activeIndex(); + if (idx < 0) return null; // favorites + return this.tabs()[idx] ?? null; + }); + + readonly isFavoritesActive = computed(() => this.activeIndex() === -1); + + constructor() { + // Debounced persistence + this.saveSubject.pipe( + debounceTime(500), + switchMap(() => { + const state: PersistedTabState = { + tabs: this.tabs(), + activeIndex: this.activeIndex(), + }; + return this.stringsService.setJson(STORAGE_KEY, state).pipe( + catchError(err => { + console.error('Failed to save tab state', err); + return of(null); + }), + ); + }), + ).subscribe(); + } + + /** + * Load persisted tab state. Call once after saved queries are loaded. + * If no persisted state, initializes with the given query GUID (from URL or first query). + */ + load(fallbackGuid?: string) { + this.stringsService.getJson(STORAGE_KEY).pipe( + catchError(() => of(null)), + take(1), + ).subscribe(state => { + if (state?.tabs?.length) { + this.tabs.set(state.tabs); + this.activeIndex.set(state.activeIndex ?? 0); + } else if (fallbackGuid) { + this.tabs.set([{ queryGuid: fallbackGuid }]); + this.activeIndex.set(0); + } + this.loaded.set(true); + }); + } + + /** + * Add a new tab and switch to it. + */ + openTab(queryGuid: string) { + this.tabs.update(t => [...t, { queryGuid }]); + this.activeIndex.set(this.tabs().length - 1); + this.persist(); + } + + /** + * Replace the active tab's query. Resets its runtime state so it re-runs. + */ + openInCurrentTab(queryGuid: string) { + const idx = this.activeIndex(); + if (idx < 0) { + // If favorites is active, open a new tab instead + this.openTab(queryGuid); + return; + } + const currentTab = this.tabs()[idx]; + if (currentTab) { + // Clean up old runtime + this.runtimeMap.delete(currentTab.queryGuid); + } + this.tabs.update(t => { + const updated = [...t]; + updated[idx] = { queryGuid }; + return updated; + }); + this.persist(); + } + + /** + * Close a tab at the given index. Cannot close index 0 (first query tab). + */ + closeTab(index: number) { + if (index <= 0) return; // can't close first tab + const tab = this.tabs()[index]; + if (tab) { + this.runtimeMap.delete(tab.queryGuid); + } + this.tabs.update(t => t.filter((_, i) => i !== index)); + // Adjust active index + const active = this.activeIndex(); + if (active >= index) { + this.activeIndex.set(Math.max(0, active - 1)); + } + this.persist(); + } + + /** + * Switch to a tab by index. Use -1 for favorites. + */ + switchTo(index: number) { + this.activeIndex.set(index); + this.persist(); + } + + /** + * Get the runtime state for a tab (creates default if not exists). + */ + getRuntime(queryGuid: string): TabRuntimeState { + let state = this.runtimeMap.get(queryGuid); + if (!state) { + state = defaultRuntime(); + this.runtimeMap.set(queryGuid, state); + } + return state; + } + + /** + * Update partial runtime state for a tab. + */ + setRuntime(queryGuid: string, partial: Partial) { + const current = this.getRuntime(queryGuid); + Object.assign(current, partial); + } + + /** + * Remove tabs for query GUIDs that no longer exist. + */ + cleanupDeletedQueries(existingGuids: Set) { + const before = this.tabs(); + const after = before.filter(t => existingGuids.has(t.queryGuid)); + if (after.length !== before.length) { + // Clean up runtime for removed tabs + for (const t of before) { + if (!existingGuids.has(t.queryGuid)) { + this.runtimeMap.delete(t.queryGuid); + } + } + this.tabs.set(after.length > 0 ? after : []); + const active = this.activeIndex(); + if (active >= after.length) { + this.activeIndex.set(Math.max(0, after.length - 1)); + } + this.persist(); + } + } + + private persist() { + this.saveSubject.next(); + } +} diff --git a/src/app/features/filings/components/filing-query-editor.component.spec.ts b/src/app/features/filings/components/filing-query-editor.component.spec.ts index 24e08ce..b82d8e8 100644 --- a/src/app/features/filings/components/filing-query-editor.component.spec.ts +++ b/src/app/features/filings/components/filing-query-editor.component.spec.ts @@ -1,5 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl } from '@angular/forms'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { FilingQueryEditorComponent } from './filing-query-editor.component'; import { describe, it, expect, vi } from 'vitest'; @@ -16,11 +17,12 @@ describe('FilingQueryEditorComponent', () => { const queryControl = new FormControl(opts.query ?? '', { nonNullable: true }); await TestBed.configureTestingModule({ - imports: [FilingQueryEditorComponent], + imports: [FilingQueryEditorComponent, HttpClientTestingModule], }).compileComponents(); fixture = TestBed.createComponent(FilingQueryEditorComponent); fixture.componentRef.setInput('queryControl', queryControl); + fixture.componentRef.setInput('queryText', opts.query ?? ''); fixture.componentRef.setInput('isQueryValid', opts.isValid ?? true); fixture.componentRef.setInput('parseErrors', opts.parseErrors ?? []); fixture.componentRef.setInput('hasParameters', opts.hasParameters ?? false); @@ -38,10 +40,10 @@ describe('FilingQueryEditorComponent', () => { expect(fixture.nativeElement.classList.contains('filing-query-editor')).toBe(true); }); - it('should render textarea with form control value', async () => { + it('should render the code editor element', async () => { await setupTest({ query: 'test query' }); - const textarea = fixture.nativeElement.querySelector('textarea'); - expect(textarea.value).toBe('test query'); + const editor = fixture.nativeElement.querySelector('app-code-editor'); + expect(editor).toBeTruthy(); }); it('should show parse errors when present', async () => { @@ -67,14 +69,12 @@ describe('FilingQueryEditorComponent', () => { expect(fixture.nativeElement.textContent).not.toContain('Query has parameters'); }); - it('should emit searchTriggered on Ctrl+Enter', async () => { + it('should emit searchTriggered when onShortcut is called with ctrl-enter', async () => { await setupTest(); const spy = vi.fn(); component.searchTriggered.subscribe(spy); - const textarea = fixture.nativeElement.querySelector('textarea'); - textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true })); - fixture.detectChanges(); + component.onShortcut('ctrl-enter'); expect(spy).toHaveBeenCalled(); }); diff --git a/src/app/features/filings/components/filing-query-editor.component.ts b/src/app/features/filings/components/filing-query-editor.component.ts index a80408b..4a99466 100644 --- a/src/app/features/filings/components/filing-query-editor.component.ts +++ b/src/app/features/filings/components/filing-query-editor.component.ts @@ -1,30 +1,33 @@ -import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core'; +import { FormControl } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { lucideAlertCircle } from '@ng-icons/lucide'; +import { lucideAlertCircle, lucideEye } from '@ng-icons/lucide'; import { QueryParseError } from '../../../core/models/query-parameter.model'; +import { FilingService } from '../../../core/services/filing.service'; +import { SavedQueriesService } from '../../../core/services/saved-queries.service'; +import { CodeEditorComponent } from '../../../components/code-editor/code-editor.component'; +import { createQueryAutocomplete } from '../../../components/code-editor/query-autocomplete'; @Component({ selector: 'app-filing-query-editor', changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'filing-query-editor' }, - imports: [ReactiveFormsModule, NgIcon], - providers: [provideIcons({ lucideAlertCircle })], + imports: [NgIcon, CodeEditorComponent], + providers: [provideIcons({ lucideAlertCircle, lucideEye })], template: `
- + [value]="peeking() ? peekValue : queryText()" + [placeholder]="'form_type = \\'8-K\\' order by snowflake desc limit 50'" + [extensions]="editorExtensions()" + (valueChange)="onEditorChange($event)" + (keyboardShortcut)="onShortcut($event)" + />
@@ -41,10 +44,27 @@ import { QueryParseError } from '../../../core/models/query-parameter.model';
-
+
Ctrl+Enter to search @if (hasParameters()) { • Query has parameters + }
@@ -52,10 +72,47 @@ import { QueryParseError } from '../../../core/models/query-parameter.model'; `, }) export class FilingQueryEditorComponent { + private readonly filingService = inject(FilingService); + private readonly savedQueriesService = inject(SavedQueriesService); + readonly queryControl = input.required>(); + readonly queryText = input.required(); readonly isQueryValid = input.required(); readonly parseErrors = input.required(); readonly hasParameters = input.required(); readonly searchTriggered = output(); + + readonly peeking = signal(false); + peekValue = ''; + + readonly editorExtensions = computed(() => [ + createQueryAutocomplete(this.filingService), + ]); + + onEditorChange(text: string) { + if (this.peeking()) return; + this.queryControl().setValue(text); + } + + onShortcut(name: string) { + if (name === 'ctrl-enter') { + this.searchTriggered.emit(); + } + } + + startPeek() { + if (!this.isQueryValid()) return; + try { + this.peekValue = this.savedQueriesService.getCompiledQuery(); + this.peeking.set(true); + } catch { + // compilation failed — ignore + } + } + + stopPeek() { + if (!this.peeking()) return; + this.peeking.set(false); + } } diff --git a/src/app/features/filings/components/filing-query-item.component.ts b/src/app/features/filings/components/filing-query-item.component.ts index 936c94c..17ad4c5 100644 --- a/src/app/features/filings/components/filing-query-item.component.ts +++ b/src/app/features/filings/components/filing-query-item.component.ts @@ -9,6 +9,7 @@ import { lucidePencil, lucideTrash2, lucideRotateCcw, + lucideExternalLink, } from '@ng-icons/lucide'; import { HlmDropdownMenuImports } from '@spartan-ng/helm/dropdown-menu'; import { HlmInputImports } from '@spartan-ng/helm/input'; @@ -30,6 +31,7 @@ import { SavedQuery } from '../../../core/models/query-parameter.model'; lucidePencil, lucideTrash2, lucideRotateCcw, + lucideExternalLink, }), ], template: ` @@ -108,6 +110,9 @@ import { SavedQuery } from '../../../core/models/query-parameter.model'; {{ query().pinned ? 'Unpin' : 'Pin' }} + @if (isBlueprint()) {
} + + + @if (rowContextMenu()) { +
+
+
{{ rowContextMenu()!.filing.ticker || rowContextMenu()!.filing.companyConformedName || 'Filing' }}
+ + +
+ @if (favoriteIds().has(rowContextMenu()!.filing.id)) { + + } @else { + + } +
+ }
`, styles: [` @@ -419,6 +452,10 @@ export class FilingResultsGridComponent implements OnInit { readonly rowData = input.required(); readonly initialColumnState = input(null); + readonly favoriteIds = input>(new Set()); + + readonly addToFavorites = output(); + readonly removeFromFavorites = output(); private gridApi!: GridApi; private readonly saveState$ = new Subject(); @@ -426,6 +463,7 @@ export class FilingResultsGridComponent implements OnInit { showColumnPanel = signal(false); columnList = signal([]); headerContextMenu = signal<{ x: number; y: number; colId: string; headerName: string } | null>(null); + rowContextMenu = signal<{ x: number; y: number; filing: Filing; colId: string | null } | null>(null); @HostListener('mousedown', ['$event']) onMouseDown(event: MouseEvent): void { @@ -445,7 +483,10 @@ export class FilingResultsGridComponent implements OnInit { @HostListener('contextmenu', ['$event']) onHostContextMenu(event: MouseEvent): void { - const headerCell = (event.target as HTMLElement).closest('.ag-header-cell'); + const target = event.target as HTMLElement; + + // Header context menu + const headerCell = target.closest('.ag-header-cell'); if (headerCell) { event.preventDefault(); const colId = headerCell.getAttribute('col-id'); @@ -458,6 +499,21 @@ export class FilingResultsGridComponent implements OnInit { headerName: (colDef?.headerName as string) || colId, }); } + return; + } + + // Row context menu + const row = target.closest('.ag-row'); + if (row && this.gridApi) { + event.preventDefault(); + const rowIndex = row.getAttribute('row-index'); + if (rowIndex == null) return; + const rowNode = this.gridApi.getDisplayedRowAtIndex(Number(rowIndex)); + const filing = rowNode?.data as Filing | undefined; + if (!filing) return; + const cell = target.closest('.ag-cell'); + const colId = cell?.getAttribute('col-id') ?? null; + this.rowContextMenu.set({ x: event.clientX, y: event.clientY, filing, colId }); } } @@ -797,6 +853,50 @@ export class FilingResultsGridComponent implements OnInit { this.headerContextMenu.set(null); } + closeRowContextMenu(): void { + this.rowContextMenu.set(null); + } + + copyCellValue(): void { + const ctx = this.rowContextMenu(); + if (!ctx) return; + let value = ''; + if (ctx.colId) { + const raw = (ctx.filing as unknown as Record)[ctx.colId]; + value = Array.isArray(raw) ? raw.join(', ') : String(raw ?? ''); + } + navigator.clipboard.writeText(value); + this.closeRowContextMenu(); + } + + copyRowData(): void { + const ctx = this.rowContextMenu(); + if (!ctx || !this.gridApi) return; + const visibleCols = this.gridApi.getColumnState().filter(c => !c.hide); + const values = visibleCols.map(c => { + const raw = (ctx.filing as unknown as Record)[c.colId]; + return Array.isArray(raw) ? raw.join(', ') : String(raw ?? ''); + }); + navigator.clipboard.writeText(values.join('\t')); + this.closeRowContextMenu(); + } + + onAddToFavorites(): void { + const ctx = this.rowContextMenu(); + if (ctx) { + this.addToFavorites.emit(ctx.filing.id); + } + this.closeRowContextMenu(); + } + + onRemoveFromFavorites(): void { + const ctx = this.rowContextMenu(); + if (ctx) { + this.removeFromFavorites.emit(ctx.filing.id); + } + this.closeRowContextMenu(); + } + contextPinColumn(direction: 'left' | 'right' | null): void { const ctx = this.headerContextMenu(); if (!ctx || !this.gridApi) return; diff --git a/src/app/features/filings/components/filing-tab-bar.component.ts b/src/app/features/filings/components/filing-tab-bar.component.ts new file mode 100644 index 0000000..74ed5d0 --- /dev/null +++ b/src/app/features/filings/components/filing-tab-bar.component.ts @@ -0,0 +1,155 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideStar, lucidePlus, lucideX } from '@ng-icons/lucide'; +import { TabEntry } from '../../../core/services/tab-manager.service'; + +@Component({ + selector: 'app-filing-tab-bar', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ lucideStar, lucidePlus, lucideX })], + template: ` +
+ + @if (showFavoritesTab()) { + + } + + + @for (tab of tabs(); track tab.queryGuid; let i = $index) { + + } + + + +
+ `, + styles: ` + :host { + display: block; + min-width: 0; + } + + .tab-bar { + display: flex; + align-items: stretch; + height: 100%; + overflow-x: auto; + scrollbar-width: thin; + scrollbar-color: #555 transparent; + } + + .tab-bar::-webkit-scrollbar { + height: 3px; + } + .tab-bar::-webkit-scrollbar-thumb { + background: #555; + border-radius: 2px; + } + .tab-bar::-webkit-scrollbar-track { + background: transparent; + } + + .tab { + display: flex; + align-items: center; + flex-shrink: 0; + max-width: 180px; + padding: 0 12px; + font-size: 12px; + color: #858585; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s, background 0.15s; + gap: 4px; + } + + .tab:hover { + color: #cccccc; + background: #2a2a2e; + } + + .tab-active { + color: #cccccc; + background: #1e1e1e; + border-bottom-color: #007acc; + } + + .tab-label { + overflow: hidden; + text-overflow: ellipsis; + } + + .tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 3px; + color: #858585; + flex-shrink: 0; + } + + .tab-close:hover { + color: #ffffff; + background: #505050; + } + + .tab-add { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + color: #858585; + background: transparent; + border: none; + cursor: pointer; + transition: color 0.15s; + } + + .tab-add:hover { + color: #cccccc; + } + `, +}) +export class FilingTabBarComponent { + readonly tabs = input.required(); + readonly activeIndex = input.required(); + readonly showFavoritesTab = input(false); + readonly queryNames = input.required>(); + + readonly tabSelected = output(); + readonly tabClosed = output(); + readonly newTabClicked = output(); +} diff --git a/src/app/features/filings/components/index.ts b/src/app/features/filings/components/index.ts index 44efe75..0b759b6 100644 --- a/src/app/features/filings/components/index.ts +++ b/src/app/features/filings/components/index.ts @@ -3,3 +3,4 @@ export * from './filing-result-row.component'; export * from './filing-results-grid.component'; export * from './filing-query-editor.component'; export * from './filing-query-item.component'; +export * from './filing-tab-bar.component'; diff --git a/src/app/features/filings/filing-search.component.html b/src/app/features/filings/filing-search.component.html index b21a637..b450905 100644 --- a/src/app/features/filings/filing-search.component.html +++ b/src/app/features/filings/filing-search.component.html @@ -66,6 +66,7 @@ [displayText]="getQueryDisplayText(entry[1])" [relativeTime]="getRelativeTime(entry[1].lastUsed)" (select)="selectSavedQuery($event)" + (openInNewTab)="openQueryInNewTab($event)" (togglePin)="onTogglePin($event)" (duplicate)="onDuplicateQuery($event)" (rename)="onRenameQuery($event)" @@ -96,6 +97,7 @@ [relativeTime]="getRelativeTime(entry[1].lastUsed)" [isBlueprint]="true" (select)="selectSavedQuery($event)" + (openInNewTab)="openQueryInNewTab($event)" (togglePin)="onTogglePin($event)" (duplicate)="onDuplicateQuery($event)" (rename)="onRenameQuery($event)" @@ -153,51 +155,79 @@

Delete Query

- +
- -
-
- - Nullabroke - {{ titleText() }} -
- + Nullabroke + + + + + + @if (showQueryEditor()) { + + }
- - @if (queryExpanded()) { - + @if (queryExpanded()) { + + } + + + + } @else { + +
+ + {{ results().length }} favorite(s) +
} - - -
@@ -208,7 +238,7 @@

Delete Query

} - @if (results().length === 0 && !loading() && !hasSearched()) { + @if (results().length === 0 && !loading() && !hasSearched() && showQueryEditor()) {

Enter a query to search SEC filings

@@ -221,12 +251,18 @@

Delete Query

@if (results().length === 0 && !loading() && hasSearched()) {
-

No filings found

+

{{ showQueryEditor() ? 'No filings found' : 'No favorites yet' }}

} @if (results().length > 0 && gridStateLoaded()) { - + }
diff --git a/src/app/features/filings/filing-search.component.ts b/src/app/features/filings/filing-search.component.ts index 26925b8..03b1ded 100644 --- a/src/app/features/filings/filing-search.component.ts +++ b/src/app/features/filings/filing-search.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal, compute import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Subject, takeUntil } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideChevronDown, @@ -25,6 +26,8 @@ import { AppChromeService } from '../../core/services/app-chrome.service'; import { FilingService } from '../../core/services/filing.service'; import { SavedQueriesService } from '../../core/services/saved-queries.service'; import { StringsService } from '../../core/services/strings.service'; +import { FavoritesService } from '../../core/services/favorites.service'; +import { TabManagerService } from '../../core/services/tab-manager.service'; import { Filing } from '../../core/models/filing.model'; import { SavedQuery } from '../../core/models/query-parameter.model'; import { AnalyticsService } from '../../core/services/analytics.service'; @@ -35,6 +38,7 @@ import { FilingResultsGridComponent, FilingQueryEditorComponent, FilingQueryItemComponent, + FilingTabBarComponent, } from './components'; @Component({ @@ -55,6 +59,7 @@ import { FilingResultsGridComponent, FilingQueryEditorComponent, FilingQueryItemComponent, + FilingTabBarComponent, ], providers: [ provideIcons({ @@ -75,6 +80,8 @@ export class FilingSearchComponent { private readonly filingService = inject(FilingService); private readonly savedQueriesService = inject(SavedQueriesService); private readonly analytics = inject(AnalyticsService); + readonly favoritesService = inject(FavoritesService); + readonly tabManager = inject(TabManagerService); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); private readonly stringsService = inject(StringsService); @@ -91,6 +98,9 @@ export class FilingSearchComponent { results = signal([]); hasSearched = signal(false); + // Cancels in-flight queries when switching tabs + private readonly tabSwitch$ = new Subject(); + // Logo animation state animatingLogo = signal(false); logoKey = signal(0); @@ -111,6 +121,7 @@ export class FilingSearchComponent { // Expose saved queries service state savedQueries = this.savedQueriesService.savedQueries; currentGuid = this.savedQueriesService.currentGuid; + currentQueryText = this.savedQueriesService.currentQuery; hasDeletedBlueprints = this.savedQueriesService.hasDeletedBlueprints; /** User-created queries (no blueprintId) - pinned first, then by lastUsed desc */ @@ -161,6 +172,22 @@ export class FilingSearchComponent { gridColumnState = signal(null); gridStateLoaded = signal(false); + // Tab-related state + showQueryEditor = computed(() => !this.tabManager.isFavoritesActive()); + showFavoritesTab = computed(() => this.favoritesService.hasFavorites()); + favoritesResults = signal([]); + favoritesLoading = signal(false); + + /** Map of query GUID → display name for the tab bar */ + tabQueryNames = computed(() => { + const queries = this.savedQueries(); + const names = new Map(); + for (const [guid, q] of Object.entries(queries)) { + names.set(guid, this.savedQueriesService.getQueryDisplayName(q)); + } + return names; + }); + constructor() { this.appChrome.visible.set(true); @@ -199,12 +226,50 @@ export class FilingSearchComponent { }, 500); - // Auto-select query from ?q= param once saved queries have loaded + // Once saved queries are loaded, initialize tab state effect(() => { const queries = this.savedQueries(); + if (Object.keys(queries).length === 0) return; + if (this.tabManager.loaded()) return; // already loaded + const qParam = this.route.snapshot.queryParamMap.get('q'); - if (qParam && queries[qParam] && this.currentGuid() !== qParam) { - this.selectSavedQuery(qParam); + const fallbackGuid = qParam && queries[qParam] + ? qParam + : Object.keys(queries)[0]; + this.tabManager.load(fallbackGuid); + }); + + // Once tabs are loaded, select the active tab's query and run it + effect(() => { + if (!this.tabManager.loaded()) return; + + // Handle favorites tab on page reload + if (this.tabManager.isFavoritesActive()) { + if (this.favoritesService.loaded() && !this.favoritesLoading()) { + this.loadFavorites(); + } + return; + } + + const tab = this.tabManager.activeTab(); + if (tab) { + const queries = this.savedQueries(); + if (queries[tab.queryGuid] && this.currentGuid() !== tab.queryGuid) { + this.savedQueriesService.selectQuery(tab.queryGuid); + this.queryControl.setValue(this.savedQueriesService.currentQuery(), { emitEvent: false }); + // Auto-run if not searched yet + const runtime = this.tabManager.getRuntime(tab.queryGuid); + if (!runtime.hasSearched) { + this.search(); + } else { + // Restore cached results + this.results.set(runtime.results); + this.loading.set(runtime.loading); + this.error.set(runtime.error); + this.hasSearched.set(runtime.hasSearched); + } + this.router.navigate([], { queryParams: { q: tab.queryGuid }, queryParamsHandling: 'merge' }); + } } }); } @@ -231,7 +296,7 @@ export class FilingSearchComponent { // Get the compiled query with parameter values replaced const compiledQuery = this.savedQueriesService.getCompiledQuery(); - + // Check compilation result const compileResult = this.savedQueriesService.canCompile(); if (!compileResult.success) { @@ -239,6 +304,9 @@ export class FilingSearchComponent { return; } + // Capture the tab this search belongs to + const targetGuid = this.tabManager.activeTab()?.queryGuid; + this.loading.set(true); this.error.set(null); this.hasSearched.set(true); @@ -251,18 +319,48 @@ export class FilingSearchComponent { this.filingService .search(compiledQuery) - .pipe(takeUntilDestroyed(this.destroyRef)) + .pipe( + takeUntil(this.tabSwitch$), + takeUntilDestroyed(this.destroyRef), + ) .subscribe({ next: (filings) => { - this.results.set(filings ?? []); - this.loading.set(false); + const data = filings ?? []; this.stopLogoAfterAnimation(animStart, ANIM_DURATION); - this.analytics.trackSearch(this.titleText(), (filings ?? []).length); + this.analytics.trackSearch(this.titleText(), data.length); + + // Always cache in the tab's runtime + if (targetGuid) { + this.tabManager.setRuntime(targetGuid, { + results: data, + loading: false, + hasSearched: true, + error: null, + }); + } + + // Only update UI if this tab is still active + if (this.tabManager.activeTab()?.queryGuid === targetGuid) { + this.results.set(data); + this.loading.set(false); + } }, error: () => { - this.results.set([]); - this.loading.set(false); this.stopLogoAfterAnimation(animStart, ANIM_DURATION); + + if (targetGuid) { + this.tabManager.setRuntime(targetGuid, { + results: [], + loading: false, + hasSearched: true, + error: 'Search failed', + }); + } + + if (this.tabManager.activeTab()?.queryGuid === targetGuid) { + this.results.set([]); + this.loading.set(false); + } }, }); } @@ -276,14 +374,213 @@ export class FilingSearchComponent { selectSavedQuery(guid: string) { if (this.renamingGuid() === guid) return; - + + // Cancel any in-flight query + this.tabSwitch$.next(); + + // Save current tab's state before switching + this.saveCurrentTabRuntime(); + + this.tabManager.openInCurrentTab(guid); this.savedQueriesService.selectQuery(guid); this.queryControl.setValue(this.savedQueriesService.currentQuery()); - // Update URL without triggering navigation this.router.navigate([], { queryParams: { q: guid }, queryParamsHandling: 'merge' }); + this.results.set([]); + this.loading.set(false); + this.error.set(null); + this.hasSearched.set(false); + this.search(); + } + + openQueryInNewTab(guid: string) { + // Cancel any in-flight query + this.tabSwitch$.next(); + + this.saveCurrentTabRuntime(); + this.tabManager.openTab(guid); + this.savedQueriesService.selectQuery(guid); + this.queryControl.setValue(this.savedQueriesService.currentQuery()); + this.router.navigate([], { queryParams: { q: guid }, queryParamsHandling: 'merge' }); + this.results.set([]); + this.loading.set(false); + this.error.set(null); + this.hasSearched.set(false); this.search(); } + switchTab(index: number) { + if (index === this.tabManager.activeIndex()) return; + + // Cancel any in-flight query from the previous tab + this.tabSwitch$.next(); + + // Save current tab state + this.saveCurrentTabRuntime(); + + this.tabManager.switchTo(index); + + if (index === -1) { + // Favorites tab — show cached results immediately, then refresh in background + const cached = this.favoritesResults(); + if (cached.length > 0) { + this.results.set(cached); + this.hasSearched.set(true); + this.loading.set(false); + this.error.set(null); + } else { + this.results.set([]); + this.hasSearched.set(false); + this.loading.set(false); + this.error.set(null); + } + this.loadFavorites(); + return; + } + + const tab = this.tabManager.tabs()[index]; + if (!tab) return; + + const runtime = this.tabManager.getRuntime(tab.queryGuid); + this.savedQueriesService.selectQuery(tab.queryGuid); + this.queryControl.setValue(this.savedQueriesService.currentQuery(), { emitEvent: false }); + this.router.navigate([], { queryParams: { q: tab.queryGuid }, queryParamsHandling: 'merge' }); + + if (!runtime.hasSearched) { + this.results.set([]); + this.loading.set(false); + this.error.set(null); + this.hasSearched.set(false); + this.search(); + } else { + this.results.set(runtime.results); + this.loading.set(runtime.loading); + this.error.set(runtime.error); + this.hasSearched.set(runtime.hasSearched); + } + } + + closeTab(index: number) { + this.tabManager.closeTab(index); + // After closing, switch to the new active tab + const activeTab = this.tabManager.activeTab(); + if (activeTab) { + const runtime = this.tabManager.getRuntime(activeTab.queryGuid); + this.savedQueriesService.selectQuery(activeTab.queryGuid); + this.queryControl.setValue(this.savedQueriesService.currentQuery(), { emitEvent: false }); + this.results.set(runtime.results); + this.loading.set(runtime.loading); + this.error.set(runtime.error); + this.hasSearched.set(runtime.hasSearched); + this.router.navigate([], { queryParams: { q: activeTab.queryGuid }, queryParamsHandling: 'merge' }); + } + } + + newTab() { + this.saveCurrentTabRuntime(); + this.savedQueriesService.newQuery(); + const newGuid = this.savedQueriesService.currentGuid(); + if (newGuid) { + this.tabManager.openTab(newGuid); + this.queryControl.setValue('', { emitEvent: false }); + this.results.set([]); + this.loading.set(false); + this.error.set(null); + this.hasSearched.set(false); + this.router.navigate([], { queryParams: { q: newGuid }, queryParamsHandling: 'merge' }); + } + } + + loadFavorites() { + const ids = this.favoritesService.favoriteIds(); + if (ids.length === 0) { + this.favoritesResults.set([]); + if (this.tabManager.isFavoritesActive()) { + this.results.set([]); + this.hasSearched.set(true); + this.loading.set(false); + } + return; + } + + if (!this.favoritesService.loaded()) { + if (this.tabManager.isFavoritesActive()) { + this.loading.set(true); + } + return; + } + + if (this.tabManager.isFavoritesActive()) { + this.loading.set(true); + this.error.set(null); + } + + const query = `id in (${ids.join(',')}) limit ${ids.length}`; + this.filingService.search(query) + .pipe( + takeUntil(this.tabSwitch$), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: (filings) => { + const data = filings ?? []; + // Always cache favorites results + this.favoritesResults.set(data); + // Only update UI if still on favorites tab + if (this.tabManager.isFavoritesActive()) { + this.results.set(data); + this.loading.set(false); + this.hasSearched.set(true); + } + }, + error: () => { + if (this.tabManager.isFavoritesActive()) { + this.results.set(this.favoritesResults()); // keep cached + this.loading.set(false); + this.hasSearched.set(true); + this.error.set('Failed to load favorites'); + } + }, + }); + } + + refreshFavorites() { + this.loadFavorites(); + } + + onAddToFavorites(filingId: number) { + this.favoritesService.addFavorite(filingId); + } + + onRemoveFromFavorites(filingId: number) { + this.favoritesService.removeFavorite(filingId); + // If on favorites tab, immediately remove from displayed results + if (this.tabManager.isFavoritesActive()) { + this.favoritesResults.update(r => r.filter(f => f.id !== filingId)); + this.results.update(r => r.filter(f => f.id !== filingId)); + // If no more favorites, switch to first query tab + if (this.favoritesService.favoriteIds().length === 0) { + this.switchTab(0); + } + } + } + + private saveCurrentTabRuntime() { + if (this.tabManager.isFavoritesActive()) { + // Cache favorites results separately + this.favoritesResults.set(this.results()); + return; + } + const tab = this.tabManager.activeTab(); + if (tab) { + this.tabManager.setRuntime(tab.queryGuid, { + results: this.results(), + loading: this.loading(), + hasSearched: this.hasSearched(), + error: this.error(), + }); + } + } + // Context Menu Actions onDuplicateQuery(guid: string) { this.savedQueriesService.duplicateQuery(guid); @@ -326,6 +623,9 @@ export class FilingSearchComponent { if (guid) { this.savedQueriesService.deleteQuery(guid); this.deleteConfirmGuid.set(null); + // Clean up any tabs referencing the deleted query + const existingGuids = new Set(Object.keys(this.savedQueries())); + this.tabManager.cleanupDeletedQueries(existingGuids); } } diff --git a/src/app/features/help/help.component.ts b/src/app/features/help/help.component.ts index 0eb3ba2..b2cf4ec 100644 --- a/src/app/features/help/help.component.ts +++ b/src/app/features/help/help.component.ts @@ -246,6 +246,58 @@ import {
Example: {{ '{' }}Tags:Tags:Presentation{{ '}' }} → 'Presentation'
+
+
+ +
+
+
Favorites
+
Injects your favorited filing IDs into the query (read-only). Use with a modifier to control format.
+
Example: {{ '{' }}favs:Favorites|array{{ '}' }} → (123,456,789)
+
+
+ + + +
+

Modifiers

+

Append |modifier to control how values are formatted in the compiled query. Works with Tags and Favorites.

+
+
+
Syntax
+ {{ '{' }}Label:Type|modifier{{ '}' }} or {{ '{' }}Label:Type:Default|modifier{{ '}' }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
csvComma-separated: 1,2,3 or 'a','b'
arrayParenthesized list for IN: (1,2,3)
pgarrayPostgreSQL array literal: array['a','b']
firstFirst item only
lastLast item only
+
+ -- Exclude favorites from results
+ id not in {{ '{' }}favs:Favorites|array{{ '}' }}
+
+ -- Tags as PG array for && overlap
+ {{ '{' }}Tags:Tags|pgarray{{ '}' }} && tags