mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-27 20:00:31 +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 =
|
export type AdvancedSearchQueryElement =
|
||||||
AdvancedSearchQueryAtom | AdvancedSearchQueryGroup
|
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