Feature: Workflows (#5121)

This commit is contained in:
shamoon
2024-01-03 00:19:19 -08:00
committed by GitHub
parent 46e6be319f
commit 3b6ce16f1c
54 changed files with 4980 additions and 2011 deletions

View File

@@ -21,7 +21,7 @@ import {
PermissionAction,
PermissionType,
} from './services/permissions.service'
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
@@ -214,13 +214,13 @@ export const routes: Routes = [
},
},
{
path: 'templates',
component: ConsumptionTemplatesComponent,
path: 'workflows',
component: WorkflowsComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.ConsumptionTemplate,
type: PermissionType.Workflow,
},
},
},

View File

@@ -176,9 +176,9 @@ export class AppComponent implements OnInit, OnDestroy {
},
},
{
anchorId: 'tour.consumption-templates',
content: $localize`Consumption templates give you finer control over the document ingestion process.`,
route: '/templates',
anchorId: 'tour.workflows',
content: $localize`Workflows give you more control over the document pipeline.`,
route: '/workflows',
backdropConfig: {
offset: 0,
},

View File

@@ -95,8 +95,8 @@ import { UsernamePipe } from './pipes/username.pipe'
import { LogoComponent } from './components/common/logo/logo.component'
import { IsNumberPipe } from './pipes/is-number.pipe'
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { WorkflowEditDialogComponent } from './components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
@@ -108,8 +108,8 @@ import { ProfileEditDialogComponent } from './components/common/profile-edit-dia
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import { ConfigComponent } from './components/admin/config/config.component'
import { SwitchComponent } from './components/common/input/switch/switch.component'
import { ConfigComponent } from './components/admin/config/config.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@@ -253,8 +253,8 @@ function initializeApp(settings: SettingsService) {
LogoComponent,
IsNumberPipe,
ShareLinksDropdownComponent,
ConsumptionTemplatesComponent,
ConsumptionTemplateEditDialogComponent,
WorkflowsComponent,
WorkflowEditDialogComponent,
MailComponent,
UsersAndGroupsComponent,
FileDropComponent,
@@ -265,8 +265,8 @@ function initializeApp(settings: SettingsService) {
PdfViewerComponent,
DocumentLinkComponent,
PreviewPopupComponent,
ConfigComponent,
SwitchComponent,
ConfigComponent,
],
imports: [
BrowserModule,

View File

@@ -27,7 +27,7 @@
@switch (option.type) {
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
}

View File

@@ -235,14 +235,14 @@
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }"
tourAnchor="tour.consumption-templates">
<a class="nav-link" routerLink="templates" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Consumption templates" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-earmark-ruled" />
</svg><span>&nbsp;<ng-container i18n>Templates</ng-container></span>
<use xlink:href="assets/bootstrap-icons.svg#boxes" />
</svg><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"

View File

@@ -1,95 +0,0 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-8">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
</div>
<div class="col">
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
</div>
</div>
<div class="row">
<div class="col-md-4">
<h5 class="border-bottom pb-2" i18n>Filters</h5>
<p class="small" i18n>Process documents that match <em>all</em> filters specified below.</p>
<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 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 path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case insensitive.</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>
</div>
<div class="col">
<div class="row">
<div class="col">
<h5 class="border-bottom pb-2" i18n>Assignments</h5>
</div>
</div>
<div class="row">
<div class="col">
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#consumption-templates'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
</div>
<div class="col">
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
<div>
<label class="form-label" i18n>Assign view permissions</label>
<div class="mb-2">
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
</div>
</div>
</div>
<label class="form-label" i18n>Assign edit permissions</label>
<div>
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
</div>
</div>
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@if (error?.non_field_errors) {
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
}
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div>
</form>

View File

@@ -1,125 +0,0 @@
import { Component } from '@angular/core'
import { FormGroup, FormControl } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { first } from 'rxjs'
import {
DocumentSource,
ConsumptionTemplate,
} from 'src/app/data/consumption-template'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { EditDialogComponent } from '../edit-dialog.component'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { MailRule } from 'src/app/data/mail-rule'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
export const DOCUMENT_SOURCE_OPTIONS = [
{
id: DocumentSource.ConsumeFolder,
name: $localize`Consume Folder`,
},
{
id: DocumentSource.ApiUpload,
name: $localize`API Upload`,
},
{
id: DocumentSource.MailFetch,
name: $localize`Mail Fetch`,
},
]
@Component({
selector: 'pngx-consumption-template-edit-dialog',
templateUrl: './consumption-template-edit-dialog.component.html',
styleUrls: ['./consumption-template-edit-dialog.component.scss'],
})
export class ConsumptionTemplateEditDialogComponent extends EditDialogComponent<ConsumptionTemplate> {
templates: ConsumptionTemplate[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
constructor(
service: ConsumptionTemplateService,
activeModal: NgbActiveModal,
correspondentService: CorrespondentService,
documentTypeService: DocumentTypeService,
storagePathService: StoragePathService,
mailRuleService: MailRuleService,
userService: UserService,
settingsService: SettingsService,
customFieldsService: CustomFieldsService
) {
super(service, activeModal, userService, settingsService)
correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
mailRuleService
.listAll()
.pipe(first())
.subscribe((result) => (this.mailRules = result.results))
customFieldsService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
getCreateTitle() {
return $localize`Create new consumption template`
}
getEditTitle() {
return $localize`Edit consumption template`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(null),
account: new FormControl(null),
filter_filename: new FormControl(null),
filter_path: new FormControl(null),
filter_mailrule: new FormControl(null),
order: new FormControl(null),
sources: new FormControl([]),
assign_title: new FormControl(null),
assign_tags: new FormControl([]),
assign_owner: new FormControl(null),
assign_document_type: new FormControl(null),
assign_correspondent: new FormControl(null),
assign_storage_path: new FormControl(null),
assign_view_users: new FormControl([]),
assign_view_groups: new FormControl([]),
assign_change_users: new FormControl([]),
assign_change_groups: new FormControl([]),
assign_custom_fields: new FormControl([]),
})
}
get sourceOptions() {
return DOCUMENT_SOURCE_OPTIONS
}
}

View File

@@ -0,0 +1,207 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
</div>
<div class="col-4">
<pngx-input-number i18n-title title="Sort order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
</div>
<div class="col">
<pngx-input-switch i18n-title title="Enabled" formControlName="enabled" [error]="error?.enabled"></pngx-input-switch>
</div>
</div>
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton i18n>Triggers</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="d-flex">
<p class="p-2" i18n>Trigger Workflow On:</p>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addTrigger()">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Trigger</ng-container>
</button>
</div>
<div ngbAccordion [closeOthers]="true">
@for (trigger of object?.triggers; track trigger; let i = $index){
<div ngbAccordionItem>
<div ngbAccordionHeader>
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
@if(trigger.id > -1) {
<span class="badge bg-dark text-light ms-2">ID: {{trigger.id}}</span>
}
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>
<ng-container i18n>Delete</ng-container>
</button>
</button>
</div>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template [ngTemplateOutlet]="triggerForm" [ngTemplateOutletContext]="{ formGroup: triggerFields.controls[i], trigger: trigger }"></ng-template>
</div>
</div>
</div>
}
</div>
</ng-template>
</div>
</div>
</div>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button class="btn-lg" ngbAccordionButton i18n>Actions</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
<div class="d-flex">
<p class="p-2" i18n>Apply Actions:</p>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addAction()">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Action</ng-container>
</button>
</div>
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
@for (action of object?.actions; track action; let i = $index){
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
<div ngbAccordionHeader>
<button ngbAccordionButton><ng-container i18n>Action</ng-container> {{i + 1}}
@if(action.id > -1) {
<span class="badge bg-dark text-light ms-2">ID: {{action.id}}</span>
}
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>
<ng-container i18n>Delete</ng-container>
</button>
</button>
</div>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
<input type="hidden" formControlName="id" />
<div class="row">
<div class="col">
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.assign_title"></pngx-input-text>
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
</div>
<div class="col">
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
<div>
<label class="form-label" i18n>Assign view permissions</label>
<div class="mb-2">
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
</div>
</div>
</div>
<label class="form-label" i18n>Assign edit permissions</label>
<div>
<div class="row mb-1">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
</div>
</div>
<div class="row">
<div class="col-lg-3">
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
</div>
<div class="col-lg-9">
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
</div>
</div>
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</ng-template>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@if (error?.non_field_errors) {
<span class="text-danger"><ng-container i18n>Error</ng-container>: {{error.non_field_errors}}</span>
}
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div>
</form>
<ng-template #triggerForm let-formGroup="formGroup" let-trigger="trigger">
<div [formGroup]="formGroup">
<input type="hidden" formControlName="id" />
<pngx-input-select i18n-title title="Trigger type" [horizontal]="true" [items]="triggerTypeOptions" formControlName="type"></pngx-input-select>
<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>
@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 insensitive.</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>
}
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
<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>
}
@if (patternRequired) {
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
}
}
</div>
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated) {
<div class="col-md-6">
<pngx-input-tags [allowCreate]="false" i18n-title title="Has 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>
</div>
}
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,5 @@
.btn.text-danger {
&:hover, &:focus {
color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important;
}
}

