Support CF queries!

This commit is contained in:
shamoon
2025-10-07 11:53:47 -07:00
parent 3d9cf696a7
commit 88fcc5f339
12 changed files with 457 additions and 56 deletions

View File

@@ -1,28 +1,36 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
@if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
</div>
</div>
</div>
} @else {
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
}
<ng-template #list let-queries="queries">
<div class="list-group list-group-flush">
@for (element of queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</ng-template>
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {

View File

@@ -206,6 +206,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Input()
applyOnClose = false
@Input()
useDropdown: boolean = true
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}

View File

@@ -210,6 +210,19 @@
[title]="null"
formControlName="values"
></pngx-input-tags>
} @else if (
isCustomFieldQueryCondition(condition.get('type').value)
) {
<pngx-custom-fields-query-dropdown
[selectionModel]="getCustomFieldQueryModel(condition)"
(selectionModelChange)="onCustomFieldQuerySelectionChange(condition, $event)"
[useDropdown]="false"
></pngx-custom-fields-query-dropdown>
@if (!isCustomFieldQueryValid(condition)) {
<div class="text-danger small" i18n>
Complete the custom field query configuration.
</div>
}
} @else {
<pngx-input-select
[items]="getConditionSelectItems(condition.get('type').value)"

View File

@@ -410,11 +410,7 @@ describe('WorkflowEditDialogComponent', () => {
conditions.at(2).get('values').setValue([4])
const addConditionOfType = (type: TriggerConditionType) => {
component.addCondition(triggerGroup as FormGroup)
const conditionArray = component.getConditionsFormArray(
triggerGroup as FormGroup
)
const newCondition = conditionArray.at(conditionArray.length - 1)
const newCondition = component.addCondition(triggerGroup as FormGroup)
newCondition.get('type').setValue(type)
return newCondition
}
@@ -447,6 +443,12 @@ describe('WorkflowEditDialogComponent', () => {
)
storagePathNot.get('values').setValue([1])
const customFieldCondition = addConditionOfType(
TriggerConditionType.CustomFieldQuery
)
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
customFieldCondition.get('values').setValue(customFieldQuery)
const formValues = component['getFormValues']()
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
@@ -458,6 +460,9 @@ describe('WorkflowEditDialogComponent', () => {
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
customFieldQuery
)
expect(formValues.triggers[0].conditions).toBeUndefined()
})
@@ -506,12 +511,22 @@ describe('WorkflowEditDialogComponent', () => {
trigger.filter_has_not_document_types = [8] as any
trigger.filter_has_storage_path = 9 as any
trigger.filter_has_not_storage_paths = [10] as any
trigger.filter_custom_field_query = JSON.stringify([
'AND',
[[1, 'exact', 'value']],
]) as any
component.object = workflow
component.ngOnInit()
const triggerGroup = component.triggerFields.at(0) as FormGroup
const conditions = component.getConditionsFormArray(triggerGroup)
expect(conditions.length).toBe(9)
expect(conditions.length).toBe(10)
const customFieldCondition = conditions.at(9) as FormGroup
expect(customFieldCondition.get('type').value).toBe(
TriggerConditionType.CustomFieldQuery
)
const model = component.getCustomFieldQueryModel(customFieldCondition)
expect(model.isValid()).toBe(true)
})
it('should expose select metadata helpers', () => {
@@ -538,6 +553,12 @@ describe('WorkflowEditDialogComponent', () => {
expect(
component.getConditionSelectItems(TriggerConditionType.TagsAll)
).toEqual([])
expect(
component.isCustomFieldQueryCondition(
TriggerConditionType.CustomFieldQuery
)
).toBe(true)
})
it('should normalize condition values for single and multi selects', () => {
@@ -562,6 +583,13 @@ describe('WorkflowEditDialogComponent', () => {
8
)
).toEqual(8)
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
expect(
component['normalizeConditionValue'](
TriggerConditionType.CustomFieldQuery,
customFieldJson
)
).toEqual(customFieldJson)
})
it('should add and remove condition form groups', () => {

View File

@@ -6,6 +6,7 @@ import {
import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import {
AbstractControl,
FormArray,
FormControl,
FormGroup,
@@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { SettingsService } from 'src/app/services/settings.service'
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CheckComponent } from '../../input/check/check.component'
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
import { EntriesComponent } from '../../input/entries/entries.component'
@@ -145,18 +151,23 @@ export enum TriggerConditionType {
DocumentTypeNot = 'document_type_not',
StoragePathIs = 'storage_path_is',
StoragePathNot = 'storage_path_not',
CustomFieldQuery = 'custom_field_query',
}
interface TriggerConditionDefinition {
id: TriggerConditionType
name: string
inputType: 'tags' | 'select'
inputType: 'tags' | 'select' | 'customFieldQuery'
allowMultipleEntries: boolean
allowMultipleValues: boolean
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
disabled?: boolean
}
type TriggerConditionOption = TriggerConditionDefinition & {
disabled?: boolean
}
const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
{
id: TriggerConditionType.TagsAny,
@@ -227,6 +238,13 @@ const TRIGGER_CONDITION_DEFINITIONS: TriggerConditionDefinition[] = [
allowMultipleValues: true,
selectItems: 'storagePaths',
},
{
id: TriggerConditionType.CustomFieldQuery,
name: $localize`Matches custom field query`,
inputType: 'customFieldQuery',
allowMultipleEntries: false,
allowMultipleValues: false,
},
]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
@@ -247,6 +265,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
TextAreaComponent,
TagsComponent,
CustomFieldsValuesComponent,
CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
ConfirmButtonComponent,
@@ -287,7 +306,12 @@ export class WorkflowEditDialogComponent
private conditionTypeOptionCache = new WeakMap<
FormArray,
TriggerConditionDefinition[]
TriggerConditionOption[]
>()
private customFieldQueryModels = new WeakMap<
FormGroup,
CustomFieldQueriesModel
>()
constructor() {
@@ -510,6 +534,7 @@ export class WorkflowEditDialogComponent
filter_has_correspondent: null as number | null,
filter_has_document_type: null as number | null,
filter_has_storage_path: null as number | null,
filter_custom_field_query: null as string | null,
}
conditions.controls.forEach((control) => {
@@ -558,6 +583,9 @@ export class WorkflowEditDialogComponent
case TriggerConditionType.StoragePathNot:
aggregate.filter_has_not_storage_paths = [...values]
break
case TriggerConditionType.CustomFieldQuery:
aggregate.filter_custom_field_query = values as string
break
}
})
@@ -576,6 +604,8 @@ export class WorkflowEditDialogComponent
aggregate.filter_has_document_type ?? null
trigger.filter_has_storage_path =
aggregate.filter_has_storage_path ?? null
trigger.filter_custom_field_query =
aggregate.filter_custom_field_query ?? null
delete trigger.conditions
@@ -593,7 +623,7 @@ export class WorkflowEditDialogComponent
private createConditionFormGroup(
type: TriggerConditionType,
initialValue?: number | number[]
initialValue?: any
): FormGroup {
const group = new FormGroup({
type: new FormControl(type),
@@ -603,11 +633,20 @@ export class WorkflowEditDialogComponent
group
.get('type')
.valueChanges.subscribe((newType: TriggerConditionType) => {
group.get('values').setValue(this.getDefaultConditionValue(newType), {
emitEvent: false,
})
if (newType === TriggerConditionType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group)
} else {
this.teardownCustomFieldQueryModel(group)
group.get('values').setValue(this.getDefaultConditionValue(newType), {
emitEvent: false,
})
}
})
if (type === TriggerConditionType.CustomFieldQuery) {
this.ensureCustomFieldQueryModel(group, initialValue)
}
return group
}
@@ -704,6 +743,15 @@ export class WorkflowEditDialogComponent
)
}
if (trigger.filter_custom_field_query) {
conditions.push(
this.createConditionFormGroup(
TriggerConditionType.CustomFieldQuery,
trigger.filter_custom_field_query
)
)
}
return conditions
}
@@ -753,10 +801,10 @@ export class WorkflowEditDialogComponent
})
}
addCondition(triggerFormGroup: FormGroup) {
addCondition(triggerFormGroup: FormGroup): FormGroup | null {
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
if (triggerIndex === -1) {
return
return null
}
const conditions = this.getConditionsFormArray(triggerFormGroup)
@@ -771,12 +819,14 @@ export class WorkflowEditDialogComponent
})
if (!availableDefinition) {
return
return null
}
conditions.push(this.createConditionFormGroup(availableDefinition.id))
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
return conditions.at(conditions.length - 1) as FormGroup
}
removeCondition(triggerFormGroup: FormGroup, conditionIndex: number) {
@@ -786,6 +836,13 @@ export class WorkflowEditDialogComponent
}
const conditions = this.getConditionsFormArray(triggerFormGroup)
const conditionGroup = conditions.at(conditionIndex) as FormGroup
if (
conditionGroup?.get('type').value ===
TriggerConditionType.CustomFieldQuery
) {
this.teardownCustomFieldQueryModel(conditionGroup)
}
conditions.removeAt(conditionIndex)
triggerFormGroup.markAsDirty()
triggerFormGroup.markAsTouched()
@@ -807,6 +864,10 @@ export class WorkflowEditDialogComponent
return this.getConditionDefinition(type)?.inputType === 'tags'
}
isCustomFieldQueryCondition(type: TriggerConditionType): boolean {
return this.getConditionDefinition(type)?.inputType === 'customFieldQuery'
}
isMultiValueCondition(type: TriggerConditionType): boolean {
switch (type) {
case TriggerConditionType.TagsAny:
@@ -843,18 +904,124 @@ export class WorkflowEditDialogComponent
}
}
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
const conditionGroup = control as FormGroup
this.ensureCustomFieldQueryModel(conditionGroup)
return this.customFieldQueryModels.get(conditionGroup)
}
onCustomFieldQuerySelectionChange(
control: AbstractControl,
model: CustomFieldQueriesModel
) {
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
}
isCustomFieldQueryValid(control: AbstractControl): boolean {
const model = this.customFieldQueryModels.get(control as FormGroup)
if (!model) {
return true
}
return model.isEmpty() || model.isValid()
}
private getConditionTypeOptionsForArray(
conditions: FormArray
): TriggerConditionOption[] {
let cached = this.conditionTypeOptionCache.get(conditions)
if (!cached) {
cached = this.conditionDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.conditionTypeOptionCache.set(conditions, cached)
}
return cached
}
private ensureCustomFieldQueryModel(
conditionGroup: FormGroup,
initialValue?: any
) {
if (this.customFieldQueryModels.has(conditionGroup)) {
return
}
const model = new CustomFieldQueriesModel()
this.customFieldQueryModels.set(conditionGroup, model)
const rawValue =
typeof initialValue === 'string'
? initialValue
: (conditionGroup.get('values').value as string)
if (rawValue) {
try {
const parsed = JSON.parse(rawValue)
const expression = new CustomFieldQueryExpression(parsed)
model.queries = [expression]
} catch (error) {
model.clear(false)
}
}
model.changed.subscribe(() => {
this.onCustomFieldQueryModelChanged(conditionGroup, model)
})
this.onCustomFieldQueryModelChanged(conditionGroup, model)
}
private teardownCustomFieldQueryModel(conditionGroup: FormGroup) {
if (!this.customFieldQueryModels.has(conditionGroup)) {
return
}
this.customFieldQueryModels.delete(conditionGroup)
}
private onCustomFieldQueryModelChanged(
conditionGroup: FormGroup,
model: CustomFieldQueriesModel
) {
const control = conditionGroup.get('values')
if (!control) {
return
}
if (!model.isValid()) {
control.setValue(null, { emitEvent: false })
return
}
if (model.isEmpty()) {
control.setValue(null, { emitEvent: false })
return
}
const serialized = JSON.stringify(model.queries[0].serialize())
control.setValue(serialized, { emitEvent: false })
}
private getDefaultConditionValue(type: TriggerConditionType) {
if (type === TriggerConditionType.CustomFieldQuery) {
return null
}
return this.isMultiValueCondition(type) ? [] : null
}
private normalizeConditionValue(
type: TriggerConditionType,
value?: number | number[]
) {
private normalizeConditionValue(type: TriggerConditionType, value?: any) {
if (value === undefined || value === null) {
return this.getDefaultConditionValue(type)
}
if (type === TriggerConditionType.CustomFieldQuery) {
if (typeof value === 'string') {
return value
}
return value ? JSON.stringify(value) : null
}
if (this.isMultiValueCondition(type)) {
return Array.isArray(value) ? [...value] : [value]
}
@@ -866,20 +1033,6 @@ export class WorkflowEditDialogComponent
return value
}
private getConditionTypeOptionsForArray(
conditions: FormArray
): TriggerConditionDefinition[] {
let cached = this.conditionTypeOptionCache.get(conditions)
if (!cached) {
cached = this.conditionDefinitions.map((definition) => ({
...definition,
disabled: false,
}))
this.conditionTypeOptionCache.set(conditions, cached)
}
return cached
}
private createTriggerField(
trigger: WorkflowTrigger,
emitEvent: boolean = false
@@ -1032,6 +1185,7 @@ export class WorkflowEditDialogComponent
filter_has_not_correspondents: [],
filter_has_not_document_types: [],
filter_has_not_storage_paths: [],
filter_custom_field_query: null,
filter_has_correspondent: null,
filter_has_document_type: null,
filter_has_storage_path: null,

View File

@@ -50,6 +50,8 @@ export interface WorkflowTrigger extends ObjectWithId {
filter_has_not_storage_paths?: number[] // StoragePath.id[]
filter_custom_field_query?: string
filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id