Added saving indicator when assigning a task
This commit is contained in:
parent
5979a04254
commit
354b27aad1
|
|
@ -5,6 +5,7 @@ import '../models/profile.dart';
|
||||||
import '../providers/profile_provider.dart';
|
import '../providers/profile_provider.dart';
|
||||||
import '../providers/tasks_provider.dart';
|
import '../providers/tasks_provider.dart';
|
||||||
import '../theme/app_surfaces.dart';
|
import '../theme/app_surfaces.dart';
|
||||||
|
import '../utils/snackbar.dart';
|
||||||
|
|
||||||
class TaskAssignmentSection extends ConsumerWidget {
|
class TaskAssignmentSection extends ConsumerWidget {
|
||||||
const TaskAssignmentSection({
|
const TaskAssignmentSection({
|
||||||
|
|
@ -39,36 +40,11 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
.toList();
|
.toList();
|
||||||
final assignedIds = assignedForTask.map((a) => a.userId).toSet();
|
final assignedIds = assignedForTask.map((a) => a.userId).toSet();
|
||||||
|
|
||||||
final activeTaskIds = tasks
|
// With concurrent assignments allowed we no longer restrict the
|
||||||
.where(
|
// eligible list based on whether the staff member already has an active
|
||||||
(task) => task.status == 'queued' || task.status == 'in_progress',
|
// task. All IT staff can be assigned to any number of tasks at once. Keep
|
||||||
)
|
// the original sort order derived from itStaff rather than filtering.
|
||||||
.map((task) => task.id)
|
final eligibleStaff = List<Profile>.from(itStaff);
|
||||||
.toSet();
|
|
||||||
|
|
||||||
final activeAssignmentsByUser = <String, Set<String>>{};
|
|
||||||
for (final assignment in assignments) {
|
|
||||||
if (!activeTaskIds.contains(assignment.taskId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
activeAssignmentsByUser
|
|
||||||
.putIfAbsent(assignment.userId, () => <String>{})
|
|
||||||
.add(assignment.taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isVacant(String userId) {
|
|
||||||
final active = activeAssignmentsByUser[userId];
|
|
||||||
if (active == null || active.isEmpty) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return active.length == 1 && active.contains(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
final eligibleStaff = itStaff
|
|
||||||
.where(
|
|
||||||
(profile) => isVacant(profile.id) || assignedIds.contains(profile.id),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -137,6 +113,9 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
Set<String> assignedIds,
|
Set<String> assignedIds,
|
||||||
String? taskTicketId,
|
String? taskTicketId,
|
||||||
) async {
|
) async {
|
||||||
|
// If there are no IT staff at all we still need to bail out. We don't
|
||||||
|
// consider vacancy anymore because everyone is eligible, so the only
|
||||||
|
// reason for the dialog to be unusable is an empty staff list.
|
||||||
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
|
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -144,7 +123,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
shape: AppSurfaces.of(context).dialogShape,
|
||||||
title: const Text('Assign IT Staff'),
|
title: const Text('Assign IT Staff'),
|
||||||
content: const Text('No vacant IT staff available.'),
|
content: const Text('No IT staff available.'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
|
@ -161,6 +140,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
|
var isSaving = false;
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
|
|
@ -187,7 +167,9 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: isSaving
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (value == true) {
|
if (value == true) {
|
||||||
selection.add(staff.id);
|
selection.add(staff.id);
|
||||||
|
|
@ -203,11 +185,17 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
onPressed: isSaving
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () async {
|
onPressed: isSaving
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() => isSaving = true);
|
||||||
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(taskAssignmentsControllerProvider)
|
.read(taskAssignmentsControllerProvider)
|
||||||
.replaceAssignments(
|
.replaceAssignments(
|
||||||
|
|
@ -216,11 +204,40 @@ class TaskAssignmentSection extends ConsumerWidget {
|
||||||
newUserIds: selection.toList(),
|
newUserIds: selection.toList(),
|
||||||
currentUserIds: assignedIds.toList(),
|
currentUserIds: assignedIds.toList(),
|
||||||
);
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
showSuccessSnackBar(
|
||||||
|
context,
|
||||||
|
'Assignment saved successfully',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorSnackBar(
|
||||||
|
context,
|
||||||
|
'Failed to save assignment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
setState(() => isSaving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: isSaving
|
||||||
|
? SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text('Save'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
271
test/task_assignment_section_test.dart
Normal file
271
test/task_assignment_section_test.dart
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:tasq/models/profile.dart';
|
||||||
|
import 'package:tasq/models/task.dart';
|
||||||
|
import 'package:tasq/models/task_assignment.dart';
|
||||||
|
import 'package:tasq/providers/profile_provider.dart';
|
||||||
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
|
import 'package:tasq/utils/snackbar.dart';
|
||||||
|
import 'package:tasq/utils/app_time.dart';
|
||||||
|
import 'package:tasq/widgets/task_assignment_section.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
AppTime.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'allows assigning IT staff to a task even when they are already on another active task',
|
||||||
|
(tester) async {
|
||||||
|
// prepare two it_staff profiles
|
||||||
|
final profile1 = Profile(
|
||||||
|
id: 'u1',
|
||||||
|
fullName: 'User One',
|
||||||
|
role: 'it_staff',
|
||||||
|
);
|
||||||
|
final profile2 = Profile(
|
||||||
|
id: 'u2',
|
||||||
|
fullName: 'User Two',
|
||||||
|
role: 'it_staff',
|
||||||
|
);
|
||||||
|
|
||||||
|
// create two active tasks; staff u1 is already assigned to the first
|
||||||
|
final now = DateTime.now();
|
||||||
|
final task1 = Task.fromMap({
|
||||||
|
'id': 't1',
|
||||||
|
'status': 'queued',
|
||||||
|
'title': 'First task',
|
||||||
|
'description': '',
|
||||||
|
'created_at': now.toIso8601String(),
|
||||||
|
'priority': 1,
|
||||||
|
});
|
||||||
|
final task2 = Task.fromMap({
|
||||||
|
'id': 't2',
|
||||||
|
'status': 'queued',
|
||||||
|
'title': 'Second task',
|
||||||
|
'description': '',
|
||||||
|
'created_at': now.toIso8601String(),
|
||||||
|
'priority': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
final assignment1 = TaskAssignment(
|
||||||
|
taskId: 't1',
|
||||||
|
userId: 'u1',
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
profilesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([profile1, profile2]),
|
||||||
|
),
|
||||||
|
tasksProvider.overrideWith((ref) => Stream.value([task1, task2])),
|
||||||
|
taskAssignmentsProvider.overrideWith(
|
||||||
|
(ref) => Stream.value([assignment1]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskAssignmentSection(taskId: 't2', canAssign: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// allow provider streams to deliver their first value
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// open the assignment dialog
|
||||||
|
await tester.tap(find.text('Assign'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// both IT staff users should be listed even though u1 is already busy
|
||||||
|
expect(find.byType(CheckboxListTile), findsNWidgets(2));
|
||||||
|
expect(find.widgetWithText(CheckboxListTile, 'User One'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(CheckboxListTile, 'User Two'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('dialog shows message when there are no IT staff at all', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
profilesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(const <Profile>[]),
|
||||||
|
),
|
||||||
|
tasksProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(<Task>[
|
||||||
|
Task.fromMap({
|
||||||
|
'id': 't',
|
||||||
|
'status': 'queued',
|
||||||
|
'title': 't',
|
||||||
|
'description': '',
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
'priority': 1,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
taskAssignmentsProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(const <TaskAssignment>[]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskAssignmentSection(taskId: 't', canAssign: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Assign'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No IT staff available.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('save button shows spinner and snack on success', (tester) async {
|
||||||
|
final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff');
|
||||||
|
final task = Task.fromMap({
|
||||||
|
'id': 't',
|
||||||
|
'status': 'queued',
|
||||||
|
'title': 't',
|
||||||
|
'description': '',
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
'priority': 1,
|
||||||
|
});
|
||||||
|
final completer = Completer<void>();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||||||
|
tasksProvider.overrideWith((ref) => Stream.value([task])),
|
||||||
|
taskAssignmentsProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(const <TaskAssignment>[]),
|
||||||
|
),
|
||||||
|
taskAssignmentsControllerProvider.overrideWith(
|
||||||
|
(ref) => _DelayedController(completer),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskAssignmentSection(taskId: 't', canAssign: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// open dialog and select the only staff
|
||||||
|
await tester.tap(find.text('Assign'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(CheckboxListTile));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// tap save, spinner should appear
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
|
||||||
|
// resolve future and let dialog close
|
||||||
|
completer.complete();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// snackbar with success message should be visible
|
||||||
|
expect(find.text('Assignment saved successfully'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('error snackbar shows when save fails', (tester) async {
|
||||||
|
final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff');
|
||||||
|
final task = Task.fromMap({
|
||||||
|
'id': 't',
|
||||||
|
'status': 'queued',
|
||||||
|
'title': 't',
|
||||||
|
'description': '',
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
'priority': 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
profilesProvider.overrideWith((ref) => Stream.value([profile])),
|
||||||
|
tasksProvider.overrideWith((ref) => Stream.value([task])),
|
||||||
|
taskAssignmentsProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(const <TaskAssignment>[]),
|
||||||
|
),
|
||||||
|
taskAssignmentsControllerProvider.overrideWith(
|
||||||
|
(ref) => _FailingController(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: TaskAssignmentSection(taskId: 't', canAssign: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Assign'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byType(CheckboxListTile));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Failed to save assignment'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper fakes used by the tests below
|
||||||
|
class _DelayedController implements TaskAssignmentsController {
|
||||||
|
_DelayedController(this._completer);
|
||||||
|
final Completer<void> _completer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> replaceAssignments({
|
||||||
|
required String taskId,
|
||||||
|
required String? ticketId,
|
||||||
|
required List<String> newUserIds,
|
||||||
|
required List<String> currentUserIds,
|
||||||
|
}) {
|
||||||
|
return _completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeAssignment({
|
||||||
|
required String taskId,
|
||||||
|
required String userId,
|
||||||
|
}) {
|
||||||
|
// not needed for these tests
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FailingController implements TaskAssignmentsController {
|
||||||
|
@override
|
||||||
|
Future<void> replaceAssignments({
|
||||||
|
required String taskId,
|
||||||
|
required String? ticketId,
|
||||||
|
required List<String> newUserIds,
|
||||||
|
required List<String> currentUserIds,
|
||||||
|
}) async {
|
||||||
|
throw Exception('server error');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeAssignment({
|
||||||
|
required String taskId,
|
||||||
|
required String userId,
|
||||||
|
}) {
|
||||||
|
return Future.value();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user