-
-
-
- @for (element of selectionModel.queries; track element.id; let i = $index) {
-
- @switch (element.type) {
- @case (CustomFieldQueryComponentType.Atom) {
-
- }
- @case (CustomFieldQueryComponentType.Expression) {
-
- }
- }
-
+@if (useDropdown) {
+
+
+
+
-
+} @else {
+
+}
+
+
+
+ @for (element of queries; track element.id; let i = $index) {
+
+ @switch (element.type) {
+ @case (CustomFieldQueryComponentType.Atom) {
+
+ }
+ @case (CustomFieldQueryComponentType.Expression) {
+
+ }
+ }
+
+ }
+
+
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
index ef56d6ac5..fc4e8ef19 100644
--- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
+++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts
@@ -120,6 +120,12 @@ export class CustomFieldQueriesModel {
})
}
+ addInitialAtom() {
+ this.addAtom(
+ new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
+ )
+ }
+
private findElement(
queryElement: CustomFieldQueryElement,
elements: any[]
@@ -206,6 +212,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
}
@@ -258,13 +267,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
public onOpenChange(open: boolean) {
if (open) {
if (this.selectionModel.queries.length === 0) {
- this.selectionModel.addAtom(
- new CustomFieldQueryAtom([
- null,
- CustomFieldQueryOperator.Exists,
- 'true',
- ])
- )
+ this.selectionModel.addInitialAtom()
}
if (
this.selectionModel.queries.length === 1 &&
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
index 7163ba289..61daa1fa2 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -156,31 +156,97 @@
Trigger for documents that match all filters specified below.
-
+
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
-
-
-
+
+
+
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
-
- @if (patternRequired) {
-
+
+ @if (matchingPatternRequired(formGroup)) {
+
}
- @if (patternRequired) {
-
+ @if (matchingPatternRequired(formGroup)) {
+
}
}
- @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
-
- }
+ @if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
+
+
+
+
+
+
+
+
+
+
+
+ }
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
index 6cfcf86b4..d026a5b2b 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.scss
@@ -7,3 +7,7 @@
.accordion-button {
font-size: 1rem;
}
+
+:host ::ng-deep .filters .paperless-input-select.mb-3 {
+ margin-bottom: 0 !important;
+}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
index 930164dce..0736e2215 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
@@ -11,8 +11,14 @@ import {
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs'
+import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import { CustomFieldDataType } from 'src/app/data/custom-field'
-import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
+import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
+import {
+ MATCHING_ALGORITHMS,
+ MATCH_AUTO,
+ MATCH_NONE,
+} from 'src/app/data/matching-model'
import { Workflow } from 'src/app/data/workflow'
import {
WorkflowAction,
@@ -31,6 +37,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.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 { NumberComponent } from '../../input/number/number.component'
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
@@ -43,6 +50,7 @@ import { EditDialogMode } from '../edit-dialog.component'
import {
DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS,
+ TriggerFilterType,
WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent,
@@ -375,6 +383,562 @@ describe('WorkflowEditDialogComponent', () => {
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
})
+ it('should require matching pattern when algorithm is not none', () => {
+ const triggerGroup = new FormGroup({
+ matching_algorithm: new FormControl(MATCH_AUTO),
+ match: new FormControl(''),
+ })
+ expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
+ triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id)
+ expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
+ triggerGroup.get('matching_algorithm').setValue(MATCH_NONE)
+ expect(component.matchingPatternRequired(triggerGroup)).toBe(false)
+ })
+
+ it('should map filter builder values into trigger filters on save', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0)
+ component.addFilter(triggerGroup as FormGroup)
+ component.addFilter(triggerGroup as FormGroup)
+ component.addFilter(triggerGroup as FormGroup)
+
+ const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
+ expect(filters.length).toBe(3)
+
+ filters.at(0).get('values').setValue([1])
+ filters.at(1).get('values').setValue([2, 3])
+ filters.at(2).get('values').setValue([4])
+
+ const addFilterOfType = (type: TriggerFilterType) => {
+ const newFilter = component.addFilter(triggerGroup as FormGroup)
+ newFilter.get('type').setValue(type)
+ return newFilter
+ }
+
+ const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
+ correspondentIs.get('values').setValue(1)
+
+ const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot)
+ correspondentNot.get('values').setValue([1])
+
+ const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
+ documentTypeIs.get('values').setValue(1)
+
+ const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
+ documentTypeNot.get('values').setValue([1])
+
+ const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
+ storagePathIs.get('values').setValue(1)
+
+ const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
+ storagePathNot.get('values').setValue([1])
+
+ const customFieldFilter = addFilterOfType(
+ TriggerFilterType.CustomFieldQuery
+ )
+ const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
+ customFieldFilter.get('values').setValue(customFieldQuery)
+
+ const formValues = component['getFormValues']()
+
+ expect(formValues.triggers[0].filter_has_tags).toEqual([1])
+ expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
+ expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
+ expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
+ expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
+ expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
+ 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].filters).toBeUndefined()
+ })
+
+ it('should ignore empty and null filter values when mapping filters', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ const tagsFilter = component.addFilter(triggerGroup)
+ tagsFilter.get('type').setValue(TriggerFilterType.TagsAny)
+ tagsFilter.get('values').setValue([])
+
+ const correspondentFilter = component.addFilter(triggerGroup)
+ correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
+ correspondentFilter.get('values').setValue(null)
+
+ const formValues = component['getFormValues']()
+
+ expect(formValues.triggers[0].filter_has_tags).toEqual([])
+ expect(formValues.triggers[0].filter_has_correspondent).toBeNull()
+ })
+
+ it('should derive single select filters from array values', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ const addFilterOfType = (type: TriggerFilterType, value: any) => {
+ const filter = component.addFilter(triggerGroup)
+ filter.get('type').setValue(type)
+ filter.get('values').setValue(value)
+ }
+
+ addFilterOfType(TriggerFilterType.CorrespondentIs, [5])
+ addFilterOfType(TriggerFilterType.DocumentTypeIs, [6])
+ addFilterOfType(TriggerFilterType.StoragePathIs, [7])
+
+ const formValues = component['getFormValues']()
+
+ expect(formValues.triggers[0].filter_has_correspondent).toEqual(5)
+ expect(formValues.triggers[0].filter_has_document_type).toEqual(6)
+ expect(formValues.triggers[0].filter_has_storage_path).toEqual(7)
+ })
+
+ it('should convert multi-value filter values when aggregating filters', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ const setFilter = (type: TriggerFilterType, value: number): void => {
+ const filter = component.addFilter(triggerGroup) as FormGroup
+ filter.get('type').setValue(type)
+ filter.get('values').setValue(value)
+ }
+
+ setFilter(TriggerFilterType.TagsAll, 11)
+ setFilter(TriggerFilterType.TagsNone, 12)
+ setFilter(TriggerFilterType.CorrespondentNot, 13)
+ setFilter(TriggerFilterType.DocumentTypeNot, 14)
+ setFilter(TriggerFilterType.StoragePathNot, 15)
+
+ const formValues = component['getFormValues']()
+
+ expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
+ expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
+ expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
+ expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
+ expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
+ })
+
+ it('should reuse filter type options and update disabled state', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+ component.addFilter(triggerGroup)
+
+ const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0)
+ const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0)
+ expect(optionsFirst).toBe(optionsSecond)
+
+ // to force disabled flag
+ component.addFilter(triggerGroup)
+ const filterArray = component.getFiltersFormArray(triggerGroup)
+ const firstFilter = filterArray.at(0)
+ firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
+
+ component.addFilter(triggerGroup)
+ const updatedFilters = component.getFiltersFormArray(triggerGroup)
+ const secondFilter = updatedFilters.at(1)
+ const options = component.getFilterTypeOptions(triggerGroup, 1)
+ const correspondentIsOption = options.find(
+ (option) => option.id === TriggerFilterType.CorrespondentIs
+ )
+ expect(correspondentIsOption.disabled).toBe(true)
+
+ firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot)
+ secondFilter.get('type').setValue(TriggerFilterType.TagsAll)
+ const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1)
+ const correspondentOptionAfter = postChangeOptions.find(
+ (option) => option.id === TriggerFilterType.CorrespondentIs
+ )
+ expect(correspondentOptionAfter.disabled).toBe(false)
+ })
+
+ it('should keep multi-entry filter options enabled and allow duplicates', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ component.filterDefinitions = [
+ {
+ id: TriggerFilterType.TagsAny,
+ name: 'Any tags',
+ inputType: 'tags',
+ allowMultipleEntries: true,
+ allowMultipleValues: true,
+ } as any,
+ {
+ id: TriggerFilterType.CorrespondentIs,
+ name: 'Correspondent is',
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'correspondents',
+ } as any,
+ ]
+
+ const firstFilter = component.addFilter(triggerGroup)
+ firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
+
+ const secondFilter = component.addFilter(triggerGroup)
+ expect(secondFilter).not.toBeNull()
+
+ const options = component.getFilterTypeOptions(triggerGroup, 1)
+ const multiEntryOption = options.find(
+ (option) => option.id === TriggerFilterType.TagsAny
+ )
+
+ expect(multiEntryOption.disabled).toBe(false)
+ expect(component.canAddFilter(triggerGroup)).toBe(true)
+ })
+
+ it('should return null when no filter definitions remain available', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ component.filterDefinitions = [
+ {
+ id: TriggerFilterType.TagsAny,
+ name: 'Any tags',
+ inputType: 'tags',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ } as any,
+ {
+ id: TriggerFilterType.CorrespondentIs,
+ name: 'Correspondent is',
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'correspondents',
+ } as any,
+ ]
+
+ const firstFilter = component.addFilter(triggerGroup)
+ firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
+ const secondFilter = component.addFilter(triggerGroup)
+ secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
+
+ expect(component.canAddFilter(triggerGroup)).toBe(false)
+ expect(component.addFilter(triggerGroup)).toBeNull()
+ })
+
+ it('should skip filter definitions without handlers when building form array', () => {
+ const originalDefinitions = component.filterDefinitions
+ component.filterDefinitions = [
+ {
+ id: 999,
+ name: 'Unsupported',
+ inputType: 'text',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ } as any,
+ ]
+
+ const trigger = {
+ filter_has_tags: [],
+ filter_has_all_tags: [],
+ filter_has_not_tags: [],
+ filter_has_not_correspondents: [],
+ filter_has_not_document_types: [],
+ filter_has_not_storage_paths: [],
+ filter_has_correspondent: null,
+ filter_has_document_type: null,
+ filter_has_storage_path: null,
+ filter_custom_field_query: null,
+ } as any
+
+ const filters = component['buildFiltersFormArray'](trigger)
+ expect(filters.length).toBe(0)
+
+ component.filterDefinitions = originalDefinitions
+ })
+
+ it('should return null when adding filter for unknown trigger form group', () => {
+ expect(component.addFilter(new FormGroup({}) as any)).toBeNull()
+ })
+
+ it('should ignore remove filter calls for unknown trigger form group', () => {
+ expect(() =>
+ component.removeFilter(new FormGroup({}) as any, 0)
+ ).not.toThrow()
+ })
+
+ it('should teardown custom field query model when removing a custom field filter', () => {
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ component.addFilter(triggerGroup)
+ const filters = component.getFiltersFormArray(triggerGroup)
+ const filterGroup = filters.at(0) as FormGroup
+ filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery)
+
+ const model = component.getCustomFieldQueryModel(filterGroup)
+ expect(model).toBeDefined()
+ expect(
+ component['getStoredCustomFieldQueryModel'](filterGroup as any)
+ ).toBe(model)
+
+ component.removeFilter(triggerGroup, 0)
+ expect(
+ component['getStoredCustomFieldQueryModel'](filterGroup as any)
+ ).toBeNull()
+ })
+
+ it('should return readable filter names', () => {
+ expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe(
+ 'Has any of these tags'
+ )
+ expect(component.getFilterName(999 as any)).toBe('')
+ })
+
+ it('should build filter form array from existing trigger filters', () => {
+ const trigger = workflow.triggers[0]
+ trigger.filter_has_tags = [1]
+ trigger.filter_has_all_tags = [2, 3]
+ trigger.filter_has_not_tags = [4]
+ trigger.filter_has_correspondent = 5 as any
+ trigger.filter_has_not_correspondents = [6] as any
+ trigger.filter_has_document_type = 7 as any
+ 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 filters = component.getFiltersFormArray(triggerGroup)
+ expect(filters.length).toBe(10)
+ const customFieldFilter = filters.at(9) as FormGroup
+ expect(customFieldFilter.get('type').value).toBe(
+ TriggerFilterType.CustomFieldQuery
+ )
+ const model = component.getCustomFieldQueryModel(customFieldFilter)
+ expect(model.isValid()).toBe(true)
+ })
+
+ it('should expose select metadata helpers', () => {
+ expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
+ true
+ )
+ expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
+ false
+ )
+
+ component.correspondents = [{ id: 1, name: 'C1' } as any]
+ component.documentTypes = [{ id: 2, name: 'DT' } as any]
+ component.storagePaths = [{ id: 3, name: 'SP' } as any]
+
+ expect(
+ component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
+ ).toEqual(component.correspondents)
+ expect(
+ component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
+ ).toEqual(component.documentTypes)
+ expect(
+ component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
+ ).toEqual(component.storagePaths)
+ expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
+ []
+ )
+
+ expect(
+ component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery)
+ ).toBe(true)
+ })
+
+ it('should return empty select items when definition is missing', () => {
+ const originalDefinitions = component.filterDefinitions
+ component.filterDefinitions = []
+
+ expect(
+ component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
+ ).toEqual([])
+
+ component.filterDefinitions = originalDefinitions
+ })
+
+ it('should return empty select items when definition has unknown source', () => {
+ const originalDefinitions = component.filterDefinitions
+ component.filterDefinitions = [
+ {
+ id: TriggerFilterType.CorrespondentIs,
+ name: 'Correspondent is',
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'unknown',
+ } as any,
+ ]
+
+ expect(
+ component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
+ ).toEqual([])
+
+ component.filterDefinitions = originalDefinitions
+ })
+
+ it('should handle custom field query selection change and validation states', () => {
+ const formGroup = new FormGroup({
+ values: new FormControl(null),
+ })
+ const model = new CustomFieldQueriesModel()
+
+ const changeSpy = jest.spyOn(
+ component as any,
+ 'onCustomFieldQueryModelChanged'
+ )
+
+ component.onCustomFieldQuerySelectionChange(formGroup, model)
+ expect(changeSpy).toHaveBeenCalledWith(formGroup, model)
+
+ expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
+ component['setCustomFieldQueryModel'](formGroup as any, model as any)
+
+ const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false)
+ const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false)
+ expect(component.isCustomFieldQueryValid(formGroup)).toBe(false)
+ expect(validSpy).toHaveBeenCalled()
+
+ validSpy.mockReturnValue(true)
+ emptySpy.mockReturnValue(true)
+ expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
+
+ emptySpy.mockReturnValue(false)
+ expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
+
+ component['clearCustomFieldQueryModel'](formGroup as any)
+ })
+
+ it('should recover from invalid custom field query json and update control on changes', () => {
+ const filterGroup = new FormGroup({
+ values: new FormControl('not-json'),
+ })
+
+ component['ensureCustomFieldQueryModel'](filterGroup, 'not-json')
+
+ const model = component['getStoredCustomFieldQueryModel'](
+ filterGroup as any
+ )
+ expect(model).toBeDefined()
+ expect(model.queries.length).toBeGreaterThan(0)
+
+ const valuesControl = filterGroup.get('values')
+ expect(valuesControl.value).toBeNull()
+
+ const expression = new CustomFieldQueryExpression([
+ CustomFieldQueryLogicalOperator.And,
+ [[1, 'exact', 'value']],
+ ])
+ model.queries = [expression]
+
+ jest.spyOn(model, 'isValid').mockReturnValue(true)
+ jest.spyOn(model, 'isEmpty').mockReturnValue(false)
+
+ model.changed.next(model)
+
+ expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize()))
+
+ component['clearCustomFieldQueryModel'](filterGroup as any)
+ })
+
+ it('should handle custom field query model change edge cases', () => {
+ const groupWithoutControl = new FormGroup({})
+ const dummyModel = {
+ isValid: jest.fn().mockReturnValue(true),
+ isEmpty: jest.fn().mockReturnValue(false),
+ }
+
+ expect(() =>
+ component['onCustomFieldQueryModelChanged'](
+ groupWithoutControl as any,
+ dummyModel as any
+ )
+ ).not.toThrow()
+
+ const groupWithControl = new FormGroup({
+ values: new FormControl('initial'),
+ })
+ const emptyModel = {
+ isValid: jest.fn().mockReturnValue(true),
+ isEmpty: jest.fn().mockReturnValue(true),
+ }
+
+ component['onCustomFieldQueryModelChanged'](
+ groupWithControl as any,
+ emptyModel as any
+ )
+
+ expect(groupWithControl.get('values').value).toBeNull()
+ })
+
+ it('should normalize filter values for single and multi selects', () => {
+ expect(
+ component['normalizeFilterValue'](TriggerFilterType.TagsAny)
+ ).toEqual([])
+ expect(
+ component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5)
+ ).toEqual([5])
+ expect(
+ component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6])
+ ).toEqual([5, 6])
+ expect(
+ component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7])
+ ).toEqual(7)
+ expect(
+ component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8)
+ ).toEqual(8)
+ const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
+ expect(
+ component['normalizeFilterValue'](
+ TriggerFilterType.CustomFieldQuery,
+ customFieldJson
+ )
+ ).toEqual(customFieldJson)
+
+ const customFieldObject = ['AND', [[1, 'exact', 'other']]]
+ expect(
+ component['normalizeFilterValue'](
+ TriggerFilterType.CustomFieldQuery,
+ customFieldObject
+ )
+ ).toEqual(JSON.stringify(customFieldObject))
+
+ expect(
+ component['normalizeFilterValue'](
+ TriggerFilterType.CustomFieldQuery,
+ false
+ )
+ ).toBeNull()
+ })
+
+ it('should add and remove filter form groups', () => {
+ component['changeDetector'] = { detectChanges: jest.fn() } as any
+ component.object = undefined
+ component.addTrigger()
+ const triggerGroup = component.triggerFields.at(0) as FormGroup
+
+ component.addFilter(triggerGroup)
+
+ component.removeFilter(triggerGroup, 0)
+ expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
+
+ component.addFilter(triggerGroup)
+ const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
+ filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
+ expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
+ })
+
it('should remove selected custom field from the form group', () => {
const formGroup = new FormGroup({
assign_custom_fields: new FormControl([1, 2, 3]),
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index ec27d6c59..f6d9e60f5 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -6,6 +6,7 @@ import {
import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import {
+ AbstractControl,
FormArray,
FormControl,
FormGroup,
@@ -14,7 +15,7 @@ import {
} from '@angular/forms'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
-import { first } from 'rxjs'
+import { Subscription, first, takeUntil } from 'rxjs'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
@@ -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'
@@ -135,10 +141,235 @@ export const WORKFLOW_ACTION_OPTIONS = [
},
]
+export enum TriggerFilterType {
+ TagsAny = 'tags_any',
+ TagsAll = 'tags_all',
+ TagsNone = 'tags_none',
+ CorrespondentIs = 'correspondent_is',
+ CorrespondentNot = 'correspondent_not',
+ DocumentTypeIs = 'document_type_is',
+ DocumentTypeNot = 'document_type_not',
+ StoragePathIs = 'storage_path_is',
+ StoragePathNot = 'storage_path_not',
+ CustomFieldQuery = 'custom_field_query',
+}
+
+interface TriggerFilterDefinition {
+ id: TriggerFilterType
+ name: string
+ inputType: 'tags' | 'select' | 'customFieldQuery'
+ allowMultipleEntries: boolean
+ allowMultipleValues: boolean
+ selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
+ disabled?: boolean
+}
+
+type TriggerFilterOption = TriggerFilterDefinition & {
+ disabled?: boolean
+}
+
+type TriggerFilterAggregate = {
+ filter_has_tags: number[]
+ filter_has_all_tags: number[]
+ filter_has_not_tags: number[]
+ filter_has_not_correspondents: number[]
+ filter_has_not_document_types: number[]
+ filter_has_not_storage_paths: number[]
+ filter_has_correspondent: number | null
+ filter_has_document_type: number | null
+ filter_has_storage_path: number | null
+ filter_custom_field_query: string | null
+}
+
+interface FilterHandler {
+ apply: (aggregate: TriggerFilterAggregate, values: any) => void
+ extract: (trigger: WorkflowTrigger) => any
+ hasValue: (value: any) => boolean
+}
+
+const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel')
+const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol(
+ 'customFieldQuerySubscription'
+)
+
+type CustomFieldFilterGroup = FormGroup & {
+ [CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel
+ [CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription
+}
+
+const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
+ {
+ id: TriggerFilterType.TagsAny,
+ name: $localize`Has any of these tags`,
+ inputType: 'tags',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ },
+ {
+ id: TriggerFilterType.TagsAll,
+ name: $localize`Has all of these tags`,
+ inputType: 'tags',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ },
+ {
+ id: TriggerFilterType.TagsNone,
+ name: $localize`Does not have these tags`,
+ inputType: 'tags',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ },
+ {
+ id: TriggerFilterType.CorrespondentIs,
+ name: $localize`Has correspondent`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'correspondents',
+ },
+ {
+ id: TriggerFilterType.CorrespondentNot,
+ name: $localize`Does not have correspondents`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ selectItems: 'correspondents',
+ },
+ {
+ id: TriggerFilterType.DocumentTypeIs,
+ name: $localize`Has document type`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'documentTypes',
+ },
+ {
+ id: TriggerFilterType.DocumentTypeNot,
+ name: $localize`Does not have document types`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ selectItems: 'documentTypes',
+ },
+ {
+ id: TriggerFilterType.StoragePathIs,
+ name: $localize`Has storage path`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ selectItems: 'storagePaths',
+ },
+ {
+ id: TriggerFilterType.StoragePathNot,
+ name: $localize`Does not have storage paths`,
+ inputType: 'select',
+ allowMultipleEntries: false,
+ allowMultipleValues: true,
+ selectItems: 'storagePaths',
+ },
+ {
+ id: TriggerFilterType.CustomFieldQuery,
+ name: $localize`Matches custom field query`,
+ inputType: 'customFieldQuery',
+ allowMultipleEntries: false,
+ allowMultipleValues: false,
+ },
+]
+
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(a) => a.id !== MATCH_AUTO
)
+const FILTER_HANDLERS: Record
= {
+ [TriggerFilterType.TagsAny]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values]
+ },
+ extract: (trigger) => trigger.filter_has_tags,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.TagsAll]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_all_tags = Array.isArray(values)
+ ? [...values]
+ : [values]
+ },
+ extract: (trigger) => trigger.filter_has_all_tags,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.TagsNone]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_not_tags = Array.isArray(values)
+ ? [...values]
+ : [values]
+ },
+ extract: (trigger) => trigger.filter_has_not_tags,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.CorrespondentIs]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_correspondent = Array.isArray(values)
+ ? (values[0] ?? null)
+ : values
+ },
+ extract: (trigger) => trigger.filter_has_correspondent,
+ hasValue: (value) => value !== null && value !== undefined,
+ },
+ [TriggerFilterType.CorrespondentNot]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_not_correspondents = Array.isArray(values)
+ ? [...values]
+ : [values]
+ },
+ extract: (trigger) => trigger.filter_has_not_correspondents,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.DocumentTypeIs]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_document_type = Array.isArray(values)
+ ? (values[0] ?? null)
+ : values
+ },
+ extract: (trigger) => trigger.filter_has_document_type,
+ hasValue: (value) => value !== null && value !== undefined,
+ },
+ [TriggerFilterType.DocumentTypeNot]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_not_document_types = Array.isArray(values)
+ ? [...values]
+ : [values]
+ },
+ extract: (trigger) => trigger.filter_has_not_document_types,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.StoragePathIs]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_storage_path = Array.isArray(values)
+ ? (values[0] ?? null)
+ : values
+ },
+ extract: (trigger) => trigger.filter_has_storage_path,
+ hasValue: (value) => value !== null && value !== undefined,
+ },
+ [TriggerFilterType.StoragePathNot]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_has_not_storage_paths = Array.isArray(values)
+ ? [...values]
+ : [values]
+ },
+ extract: (trigger) => trigger.filter_has_not_storage_paths,
+ hasValue: (value) => Array.isArray(value) && value.length > 0,
+ },
+ [TriggerFilterType.CustomFieldQuery]: {
+ apply: (aggregate, values) => {
+ aggregate.filter_custom_field_query = values as string
+ },
+ extract: (trigger) => trigger.filter_custom_field_query,
+ hasValue: (value) =>
+ typeof value === 'string' && value !== null && value.trim().length > 0,
+ },
+}
+
@Component({
selector: 'pngx-workflow-edit-dialog',
templateUrl: './workflow-edit-dialog.component.html',
@@ -153,6 +384,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
TextAreaComponent,
TagsComponent,
CustomFieldsValuesComponent,
+ CustomFieldsQueryDropdownComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
ConfirmButtonComponent,
@@ -170,6 +402,8 @@ export class WorkflowEditDialogComponent
{
public WorkflowTriggerType = WorkflowTriggerType
public WorkflowActionType = WorkflowActionType
+ public TriggerFilterType = TriggerFilterType
+ public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService
@@ -189,6 +423,11 @@ export class WorkflowEditDialogComponent
private allowedActionTypes = []
+ private readonly triggerFilterOptionsMap = new WeakMap<
+ FormArray,
+ TriggerFilterOption[]
+ >()
+
constructor() {
super()
this.service = inject(WorkflowService)
@@ -390,6 +629,416 @@ export class WorkflowEditDialogComponent
return this.objectForm.get('actions') as FormArray
}
+ protected override getFormValues(): any {
+ const formValues = super.getFormValues()
+
+ if (formValues?.triggers?.length) {
+ formValues.triggers = formValues.triggers.map(
+ (trigger: any, index: number) => {
+ const triggerFormGroup = this.triggerFields.at(index) as FormGroup
+ const filters = this.getFiltersFormArray(triggerFormGroup)
+
+ const aggregate: TriggerFilterAggregate = {
+ filter_has_tags: [],
+ filter_has_all_tags: [],
+ filter_has_not_tags: [],
+ filter_has_not_correspondents: [],
+ filter_has_not_document_types: [],
+ filter_has_not_storage_paths: [],
+ filter_has_correspondent: null,
+ filter_has_document_type: null,
+ filter_has_storage_path: null,
+ filter_custom_field_query: null,
+ }
+
+ for (const control of filters.controls) {
+ const type = control.get('type').value as TriggerFilterType
+ const values = control.get('values').value
+
+ if (values === null || values === undefined) {
+ continue
+ }
+
+ if (Array.isArray(values) && values.length === 0) {
+ continue
+ }
+
+ const handler = FILTER_HANDLERS[type]
+ handler?.apply(aggregate, values)
+ }
+
+ trigger.filter_has_tags = aggregate.filter_has_tags
+ trigger.filter_has_all_tags = aggregate.filter_has_all_tags
+ trigger.filter_has_not_tags = aggregate.filter_has_not_tags
+ trigger.filter_has_not_correspondents =
+ aggregate.filter_has_not_correspondents
+ trigger.filter_has_not_document_types =
+ aggregate.filter_has_not_document_types
+ trigger.filter_has_not_storage_paths =
+ aggregate.filter_has_not_storage_paths
+ trigger.filter_has_correspondent =
+ aggregate.filter_has_correspondent ?? null
+ trigger.filter_has_document_type =
+ 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.filters
+
+ return trigger
+ }
+ )
+ }
+
+ return formValues
+ }
+
+ public matchingPatternRequired(formGroup: FormGroup): boolean {
+ return formGroup.get('matching_algorithm').value !== MATCH_NONE
+ }
+
+ private createFilterFormGroup(
+ type: TriggerFilterType,
+ initialValue?: any
+ ): FormGroup {
+ const group = new FormGroup({
+ type: new FormControl(type),
+ values: new FormControl(this.normalizeFilterValue(type, initialValue)),
+ })
+
+ group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => {
+ if (newType === TriggerFilterType.CustomFieldQuery) {
+ this.ensureCustomFieldQueryModel(group)
+ } else {
+ this.clearCustomFieldQueryModel(group)
+ group.get('values').setValue(this.getDefaultFilterValue(newType), {
+ emitEvent: false,
+ })
+ }
+ })
+
+ if (type === TriggerFilterType.CustomFieldQuery) {
+ this.ensureCustomFieldQueryModel(group, initialValue)
+ }
+
+ return group
+ }
+
+ private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray {
+ const filters = new FormArray([])
+
+ for (const definition of this.filterDefinitions) {
+ const handler = FILTER_HANDLERS[definition.id]
+ if (!handler) {
+ continue
+ }
+
+ const value = handler.extract(trigger)
+ if (!handler.hasValue(value)) {
+ continue
+ }
+
+ filters.push(this.createFilterFormGroup(definition.id, value))
+ }
+
+ return filters
+ }
+
+ getFiltersFormArray(formGroup: FormGroup): FormArray {
+ return formGroup.get('filters') as FormArray
+ }
+
+ getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) {
+ const filters = this.getFiltersFormArray(formGroup)
+ const options = this.getFilterTypeOptionsForArray(filters)
+ const currentType = filters.at(filterIndex).get('type')
+ .value as TriggerFilterType
+ const usedTypes = new Set(
+ filters.controls.map(
+ (control) => control.get('type').value as TriggerFilterType
+ )
+ )
+
+ for (const option of options) {
+ if (option.allowMultipleEntries) {
+ option.disabled = false
+ continue
+ }
+
+ option.disabled = usedTypes.has(option.id) && option.id !== currentType
+ }
+
+ return options
+ }
+
+ canAddFilter(formGroup: FormGroup): boolean {
+ const filters = this.getFiltersFormArray(formGroup)
+ const usedTypes = new Set(
+ filters.controls.map(
+ (control) => control.get('type').value as TriggerFilterType
+ )
+ )
+
+ return this.filterDefinitions.some((definition) => {
+ if (definition.allowMultipleEntries) {
+ return true
+ }
+ return !usedTypes.has(definition.id)
+ })
+ }
+
+ addFilter(triggerFormGroup: FormGroup): FormGroup | null {
+ const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
+ if (triggerIndex === -1) {
+ return null
+ }
+
+ const filters = this.getFiltersFormArray(triggerFormGroup)
+
+ const availableDefinition = this.filterDefinitions.find((definition) => {
+ if (definition.allowMultipleEntries) {
+ return true
+ }
+ return !filters.controls.some(
+ (control) => control.get('type').value === definition.id
+ )
+ })
+
+ if (!availableDefinition) {
+ return null
+ }
+
+ filters.push(this.createFilterFormGroup(availableDefinition.id))
+ triggerFormGroup.markAsDirty()
+ triggerFormGroup.markAsTouched()
+
+ return filters.at(-1) as FormGroup
+ }
+
+ removeFilter(triggerFormGroup: FormGroup, filterIndex: number) {
+ const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
+ if (triggerIndex === -1) {
+ return
+ }
+
+ const filters = this.getFiltersFormArray(triggerFormGroup)
+ const filterGroup = filters.at(filterIndex) as FormGroup
+ if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) {
+ this.clearCustomFieldQueryModel(filterGroup)
+ }
+ filters.removeAt(filterIndex)
+ triggerFormGroup.markAsDirty()
+ triggerFormGroup.markAsTouched()
+ }
+
+ getFilterDefinition(
+ type: TriggerFilterType
+ ): TriggerFilterDefinition | undefined {
+ return this.filterDefinitions.find((definition) => definition.id === type)
+ }
+
+ getFilterName(type: TriggerFilterType): string {
+ return this.getFilterDefinition(type)?.name ?? ''
+ }
+
+ isTagsFilter(type: TriggerFilterType): boolean {
+ return this.getFilterDefinition(type)?.inputType === 'tags'
+ }
+
+ isCustomFieldQueryFilter(type: TriggerFilterType): boolean {
+ return this.getFilterDefinition(type)?.inputType === 'customFieldQuery'
+ }
+
+ isMultiValueFilter(type: TriggerFilterType): boolean {
+ switch (type) {
+ case TriggerFilterType.TagsAny:
+ case TriggerFilterType.TagsAll:
+ case TriggerFilterType.TagsNone:
+ case TriggerFilterType.CorrespondentNot:
+ case TriggerFilterType.DocumentTypeNot:
+ case TriggerFilterType.StoragePathNot:
+ return true
+ default:
+ return false
+ }
+ }
+
+ isSelectMultiple(type: TriggerFilterType): boolean {
+ return !this.isTagsFilter(type) && this.isMultiValueFilter(type)
+ }
+
+ getFilterSelectItems(type: TriggerFilterType) {
+ const definition = this.getFilterDefinition(type)
+ if (!definition || definition.inputType !== 'select') {
+ return []
+ }
+
+ switch (definition.selectItems) {
+ case 'correspondents':
+ return this.correspondents
+ case 'documentTypes':
+ return this.documentTypes
+ case 'storagePaths':
+ return this.storagePaths
+ default:
+ return []
+ }
+ }
+
+ getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
+ return this.ensureCustomFieldQueryModel(control as FormGroup)
+ }
+
+ onCustomFieldQuerySelectionChange(
+ control: AbstractControl,
+ model: CustomFieldQueriesModel
+ ) {
+ this.onCustomFieldQueryModelChanged(control as FormGroup, model)
+ }
+
+ isCustomFieldQueryValid(control: AbstractControl): boolean {
+ const model = this.getStoredCustomFieldQueryModel(control as FormGroup)
+ if (!model) {
+ return true
+ }
+
+ return model.isEmpty() || model.isValid()
+ }
+
+ private getFilterTypeOptionsForArray(
+ filters: FormArray
+ ): TriggerFilterOption[] {
+ let cached = this.triggerFilterOptionsMap.get(filters)
+ if (!cached) {
+ cached = this.filterDefinitions.map((definition) => ({
+ ...definition,
+ disabled: false,
+ }))
+ this.triggerFilterOptionsMap.set(filters, cached)
+ }
+ return cached
+ }
+
+ private ensureCustomFieldQueryModel(
+ filterGroup: FormGroup,
+ initialValue?: any
+ ): CustomFieldQueriesModel {
+ const existingModel = this.getStoredCustomFieldQueryModel(filterGroup)
+ if (existingModel) {
+ return existingModel
+ }
+
+ const model = new CustomFieldQueriesModel()
+ this.setCustomFieldQueryModel(filterGroup, model)
+
+ const rawValue =
+ typeof initialValue === 'string'
+ ? initialValue
+ : (filterGroup.get('values').value as string)
+
+ if (rawValue) {
+ try {
+ const parsed = JSON.parse(rawValue)
+ const expression = new CustomFieldQueryExpression(parsed)
+ model.queries = [expression]
+ } catch {
+ model.clear(false)
+ model.addInitialAtom()
+ }
+ }
+
+ const subscription = model.changed
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe(() => {
+ this.onCustomFieldQueryModelChanged(filterGroup, model)
+ })
+ filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
+ filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription
+
+ this.onCustomFieldQueryModelChanged(filterGroup, model)
+
+ return model
+ }
+
+ private clearCustomFieldQueryModel(filterGroup: FormGroup) {
+ const group = filterGroup as CustomFieldFilterGroup
+ group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
+ delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]
+ delete group[CUSTOM_FIELD_QUERY_MODEL_KEY]
+ }
+
+ private getStoredCustomFieldQueryModel(
+ filterGroup: FormGroup
+ ): CustomFieldQueriesModel | null {
+ return (
+ (filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ??
+ null
+ )
+ }
+
+ private setCustomFieldQueryModel(
+ filterGroup: FormGroup,
+ model: CustomFieldQueriesModel
+ ) {
+ const group = filterGroup as CustomFieldFilterGroup
+ group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model
+ }
+
+ private onCustomFieldQueryModelChanged(
+ filterGroup: FormGroup,
+ model: CustomFieldQueriesModel
+ ) {
+ const control = filterGroup.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 getDefaultFilterValue(type: TriggerFilterType) {
+ if (type === TriggerFilterType.CustomFieldQuery) {
+ return null
+ }
+ return this.isMultiValueFilter(type) ? [] : null
+ }
+
+ private normalizeFilterValue(type: TriggerFilterType, value?: any) {
+ if (value === undefined || value === null) {
+ return this.getDefaultFilterValue(type)
+ }
+
+ if (type === TriggerFilterType.CustomFieldQuery) {
+ if (typeof value === 'string') {
+ return value
+ }
+ return value ? JSON.stringify(value) : null
+ }
+
+ if (this.isMultiValueFilter(type)) {
+ return Array.isArray(value) ? [...value] : [value]
+ }
+
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value[0] : null
+ }
+
+ return value
+ }
+
private createTriggerField(
trigger: WorkflowTrigger,
emitEvent: boolean = false
@@ -405,16 +1054,7 @@ export class WorkflowEditDialogComponent
matching_algorithm: new FormControl(trigger.matching_algorithm),
match: new FormControl(trigger.match),
is_insensitive: new FormControl(trigger.is_insensitive),
- filter_has_tags: new FormControl(trigger.filter_has_tags),
- filter_has_correspondent: new FormControl(
- trigger.filter_has_correspondent
- ),
- filter_has_document_type: new FormControl(
- trigger.filter_has_document_type
- ),
- filter_has_storage_path: new FormControl(
- trigger.filter_has_storage_path
- ),
+ filters: this.buildFiltersFormArray(trigger),
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
schedule_recurring_interval_days: new FormControl(
@@ -537,6 +1177,12 @@ export class WorkflowEditDialogComponent
filter_path: null,
filter_mailrule: null,
filter_has_tags: [],
+ filter_has_all_tags: [],
+ filter_has_not_tags: [],
+ 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,
diff --git a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html
index 56d404fd5..079790c4b 100644
--- a/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html
+++ b/src-ui/src/app/components/common/email-document-dialog/email-document-dialog.component.html
@@ -1,5 +1,9 @@
@@ -22,11 +26,14 @@
-
+