Gotta start somewhere

This commit is contained in:
shamoon 2024-09-01 00:29:52 -07:00
parent d7ba6d98d3
commit f0157a36fb
9 changed files with 275 additions and 101 deletions

View File

@ -108,6 +108,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CustomFieldsLookupDropdownComponent } from './components/common/custom-fields-lookup-dropdown/custom-fields-lookup-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
@ -485,6 +486,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
CustomFieldsLookupDropdownComponent,
ProfileEditDialogComponent,
DocumentLinkComponent,
PreviewPopupComponent,

View File

@ -0,0 +1,44 @@
<div class="btn-group w-100" ngbDropdown role="group" #dropdown="ngbDropdown">
<button class="btn btn-sm btn-outline-primary" id="dropdown_{{name}}" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
</button>
<div class="dropdown-menu shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<button type="button" class="btn btn-sm btn-outline-primary ms-3" (click)="addQuery()" [disabled]="disabled">
<i-bs name="plus"></i-bs>&nbsp;Add query
</button>
<div class="list-group list-group-flush">
@for (query of selectionModel.queries; track query; let i = $index) {
<div class="list-group-item d-flex">
<div class="input-group input-group-sm flex-shrink-1 flex-nowrap">
<ng-select
class="w-50"
[items]="customFields"
[(ngModel)]="query.field"
[disabled]="disabled"
bindLabel="name"
bindValue="id"
></ng-select>
<select class="w-25 form-control" [(ngModel)]="query.operator" [disabled]="disabled">
<option *ngFor="let operator of getOperatorsForField(query.field)" [ngValue]="operator">{{operator}}</option>
</select>
@switch (query.operator) {
@case ('exists') {
<select class="w-25 form-control" [(ngModel)]="query.value" [disabled]="disabled">
<option value="true" i18n>true</option>
<option value="false" i18n>false</option>
</select>
}
@default {
<input class="w-25 form-control" type="text" [(ngModel)]="query.value" [disabled]="disabled">
}
}
</div>
<button class="btn btn-link btn-sm pe-0" type="button" (click)="removeQuery(i)" [disabled]="disabled">
<i-bs name="x"></i-bs>
</button>
</div>
}
</div>
</div>
</div>

View File

@ -0,0 +1,8 @@
.dropdown-menu {
width: 450px;
}
::ng-deep .ng-select-container {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}

View File

@ -0,0 +1,158 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
export class CustomFieldQuery {
public changed = new Subject<CustomFieldQuery>()
private _field: string
set field(value: string) {
this._field = value
this.changed.next(this)
}
get field(): string {
return this._field
}
private _operator: string
set operator(value: string) {
this._operator = value
this.changed.next(this)
}
get operator(): string {
return this._operator
}
private _value: string
set value(value: string) {
this._value = value
this.changed.next(this)
}
get value(): string {
return this._value
}
constructor(
field: string = null,
operator: string = null,
value: string = null
) {
this.field = field
this.operator = operator
this.value = value
}
}
export class CustomFieldQueriesModel {
// matchingModel: MatchingModel
queries: CustomFieldQuery[] = []
changed = new Subject<CustomFieldQueriesModel>()
public clear(fireEvent = true) {
this.queries = []
if (fireEvent) {
this.changed.next(this)
}
}
public addQuery(query: CustomFieldQuery = new CustomFieldQuery()) {
this.queries.push(query)
query.changed.subscribe(() => {
if (query.field && query.operator && query.value) {
this.changed.next(this)
}
})
}
public removeQuery(index: number) {
const query = this.queries.splice(index, 1)[0]
query.changed.complete()
this.changed.next(this)
}
}
@Component({
selector: 'pngx-custom-fields-lookup-dropdown',
templateUrl: './custom-fields-lookup-dropdown.component.html',
styleUrls: ['./custom-fields-lookup-dropdown.component.scss'],
})
export class CustomFieldsLookupDropdownComponent {
@Input()
title: string
@Input()
filterPlaceholder: string = ''
@Input()
icon: string
@Input()
allowSelectNone: boolean = false
@Input()
editing = false
@Input()
applyOnClose = false
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@Input()
disabled: boolean = false
_selectionModel: CustomFieldQueriesModel = new CustomFieldQueriesModel()
@Input()
set selectionModel(model: CustomFieldQueriesModel) {
model.changed.subscribe((updatedModel) => {
this.selectionModelChange.next(updatedModel)
})
this._selectionModel = model
}
get selectionModel(): CustomFieldQueriesModel {
return this._selectionModel
}
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(protected customFieldsService: CustomFieldsService) {
this.getFields()
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
private getFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
})
}
public addQuery() {
this.selectionModel.addQuery()
}
public removeQuery(index: number) {
this.selectionModel.removeQuery(index)
}
getOperatorsForField(field: CustomField): string[] {
return ['exact', 'in', 'isnull', 'exists']
// TODO: implement this
}
}

View File

@ -86,15 +86,10 @@
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[manyToOne]="true"
[(selectionModel)]="customFieldSelectionModel"
<pngx-custom-fields-lookup-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
(opened)="onCustomFieldsDropdownOpen()"
[documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
></pngx-custom-fields-lookup-dropdown>
}
<pngx-dates-dropdown
title="Dates" i18n-title