View File

@@ -18,24 +18,69 @@ import { PermissionsUserComponent } from '../../input/permissions/permissions-us
import { SelectComponent } from '../../input/select/select.component'
import { TagsComponent } from '../../input/tags/tags.component'
import { TextComponent } from '../../input/text/text.component'
import { SwitchComponent } from '../../input/switch/switch.component'
import { EditDialogMode } from '../edit-dialog.component'
import { ConsumptionTemplateEditDialogComponent } from './consumption-template-edit-dialog.component'
import {
DOCUMENT_SOURCE_OPTIONS,
WORKFLOW_ACTION_OPTIONS,
WORKFLOW_TYPE_OPTIONS,
WorkflowEditDialogComponent,
} from './workflow-edit-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { Workflow } from 'src/app/data/workflow'
import {
WorkflowTriggerType,
DocumentSource,
} from 'src/app/data/workflow-trigger'
import { CdkDragDrop } from '@angular/cdk/drag-drop'
import {
WorkflowAction,
WorkflowActionType,
} from 'src/app/data/workflow-action'
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
const workflow: Workflow = {
name: 'Workflow 1',
id: 1,
order: 1,
enabled: true,
triggers: [
{
id: 1,
type: WorkflowTriggerType.Consumption,
sources: [DocumentSource.ConsumeFolder],
filter_filename: '*',
},
],
actions: [
{
id: 1,
type: WorkflowActionType.Assignment,
assign_title: 'foo',
},
{
id: 4,
type: WorkflowActionType.Assignment,
assign_owner: 2,
},
],
}
describe('ConsumptionTemplateEditDialogComponent', () => {
let component: ConsumptionTemplateEditDialogComponent
let component: WorkflowEditDialogComponent
let settingsService: SettingsService
let fixture: ComponentFixture<ConsumptionTemplateEditDialogComponent>
let fixture: ComponentFixture<WorkflowEditDialogComponent>
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
ConsumptionTemplateEditDialogComponent,
WorkflowEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
NumberComponent,
SwitchComponent,
TagsComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
@@ -113,7 +158,7 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
],
}).compileComponents()
fixture = TestBed.createComponent(ConsumptionTemplateEditDialogComponent)
fixture = TestBed.createComponent(WorkflowEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
@@ -121,15 +166,70 @@ describe('ConsumptionTemplateEditDialogComponent', () => {
fixture.detectChanges()
})
it('should support create and edit modes', () => {
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
expect(component.object).toBeUndefined()
component.addAction()
expect(component.object).not.toBeUndefined()
expect(component.object.actions).toHaveLength(1)
component.object = undefined
component.addTrigger()
expect(component.object).not.toBeUndefined()
expect(component.object.triggers).toHaveLength(1)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should return source options, type options, type name', () => {
// coverage
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
expect(
component.getTriggerTypeOptionName(WorkflowTriggerType.DocumentAdded)
).toEqual('Document Added')
expect(component.getTriggerTypeOptionName(null)).toEqual('')
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
expect(
component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment')
expect(component.getActionTypeOptionName(null)).toEqual('')
})
it('should support add and remove triggers and actions', () => {
component.object = workflow
component.addTrigger()
expect(component.object.triggers.length).toEqual(2)
component.addAction()
expect(component.object.actions.length).toEqual(3)
component.removeTrigger(1)
expect(component.object.triggers.length).toEqual(1)
component.removeAction(1)
expect(component.object.actions.length).toEqual(2)
})
it('should update order and remove ids from actions on drag n drop', () => {
const action1 = workflow.actions[0]
const action2 = workflow.actions[1]
component.object = workflow
component.onActionDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
WorkflowAction[]
>)
expect(component.object.actions).toEqual([action2, action1])
expect(action1.id).toBeNull()
expect(action2.id).toBeNull()
})
it('should not include auto matching in algorithms', () => {
expect(component.getMatchingAlgorithms()).not.toContain(
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
)
})
})

