mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-30 18:27:45 -05:00
Feature: email, webhook workflow actions (#8108)
This commit is contained in:
@@ -322,6 +322,33 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.Email) {
|
||||
<div class="row" [formGroup]="formGroup.get('email')">
|
||||
<input type="hidden" formControlName="id" />
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Email subject" formControlName="subject" [error]="error?.actions?.[i]?.email?.subject"></pngx-input-text>
|
||||
<pngx-input-textarea i18n-title title="Email body" formControlName="body" [error]="error?.actions?.[i]?.email?.body"></pngx-input-textarea>
|
||||
<pngx-input-text i18n-title title="Email recipients" formControlName="to" [error]="error?.actions?.[i]?.email?.to"></pngx-input-text>
|
||||
<pngx-input-switch i18n-title title="Attach document" formControlName="include_document"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case (WorkflowActionType.Webhook) {
|
||||
<div class="row" [formGroup]="formGroup.get('webhook')">
|
||||
<input type="hidden" formControlName="id" />
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Webhook url" formControlName="url" [error]="error?.actions?.[i]?.url"></pngx-input-text>
|
||||
<pngx-input-switch i18n-title title="Use parameters for webhook body" formControlName="use_params"></pngx-input-switch>
|
||||
@if (formGroup.get('webhook').value['use_params']) {
|
||||
<pngx-input-entries i18n-title title="Webhook params" formControlName="params" [error]="error?.actions?.[i]?.params"></pngx-input-entries>
|
||||
} @else {
|
||||
<pngx-input-textarea i18n-title title="Webhook body" formControlName="body" [error]="error?.actions?.[i]?.body"></pngx-input-textarea>
|
||||
}
|
||||
<pngx-input-entries i18n-title title="Webhook headers" formControlName="headers" [error]="error?.actions?.[i]?.headers"></pngx-input-entries>
|
||||
<pngx-input-switch i18n-title title="Include document" formControlName="include_document"></pngx-input-switch>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -347,4 +347,15 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
component.actionFields.at(0).get('remove_change_groups').disabled
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should prune empty nested objects on save', () => {
|
||||
component.object = workflow
|
||||
component.addTrigger()
|
||||
component.addAction()
|
||||
expect(component.objectForm.get('actions').value[0].email).not.toBeNull()
|
||||
expect(component.objectForm.get('actions').value[0].webhook).not.toBeNull()
|
||||
component.save()
|
||||
expect(component.objectForm.get('actions').value[0].email).toBeNull()
|
||||
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
|
||||
})
|
||||
})
|
||||
|
@@ -96,6 +96,14 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
id: WorkflowActionType.Removal,
|
||||
name: $localize`Removal`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.Email,
|
||||
name: $localize`Email`,
|
||||
},
|
||||
{
|
||||
id: WorkflowActionType.Webhook,
|
||||
name: $localize`Webhook`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
@@ -402,6 +410,22 @@ export class WorkflowEditDialogComponent
|
||||
remove_all_custom_fields: new FormControl(
|
||||
action.remove_all_custom_fields
|
||||
),
|
||||
email: new FormGroup({
|
||||
id: new FormControl(action.email?.id),
|
||||
subject: new FormControl(action.email?.subject),
|
||||
body: new FormControl(action.email?.body),
|
||||
to: new FormControl(action.email?.to),
|
||||
include_document: new FormControl(!!action.email?.include_document),
|
||||
}),
|
||||
webhook: new FormGroup({
|
||||
id: new FormControl(action.webhook?.id),
|
||||
url: new FormControl(action.webhook?.url),
|
||||
use_params: new FormControl(action.webhook?.use_params),
|
||||
params: new FormControl(action.webhook?.params),
|
||||
body: new FormControl(action.webhook?.body),
|
||||
headers: new FormControl(action.webhook?.headers),
|
||||
include_document: new FormControl(!!action.webhook?.include_document),
|
||||
}),
|
||||
}),
|
||||
{ emitEvent }
|
||||
)
|
||||
@@ -503,6 +527,22 @@ export class WorkflowEditDialogComponent
|
||||
remove_all_permissions: false,
|
||||
remove_custom_fields: [],
|
||||
remove_all_custom_fields: false,
|
||||
email: {
|
||||
id: null,
|
||||
subject: null,
|
||||
body: null,
|
||||
to: null,
|
||||
include_document: false,
|
||||
},
|
||||
webhook: {
|
||||
id: null,
|
||||
url: null,
|
||||
use_params: true,
|
||||
params: null,
|
||||
body: null,
|
||||
headers: null,
|
||||
include_document: false,
|
||||
},
|
||||
}
|
||||
this.object.actions.push(action)
|
||||
this.createActionField(action)
|
||||
@@ -533,4 +573,18 @@ export class WorkflowEditDialogComponent
|
||||
c.get('id').setValue(null, { emitEvent: false })
|
||||
)
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.objectForm
|
||||
.get('actions')
|
||||
.value.forEach((action: WorkflowAction, i) => {
|
||||
if (action.type !== WorkflowActionType.Webhook) {
|
||||
action.webhook = null
|
||||
}
|
||||
if (action.type !== WorkflowActionType.Email) {
|
||||
action.email = null
|
||||
}
|
||||
})
|
||||
super.save()
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,29 @@
|
||||
<div class="mb-3" [class.pb-3]="error">
|
||||
<div class="row">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
@if (title) {
|
||||
<label class="form-label mb-0" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="addEntry()">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="position-relative">
|
||||
@for (entry of entries; let i = $index; track entry[0]) {
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)">
|
||||
<i-bs class="text-danger" name="trash"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||
}
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,65 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
FormsModule,
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { EntriesComponent } from './entries.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('EntriesComponent', () => {
|
||||
let component: EntriesComponent
|
||||
let fixture: ComponentFixture<EntriesComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [EntriesComponent],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EntriesComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should add an entry', () => {
|
||||
component.addEntry()
|
||||
expect(component.entries.length).toBe(1)
|
||||
expect(component.entries[0]).toEqual(['', ''])
|
||||
})
|
||||
|
||||
it('should remove an entry', () => {
|
||||
component.addEntry()
|
||||
component.addEntry()
|
||||
expect(component.entries.length).toBe(2)
|
||||
component.removeEntry(0)
|
||||
expect(component.entries.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should write value correctly', () => {
|
||||
const newValue = { key1: 'value1', key2: 'value2' }
|
||||
component.writeValue(newValue)
|
||||
expect(component.entries).toEqual(Object.entries(newValue))
|
||||
component.writeValue(null)
|
||||
expect(component.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('should correctly generate the value on input change', () => {
|
||||
const onChangeSpy = jest.spyOn(component, 'onChange')
|
||||
component.entries = [
|
||||
['key1', 'value1'],
|
||||
['key2', ''],
|
||||
['', ''],
|
||||
]
|
||||
component.inputChange()
|
||||
// Only the first two entries should be included
|
||||
expect(onChangeSpy).toHaveBeenCalledWith({ key1: 'value1', key2: '' })
|
||||
})
|
||||
})
|
@@ -0,0 +1,48 @@
|
||||
import { Component, forwardRef } from '@angular/core'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => EntriesComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
selector: 'pngx-input-entries',
|
||||
templateUrl: './entries.component.html',
|
||||
styleUrl: './entries.component.scss',
|
||||
})
|
||||
export class EntriesComponent extends AbstractInputComponent<object> {
|
||||
entries = []
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
inputChange(): void {
|
||||
// Remove empty keys
|
||||
this.onChange(
|
||||
Object.fromEntries(this.entries.filter(([key]) => key?.length))
|
||||
)
|
||||
}
|
||||
|
||||
writeValue(newValue: any): void {
|
||||
if (!newValue) {
|
||||
newValue = {}
|
||||
}
|
||||
this.entries = Object.entries(newValue)
|
||||
this.value = newValue
|
||||
}
|
||||
|
||||
addEntry(): void {
|
||||
this.entries.push(['', ''])
|
||||
this.inputChange()
|
||||
}
|
||||
|
||||
removeEntry(index: number): void {
|
||||
this.entries.splice(index, 1)
|
||||
this.inputChange()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user