mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-14 02:36:10 -05:00
Feature: Advanced Workflow Trigger Filters (#11029)
This commit is contained in:
@@ -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"> {{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"> {{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) {
|
||||
|
@@ -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 &&
|
||||
|
@@ -156,31 +156,97 @@
|
||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
}
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
}
|
||||
@if (patternRequired) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<div class="col-md-6">
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<div class="trigger-filters mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="form-label mb-0" i18n>Advanced Filters</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary ms-auto"
|
||||
(click)="addFilter(formGroup)"
|
||||
[disabled]="!canAddFilter(formGroup)"
|
||||
>
|
||||
<i-bs name="plus-circle"></i-bs> <span i18n>Add filter</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="mt-2 list-group filters" formArrayName="filters">
|
||||
@if (getFiltersFormArray(formGroup).length === 0) {
|
||||
<p class="text-muted small" i18n>No advanced workflow filters defined.</p>
|
||||
}
|
||||
@for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) {
|
||||
<li [formGroupName]="filterIndex" class="list-group-item">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="w-25">
|
||||
<pngx-input-select
|
||||
i18n-title
|
||||
[items]="getFilterTypeOptions(formGroup, filterIndex)"
|
||||
formControlName="type"
|
||||
[allowNull]="false"
|
||||
></pngx-input-select>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
@if (isTagsFilter(filter.get('type').value)) {
|
||||
<pngx-input-tags
|
||||
[allowCreate]="false"
|
||||
[title]="null"
|
||||
formControlName="values"
|
||||
></pngx-input-tags>
|
||||
} @else if (
|
||||
isCustomFieldQueryFilter(filter.get('type').value)
|
||||
) {
|
||||
<pngx-custom-fields-query-dropdown
|
||||
[selectionModel]="getCustomFieldQueryModel(filter)"
|
||||
(selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)"
|
||||
[useDropdown]="false"
|
||||
></pngx-custom-fields-query-dropdown>
|
||||
@if (!isCustomFieldQueryValid(filter)) {
|
||||
<div class="text-danger small" i18n>
|
||||
Complete the custom field query configuration.
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<pngx-input-select
|
||||
[items]="getFilterSelectItems(filter.get('type').value)"
|
||||
[allowNull]="true"
|
||||
[multiple]="isSelectMultiple(filter.get('type').value)"
|
||||
formControlName="values"
|
||||
></pngx-input-select>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-danger p-0"
|
||||
(click)="removeFilter(formGroup, filterIndex)"
|
||||
>
|
||||
<i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
@@ -7,3 +7,7 @@
|
||||
.accordion-button {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
@@ -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]),
|
||||
|
@@ -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, FilterHandler> = {
|
||||
[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,
|
||||
|
@@ -1,66 +1,68 @@
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
@if (title || removable) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (showFilter) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (showFilter) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
@if (getSuggestions().length > 0) {
|
||||
<small>
|
||||
<span i18n>Suggestions:</span>
|
||||
@for (s of getSuggestions(); track s) {
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
@if (getSuggestions().length > 0) {
|
||||
<small>
|
||||
<span i18n>Suggestions:</span>
|
||||
@for (s of getSuggestions(); track s) {
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,8 +1,10 @@
|
||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||
</div>
|
||||
@if (title) {
|
||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||
</div>
|
||||
}
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
|
@@ -40,6 +40,18 @@ export interface WorkflowTrigger extends ObjectWithId {
|
||||
|
||||
filter_has_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_all_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_not_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_not_correspondents?: number[] // Correspondent.id[]
|
||||
|
||||
filter_has_not_document_types?: number[] // DocumentType.id[]
|
||||
|
||||
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
|
||||
|
Reference in New Issue
Block a user