View File

@@ -0,0 +1,300 @@
import { Component, OnInit } from '@angular/core'
import { FormGroup, FormControl, FormArray } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { first } from 'rxjs'
import { Workflow } from 'src/app/data/workflow'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { EditDialogComponent } from '../edit-dialog.component'
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
import { MailRule } from 'src/app/data/mail-rule'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
import {
DocumentSource,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
import {
WorkflowAction,
WorkflowActionType,
} from 'src/app/data/workflow-action'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import {
MATCHING_ALGORITHMS,
MATCH_AUTO,
MATCH_NONE,
} from 'src/app/data/matching-model'
export const DOCUMENT_SOURCE_OPTIONS = [
{
id: DocumentSource.ConsumeFolder,
name: $localize`Consume Folder`,
},
{
id: DocumentSource.ApiUpload,
name: $localize`API Upload`,
},
{
id: DocumentSource.MailFetch,
name: $localize`Mail Fetch`,
},
]
export const WORKFLOW_TYPE_OPTIONS = [
{
id: WorkflowTriggerType.Consumption,
name: $localize`Consumption Started`,
},
{
id: WorkflowTriggerType.DocumentAdded,
name: $localize`Document Added`,
},
{
id: WorkflowTriggerType.DocumentUpdated,
name: $localize`Document Updated`,
},
]
export const WORKFLOW_ACTION_OPTIONS = [
{
id: WorkflowActionType.Assignment,
name: $localize`Assignment`,
},
]
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
(a) => a.id !== MATCH_AUTO
)
@Component({
selector: 'pngx-workflow-edit-dialog',
templateUrl: './workflow-edit-dialog.component.html',
styleUrls: ['./workflow-edit-dialog.component.scss'],
})
export class WorkflowEditDialogComponent
extends EditDialogComponent<Workflow>
implements OnInit
{
public WorkflowTriggerType = WorkflowTriggerType
templates: Workflow[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
mailRules: MailRule[]
customFields: CustomField[]
expandedItem: number = null
constructor(
service: WorkflowService,
activeModal: NgbActiveModal,
correspondentService: CorrespondentService,
documentTypeService: DocumentTypeService,
storagePathService: StoragePathService,
mailRuleService: MailRuleService,
userService: UserService,
settingsService: SettingsService,
customFieldsService: CustomFieldsService
) {
super(service, activeModal, userService, settingsService)
correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
storagePathService
.listAll()
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
mailRuleService
.listAll()
.pipe(first())
.subscribe((result) => (this.mailRules = result.results))
customFieldsService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
getCreateTitle() {
return $localize`Create new workflow`
}
getEditTitle() {
return $localize`Edit workflow`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(null),
order: new FormControl(null),
enabled: new FormControl(true),
triggers: new FormArray([]),
actions: new FormArray([]),
})
}
getMatchingAlgorithms() {
// No auto matching
return TRIGGER_MATCHING_ALGORITHMS
}
ngOnInit(): void {
super.ngOnInit()
this.updateTriggerActionFields()
}
get triggerFields(): FormArray {
return this.objectForm.get('triggers') as FormArray
}
get actionFields(): FormArray {
return this.objectForm.get('actions') as FormArray
}
private updateTriggerActionFields(emitEvent: boolean = false) {
this.triggerFields.clear({ emitEvent: false })
this.object?.triggers.forEach((trigger) => {
this.triggerFields.push(
new FormGroup({
id: new FormControl(trigger.id),
type: new FormControl(trigger.type),
sources: new FormControl(trigger.sources),
filter_filename: new FormControl(trigger.filter_filename),
filter_path: new FormControl(trigger.filter_path),
filter_mailrule: new FormControl(trigger.filter_mailrule),
matching_algorithm: new FormControl(MATCH_NONE),
match: new FormControl(''),
is_insensitive: new FormControl(true),
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
),
}),
{ emitEvent }
)
})
this.actionFields.clear({ emitEvent: false })
this.object?.actions.forEach((action) => {
this.actionFields.push(
new FormGroup({
id: new FormControl(action.id),
type: new FormControl(action.type),
assign_title: new FormControl(action.assign_title),
assign_tags: new FormControl(action.assign_tags),
assign_owner: new FormControl(action.assign_owner),
assign_document_type: new FormControl(action.assign_document_type),
assign_correspondent: new FormControl(action.assign_correspondent),
assign_storage_path: new FormControl(action.assign_storage_path),
assign_view_users: new FormControl(action.assign_view_users),
assign_view_groups: new FormControl(action.assign_view_groups),
assign_change_users: new FormControl(action.assign_change_users),
assign_change_groups: new FormControl(action.assign_change_groups),
assign_custom_fields: new FormControl(action.assign_custom_fields),
}),
{ emitEvent }
)
})
}
get sourceOptions() {
return DOCUMENT_SOURCE_OPTIONS
}
get triggerTypeOptions() {
return WORKFLOW_TYPE_OPTIONS
}
getTriggerTypeOptionName(type: WorkflowTriggerType): string {
return this.triggerTypeOptions.find((t) => t.id === type)?.name ?? ''
}
addTrigger() {
if (!this.object) {
this.object = Object.assign({}, this.objectForm.value)
}
this.object.triggers.push({
type: WorkflowTriggerType.Consumption,
sources: [],
filter_filename: null,
filter_path: null,
filter_mailrule: null,
filter_has_tags: [],
filter_has_correspondent: null,
filter_has_document_type: null,
})
this.updateTriggerActionFields()
}
get actionTypeOptions() {
return WORKFLOW_ACTION_OPTIONS
}
getActionTypeOptionName(type: WorkflowActionType): string {
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
}
addAction() {
if (!this.object) {
this.object = Object.assign({}, this.objectForm.value)
}
this.object.actions.push({
type: WorkflowActionType.Assignment,
assign_title: null,
assign_tags: [],
assign_document_type: null,
assign_correspondent: null,
assign_storage_path: null,
assign_owner: null,
assign_view_users: [],
assign_view_groups: [],
assign_change_users: [],
assign_change_groups: [],
assign_custom_fields: [],
})
this.updateTriggerActionFields()
}
removeTrigger(index: number) {
this.object.triggers.splice(index, 1)
this.updateTriggerActionFields()
}
removeAction(index: number) {
this.object.actions.splice(index, 1)
this.updateTriggerActionFields()
}
onActionDrop(event: CdkDragDrop<WorkflowAction[]>) {
moveItemInArray(
this.object.actions,
event.previousIndex,
event.currentIndex
)
// removing id will effectively re-create the actions in this order
this.object.actions.forEach((a) => (a.id = null))
this.updateTriggerActionFields()
}
}

