TF-4336 Fix INBOX should be above Favorite folder (#4340)

This commit is contained in:
Dat Vu
2026-02-24 11:58:22 +07:00
committed by GitHub
parent 9cd75b2885
commit 9afe4d89e3
2 changed files with 371 additions and 18 deletions
@@ -253,6 +253,10 @@ class MailboxController extends BaseMailboxController
dashboardController: mailboxDashBoardController,
toastManager: toastManager,
);
} else if (failure is CreateDefaultMailboxFailure) {
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
} else {
super.handleFailureViewState(failure);
}
@@ -273,9 +277,6 @@ class MailboxController extends BaseMailboxController
},
(success) {
if (success is GetAllMailboxSuccess) {
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
mailboxDashBoardController.updateRefreshAllMailboxState(Right(RefreshAllMailboxSuccess()));
_handleCreateDefaultFolderIfMissing(mailboxDashBoardController.mapDefaultMailboxIdByRole);
_handleDataFromNavigationRouter();
@@ -283,10 +284,6 @@ class MailboxController extends BaseMailboxController
if (PlatformInfo.isIOS) {
_updateMailboxIdsBlockNotificationToKeychain(success.mailboxList);
}
} else if (success is CreateDefaultMailboxAllSuccess) {
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
}
});
}
@@ -614,7 +611,7 @@ class MailboxController extends BaseMailboxController
.mailboxList
.listSubscribedMailboxesAndDefaultMailboxes;
await refreshTree(listMailboxDisplayed);
await refreshTree(listMailboxDisplayed.withoutVirtualMailbox);
if (currentContext != null) {
syncAllMailboxWithDisplayName(currentContext!);
@@ -705,22 +702,32 @@ class MailboxController extends BaseMailboxController
.whereNot((role) => mapDefaultMailboxRole.containsKey(role) || findNodeByNameOnFirstLevel(role.value) != null)
.toList();
if (listRoleMissing.isEmpty || accountId == null || session == null) {
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
return;
}
final mapRoles = {
for (var role in listRoleMissing)
Id(uuid.v1()) : role
};
log('MailboxController::_handleCreateDefaultFolderIfMissing():mapRoles: $mapRoles');
if (mapRoles.isNotEmpty && accountId != null && session != null) {
consumeState(_createDefaultMailboxInteractor.execute(
session!,
accountId!,
mapRoles,
));
}
consumeState(_createDefaultMailboxInteractor.execute(
session!,
accountId!,
mapRoles,
));
}
Future<void> _handleCreateDefaultFolderIfMissingSuccess(CreateDefaultMailboxAllSuccess success) async {
if (success.listMailbox.isEmpty) return;
if (success.listMailbox.isEmpty) {
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
return;
}
Set<Role?> existingRoles = {};
Set<MailboxName> existingNamesWithoutParent = {};
@@ -733,10 +740,13 @@ class MailboxController extends BaseMailboxController
allMailboxes.add(mailbox.toPresentationMailbox());
}
await buildTree(allMailboxes);
await buildTree(allMailboxes.withoutVirtualMailbox);
if (currentContext != null) {
syncAllMailboxWithDisplayName(currentContext!);
}
autoCreateVirtualFolder(
mailboxDashBoardController.isAINeedsActionEnabled,
);
_setMapMailbox();
_setOutboxMailbox();
}
@@ -1320,7 +1330,7 @@ class MailboxController extends BaseMailboxController
currentMailboxState = success.currentMailboxState;
log('MailboxController::_handleGetAllMailboxSuccess:currentMailboxState: $currentMailboxState');
final listMailboxDisplayed = success.mailboxList.listSubscribedMailboxesAndDefaultMailboxes;
await buildTree(listMailboxDisplayed);
await buildTree(listMailboxDisplayed.withoutVirtualMailbox);
if (currentContext != null) {
syncAllMailboxWithDisplayName(currentContext!);
}
@@ -1,7 +1,13 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jmap_dart_client/jmap/core/id.dart';
import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart';
import 'package:jmap_dart_client/jmap/mail/mailbox/namespace.dart';
import 'package:model/extensions/list_presentation_mailbox_extension.dart';
import 'package:model/extensions/presentation_mailbox_extension.dart';
import 'package:model/mailbox/expand_mode.dart';
import 'package:model/mailbox/mailbox_state.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:model/mailbox/select_mode.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_node.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree.dart';
import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_tree_builder.dart';
@@ -261,4 +267,341 @@ void main() {
);
});
});
group('Virtual Folders Exclusion & Sorting Impact', () {
late List<PresentationMailbox> filteredMailboxes;
final virtualStarred = PresentationMailbox.favoriteFolder;
final virtualActionRequired = PresentationMailbox.actionRequiredFolder;
setUp(() {
final allMailboxesIncludingVirtual = [
PresentationMailbox(
MailboxId(Id("default_root")),
parentId: null,
name: MailboxName('Inbox'),
role: Role('inbox'),
sortOrder: SortOrder(sortValue: 10),
),
PresentationMailbox(
MailboxId(Id("default_child")),
parentId: MailboxId(Id("default_root")),
name: MailboxName('Sub Inbox'),
),
PresentationMailbox(
MailboxId(Id("personal_root")),
parentId: null,
name: MailboxName('My Folder'),
namespace: Namespace('Personal'),
),
PresentationMailbox(
MailboxId(Id("team_root")),
parentId: null,
name: MailboxName('Team Folder'),
namespace: Namespace('Team'),
),
virtualStarred,
virtualActionRequired,
];
filteredMailboxes = allMailboxesIncludingVirtual.withoutVirtualMailbox;
});
void expectNoVirtualFoldersRecursively(List<MailboxNode>? nodes) {
if (nodes == null || nodes.isEmpty) return;
for (final node in nodes) {
expect(
node.item.isVirtualFolder,
isFalse,
reason: 'Found Virtual Folder: ${node.item.name?.name}',
);
expectNoVirtualFoldersRecursively(node.childrenItems);
}
}
test(
'generateMailboxTreeInUI: Trees and nested sub-trees must exclude virtual folders',
() async {
final generatedTrees = await TreeBuilder().generateMailboxTreeInUI(
allMailboxes: filteredMailboxes,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
expectNoVirtualFoldersRecursively(
generatedTrees.defaultTree.root.childrenItems,
);
expectNoVirtualFoldersRecursively(
generatedTrees.personalTree.root.childrenItems,
);
expectNoVirtualFoldersRecursively(
generatedTrees.teamMailboxTree.root.childrenItems,
);
expect(
generatedTrees.allMailboxes,
everyElement(
isNot(predicate<PresentationMailbox>((m) => m.isVirtualFolder))),
);
});
test(
'generateMailboxTreeInUIAfterRefreshChanges: Trees and nested sub-trees must exclude virtual folders',
() async {
final generatedTrees =
await TreeBuilder().generateMailboxTreeInUIAfterRefreshChanges(
allMailboxes: filteredMailboxes,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
expectNoVirtualFoldersRecursively(
generatedTrees.defaultTree.root.childrenItems,
);
expectNoVirtualFoldersRecursively(
generatedTrees.personalTree.root.childrenItems,
);
expectNoVirtualFoldersRecursively(
generatedTrees.teamMailboxTree.root.childrenItems,
);
});
test(
'Inbox is first when virtual folders are excluded via withoutVirtualMailbox before generateMailboxTreeInUI',
() async {
final allMailboxesIncludingVirtual = [
PresentationMailbox(
MailboxId(Id("4")),
parentId: null,
name: MailboxName('Sent'),
sortOrder: SortOrder(sortValue: 50),
role: Role('sent'),
),
PresentationMailbox(
MailboxId(Id("1")),
parentId: null,
name: MailboxName('Inbox'),
sortOrder: SortOrder(sortValue: 10),
role: Role('inbox'),
),
virtualStarred,
virtualActionRequired,
];
final filtered = allMailboxesIncludingVirtual.withoutVirtualMailbox;
final generatedTree = await TreeBuilder().generateMailboxTreeInUI(
allMailboxes: filtered,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
final children = generatedTree.defaultTree.root.childrenItems ?? [];
expect(
children,
isNotEmpty,
reason:
'Default tree must have at least one mailbox after filtering virtual folders',
);
expect(
children.first.item.id.id.value,
equals('1'),
reason:
'Inbox (sortOrder=10) must be first when virtual folders are excluded',
);
expect(
children,
everyElement(
isNot(predicate<MailboxNode>((n) => n.item.isVirtualFolder))),
reason: 'No virtual folders should appear in the tree',
);
});
test(
'Regression: Virtual folders with null sortOrder sort to top when passed to generateMailboxTreeInUI',
() async {
final testCaseWithVirtualFolders = [
PresentationMailbox(MailboxId(Id("4")),
parentId: null,
name: MailboxName('Sent'),
sortOrder: SortOrder(sortValue: 50),
role: Role('sent')),
PresentationMailbox(MailboxId(Id("1")),
parentId: null,
name: MailboxName('Inbox'),
sortOrder: SortOrder(sortValue: 10),
role: Role('inbox')),
virtualStarred,
];
final generatedTree = await TreeBuilder().generateMailboxTreeInUI(
allMailboxes: testCaseWithVirtualFolders,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
final children = generatedTree.defaultTree.root.childrenItems ?? [];
expect(
children,
isNotEmpty,
reason: 'Virtual folder must have been routed to the default tree',
);
expect(
children.first.item.id,
equals(virtualStarred.id),
reason:
'Virtual folder with null sortOrder sorts to top — controller must pass allMailboxes.withoutVirtualMailbox to buildTree',
);
});
});
group('Routing & Sorting', () {
test('Mailboxes are routed to correct trees and sorted alphabetically',
() async {
final mailboxes = [
PresentationMailbox(MailboxId(Id("1")),
parentId: null, name: MailboxName('Inbox'), role: Role('inbox')),
PresentationMailbox(MailboxId(Id("3")),
parentId: MailboxId(Id("1")), name: MailboxName('Zebra')),
PresentationMailbox(MailboxId(Id("2")),
parentId: MailboxId(Id("1")), name: MailboxName('Apple')),
PresentationMailbox(
MailboxId(Id("4")),
parentId: null,
name: MailboxName('Personal'),
namespace: Namespace('Personal'),
),
PresentationMailbox(
MailboxId(Id("5")),
parentId: null,
name: MailboxName('Team'),
namespace: Namespace('Team'),
),
];
final result =
await TreeBuilder().generateMailboxTreeInUIAfterRefreshChanges(
allMailboxes: mailboxes,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
expect(result.defaultTree.root.childrenItems?.length, equals(1));
expect(
result.personalTree.root.childrenItems,
isNotEmpty,
reason: 'Personal tree must have at least one mailbox',
);
expect(
result.personalTree.root.childrenItems?.first.item.name?.name,
equals('Personal'),
);
expect(
result.teamMailboxTree.root.childrenItems,
isNotEmpty,
reason: 'Team tree must have at least one mailbox',
);
expect(
result.teamMailboxTree.root.childrenItems?.first.item.name?.name,
equals('Team'),
);
final inboxChildren =
result.defaultTree.root.childrenItems?.first.childrenItems ?? [];
expect(inboxChildren.length, equals(2));
expect(
inboxChildren[0].item.name?.name,
equals('Apple'),
reason: 'Apple must sort before Zebra',
);
expect(inboxChildren[1].item.name?.name, equals('Zebra'));
});
});
group('UI State Preservation', () {
test('Preserves expandMode and selectMode from previous trees', () async {
final targetId = MailboxId(Id("1"));
final mailbox = PresentationMailbox(
targetId,
parentId: null,
name: MailboxName('Inbox'),
role: Role('inbox'),
);
final oldNode = MailboxNode(
mailbox,
expandMode: ExpandMode.EXPAND,
selectMode: SelectMode.ACTIVE,
);
final currentDefaultTree = MailboxTree(MailboxNode.root())
..root.addChildNode(oldNode);
final result =
await TreeBuilder().generateMailboxTreeInUIAfterRefreshChanges(
allMailboxes: [mailbox],
currentDefaultTree: currentDefaultTree,
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
);
final newNode = result.defaultTree.root.childrenItems?.first;
expect(
newNode?.expandMode,
equals(ExpandMode.EXPAND),
reason: 'expandMode must be carried over',
);
expect(
newNode?.selectMode,
equals(SelectMode.ACTIVE),
reason: 'selectMode must be carried over',
);
});
});
group('Cascading Deactivation (generateMailboxTreeInUI only)', () {
test('Selecting a parent deactivates it and cascades to its children',
() async {
final parentId = MailboxId(Id("parent_1"));
final childId = MailboxId(Id("child_1"));
final grandChildId = MailboxId(Id("grandchild_1"));
final mailboxes = [
PresentationMailbox(
parentId,
parentId: null,
name: MailboxName('Parent'),
role: Role('inbox'),
),
PresentationMailbox(
childId,
parentId: parentId,
name: MailboxName('Child'),
),
PresentationMailbox(
grandChildId,
parentId: childId,
name: MailboxName('Grandchild'),
),
];
final result = await TreeBuilder().generateMailboxTreeInUI(
allMailboxes: mailboxes,
currentDefaultTree: MailboxTree(MailboxNode.root()),
currentPersonalTree: MailboxTree(MailboxNode.root()),
currentTeamMailboxTree: MailboxTree(MailboxNode.root()),
mailboxIdSelected: parentId,
);
final parentNode = result.defaultTree.root.childrenItems?.first;
final childNode = parentNode?.childrenItems?.first;
final grandChildNode = childNode?.childrenItems?.first;
expect(parentNode?.nodeState, equals(MailboxState.deactivated));
expect(childNode?.nodeState, equals(MailboxState.deactivated));
expect(grandChildNode?.nodeState, equals(MailboxState.deactivated));
});
});
}