Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog/v0.4.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# v0.4.0

## Improvements
- Refactored filing search into sub-components: result row, status bar, query item, query editor
- Filing result row redesigned with CSS grid layout: ticker, title, description, tags, and date
- Filing result row: description promoted to primary title, company name shown as subtitle
15 changes: 14 additions & 1 deletion src/app/components/welcome-modal/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
export const APP_VERSION = '0.3.1';
export const APP_VERSION = '0.4.0';

export interface ChangelogEntry {
version: string;
sections: { title: string; items: string[] }[];
}

export const CHANGELOG: ChangelogEntry[] = [
{
version: '0.4.0',
sections: [
{
title: 'Improvements',
items: [
'Refactored filing search into sub-components: result row, status bar, query item, query editor',
'Filing result row redesigned with CSS grid layout: ticker, title, description, tags, and date',
'Filing result row: description promoted to primary title, company name shown as subtitle',
],
},
],
},
{
version: '0.3.1',
sections: [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl } from '@angular/forms';
import { FilingQueryEditorComponent } from './filing-query-editor.component';
import { describe, it, expect, vi } from 'vitest';

describe('FilingQueryEditorComponent', () => {
let component: FilingQueryEditorComponent;
let fixture: ComponentFixture<FilingQueryEditorComponent>;

const setupTest = async (opts: {
query?: string;
isValid?: boolean;
parseErrors?: { message: string; startIndex: number; endIndex: number }[];
hasParameters?: boolean;
} = {}) => {
const queryControl = new FormControl<string>(opts.query ?? '', { nonNullable: true });

await TestBed.configureTestingModule({
imports: [FilingQueryEditorComponent],
}).compileComponents();

fixture = TestBed.createComponent(FilingQueryEditorComponent);
fixture.componentRef.setInput('queryControl', queryControl);
fixture.componentRef.setInput('isQueryValid', opts.isValid ?? true);
fixture.componentRef.setInput('parseErrors', opts.parseErrors ?? []);
fixture.componentRef.setInput('hasParameters', opts.hasParameters ?? false);
fixture.detectChanges();
component = fixture.componentInstance;
};

it('should create', async () => {
await setupTest();
expect(component).toBeTruthy();
});

it('should have the filing-query-editor host class', async () => {
await setupTest();
expect(fixture.nativeElement.classList.contains('filing-query-editor')).toBe(true);
});

it('should render textarea with form control value', async () => {
await setupTest({ query: 'test query' });
const textarea = fixture.nativeElement.querySelector('textarea');
expect(textarea.value).toBe('test query');
});

it('should show parse errors when present', async () => {
await setupTest({
parseErrors: [{ message: 'Unexpected token', startIndex: 0, endIndex: 5 }],
});
expect(fixture.nativeElement.textContent).toContain('Unexpected token');
});

it('should not show parse errors section when empty', async () => {
await setupTest({ parseErrors: [] });
const errorSection = fixture.nativeElement.querySelector('.bg-\\[\\#5a1d1d\\]');
expect(errorSection).toBeNull();
});

it('should show parameters indicator when hasParameters is true', async () => {
await setupTest({ hasParameters: true });
expect(fixture.nativeElement.textContent).toContain('Query has parameters');
});

it('should not show parameters indicator when hasParameters is false', async () => {
await setupTest({ hasParameters: false });
expect(fixture.nativeElement.textContent).not.toContain('Query has parameters');
});

it('should emit searchTriggered on 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();

expect(spy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideAlertCircle } from '@ng-icons/lucide';

import { QueryParseError } from '../../../core/models/query-parameter.model';

@Component({
selector: 'app-filing-query-editor',
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'filing-query-editor' },
imports: [ReactiveFormsModule, NgIcon],
providers: [provideIcons({ lucideAlertCircle })],
template: `
<div class="bg-[#252526] border-t border-[#3c3c3c]">
<div class="flex">
<textarea
[formControl]="queryControl()"
(keydown.control.enter)="searchTriggered.emit()"
(keydown.meta.enter)="searchTriggered.emit()"
placeholder="form_type = '8-K' order by snowflake desc limit 50"
spellcheck="false"
aria-label="Search query"
class="flex-1 h-20 px-3 py-2 bg-[#1e1e1e] text-[#d4d4d4] placeholder-[#6e6e6e] text-[13px] font-mono border-r border-[#3c3c3c] focus:outline-none resize-none"
[class.border-red-500]="!isQueryValid()"
[class.border-l-2]="!isQueryValid()"
></textarea>
</div>

<!-- Parse Errors -->
@if (parseErrors().length > 0) {
<div class="px-3 py-1.5 bg-[#5a1d1d] border-t border-[#3c3c3c]">
@for (err of parseErrors(); track err.startIndex) {
<div class="flex items-center gap-1.5 text-[11px] text-[#f48771]">
<ng-icon name="lucideAlertCircle" class="text-[12px]" />
{{ err.message }}
</div>
}
</div>
}

<!-- Toolbar -->
<div class="px-3 py-1.5 text-[11px] text-[#6e6e6e] border-t border-[#3c3c3c] flex items-center justify-between">
<div>
<kbd class="text-[#858585] font-sans">Ctrl+Enter</kbd> to search
@if (hasParameters()) {
<span class="ml-3 text-[#4ec9b0]">• Query has parameters</span>
}
</div>
</div>
</div>
`,
})
export class FilingQueryEditorComponent {
readonly queryControl = input.required<FormControl<string>>();
readonly isQueryValid = input.required<boolean>();
readonly parseErrors = input.required<QueryParseError[]>();
readonly hasParameters = input.required<boolean>();

readonly searchTriggered = output<void>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FilingQueryItemComponent } from './filing-query-item.component';
import { describe, it, expect, vi } from 'vitest';
import { SavedQuery } from '../../../core/models/query-parameter.model';

describe('FilingQueryItemComponent', () => {
let component: FilingQueryItemComponent;
let fixture: ComponentFixture<FilingQueryItemComponent>;

const mockQuery: SavedQuery = {
query: "form_type = '10-K'",
values: [],
name: 'Annual Reports',
pinned: false,
lastUsed: '2024-01-15T10:00:00Z',
};

const setupTest = async (opts: {
query?: SavedQuery;
isActive?: boolean;
isRenaming?: boolean;
renameValue?: string;
isBlueprint?: boolean;
} = {}) => {
await TestBed.configureTestingModule({
imports: [FilingQueryItemComponent],
}).compileComponents();

fixture = TestBed.createComponent(FilingQueryItemComponent);
fixture.componentRef.setInput('guid', 'test-guid-1');
fixture.componentRef.setInput('query', opts.query ?? mockQuery);
fixture.componentRef.setInput('isActive', opts.isActive ?? false);
fixture.componentRef.setInput('isRenaming', opts.isRenaming ?? false);
fixture.componentRef.setInput('renameValue', opts.renameValue ?? '');
fixture.componentRef.setInput('color', '#ff5500');
fixture.componentRef.setInput('iconText', '10-K');
fixture.componentRef.setInput('displayText', 'Annual Reports');
fixture.componentRef.setInput('relativeTime', '2h ago');
fixture.componentRef.setInput('isBlueprint', opts.isBlueprint ?? false);
fixture.detectChanges();
component = fixture.componentInstance;
};

it('should create', async () => {
await setupTest();
expect(component).toBeTruthy();
});

it('should have the filing-query-item host class', async () => {
await setupTest();
expect(fixture.nativeElement.classList.contains('filing-query-item')).toBe(true);
});

it('should render display text', async () => {
await setupTest();
expect(fixture.nativeElement.textContent).toContain('Annual Reports');
});

it('should render icon text', async () => {
await setupTest();
expect(fixture.nativeElement.textContent).toContain('10-K');
});

it('should show pin icon when query is pinned', async () => {
await setupTest({ query: { ...mockQuery, pinned: true } });
const pinIcon = fixture.nativeElement.querySelector('ng-icon[name="lucidePin"]');
expect(pinIcon).toBeTruthy();
});

it('should not show pin icon when query is not pinned', async () => {
await setupTest({ query: { ...mockQuery, pinned: false } });
const pinIcon = fixture.nativeElement.querySelector('ng-icon[name="lucidePin"]');
expect(pinIcon).toBeNull();
});

it('should show rename input when isRenaming is true', async () => {
await setupTest({ isRenaming: true, renameValue: 'New Name' });
const input = fixture.nativeElement.querySelector('input');
expect(input).toBeTruthy();
expect(input.value).toBe('New Name');
});

it('should show link when not renaming', async () => {
await setupTest({ isRenaming: false });
const link = fixture.nativeElement.querySelector('a');
expect(link).toBeTruthy();
});

it('should emit select when clicked', async () => {
await setupTest();
const spy = vi.fn();
component.select.subscribe(spy);

const link = fixture.nativeElement.querySelector('a');
link.click();

expect(spy).toHaveBeenCalledWith('test-guid-1');
});

it('should emit saveRename on checkmark click', async () => {
await setupTest({ isRenaming: true });
const spy = vi.fn();
component.saveRename.subscribe(spy);

const saveBtn = fixture.nativeElement.querySelector('button[title="Save"]');
saveBtn.click();

expect(spy).toHaveBeenCalled();
});

it('should show relative time', async () => {
await setupTest();
expect(fixture.nativeElement.textContent).toContain('2h ago');
});
});
Loading