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/tasks_provider.dart';
|
||||
import '../theme/app_surfaces.dart';
|
||||
import '../utils/snackbar.dart';
|
||||
|
||||
class TaskAssignmentSection extends ConsumerWidget {
|
||||
const TaskAssignmentSection({
|
||||
|
|
@ -39,36 +40,11 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
.toList();
|
||||
final assignedIds = assignedForTask.map((a) => a.userId).toSet();
|
||||
|
||||
final activeTaskIds = tasks
|
||||
.where(
|
||||
(task) => task.status == 'queued' || task.status == 'in_progress',
|
||||
)
|
||||
.map((task) => task.id)
|
||||
.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();
|
||||
// With concurrent assignments allowed we no longer restrict the
|
||||
// eligible list based on whether the staff member already has an active
|
||||
// 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.
|
||||
final eligibleStaff = List<Profile>.from(itStaff);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -137,6 +113,9 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
Set<String> assignedIds,
|
||||
String? taskTicketId,
|
||||
) 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) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
|
|
@ -144,7 +123,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
return AlertDialog(
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
title: const Text('Assign IT Staff'),
|
||||
content: const Text('No vacant IT staff available.'),
|
||||
content: const Text('No IT staff available.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
|
|
@ -161,6 +140,7 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
var isSaving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
|
|
@ -187,7 +167,9 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
horizontal: 12,
|
||||
vertical: 2,
|
||||
),
|
||||
onChanged: (value) {
|
||||
onChanged: isSaving
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
selection.add(staff.id);
|
||||
|
|
@ -203,11 +185,17 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () async {
|
||||
onPressed: isSaving
|
||||
? null
|
||||
: () async {
|
||||
setState(() => isSaving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(taskAssignmentsControllerProvider)
|
||||
.replaceAssignments(
|
||||
|
|
@ -216,11 +204,40 @@ class TaskAssignmentSection extends ConsumerWidget {
|
|||
newUserIds: selection.toList(),
|
||||
currentUserIds: assignedIds.toList(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
'Assignment saved successfully',
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
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