View File

@ -839,9 +839,7 @@ describe('FilterEditorComponent', () => {
}))
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
@ -852,10 +850,10 @@ describe('FilterEditorComponent', () => {
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
expect(component.customFieldQueriesModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
expect(component.customFieldQueriesModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
@ -869,9 +867,7 @@ describe('FilterEditorComponent', () => {
}))
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
@ -882,10 +878,10 @@ describe('FilterEditorComponent', () => {
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
expect(component.customFieldQueriesModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
expect(component.customFieldQueriesModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
@ -898,25 +894,19 @@ describe('FilterEditorComponent', () => {
}))
it('should ingest filter rules for has any custom field', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.getSelectedItems()).toHaveLength(0)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: '1',
},
]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
1
)
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
expect(component.customFieldQueriesModel.getSelectedItems()).toHaveLength(1)
expect(component.customFieldQueriesModel.get(null)).toBeTruthy()
}))
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.getExcludedItems()).toHaveLength(0)
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
@ -927,10 +917,10 @@ describe('FilterEditorComponent', () => {
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
expect(component.customFieldQueriesModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
expect(component.customFieldQueriesModel.getExcludedItems()).toEqual(
custom_fields
)
// coverage

View File

@ -12,7 +12,7 @@ import {
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { Observable, Subject, Subscription, from } from 'rxjs'
import { Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@ -63,6 +63,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_LOOKUP,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@ -92,13 +93,16 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
import { SearchService } from 'src/app/services/rest/search.service'
import {
CustomFieldQueriesModel,
CustomFieldQuery,
} from '../../common/custom-fields-lookup-dropdown/custom-fields-lookup-dropdown.component'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
const TEXT_FILTER_TARGET_ASN = 'asn'
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null'
@ -134,10 +138,6 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
name: $localize`Title & content`,
},
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
{
id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
name: $localize`Custom fields`,
},
{
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`,
@ -321,7 +321,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string
dateCreatedAfter: string
@ -356,7 +356,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this.customFieldQueriesModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@ -383,8 +383,7 @@ export class FilterEditorComponent
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
case FILTER_CUSTOM_FIELDS_TEXT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
console.log('FILTER_CUSTOM_FIELDS_TEXT', rule.value)
break
case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',')
@ -523,35 +522,28 @@ export class FilterEditorComponent
false
)
break
case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
case FILTER_CUSTOM_FIELDS_LOOKUP:
// TODO: fully implement
const query = JSON.parse(rule.value)
this.customFieldQueriesModel.addQuery(
new CustomFieldQuery(query[0], query[1], query[2])
)
break
case FILTER_HAS_CUSTOM_FIELDS_ALL:
console.log('FILTER_HAS_CUSTOM_FIELDS_ALL', rule.value)
// TODO: fully implement
break
case FILTER_HAS_CUSTOM_FIELDS_ANY:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
console.log('FILTER_HAS_CUSTOM_FIELDS_ANY', rule.value)
// TODO: fully implement
break
case FILTER_HAS_ANY_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
null,
ToggleableItemState.Selected,
false
)
console.log('FILTER_HAS_ANY_CUSTOM_FIELDS', rule.value)
// TODO: fully implement
break
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
console.log('FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS', rule.value)
// TODO: fully implement
break
case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
@ -655,15 +647,6 @@ export class FilterEditorComponent
})
}
}
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: this._textFilter,
})
}
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY
@ -768,35 +751,24 @@ export class FilterEditorComponent
})
})
}
if (this.customFieldSelectionModel.isNoneSelected()) {
let queries = this.customFieldQueriesModel.queries
.filter((query) => query.field && query.operator)
.map((query) => [query.field, query.operator, query.value])
console.log(
'this.customFieldQueriesModel.queries',
this.customFieldQueriesModel.queries
)
console.log('queries', queries)
if (queries.length > 0) {
filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
})
} else {
const customFieldFilterType =
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_CUSTOM_FIELDS_ALL
: FILTER_HAS_CUSTOM_FIELDS_ANY
this.customFieldSelectionModel
.getSelectedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: customFieldFilterType,
value: field.id?.toString(),
})
})
this.customFieldSelectionModel
.getExcludedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: field.id?.toString(),
})
rule_type: FILTER_CUSTOM_FIELDS_LOOKUP,
value:
queries.length === 1
? JSON.stringify(queries[0])
: JSON.stringify(queries),
})
}
// TODO: fully implement custom fields
if (this.dateCreatedBefore) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
@ -1079,10 +1051,6 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text, updateRules = true) {
this._textFilter = text
if (updateRules) {

View File

@ -55,6 +55,8 @@ export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_CUSTOM_FIELDS_LOOKUP = 42
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
@ -317,6 +319,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: false,
default: true,
},
{
id: FILTER_CUSTOM_FIELDS_LOOKUP,
filtervar: 'custom_field_lookup',
datatype: 'string',
multi: false,
},
]
export interface FilterRuleType {

View File

@ -507,6 +507,7 @@ class SavedViewFilterRule(models.Model):
(39, _("has custom field in")),
(40, _("does not have custom field in")),
(41, _("does not have custom field")),
(42, _("custom fields lookup")),
]
saved_view = models.ForeignKey(