mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-30 19:45:44 +00:00
Enhancement: relative date custom field queries (#14549)
This commit is contained in:
1 parent
c21bdab5c8
commit
67c194978e
7 files changed
+353
-19
No files matched your search
+52
-18
@@ -33,21 +33,54 @@
|
||||
|
||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
ngbDatepicker
|
||||
#d="ngbDatepicker"
|
||||
[footerTemplate]="datePickerFooterTemplate" />
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||
<i-bs name="calendar-event"></i-bs>
|
||||
</button>
|
||||
<ng-template #datePickerFooterTemplate>
|
||||
<div class="btn-group-xs border-top p-2 d-flex">
|
||||
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
||||
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
||||
@let relativeDate = getRelativeDate(atom);
|
||||
<div class="d-flex gap-2 w-100 ms-0 mt-1">
|
||||
<fieldset class="btn-group btn-group-sm flex-shrink-0 date-mode" aria-label="Date mode" i18n-aria-label>
|
||||
<input type="radio" class="btn-check" [id]="atom.id + '_fixed'" [name]="atom.id + '_dateMode'" [checked]="!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Fixed date" i18n-aria-label>
|
||||
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_fixed'" title="Fixed date" i18n-title>
|
||||
<i-bs name="calendar"></i-bs><span class="d-none d-md-inline ms-2" i18n>Fixed</span>
|
||||
</label>
|
||||
<input type="radio" class="btn-check" [id]="atom.id + '_relative'" [name]="atom.id + '_dateMode'" [checked]="!!relativeDate" (change)="toggleRelativeDate(atom)" [disabled]="disabled" aria-label="Relative date" i18n-aria-label>
|
||||
<label class="btn btn-outline-secondary text-nowrap" [for]="atom.id + '_relative'" title="Relative date" i18n-title>
|
||||
<i-bs name="clock-history"></i-bs><span class="d-none d-md-inline ms-2" i18n>Relative</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<div class="input-group input-group-sm flex-grow-1">
|
||||
@if (relativeDate) {
|
||||
@if (relativeDate.unit.includes('{n}')) {
|
||||
<input class="form-control w-25 flex-grow-0" type="number" min="0" aria-label="Amount" i18n-aria-label
|
||||
[ngModel]="relativeDate.amount"
|
||||
(ngModelChange)="updateRelativeDate(atom, { amount: $event })"
|
||||
[ngModelOptions]="{standalone: true}" />
|
||||
}
|
||||
<label class="visually-hidden" [for]="atom.id + '_unit'" i18n>Unit</label>
|
||||
<select class="form-select" [id]="atom.id + '_unit'"
|
||||
[ngModel]="relativeDate.unit"
|
||||
(ngModelChange)="updateRelativeDate(atom, { unit: $event })"
|
||||
[ngModelOptions]="{standalone: true}">
|
||||
@for (unit of relativeDateUnits; track unit.id) {
|
||||
<option [ngValue]="unit.id">{{unit.label}}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input class="form-control" placeholder="yyyy-mm-dd"
|
||||
[(ngModel)]="atom.value"
|
||||
[ngModelOptions]="{standalone: true}"
|
||||
ngbDatepicker
|
||||
#d="ngbDatepicker"
|
||||
[footerTemplate]="datePickerFooterTemplate" />
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||
<i-bs name="calendar-event"></i-bs>
|
||||
</button>
|
||||
<ng-template #datePickerFooterTemplate>
|
||||
<div class="btn-group-xs border-top p-2 d-flex">
|
||||
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
|
||||
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
|
||||
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
|
||||
@@ -83,6 +116,7 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template #queryAtom let-atom="atom">
|
||||
<label class="visually-hidden" [for]="atom.id + '_operator'" i18n>Operator</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<ng-select #fieldSelects
|
||||
class="paperless-input-select"
|
||||
@@ -96,7 +130,7 @@
|
||||
[appendTo]="selectAppendTo"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
<select class="w-25 form-select" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||
<select class="w-25 form-select" [id]="atom.id + '_operator'" [class.rounded-end]="hasDateValueRow(atom)" [(ngModel)]="atom.operator" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||
@for (operator of getOperatorsForField(atom.field); track operator.label) {
|
||||
<option [ngValue]="operator.value">{{operator.label}}</option>
|
||||
}
|
||||
@@ -151,10 +185,10 @@
|
||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [ngModelOptions]="{standalone: true}" [disabled]="disabled">
|
||||
}
|
||||
}
|
||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm text-danger pe-0 align-self-center" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||
<i-bs name="x-circle"></i-bs>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #queryExpression let-expression="expression">
|
||||
|
||||
+15
@@ -5,6 +5,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
--ng-select-border-radius: var(--bs-border-radius);
|
||||
--ng-select-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2));
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select-container {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
@@ -41,3 +46,13 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.date-mode .btn {
|
||||
--bs-btn-color: var(--bs-secondary-color);
|
||||
--bs-btn-hover-bg: transparent;
|
||||
--bs-btn-hover-color: var(--bs-body-color);
|
||||
--bs-btn-hover-border-color: var(--bs-border-color);
|
||||
--bs-btn-active-bg: transparent;
|
||||
--bs-btn-active-color: var(--bs-primary);
|
||||
--bs-btn-active-border-color: var(--bs-border-color);
|
||||
}
|
||||
+78
-1
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { LOCALE_ID } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbDateAdapter, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
@@ -11,6 +11,7 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
CustomFieldQueryOperatorGroups,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
CustomFieldQueryElement,
|
||||
CustomFieldQueryExpression,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import {
|
||||
CustomFieldQueriesModel,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
@@ -48,6 +50,12 @@ const customFields = [
|
||||
data_type: CustomFieldDataType.Monetary,
|
||||
extra_data: { default_currency: 'EUR' },
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Test Date Field',
|
||||
data_type: CustomFieldDataType.Date,
|
||||
extra_data: {},
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
@@ -69,6 +77,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: LOCALE_ID, useValue: 'de' },
|
||||
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@@ -237,6 +246,74 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
||||
expect(component.selectionModel.queries.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should build relative date values from amount and unit', async () => {
|
||||
component.useDropdown = false
|
||||
const atom = new CustomFieldQueryAtom([
|
||||
4,
|
||||
CustomFieldQueryOperator.GreaterThanOrEqual,
|
||||
'2026-10-22',
|
||||
])
|
||||
component.selectionModel.addAtom(atom)
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelector(selector)
|
||||
expect(query('[ngbDatepicker]')).not.toBeNull()
|
||||
expect(query('input[id$="_fixed"]').checked).toBe(true)
|
||||
|
||||
query('input[id$="_relative"]').click()
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
expect(atom.value).toBeNull()
|
||||
const amount: HTMLInputElement = query('input[aria-label="Amount"]')
|
||||
const unit: HTMLSelectElement = query('select[id$="_unit"]')
|
||||
amount.value = '30'
|
||||
amount.dispatchEvent(new Event('input'))
|
||||
expect(atom.serialize()).toEqual([4, 'gte', '30 days ago'])
|
||||
unit.selectedIndex = 6
|
||||
unit.dispatchEvent(new Event('change'))
|
||||
expect(atom.serialize()).toEqual([4, 'gte', 'in 30 weeks'])
|
||||
unit.selectedIndex = 0
|
||||
unit.dispatchEvent(new Event('change'))
|
||||
fixture.detectChanges()
|
||||
expect(atom.serialize()).toEqual([4, 'gte', 'today'])
|
||||
expect(query('input[aria-label="Amount"]')).toBeNull()
|
||||
|
||||
query('input[id$="_fixed"]').click()
|
||||
fixture.detectChanges()
|
||||
expect(atom.value).toBeNull()
|
||||
expect(query('[ngbDatepicker]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should restore a saved relative date value', async () => {
|
||||
component.useDropdown = false
|
||||
const atom = new CustomFieldQueryAtom([
|
||||
4,
|
||||
CustomFieldQueryOperator.Exact,
|
||||
'in 2 months',
|
||||
])
|
||||
component.selectionModel.addAtom(atom)
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
expect(component.getRelativeDate(atom)).toEqual({
|
||||
amount: 2,
|
||||
unit: 'in {n} months',
|
||||
})
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('input[aria-label="Amount"]').value
|
||||
).toBe('2')
|
||||
|
||||
const today = new CustomFieldQueryAtom([
|
||||
4,
|
||||
CustomFieldQueryOperator.Exact,
|
||||
'today',
|
||||
])
|
||||
expect(component.getRelativeDate(today)).toEqual({
|
||||
amount: null,
|
||||
unit: 'today',
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomFieldQueriesModel', () => {
|
||||
let model: CustomFieldQueriesModel
|
||||
|
||||
|
||||
+81
@@ -49,6 +49,26 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||
|
||||
interface RelativeDate {
|
||||
// The expression sent to the API, with {n} standing in for the amount
|
||||
amount: number | null
|
||||
unit: string
|
||||
}
|
||||
|
||||
function parseRelativeDate(
|
||||
value: string,
|
||||
unit: string
|
||||
): RelativeDate | undefined {
|
||||
const [prefix, suffix] = unit.split('{n}')
|
||||
if (suffix === undefined) {
|
||||
return value === unit ? { amount: null, unit } : undefined
|
||||
}
|
||||
if (!value.startsWith(prefix) || !value.endsWith(suffix)) return undefined
|
||||
const amount = value.slice(prefix.length, value.length - suffix.length)
|
||||
if (!amount || !Number.isInteger(+amount) || +amount < 0) return undefined
|
||||
return { amount: +amount, unit }
|
||||
}
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||
private rootSubscriptions: Subscription[] = []
|
||||
@@ -305,6 +325,67 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
|
||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||
|
||||
public readonly relativeDateUnits = [
|
||||
{ id: 'today', label: $localize`Today` },
|
||||
{ id: '{n} days ago', label: $localize`days ago` },
|
||||
{ id: '{n} weeks ago', label: $localize`weeks ago` },
|
||||
{ id: '{n} months ago', label: $localize`months ago` },
|
||||
{ id: '{n} years ago', label: $localize`years ago` },
|
||||
{ id: 'in {n} days', label: $localize`days from now` },
|
||||
{ id: 'in {n} weeks', label: $localize`weeks from now` },
|
||||
{ id: 'in {n} months', label: $localize`months from now` },
|
||||
{ id: 'in {n} years', label: $localize`years from now` },
|
||||
]
|
||||
|
||||
private readonly relativeDates = new Map<string, RelativeDate>()
|
||||
|
||||
getRelativeDate(atom: CustomFieldQueryAtom): RelativeDate | undefined {
|
||||
if (!this.relativeDates.has(atom.id) && typeof atom.value === 'string') {
|
||||
for (const { id } of this.relativeDateUnits) {
|
||||
const relativeDate = parseRelativeDate(atom.value, id)
|
||||
if (relativeDate) {
|
||||
this.relativeDates.set(atom.id, relativeDate)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.relativeDates.get(atom.id)
|
||||
}
|
||||
|
||||
hasDateValueRow(atom: CustomFieldQueryAtom): boolean {
|
||||
return (
|
||||
this.getCustomFieldByID(atom.field)?.data_type ===
|
||||
CustomFieldDataType.Date &&
|
||||
atom.operator !== CustomFieldQueryOperator.Exists &&
|
||||
atom.operator !== CustomFieldQueryOperator.IsNull
|
||||
)
|
||||
}
|
||||
|
||||
toggleRelativeDate(atom: CustomFieldQueryAtom) {
|
||||
if (this.getRelativeDate(atom)) {
|
||||
this.relativeDates.delete(atom.id)
|
||||
atom.value = null
|
||||
} else {
|
||||
this.relativeDates.set(atom.id, { amount: null, unit: '{n} days ago' })
|
||||
this.updateRelativeDate(atom, {})
|
||||
}
|
||||
}
|
||||
|
||||
updateRelativeDate(
|
||||
atom: CustomFieldQueryAtom,
|
||||
change: Partial<RelativeDate>
|
||||
) {
|
||||
const { amount, unit } = Object.assign(
|
||||
this.relativeDates.get(atom.id),
|
||||
change
|
||||
)
|
||||
if (!unit.includes('{n}')) {
|
||||
atom.value = unit
|
||||
} else {
|
||||
atom.value = amount === null ? null : unit.replace('{n}', `${amount}`)
|
||||
}
|
||||
}
|
||||
|
||||
public customFieldSearchFn = (term: string, field: CustomField): boolean =>
|
||||
matchesSearchText(field?.name, term)
|
||||
|
||||
|
||||
Reference in new issue
Block a user