Feature: Workflows (#5121)

This commit is contained in:
shamoon
2024-01-03 00:19:19 -08:00
committed by GitHub
parent ae7940e040
commit 1141b767a0
54 changed files with 4980 additions and 2011 deletions

View File

@@ -0,0 +1,49 @@
<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 Workflow</ng-container>
</button>
</pngx-page-header>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col" i18n>Sort order</div>
<div class="col" i18n>Status</div>
<div class="col" i18n>Triggers</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@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)="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.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.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>
</button>
</div>
</div>
</div>
</li>
}
@if (workflows.length === 0) {
<li class="list-group-item" i18n>No workflows defined.</li>
}
</ul>

View File

@@ -0,0 +1,194 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
NgbModal,
NgbPaginationModule,
NgbModalRef,
NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { Workflow } from 'src/app/data/workflow'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
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 { 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 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',
},
],
},
]
describe('WorkflowsComponent', () => {
let component: WorkflowsComponent
let fixture: ComponentFixture<WorkflowsComponent>
let workflowService: WorkflowService
let modalService: NgbModal
let toastService: ToastService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
WorkflowsComponent,
IfPermissionsDirective,
PageHeaderComponent,
ConfirmDialogComponent,
],
providers: [
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
currentUserHasObjectPermissions: () => true,
currentUserOwnsObject: () => true,
},
},
],
imports: [
HttpClientTestingModule,
NgbPaginationModule,
FormsModule,
ReactiveFormsModule,
NgbModalModule,
],
})
workflowService = TestBed.inject(WorkflowService)
jest.spyOn(workflowService, 'listAll').mockReturnValue(
of({
count: workflows.length,
all: workflows.map((o) => o.id),
results: workflows,
})
)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(WorkflowsComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create, show notification on error / success', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
// fail first
editDialog.failed.emit({ error: 'error creating item' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(workflows[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it('should support edit, show notification on error / success', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
editButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as WorkflowEditDialogComponent
expect(editDialog.object).toEqual(workflows[0])
// fail first
editDialog.failed.emit({ error: 'error editing item' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(workflows[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
})
it('should support delete, show notification on error / success', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const deleteSpy = jest.spyOn(workflowService, 'delete')
const reloadSpy = jest.spyOn(component, 'reload')
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
deleteButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as ConfirmDialogComponent
// fail first
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
editDialog.confirmClicked.emit()
expect(toastErrorSpy).toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
// succeed
deleteSpy.mockReturnValueOnce(of(true))
editDialog.confirmClicked.emit()
expect(reloadSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,115 @@
import { Component, OnInit } from '@angular/core'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { Subject, takeUntil } from 'rxjs'
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 {
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-workflows',
templateUrl: './workflows.component.html',
styleUrls: ['./workflows.component.scss'],
})
export class WorkflowsComponent
extends ComponentWithPermissions
implements OnInit
{
public workflows: Workflow[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private workflowService: WorkflowService,
public permissionsService: PermissionsService,
private modalService: NgbModal,
private toastService: ToastService
) {
super()
}
ngOnInit() {
this.reload()
}
reload() {
this.workflowService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this.workflows = r.results
})
}
getTypesList(template: Workflow): string {
return template.triggers
.map(
(trigger) =>
WORKFLOW_TYPE_OPTIONS.find((t) => t.id === trigger.type).name
)
.join(', ')
}
editWorkflow(workflow: Workflow) {
const modal = this.modalService.open(WorkflowEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = workflow
? EditDialogMode.EDIT
: EditDialogMode.CREATE
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((newWorkflow) => {
this.toastService.showInfo(
$localize`Saved workflow "${newWorkflow.name}".`
)
this.workflowService.clearCache()
this.reload()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving workflow.`, e)
})
}
deleteWorkflow(workflow: Workflow) {
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
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.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted workflow`)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting workflow.`, e)
},
})
})
}
}