Simple Approval Workflow
A complete example of a single-level approval workflow.
Overview
This workflow demonstrates:
- Validation task
- User task for approval decision
- Conditional routing based on decision
- Post-processing for approved/rejected states
Workflow Diagram
Complete Implementation
Workflow Definition
dart
import 'package:vyuh_workflow_engine/vyuh_workflow_engine.dart';
final simpleApprovalWorkflow = WorkflowBuilder(
'SIMPLE-APPROVAL',
'Simple Approval Workflow',
description: 'Single-level approval with approve/reject decision',
)
.start('begin')
// Step 1: Validate the request
.task('validateRequest',
execute: (ctx) async {
// Use getInitial<T> for original workflow input
final entityId = ctx.getInitial<String>('entityId');
final entityType = ctx.getInitial<String>('entityType');
final submittedBy = ctx.getInitial<String>('submittedBy');
// Validation - throw to fail the task
if (entityId == null || entityType == null || submittedBy == null) {
throw ArgumentError('Missing required fields: entityId, entityType, submittedBy');
}
// Fetch entity details (simulated)
// In real code: final entity = await entityService.getById(entityId);
// Return Map<String, dynamic> - builder wraps in TaskSuccess
return {
'entityId': entityId,
'entityType': entityType,
'entityName': 'Sample Entity', // entity.name
'entityDetails': 'Entity details here', // entity.summary
'submittedBy': submittedBy,
'submittedAt': DateTime.now().toIso8601String(),
};
},
)
// Step 2: Wait for approval decision
.userTask('approvalDecision',
signal: 'approval_decision',
schemaType: 'userTask.approval',
title: 'Approve {{entityType}}: {{entityName}}',
description: 'Please review this {{entityType}} and make an approval decision.',
assignToRole: 'approvers',
storeAs: 'approvalResult',
)
// Step 3: Route based on decision
.oneOf('routeDecision', [
Branch.whenFn(
(output) => output['approvalResult']?['decision'] == 'approved',
then: 'processApproval',
),
Branch.whenFn(
(output) => output['approvalResult']?['decision'] == 'rejected',
then: 'processRejection',
),
Branch.otherwise(then: 'handleUnknown'),
])
// Step 4a: Process approved
.task('processApproval',
execute: (ctx) async {
// Use getAny<T> for accumulated output from earlier nodes
final entityId = ctx.getAny<String>('entityId')!;
// Use get<T> for previous node output (the gateway passes through)
final approvedBy = ctx.get<String>('approvalResult.completedBy')!;
final comments = ctx.get<String>('approvalResult.comments');
// Update entity status (simulated)
// await entityService.updateStatus(entityId, 'approved');
// Send notification (simulated)
// await notificationService.send(...);
// Return Map - builder wraps in TaskSuccess
return {
'approvedBy': approvedBy,
'approvedAt': DateTime.now().toIso8601String(),
'comments': comments,
};
},
)
// Step 4b: Process rejected
.task('processRejection',
execute: (ctx) async {
// Use getAny<T> for accumulated output from earlier nodes
final entityId = ctx.getAny<String>('entityId')!;
// Use get<T> for previous node output (the gateway passes through)
final rejectedBy = ctx.get<String>('approvalResult.completedBy')!;
final reason = ctx.get<String>('approvalResult.reason');
// Update entity status (simulated)
// await entityService.updateStatus(entityId, 'rejected');
// Return Map - builder wraps in TaskSuccess
return {
'rejectedBy': rejectedBy,
'rejectedAt': DateTime.now().toIso8601String(),
'reason': reason,
};
},
)
// Step 4c: Handle unknown decision
.task('handleUnknown',
execute: (ctx) async {
final decision = ctx.get<String>('approvalResult.decision');
// Throw to fail the task - builder wraps in TaskFailure
throw StateError('Unknown decision: $decision');
},
)
// End nodes
.end('approved', name: 'Approved')
.end('rejected', name: 'Rejected')
.end('error', name: 'Error')
// Define flow edges
.connect('begin', 'validateRequest')
.connect('validateRequest', 'approvalDecision')
.connect('approvalDecision', 'routeDecision')
.connect('processApproval', 'approved')
.connect('processRejection', 'rejected')
.connect('handleUnknown', 'error')
.build();Task Executor (Alternative)
Instead of inline execute, use a registered task executor:
dart
class ApprovalProcessingExecutor extends TaskExecutor {
static const _schemaType = 'task.approval.process';
static final typeDescriptor = TypeDescriptor<TaskExecutor>(
schemaType: _schemaType,
title: 'Approval Processing Executor',
fromJson: (_) => ApprovalProcessingExecutor(),
);
@override
String get schemaType => _schemaType;
@override
String get name => 'Approval Processing';
@override
Future<TaskResult> execute(ExecutionContext context) async {
// Use getAny<T> for data from earlier nodes
final entityId = context.getAny<String>('entityId')!;
// Use get<T> for previous node output
final result = context.get<Map<String, dynamic>>('approvalResult')!;
final decision = result['decision'] as String;
if (decision == 'approved') {
await processApproval(entityId, result);
return TaskSuccess(output: {'processed': 'approved'});
} else if (decision == 'rejected') {
await processRejection(entityId, result);
return TaskSuccess(output: {'processed': 'rejected'});
} else {
return TaskFailure.validation('Unknown decision: $decision');
}
}
}User Task Executor
User task executors always return WaitForUserTaskResult because they must wait for human interaction:
dart
class ApprovalUserTaskExecutor extends UserTaskExecutor {
static const _schemaType = 'userTask.approval';
static final typeDescriptor = TypeDescriptor<UserTaskExecutor>(
schemaType: _schemaType,
title: 'Approval Task',
fromJson: (_) => ApprovalUserTaskExecutor(),
);
@override
String get schemaType => _schemaType;
@override
String get name => 'Approval Task';
@override
Future<WaitForUserTaskResult> execute(ExecutionContext context) async {
// Use get<T> for previous node output
final entityType = context.get<String>('entityType') ?? 'Item';
final entityName = context.get<String>('entityName') ?? 'Unknown';
final roleId = context.get<String>('approverRoleId') ?? 'approvers';
return WaitForUserTaskResult(
signalName: context.signalName!,
config: UserTaskConfiguration(
title: 'Approve $entityType: $entityName',
description: 'Please review and approve this ${entityType.toLowerCase()}',
schemaType: schemaType,
assignedToRoleId: roleId,
priority: UserTaskPriority.high,
input: context.input,
storeAs: 'approvalDecision',
),
);
}
@override
Map<String, dynamic> processOutput(Map<String, dynamic> userOutput) {
final decision = userOutput['decision'];
if (decision != 'approved' && decision != 'rejected') {
throw ArgumentError('Decision must be "approved" or "rejected"');
}
return userOutput;
}
}Starting the Workflow
dart
// Start workflow
final instance = await engine.startWorkflow(
workflowCode: simpleApprovalWorkflow.code, // Use workflowCode for semantic lookup
input: {
'entityId': 'DOC-12345',
'entityType': 'Document',
'submittedBy': '[email protected]',
},
);
print('Workflow started: ${instance.id}');
print('Status: ${instance.status}'); // WaitingForSignalCompleting the User Task
dart
// Get pending tasks for approver via storage
final tasks = await engine.storage.userTaskInstances.query(
assignedToRoleId: 'approvers',
status: TaskStatus.pending,
);
// Complete the task with approval output
final task = tasks.first;
final completedTask = task.completeWithOutput(
'[email protected]',
{'decision': 'approved', 'comments': 'Looks good, approved!'},
);
// Update in storage
await engine.storage.userTaskInstances.update(completedTask);
// Signal the workflow to continue
await engine.sendSignal(
workflowInstanceId: task.workflowInstanceId,
node: task.nodeId,
payload: completedTask.output,
);Output Schema
Final workflow output after approval:
json
{
"entityId": "DOC-12345",
"entityType": "Document",
"entityName": "Q4 Report",
"entityDetails": "Quarterly financial report...",
"submittedBy": "[email protected]",
"submittedAt": "2024-01-15T10:30:00Z",
"approvalResult": {
"decision": "approved",
"completedBy": "[email protected]",
"completedAt": "2024-01-15T14:22:00Z",
"comments": "Looks good, approved!"
},
"approvedBy": "[email protected]",
"approvedAt": "2024-01-15T14:22:01Z",
"comments": "Looks good, approved!"
}Testing
dart
test('workflow approves entity on approval decision', () async {
// Start workflow
final instance = await engine.startWorkflow(
workflowCode: 'SIMPLE-APPROVAL',
input: {
'entityId': 'TEST-001',
'entityType': 'TestEntity',
'submittedBy': '[email protected]',
},
);
// Verify waiting for user task
expect(instance.status, WorkflowStatus.waitingForSignal);
// Get user task via storage
final tasks = await engine.storage.userTaskInstances.query(
workflowInstanceId: instance.id,
status: TaskStatus.pending,
);
expect(tasks.length, 1);
expect(tasks.first.schemaType, 'userTask.approval');
// Complete via signal
await engine.sendSignal(
workflowInstanceId: instance.id,
node: tasks.first.nodeId,
payload: {
'decision': 'approved',
'completedBy': '[email protected]',
},
);
// Verify completion
final completed = await engine.getWorkflowInstance(instance.id);
expect(completed!.status, WorkflowStatus.completed);
expect(completed.output['approvalResult']['decision'], 'approved');
});Next Steps
- Multi-Level Approval - Chain of approvers
- Parallel Tasks - Concurrent execution
- User Tasks - User task reference