View File

@@ -0,0 +1,4 @@
.accordion {
--bs-accordion-btn-padding-x: 0.75rem;
--bs-accordion-btn-padding-y: 0.375rem;
}

View File

@@ -1,27 +1,27 @@
<div class="mb-3">
<div class="row">
@if (horizontal) {
@if (!horizontal) {
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
<label class="form-label" [for]="inputId">{{title}}</label>
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
}
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (!horizontal) {
<label class="form-check-label" [for]="inputId">{{title}}</label>
}
@if (hint) {
<div class="form-text text-muted">{{hint}}</div>
}
</div>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
}
<div [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check form-switch">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
@if (horizontal) {
<label class="form-check-label" [for]="inputId">{{title}}</label>
}
@if (hint) {
<div class="form-text text-muted">{{hint}}</div>
}
</div>
</div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
<pngx-page-header title="Consumption Templates" i18n-title>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ConsumptionTemplate }">
<pngx-page-header title="Workflows" i18n-title>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }">
<svg class="sidebaricon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
</svg>
<ng-container i18n>Add Template</ng-container>
<ng-container i18n>Add Workflow</ng-container>
</button>
</pngx-page-header>
@@ -13,25 +13,27 @@
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Sort order</div>
<div class="col" i18n>Document Sources</div>
<div class="col" i18n>Status</div>
<div class="col" i18n>Triggers</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@for (template of templates; track template) {
@for (workflow of workflows; track workflow.id) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editTemplate(template)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.ConsumptionTemplate)">{{template.name}}</button></div>
<div class="col d-flex align-items-center"><code>{{template.order}}</code></div>
<div class="col d-flex align-items-center">{{getSourceList(template)}}</div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
<div class="col d-flex align-items-center"><code>{{workflow.order}}</code></div>
<div class="col d-flex align-items-center"><code> @if(workflow.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code></div>
<div class="col d-flex align-items-center">{{getTypesList(workflow)}}</div>
<div class="col">
<div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
</svg>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ConsumptionTemplate }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg>&nbsp;<ng-container i18n>Delete</ng-container>
@@ -41,7 +43,7 @@
</div>
</li>
}
@if (templates.length === 0) {
<li class="list-group-item" i18n>No templates defined.</li>
@if (workflows.length === 0) {
<li class="list-group-item" i18n>No workflows defined.</li>
}
</ul>

View File

@@ -9,55 +9,76 @@ import {
NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import {
DocumentSource,
ConsumptionTemplate,
} from 'src/app/data/consumption-template'
import { Workflow } from 'src/app/data/workflow'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ConsumptionTemplatesComponent } from './consumption-templates.component'
import { ConsumptionTemplateEditDialogComponent } from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
import { WorkflowsComponent } from './workflows.component'
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { PermissionsService } from 'src/app/services/permissions.service'
import {
DocumentSource,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
import { WorkflowActionType } from 'src/app/data/workflow-action'
const templates: ConsumptionTemplate[] = [
const workflows: Workflow[] = [
{
id: 0,
name: 'Template 1',
order: 0,
sources: [
DocumentSource.ConsumeFolder,
DocumentSource.ApiUpload,
DocumentSource.MailFetch,
name: 'Workflow 1',
id: 1,
order: 1,
enabled: true,
triggers: [
{
id: 1,
type: WorkflowTriggerType.Consumption,
sources: [DocumentSource.ConsumeFolder],
filter_filename: '*',
},
],
actions: [
{
id: 1,
type: WorkflowActionType.Assignment,
assign_title: 'foo',
},
],
filter_filename: 'foo',
filter_path: 'bar',
assign_tags: [1, 2, 3],
},
{
id: 1,
name: 'Template 2',
order: 1,
sources: [DocumentSource.MailFetch],
filter_filename: null,
filter_path: 'foo/bar',
assign_owner: 1,
name: 'Workflow 2',
id: 2,
order: 2,
enabled: true,
triggers: [
{
id: 2,
type: WorkflowTriggerType.DocumentAdded,
filter_filename: 'foo',
},
],
actions: [
{
id: 2,
type: WorkflowActionType.Assignment,
assign_title: 'bar',
},
],
},
]
describe('ConsumptionTemplatesComponent', () => {
let component: ConsumptionTemplatesComponent
let fixture: ComponentFixture<ConsumptionTemplatesComponent>
let consumptionTemplateService: ConsumptionTemplateService
describe('WorkflowsComponent', () => {
let component: WorkflowsComponent
let fixture: ComponentFixture<WorkflowsComponent>
let workflowService: WorkflowService
let modalService: NgbModal
let toastService: ToastService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
ConsumptionTemplatesComponent,
WorkflowsComponent,
IfPermissionsDirective,
PageHeaderComponent,
ConfirmDialogComponent,
@@ -81,18 +102,18 @@ describe('ConsumptionTemplatesComponent', () => {
],
})
consumptionTemplateService = TestBed.inject(ConsumptionTemplateService)
jest.spyOn(consumptionTemplateService, 'listAll').mockReturnValue(
workflowService = TestBed.inject(WorkflowService)
jest.spyOn(workflowService, 'listAll').mockReturnValue(
of({
count: templates.length,
all: templates.map((o) => o.id),
results: templates,
count: workflows.length,
all: workflows.map((o) => o.id),
results: workflows,
})
)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(ConsumptionTemplatesComponent)
fixture = TestBed.createComponent(WorkflowsComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
@@ -108,8 +129,7 @@ describe('ConsumptionTemplatesComponent', () => {
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog =
modal.componentInstance as ConsumptionTemplateEditDialogComponent
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
// fail first
editDialog.failed.emit({ error: 'error creating item' })
@@ -117,7 +137,7 @@ describe('ConsumptionTemplatesComponent', () => {
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(templates[0])
editDialog.succeeded.emit(workflows[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
@@ -133,9 +153,8 @@ describe('ConsumptionTemplatesComponent', () => {
editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog =
modal.componentInstance as ConsumptionTemplateEditDialogComponent
expect(editDialog.object).toEqual(templates[0])
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
expect(editDialog.object).toEqual(workflows[0])
// fail first
editDialog.failed.emit({ error: 'error editing item' })
@@ -143,7 +162,7 @@ describe('ConsumptionTemplatesComponent', () => {
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(templates[0])
editDialog.succeeded.emit(workflows[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
@@ -152,7 +171,7 @@ describe('ConsumptionTemplatesComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const deleteSpy = jest.spyOn(consumptionTemplateService, 'delete')
const deleteSpy = jest.spyOn(workflowService, 'delete')
const reloadSpy = jest.spyOn(component, 'reload')
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]

View File

@@ -1,33 +1,33 @@
import { Component, OnInit } from '@angular/core'
import { ConsumptionTemplateService } from 'src/app/services/rest/consumption-template.service'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { Subject, takeUntil } from 'rxjs'
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
import { Workflow } from 'src/app/data/workflow'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ToastService } from 'src/app/services/toast.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import {
ConsumptionTemplateEditDialogComponent,
DOCUMENT_SOURCE_OPTIONS,
} from '../../common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
WorkflowEditDialogComponent,
WORKFLOW_TYPE_OPTIONS,
} from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
@Component({
selector: 'pngx-consumption-templates',
templateUrl: './consumption-templates.component.html',
styleUrls: ['./consumption-templates.component.scss'],
selector: 'pngx-workflows',
templateUrl: './workflows.component.html',
styleUrls: ['./workflows.component.scss'],
})
export class ConsumptionTemplatesComponent
export class WorkflowsComponent
extends ComponentWithPermissions
implements OnInit
{
public templates: ConsumptionTemplate[] = []
public workflows: Workflow[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private consumptionTemplateService: ConsumptionTemplateService,
private workflowService: WorkflowService,
public permissionsService: PermissionsService,
private modalService: NgbModal,
private toastService: ToastService
@@ -40,68 +40,74 @@ export class ConsumptionTemplatesComponent
}
reload() {
this.consumptionTemplateService
this.workflowService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this.templates = r.results
this.workflows = r.results
})
}
getSourceList(template: ConsumptionTemplate): string {
return template.sources
.map((id) => DOCUMENT_SOURCE_OPTIONS.find((s) => s.id === id).name)
getTypesList(template: Workflow): string {
return template.triggers
.map(
(trigger) =>
WORKFLOW_TYPE_OPTIONS.find((t) => t.id === trigger.type).name
)
.join(', ')
}
editTemplate(rule: ConsumptionTemplate) {
const modal = this.modalService.open(
ConsumptionTemplateEditDialogComponent,
{
backdrop: 'static',
size: 'xl',
}
)
modal.componentInstance.dialogMode = rule
editWorkflow(workflow: Workflow) {
const modal = this.modalService.open(WorkflowEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = workflow
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = rule
if (workflow) {
// quick "deep" clone so original doesnt get modified
const clone = Object.assign({}, workflow)
clone.actions = [...workflow.actions]
clone.triggers = [...workflow.triggers]
modal.componentInstance.object = clone
}
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newTemplate) => {
.subscribe((newWorkflow) => {
this.toastService.showInfo(
$localize`Saved template "${newTemplate.name}".`
$localize`Saved workflow "${newWorkflow.name}".`
)
this.consumptionTemplateService.clearCache()
this.workflowService.clearCache()
this.reload()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving template.`, e)
this.toastService.showError($localize`Error saving workflow.`, e)
})
}
deleteTemplate(rule: ConsumptionTemplate) {
deleteWorkflow(workflow: Workflow) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm delete template`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this template.`
modal.componentInstance.title = $localize`Confirm delete workflow`
modal.componentInstance.messageBold = $localize`This operation will permanently delete this workflow.`
modal.componentInstance.message = $localize`This operation cannot be undone.`
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.consumptionTemplateService.delete(rule).subscribe({
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted template`)
this.consumptionTemplateService.clearCache()
this.toastService.showInfo($localize`Deleted workflow`)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting template.`, e)
this.toastService.showError($localize`Error deleting workflow.`, e)
},
})
})

View File

@@ -1,23 +1,10 @@
import { ObjectWithId } from './object-with-id'
export enum DocumentSource {
ConsumeFolder = 1,
ApiUpload = 2,
MailFetch = 3,
export enum WorkflowActionType {
Assignment = 1,
}
export interface ConsumptionTemplate extends ObjectWithId {
name: string
order: number
sources: DocumentSource[]
filter_filename: string
filter_path?: string
filter_mailrule?: number // MailRule.id
export interface WorkflowAction extends ObjectWithId {
type: WorkflowActionType
assign_title?: string

View File

@@ -0,0 +1,37 @@
import { ObjectWithId } from './object-with-id'
export enum DocumentSource {
ConsumeFolder = 1,
ApiUpload = 2,
MailFetch = 3,
}
export enum WorkflowTriggerType {
Consumption = 1,
DocumentAdded = 2,
DocumentUpdated = 3,
}
export interface WorkflowTrigger extends ObjectWithId {
type: WorkflowTriggerType
sources?: DocumentSource[]
filter_filename?: string
filter_path?: string
filter_mailrule?: number // MailRule.id
match?: string
matching_algorithm?: number
is_insensitive?: boolean
filter_has_tags?: number[] // Tag.id[]
filter_has_correspondent?: number // Correspondent.id
filter_has_document_type?: number // DocumentType.id
}

View File

@@ -0,0 +1,15 @@
import { ObjectWithId } from './object-with-id'
import { WorkflowAction } from './workflow-action'
import { WorkflowTrigger } from './workflow-trigger'
export interface Workflow extends ObjectWithId {
name: string
order: number
enabled: boolean
triggers: WorkflowTrigger[]
actions: WorkflowAction[]
}

View File

@@ -252,10 +252,18 @@ describe('PermissionsService', () => {
'view_sharelink',
'change_sharelink',
'delete_sharelink',
'add_consumptiontemplate',
'view_consumptiontemplate',
'change_consumptiontemplate',
'delete_consumptiontemplate',
'add_workflow',
'view_workflow',
'change_workflow',
'delete_workflow',
'add_workflowtrigger',
'view_workflowtrigger',
'change_workflowtrigger',
'delete_workflowtrigger',
'add_workflowaction',
'view_workflowaction',
'change_workflowaction',
'delete_workflowaction',
'add_customfield',
'view_customfield',
'change_customfield',

View File

@@ -25,8 +25,10 @@ export enum PermissionType {
Group = '%s_group',
Admin = '%s_logentry',
ShareLink = '%s_sharelink',
ConsumptionTemplate = '%s_consumptiontemplate',
CustomField = '%s_customfield',
Workflow = '%s_workflow',
WorkflowTrigger = '%s_workflowtrigger',
WorkflowAction = '%s_workflowaction',
}
@Injectable({

View File

@@ -1,64 +0,0 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { ConsumptionTemplateService } from './consumption-template.service'
import {
DocumentSource,
ConsumptionTemplate,
} from 'src/app/data/consumption-template'
let httpTestingController: HttpTestingController
let service: ConsumptionTemplateService
const endpoint = 'consumption_templates'
const templates: ConsumptionTemplate[] = [
{
name: 'Template 1',
id: 1,
order: 1,
filter_filename: '*test*',
filter_path: null,
sources: [DocumentSource.ApiUpload],
assign_correspondent: 2,
},
{
name: 'Template 2',
id: 2,
order: 2,
filter_filename: null,
filter_path: '/test/',
sources: [DocumentSource.ConsumeFolder, DocumentSource.ApiUpload],
assign_document_type: 1,
},
]
// run common tests
commonAbstractPaperlessServiceTests(
'consumption_templates',
ConsumptionTemplateService
)
describe(`Additional service tests for ConsumptionTemplateService`, () => {
it('should reload', () => {
service.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
req.flush({
results: templates,
})
expect(service.allTemplates).toEqual(templates)
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ConsumptionTemplateService)
})
afterEach(() => {
httpTestingController.verify()
})
})

View File

@@ -0,0 +1,85 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { WorkflowService } from './workflow.service'
import { Workflow } from 'src/app/data/workflow'
import {
DocumentSource,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
import { WorkflowActionType } from 'src/app/data/workflow-action'
let httpTestingController: HttpTestingController
let service: WorkflowService
const endpoint = 'workflows'
const workflows: Workflow[] = [
{
name: 'Workflow 1',
id: 1,
order: 1,
enabled: true,
triggers: [
{
id: 1,
type: WorkflowTriggerType.Consumption,
sources: [DocumentSource.ConsumeFolder],
filter_filename: '*',
},
],
actions: [
{
id: 1,
type: WorkflowActionType.Assignment,
assign_title: 'foo',
},
],
},
{
name: 'Workflow 2',
id: 2,
order: 2,
enabled: true,
triggers: [
{
id: 2,
type: WorkflowTriggerType.DocumentAdded,
filter_filename: 'foo',
},
],
actions: [
{
id: 2,
type: WorkflowActionType.Assignment,
assign_title: 'bar',
},
],
},
]
// run common tests
commonAbstractPaperlessServiceTests(endpoint, WorkflowService)
describe(`Additional service tests for WorkflowService`, () => {
it('should reload', () => {
service.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
req.flush({
results: workflows,
})
expect(service.allWorkflows).toEqual(workflows)
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(WorkflowService)
})
afterEach(() => {
httpTestingController.verify()
})
})

View File

@@ -1,42 +1,42 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { tap } from 'rxjs'
import { ConsumptionTemplate } from 'src/app/data/consumption-template'
import { Workflow } from 'src/app/data/workflow'
import { AbstractPaperlessService } from './abstract-paperless-service'
@Injectable({
providedIn: 'root',
})
export class ConsumptionTemplateService extends AbstractPaperlessService<ConsumptionTemplate> {
export class WorkflowService extends AbstractPaperlessService<Workflow> {
loading: boolean
constructor(http: HttpClient) {
super(http, 'consumption_templates')
super(http, 'workflows')
}
public reload() {
this.loading = true
this.listAll().subscribe((r) => {
this.templates = r.results
this.workflows = r.results
this.loading = false
})
}
private templates: ConsumptionTemplate[] = []
private workflows: Workflow[] = []
public get allTemplates(): ConsumptionTemplate[] {
return this.templates
public get allWorkflows(): Workflow[] {
return this.workflows
}
create(o: ConsumptionTemplate) {
create(o: Workflow) {
return super.create(o).pipe(tap(() => this.reload()))
}
update(o: ConsumptionTemplate) {
update(o: Workflow) {
return super.update(o).pipe(tap(() => this.reload()))
}
delete(o: ConsumptionTemplate) {
delete(o: Workflow) {
return super.delete(o).pipe(tap(() => this.reload()))
}
}

View File

@@ -647,8 +647,6 @@ code {
}
.accordion {
--bs-accordion-btn-padding-x: 0.75rem;
--bs-accordion-btn-padding-y: 0.375rem;
--bs-accordion-btn-bg: var(--bs-light);
--bs-accordion-btn-color: var(--bs-primary);
--bs-accordion-color: var(--bs-body-color);