mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-25 02:40:32 +00:00
Advanced search dialog
This commit is contained in:
+347
@@ -0,0 +1,347 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="advanced-search-dialog-title" i18n>
|
||||
Advanced search
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
aria-label="Close"
|
||||
i18n-aria-label
|
||||
(click)="cancel()"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (unreadable) {
|
||||
<div class="alert alert-warning d-flex flex-wrap gap-2 align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span i18n
|
||||
>The current query uses syntax this editor can't show. It still works
|
||||
as typed.</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
(click)="startOver()"
|
||||
i18n
|
||||
>
|
||||
Start a new query
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: root, parent: null, depth: 0 }
|
||||
"
|
||||
></ng-container>
|
||||
|
||||
<div class="mt-4">
|
||||
<label
|
||||
class="form-label small text-muted"
|
||||
for="advanced-search-preview"
|
||||
i18n
|
||||
>Query</label
|
||||
>
|
||||
<pre
|
||||
id="advanced-search-preview"
|
||||
class="query-preview mb-0 p-2 rounded border"
|
||||
>@if (generatedQuery) {{{ generatedQuery }}} @else {<span class="text-muted fst-italic" i18n>Nothing to search for yet</span>}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
(click)="cancel()"
|
||||
i18n
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
(click)="apply()"
|
||||
[disabled]="!generatedQuery"
|
||||
i18n
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-template
|
||||
#groupTemplate
|
||||
let-group="group"
|
||||
let-parent="parent"
|
||||
let-depth="depth"
|
||||
>
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<div class="d-flex flex-grow-1 flex-column">
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div
|
||||
class="btn-group btn-group-xs"
|
||||
role="group"
|
||||
aria-label="Match"
|
||||
i18n-aria-label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Or"
|
||||
id="advancedSearchAny_{{ idFor(group) }}"
|
||||
name="advancedSearchAny_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAny_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Any</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.And"
|
||||
id="advancedSearchAll_{{ idFor(group) }}"
|
||||
name="advancedSearchAll_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-primary"
|
||||
for="advancedSearchAll_{{ idFor(group) }}"
|
||||
i18n
|
||||
>All</label
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[(ngModel)]="group.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[value]="LogicalOperator.Not"
|
||||
id="advancedSearchNot_{{ idFor(group) }}"
|
||||
name="advancedSearchNot_{{ idFor(group) }}"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-outline-secondary"
|
||||
for="advancedSearchNot_{{ idFor(group) }}"
|
||||
i18n
|
||||
>Not</label
|
||||
>
|
||||
</div>
|
||||
<span class="small text-muted ms-2">
|
||||
@switch (group.operator) {
|
||||
@case (LogicalOperator.And) {
|
||||
<ng-container i18n>match all of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Or) {
|
||||
<ng-container i18n>match any of these</ng-container>
|
||||
}
|
||||
@case (LogicalOperator.Not) {
|
||||
<ng-container i18n>match none of these</ng-container>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of group.children; track element) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@if (element.type === ElementType.Group) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
groupTemplate;
|
||||
context: { group: element, parent: group, depth: depth + 1 }
|
||||
"
|
||||
></ng-container>
|
||||
} @else {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="
|
||||
atomTemplate;
|
||||
context: { atom: element, parent: group }
|
||||
"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="btn-group-vertical align-self-start ms-2 ps-2 border-start"
|
||||
role="group"
|
||||
aria-label="Group actions"
|
||||
i18n-aria-label
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add condition"
|
||||
i18n-title
|
||||
(click)="addAtom(group)"
|
||||
[disabled]="group.children.length >= maxAtoms"
|
||||
>
|
||||
<i-bs name="node-plus"></i-bs>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-primary"
|
||||
title="Add group"
|
||||
i18n-title
|
||||
(click)="addGroup(group)"
|
||||
[disabled]="depth >= maxDepth"
|
||||
>
|
||||
<i-bs name="braces"></i-bs>
|
||||
</button>
|
||||
@if (parent) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary text-danger"
|
||||
aria-label="Remove group"
|
||||
i18n-aria-label
|
||||
(click)="remove(parent, group)"
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #atomTemplate let-atom="atom" let-parent="parent">
|
||||
<div class="d-flex align-items-center gap-1 w-100">
|
||||
<div class="input-group input-group-sm flex-wrap">
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.field"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onFieldChange(atom)"
|
||||
aria-label="Field"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (fieldGroup of fieldGroups; track fieldGroup.label) {
|
||||
<optgroup [label]="fieldGroup.label">
|
||||
@for (field of fieldGroup.fields; track field) {
|
||||
<option [ngValue]="field">{{ fieldLabels[field] }}</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
class="form-select flex-grow-0 w-auto"
|
||||
[(ngModel)]="atom.operator"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="onOperatorChange(atom)"
|
||||
aria-label="Condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (operator of operatorsFor(atom); track operator) {
|
||||
<option [ngValue]="operator">
|
||||
{{ operatorLabel(atom, operator) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
@switch (atom.operator) {
|
||||
@case (Operator.DateKeyword) {
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Period"
|
||||
i18n-aria-label
|
||||
>
|
||||
<option [ngValue]="''" disabled i18n>Choose a period</option>
|
||||
@for (keyword of dateKeywords; track keyword) {
|
||||
<option [ngValue]="keyword">
|
||||
{{ dateKeywordLabels[keyword] }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.WithinLast) {
|
||||
<input
|
||||
class="form-control amount"
|
||||
type="number"
|
||||
min="1"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Amount"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<select
|
||||
class="form-select"
|
||||
[(ngModel)]="atom.unit"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Unit"
|
||||
i18n-aria-label
|
||||
>
|
||||
@for (unit of dateUnits; track unit) {
|
||||
<option [ngValue]="unit">{{ dateUnitLabels[unit] }}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
@case (Operator.Between) {
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="From"
|
||||
i18n-aria-label
|
||||
/>
|
||||
<span class="input-group-text" i18n>and</span>
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="kindOf(atom) === FieldKind.Date ? 'date' : 'number'"
|
||||
[(ngModel)]="atom.valueTo"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="To"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
@default {
|
||||
@if (kindOf(atom) === FieldKind.Date) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="date"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Date"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else if (kindOf(atom) === FieldKind.Number) {
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="0"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
aria-label="Number"
|
||||
i18n-aria-label
|
||||
/>
|
||||
} @else {
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[placeholder]="placeholderFor(atom)"
|
||||
aria-label="Value"
|
||||
i18n-aria-label
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-link btn-sm text-danger px-1"
|
||||
type="button"
|
||||
(click)="remove(parent, atom)"
|
||||
aria-label="Remove condition"
|
||||
i18n-aria-label
|
||||
>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
+11
@@ -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;
|
||||
}
|
||||
+186
@@ -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<AdvancedSearchDialogComponent>
|
||||
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()
|
||||
})
|
||||
})
|
||||
+197
@@ -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<string>()
|
||||
|
||||
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<object, number>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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, string> =
|
||||
{
|
||||
[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, string>
|
||||
> = {
|
||||
[AdvancedSearchOperator.AtLeast]: $localize`is on or after`,
|
||||
[AdvancedSearchOperator.AtMost]: $localize`is on or before`,
|
||||
}
|
||||
|
||||
export const ADVANCED_SEARCH_DATE_KEYWORD_LABELS: Record<string, string> = {
|
||||
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`,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user