From 856140c068e10c12b5232c03b7e514bab75415bb Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:39:48 -0700 Subject: [PATCH] Advanced search dialog --- .../advanced-search-dialog.component.html | 347 ++++++++++++++++++ .../advanced-search-dialog.component.scss | 11 + .../advanced-search-dialog.component.spec.ts | 186 ++++++++++ .../advanced-search-dialog.component.ts | 197 ++++++++++ src-ui/src/app/data/advanced-search-query.ts | 118 ++++++ 5 files changed, 859 insertions(+) create mode 100644 src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.html create mode 100644 src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.scss create mode 100644 src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.ts diff --git a/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.html b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.html new file mode 100644 index 000000000..2d5d4e354 --- /dev/null +++ b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.html @@ -0,0 +1,347 @@ + + + + + +
+
+
+
+ + + + + + +
+ + @switch (group.operator) { + @case (LogicalOperator.And) { + match all of these + } + @case (LogicalOperator.Or) { + match any of these + } + @case (LogicalOperator.Not) { + match none of these + } + } + +
+
+ @for (element of group.children; track element) { +
+ @if (element.type === ElementType.Group) { + + } @else { + + } +
+ } +
+
+
+ + + @if (parent) { + + } +
+
+
+ + +
+
+ + + + @switch (atom.operator) { + @case (Operator.DateKeyword) { + + } + @case (Operator.WithinLast) { + + + } + @case (Operator.Between) { + + and + + } + @default { + @if (kindOf(atom) === FieldKind.Date) { + + } @else if (kindOf(atom) === FieldKind.Number) { + + } @else { + + } + } + } +
+ +
+
diff --git a/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.scss b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.scss new file mode 100644 index 000000000..4e5939648 --- /dev/null +++ b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.scss @@ -0,0 +1,11 @@ +.query-preview { + font-size: 0.8125rem; + white-space: pre-wrap; + word-break: break-word; + background-color: var(--pngx-bg-darker); + border-color: var(--bs-border-color) !important; +} + +.input-group .amount { + flex: 0 1 5rem; +} diff --git a/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.spec.ts b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.spec.ts new file mode 100644 index 000000000..60a7ecfdd --- /dev/null +++ b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.spec.ts @@ -0,0 +1,186 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { + AdvancedSearchDateUnit, + AdvancedSearchField, + AdvancedSearchLogicalOperator, + AdvancedSearchOperator, + AdvancedSearchQueryAtom, + AdvancedSearchQueryElementType, + AdvancedSearchQueryGroup, +} from 'src/app/data/advanced-search-query' +import { AdvancedSearchDialogComponent } from './advanced-search-dialog.component' + +describe('AdvancedSearchDialogComponent', () => { + let component: AdvancedSearchDialogComponent + let fixture: ComponentFixture + let activeModal: NgbActiveModal + + const firstAtom = (): AdvancedSearchQueryAtom => + component.root.children[0] as AdvancedSearchQueryAtom + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + AdvancedSearchDialogComponent, + NgxBootstrapIconsModule.pick(allIcons), + ], + providers: [NgbActiveModal], + }).compileComponents() + + fixture = TestBed.createComponent(AdvancedSearchDialogComponent) + activeModal = TestBed.inject(NgbActiveModal) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should start with one empty condition and nothing to search for', () => { + expect(component.root.children).toHaveLength(1) + expect(component.generatedQuery).toBe('') + expect(component.unreadable).toBeFalsy() + }) + + it('should show an existing query as conditions', () => { + component.query = 'title:invoice AND NOT tag:paid' + expect(component.unreadable).toBeFalsy() + expect(component.root.children).toHaveLength(2) + expect(component.generatedQuery).toBe('title:invoice AND NOT tag:paid') + }) + + it('should flag a query it cannot show and start empty', () => { + component.query = 'title:invoice^2' + expect(component.unreadable).toBeTruthy() + expect(component.generatedQuery).toBe('') + }) + + it('should clear the warning when starting a new query', () => { + component.query = 'title:invoice^2' + component.startOver() + expect(component.unreadable).toBeFalsy() + expect(component.root.children).toHaveLength(1) + }) + + it('should treat an empty query as a fresh start', () => { + component.query = ' ' + expect(component.unreadable).toBeFalsy() + expect(component.root.children).toHaveLength(1) + }) + + it('should write the query as conditions are filled in', () => { + const atom = firstAtom() + atom.field = AdvancedSearchField.Title + atom.value = 'unpaid invoice' + expect(component.generatedQuery).toBe('title:unpaid AND title:invoice') + }) + + it('should offer the conditions of the chosen field', () => { + const atom = firstAtom() + atom.field = AdvancedSearchField.Added + component.onFieldChange(atom) + expect(component.operatorsFor(atom)).toContain( + AdvancedSearchOperator.WithinLast + ) + expect(component.operatorsFor(atom)).not.toContain( + AdvancedSearchOperator.Phrase + ) + }) + + it('should keep a condition the new field still offers', () => { + const atom = firstAtom() + atom.operator = AdvancedSearchOperator.Phrase + atom.field = AdvancedSearchField.Correspondent + component.onFieldChange(atom) + expect(atom.operator).toBe(AdvancedSearchOperator.Phrase) + }) + + it('should replace a condition the new field does not offer, and clear the value', () => { + const atom = firstAtom() + atom.operator = AdvancedSearchOperator.Phrase + atom.value = 'invoice' + atom.field = AdvancedSearchField.ASN + component.onFieldChange(atom) + expect(atom.operator).toBe(AdvancedSearchOperator.Equals) + expect(atom.value).toBe('') + }) + + it('should give a within-the-last condition a unit to start from', () => { + const atom = firstAtom() + atom.field = AdvancedSearchField.Added + atom.operator = AdvancedSearchOperator.WithinLast + component.onOperatorChange(atom) + expect(atom.unit).toBe(AdvancedSearchDateUnit.Day) + atom.value = '3' + expect(component.generatedQuery).toBe('added:[-3 days to now]') + }) + + it('should label date comparisons as dates', () => { + const atom = firstAtom() + atom.field = AdvancedSearchField.Created + expect( + component.operatorLabel(atom, AdvancedSearchOperator.AtLeast) + ).toEqual('is on or after') + atom.field = AdvancedSearchField.ASN + expect( + component.operatorLabel(atom, AdvancedSearchOperator.AtLeast) + ).toEqual('is at least') + }) + + it('should add and remove conditions', () => { + component.addAtom(component.root) + expect(component.root.children).toHaveLength(2) + component.remove(component.root, component.root.children[1]) + expect(component.root.children).toHaveLength(1) + }) + + it('should add a group, which starts as Any', () => { + component.addGroup(component.root) + const group = component.root.children[1] as AdvancedSearchQueryGroup + expect(group.type).toBe(AdvancedSearchQueryElementType.Group) + expect(group.operator).toBe(AdvancedSearchLogicalOperator.Or) + expect(group.children).toHaveLength(1) + }) + + it('should give every group its own id, once', () => { + component.addGroup(component.root) + const group = component.root.children[1] as AdvancedSearchQueryGroup + expect(component.idFor(component.root)).not.toEqual(component.idFor(group)) + expect(component.idFor(group)).toEqual(component.idFor(group)) + }) + + it('should apply the query and close', () => { + const emitSpy = jest.spyOn(component.queryApplied, 'emit') + const closeSpy = jest.spyOn(activeModal, 'close') + const atom = firstAtom() + atom.value = 'invoice' + component.apply() + expect(emitSpy).toHaveBeenCalledWith('content:invoice') + expect(closeSpy).toHaveBeenCalled() + }) + + it('should close without applying on cancel', () => { + const emitSpy = jest.spyOn(component.queryApplied, 'emit') + const closeSpy = jest.spyOn(activeModal, 'close') + component.cancel() + expect(emitSpy).not.toHaveBeenCalled() + expect(closeSpy).toHaveBeenCalled() + }) + + it('should show the query it will apply', () => { + component.query = 'content:invoice OR content:receipt' + fixture.detectChanges() + const preview = fixture.nativeElement.querySelector( + '#advanced-search-preview' + ) + expect(preview.textContent).toContain('content:invoice OR content:receipt') + }) + + it('should not offer to apply an empty query', () => { + fixture.detectChanges() + const apply = Array.from( + fixture.nativeElement.querySelectorAll('.modal-footer button') + ).pop() as HTMLButtonElement + expect(apply.disabled).toBeTruthy() + }) +}) diff --git a/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.ts b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.ts new file mode 100644 index 000000000..5325debf5 --- /dev/null +++ b/src-ui/src/app/components/common/advanced-search-dialog/advanced-search-dialog.component.ts @@ -0,0 +1,197 @@ +import { NgTemplateOutlet } from '@angular/common' +import { Component, EventEmitter, inject, Input, Output } from '@angular/core' +import { FormsModule } from '@angular/forms' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { + ADVANCED_SEARCH_DATE_KEYWORD_LABELS, + ADVANCED_SEARCH_DATE_KEYWORDS, + ADVANCED_SEARCH_DATE_OPERATOR_LABELS, + ADVANCED_SEARCH_DATE_UNIT_LABELS, + ADVANCED_SEARCH_FIELD_GROUPS, + ADVANCED_SEARCH_FIELD_KINDS, + ADVANCED_SEARCH_FIELD_LABELS, + ADVANCED_SEARCH_MAX_ATOMS, + ADVANCED_SEARCH_MAX_DEPTH, + ADVANCED_SEARCH_OPERATOR_LABELS, + ADVANCED_SEARCH_OPERATORS_BY_KIND, + AdvancedSearchDateUnit, + AdvancedSearchField, + AdvancedSearchFieldKind, + AdvancedSearchLogicalOperator, + AdvancedSearchOperator, + AdvancedSearchQueryAtom, + AdvancedSearchQueryElement, + AdvancedSearchQueryElementType, + AdvancedSearchQueryGroup, +} from 'src/app/data/advanced-search-query' +import { + parseAdvancedSearchQuery, + serializeAdvancedSearchQuery, +} from 'src/app/utils/advanced-search-query' +import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' + +@Component({ + selector: 'pngx-advanced-search-dialog', + templateUrl: './advanced-search-dialog.component.html', + styleUrl: './advanced-search-dialog.component.scss', + imports: [FormsModule, NgTemplateOutlet, NgxBootstrapIconsModule], +}) +export class AdvancedSearchDialogComponent extends LoadingComponentWithPermissions { + private activeModal = inject(NgbActiveModal) + + public readonly ElementType = AdvancedSearchQueryElementType + public readonly LogicalOperator = AdvancedSearchLogicalOperator + public readonly Operator = AdvancedSearchOperator + public readonly FieldKind = AdvancedSearchFieldKind + public readonly fieldGroups = ADVANCED_SEARCH_FIELD_GROUPS + public readonly fieldLabels = ADVANCED_SEARCH_FIELD_LABELS + public readonly dateKeywords = ADVANCED_SEARCH_DATE_KEYWORDS + public readonly dateKeywordLabels = ADVANCED_SEARCH_DATE_KEYWORD_LABELS + public readonly dateUnits = Object.values(AdvancedSearchDateUnit) + public readonly dateUnitLabels = ADVANCED_SEARCH_DATE_UNIT_LABELS + public readonly maxDepth = ADVANCED_SEARCH_MAX_DEPTH + public readonly maxAtoms = ADVANCED_SEARCH_MAX_ATOMS + + @Output() + public queryApplied = new EventEmitter() + + public root: AdvancedSearchQueryGroup = this.emptyRoot() + + // True when the query in the search box uses syntax the editor cannot show + public unreadable: boolean = false + + private _query: string = '' + + @Input() + set query(query: string) { + this._query = query ?? '' + const parsed = parseAdvancedSearchQuery(this._query) + this.unreadable = !!this._query.trim() && !parsed + this.root = parsed ?? this.emptyRoot() + } + + get query(): string { + return this._query + } + + constructor() { + super() + this.loading.set(false) + } + + // Stable ids for the radio groups, without putting them in the query model + private ids = new WeakMap() + private nextId = 0 + + public idFor(element: AdvancedSearchQueryElement): number { + if (!this.ids.has(element)) { + this.ids.set(element, this.nextId++) + } + return this.ids.get(element) + } + + private emptyRoot(): AdvancedSearchQueryGroup { + return { + type: AdvancedSearchQueryElementType.Group, + operator: AdvancedSearchLogicalOperator.And, + children: [this.newAtom()], + } + } + + private newAtom(): AdvancedSearchQueryAtom { + return { + type: AdvancedSearchQueryElementType.Atom, + field: AdvancedSearchField.Content, + operator: AdvancedSearchOperator.AllWords, + value: '', + } + } + + public get generatedQuery(): string { + return serializeAdvancedSearchQuery(this.root) + } + + public kindOf(atom: AdvancedSearchQueryAtom): AdvancedSearchFieldKind { + return ADVANCED_SEARCH_FIELD_KINDS[atom.field] + } + + public operatorsFor(atom: AdvancedSearchQueryAtom): AdvancedSearchOperator[] { + return ADVANCED_SEARCH_OPERATORS_BY_KIND[this.kindOf(atom)] + } + + public operatorLabel( + atom: AdvancedSearchQueryAtom, + operator: AdvancedSearchOperator + ): string { + return this.kindOf(atom) === AdvancedSearchFieldKind.Date + ? (ADVANCED_SEARCH_DATE_OPERATOR_LABELS[operator] ?? + ADVANCED_SEARCH_OPERATOR_LABELS[operator]) + : ADVANCED_SEARCH_OPERATOR_LABELS[operator] + } + + public placeholderFor(atom: AdvancedSearchQueryAtom): string { + switch (atom.operator) { + case AdvancedSearchOperator.Phrase: + return $localize`phrase` + case AdvancedSearchOperator.StartsWith: + return $localize`beginning of a word` + default: + return $localize`words` + } + } + + public onFieldChange(atom: AdvancedSearchQueryAtom) { + // Keep the condition only if the new field still offers it + if (!this.operatorsFor(atom).includes(atom.operator)) { + atom.operator = this.operatorsFor(atom)[0] + } + this.clearValues(atom) + } + + public onOperatorChange(atom: AdvancedSearchQueryAtom) { + this.clearValues(atom) + } + + private clearValues(atom: AdvancedSearchQueryAtom) { + atom.value = '' + atom.valueTo = undefined + atom.unit = + atom.operator === AdvancedSearchOperator.WithinLast + ? AdvancedSearchDateUnit.Day + : undefined + } + + public addAtom(group: AdvancedSearchQueryGroup) { + group.children.push(this.newAtom()) + } + + public addGroup(group: AdvancedSearchQueryGroup) { + group.children.push({ + type: AdvancedSearchQueryElementType.Group, + operator: AdvancedSearchLogicalOperator.Or, + children: [this.newAtom()], + }) + } + + public remove( + parent: AdvancedSearchQueryGroup, + element: AdvancedSearchQueryElement + ) { + parent.children = parent.children.filter((child) => child !== element) + } + + public startOver() { + this.unreadable = false + this.root = this.emptyRoot() + } + + public apply() { + this.queryApplied.emit(this.generatedQuery) + this.activeModal.close() + } + + public cancel() { + this.activeModal.close() + } +} diff --git a/src-ui/src/app/data/advanced-search-query.ts b/src-ui/src/app/data/advanced-search-query.ts index 5bfd7c5a3..8ee950c0e 100644 --- a/src-ui/src/app/data/advanced-search-query.ts +++ b/src-ui/src/app/data/advanced-search-query.ts @@ -147,3 +147,121 @@ export interface AdvancedSearchQueryGroup { export type AdvancedSearchQueryElement = AdvancedSearchQueryAtom | AdvancedSearchQueryGroup + +export const ADVANCED_SEARCH_MAX_DEPTH = 2 +export const ADVANCED_SEARCH_MAX_ATOMS = 10 + +export const ADVANCED_SEARCH_FIELD_LABELS: Record = + { + [AdvancedSearchField.Any]: $localize`Any field`, + [AdvancedSearchField.Title]: $localize`Title`, + [AdvancedSearchField.Content]: $localize`Content`, + [AdvancedSearchField.OriginalFilename]: $localize`File name`, + [AdvancedSearchField.NoteText]: $localize`Note text`, + [AdvancedSearchField.NoteAuthor]: $localize`Note author`, + [AdvancedSearchField.CustomFieldName]: $localize`Custom field name`, + [AdvancedSearchField.CustomFieldValue]: $localize`Custom field value`, + [AdvancedSearchField.Correspondent]: $localize`Correspondent name`, + [AdvancedSearchField.DocumentType]: $localize`Document type name`, + [AdvancedSearchField.StoragePath]: $localize`Storage path name`, + [AdvancedSearchField.Tag]: $localize`Tag name`, + [AdvancedSearchField.ASN]: $localize`ASN`, + [AdvancedSearchField.PageCount]: $localize`Pages`, + [AdvancedSearchField.NumNotes]: $localize`Number of notes`, + [AdvancedSearchField.Created]: $localize`Created`, + [AdvancedSearchField.Added]: $localize`Added`, + [AdvancedSearchField.Modified]: $localize`Modified`, + [AdvancedSearchField.Checksum]: $localize`Checksum`, + } + +export const ADVANCED_SEARCH_FIELD_GROUPS: { + label: string + fields: AdvancedSearchField[] +}[] = [ + { + label: $localize`Text`, + fields: [ + AdvancedSearchField.Any, + AdvancedSearchField.Title, + AdvancedSearchField.Content, + AdvancedSearchField.OriginalFilename, + AdvancedSearchField.NoteText, + AdvancedSearchField.NoteAuthor, + AdvancedSearchField.CustomFieldName, + AdvancedSearchField.CustomFieldValue, + ], + }, + { + // Matched as text against the name, unlike the filter dropdowns + label: $localize`Names`, + fields: [ + AdvancedSearchField.Correspondent, + AdvancedSearchField.DocumentType, + AdvancedSearchField.StoragePath, + AdvancedSearchField.Tag, + ], + }, + { + label: $localize`Numbers`, + fields: [ + AdvancedSearchField.ASN, + AdvancedSearchField.PageCount, + AdvancedSearchField.NumNotes, + ], + }, + { + label: $localize`Dates`, + fields: [ + AdvancedSearchField.Created, + AdvancedSearchField.Added, + AdvancedSearchField.Modified, + ], + }, + { label: $localize`Other`, fields: [AdvancedSearchField.Checksum] }, +] + +export const ADVANCED_SEARCH_OPERATOR_LABELS: Record< + AdvancedSearchOperator, + string +> = { + [AdvancedSearchOperator.AllWords]: $localize`contains all words`, + [AdvancedSearchOperator.AnyWord]: $localize`contains any word`, + [AdvancedSearchOperator.Phrase]: $localize`contains the phrase`, + [AdvancedSearchOperator.StartsWith]: $localize`starts with`, + [AdvancedSearchOperator.Equals]: $localize`is`, + [AdvancedSearchOperator.AtLeast]: $localize`is at least`, + [AdvancedSearchOperator.AtMost]: $localize`is at most`, + [AdvancedSearchOperator.Between]: $localize`is between`, + [AdvancedSearchOperator.DateKeyword]: $localize`is`, + [AdvancedSearchOperator.WithinLast]: $localize`is within the last`, +} + +// Comparing dates reads differently than comparing counts +export const ADVANCED_SEARCH_DATE_OPERATOR_LABELS: Partial< + Record +> = { + [AdvancedSearchOperator.AtLeast]: $localize`is on or after`, + [AdvancedSearchOperator.AtMost]: $localize`is on or before`, +} + +export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record = { + today: $localize`today`, + yesterday: $localize`yesterday`, + tomorrow: $localize`tomorrow`, + 'previous week': $localize`previous week`, + 'this month': $localize`this month`, + 'previous month': $localize`previous month`, + 'previous quarter': $localize`previous quarter`, + 'this year': $localize`this year`, + 'previous year': $localize`previous year`, +} + +export const ADVANCED_SEARCH_DATE_UNIT_LABELS: Record< + AdvancedSearchDateUnit, + string +> = { + [AdvancedSearchDateUnit.Day]: $localize`days`, + [AdvancedSearchDateUnit.Week]: $localize`weeks`, + [AdvancedSearchDateUnit.Month]: $localize`months`, + [AdvancedSearchDateUnit.Year]: $localize`years`, +}