TF-4282 Fix sort of web search suggestion (#4289)

This commit is contained in:
Dat Dang
2026-01-30 11:19:27 +07:00
committed by GitHub
parent 88d217f787
commit 342d2f295a
8 changed files with 561 additions and 72 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ mixin MailAPIMixin on HandleSetErrorMixin {
if (limit != null) queryEmailMethod.addLimit(limit);
if (position != null) queryEmailMethod.addPosition(position);
if (position != null && position > 0) queryEmailMethod.addPosition(position);
if (sort != null) queryEmailMethod.addSorts(sort);
@@ -138,6 +138,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/labels/handle_logic_label_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/notify_thread_detail_setting_updated.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/quick_search_emails_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/reopen_composer_cache_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/select_search_filter_action_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/set_error_extension.dart';
@@ -5,26 +5,15 @@ import 'package:dartz/dartz.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:jmap_dart_client/jmap/core/filter/filter.dart';
import 'package:jmap_dart_client/jmap/core/session/session.dart';
import 'package:jmap_dart_client/jmap/core/sort/comparator.dart';
import 'package:jmap_dart_client/jmap/core/unsigned_int.dart';
import 'package:jmap_dart_client/jmap/core/user_name.dart';
import 'package:jmap_dart_client/jmap/core/utc_date.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_comparator.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_comparator_property.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart';
import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
import 'package:labels/model/label.dart';
import 'package:model/email/presentation_email.dart';
import 'package:model/extensions/email_filter_condition_extension.dart';
import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:tmail_ui_user/features/base/base_controller.dart';
import 'package:tmail_ui_user/features/base/mixin/date_range_picker_mixin.dart';
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/get_all_recent_search_latest_state.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/quick_search_email_state.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/get_all_recent_search_latest_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/quick_search_email_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/save_recent_search_interactor.dart';
@@ -38,7 +27,7 @@ import 'package:tmail_ui_user/features/thread/presentation/model/search_state.da
import 'package:tmail_ui_user/features/thread/presentation/model/search_status.dart';
class SearchController extends BaseController with DateRangePickerMixin {
final QuickSearchEmailInteractor _quickSearchEmailInteractor;
final QuickSearchEmailInteractor quickSearchEmailInteractor;
final SaveRecentSearchInteractor _saveRecentSearchInteractor;
final GetAllRecentSearchLatestInteractor _getAllRecentSearchLatestInteractor;
@@ -58,7 +47,7 @@ class SearchController extends BaseController with DateRangePickerMixin {
String currentSearchText = '';
SearchController(
this._quickSearchEmailInteractor,
this.quickSearchEmailInteractor,
this._saveRecentSearchInteractor,
this._getAllRecentSearchLatestInteractor,
);
@@ -108,59 +97,6 @@ class SearchController extends BaseController with DateRangePickerMixin {
listFilterOnSuggestionForm.remove(searchFilter);
}
Future<List<PresentationEmail>> quickSearchEmails({
required Session session,
required AccountId accountId,
required String ownEmailAddress,
required String query,
}) async {
currentSearchText = query;
return await _quickSearchEmailInteractor.execute(
session,
accountId,
limit: UnsignedInt(5),
sort: <Comparator>{}..add(
EmailComparator(EmailComparatorProperty.receivedAt)
..setIsAscending(false)),
filter: _mappingToFilterOnSuggestionForm(
currentUserEmail: ownEmailAddress,
query: query,
),
properties: EmailUtils.getPropertiesForEmailGetMethod(session, accountId),
).then((result) => result.fold(
(failure) => <PresentationEmail>[],
(success) => success is QuickSearchEmailSuccess
? success.emailList
: <PresentationEmail>[]
));
}
Filter? _mappingToFilterOnSuggestionForm({required String query, required String currentUserEmail}) {
log('SearchController::_mappingToFilterOnSuggestionForm():query: $query');
final filterCondition = EmailFilterCondition(
text: query.isNotEmpty == true ? query : null,
after: listFilterOnSuggestionForm.contains(QuickSearchFilter.last7Days)
? EmailReceiveTimeType.last7Days.toOldestUTCDate()
: null,
before: listFilterOnSuggestionForm.contains(QuickSearchFilter.last7Days)
? EmailReceiveTimeType.last7Days.toLatestUTCDate()
: null,
hasAttachment: listFilterOnSuggestionForm.contains(QuickSearchFilter.hasAttachment)
? true
: null,
from: listFilterOnSuggestionForm.contains(QuickSearchFilter.fromMe) && currentUserEmail.isNotEmpty
? currentUserEmail
: null,
hasKeyword: listFilterOnSuggestionForm.contains(QuickSearchFilter.starred)
? KeyWordIdentifier.emailFlagged.value
: null
);
return filterCondition.hasCondition
? filterCondition
: null;
}
void applyFilterSuggestionToSearchFilter(String currentUserEmail) {
final receiveTime = listFilterOnSuggestionForm.contains(QuickSearchFilter.last7Days)
? EmailReceiveTimeType.last7Days
@@ -0,0 +1,69 @@
import 'package:core/utils/app_logger.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:jmap_dart_client/jmap/core/filter/filter.dart';
import 'package:jmap_dart_client/jmap/core/session/session.dart';
import 'package:jmap_dart_client/jmap/core/unsigned_int.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart';
import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
import 'package:model/email/presentation_email.dart';
import 'package:model/extensions/email_filter_condition_extension.dart';
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/quick_search_email_state.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart';
extension QuickSearchEmailsExtension on SearchController {
Future<List<PresentationEmail>> quickSearchEmails({
required Session session,
required AccountId accountId,
required String ownEmailAddress,
required String query,
}) async {
currentSearchText = query;
return await quickSearchEmailInteractor.execute(
session,
accountId,
limit: UnsignedInt(5),
sort: searchEmailFilter.value.sortOrderType
.getSortOrder()
.toNullable(),
filter: _mappingToFilterOnSuggestionForm(
currentUserEmail: ownEmailAddress,
query: query,
),
properties: EmailUtils.getPropertiesForEmailGetMethod(session, accountId),
).then((result) => result.fold(
(failure) => <PresentationEmail>[],
(success) => success is QuickSearchEmailSuccess
? success.emailList
: <PresentationEmail>[]
));
}
Filter? _mappingToFilterOnSuggestionForm({required String query, required String currentUserEmail}) {
log('SearchController::_mappingToFilterOnSuggestionForm():query: $query');
final filterCondition = EmailFilterCondition(
text: query.isNotEmpty == true ? query : null,
after: listFilterOnSuggestionForm.contains(QuickSearchFilter.last7Days)
? EmailReceiveTimeType.last7Days.toOldestUTCDate()
: null,
before: listFilterOnSuggestionForm.contains(QuickSearchFilter.last7Days)
? EmailReceiveTimeType.last7Days.toLatestUTCDate()
: null,
hasAttachment: listFilterOnSuggestionForm.contains(QuickSearchFilter.hasAttachment)
? true
: null,
from: listFilterOnSuggestionForm.contains(QuickSearchFilter.fromMe) && currentUserEmail.isNotEmpty
? currentUserEmail
: null,
hasKeyword: listFilterOnSuggestionForm.contains(QuickSearchFilter.starred)
? KeyWordIdentifier.emailFlagged.value
: null
);
return filterCondition.hasCondition
? filterCondition
: null;
}
}
@@ -81,7 +81,7 @@ class ThreadAPI with HandleSetErrorMixin, MailAPIMixin {
if (limit != null) queryEmailMethod.addLimit(limit);
if (position != null) queryEmailMethod.addPosition(position);
if (position != null && position > 0) queryEmailMethod.addPosition(position);
if (sort != null) queryEmailMethod.addSorts(sort);
@@ -0,0 +1,253 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:jmap_dart_client/http/http_client.dart';
import 'package:jmap_dart_client/jmap/core/error/set_error.dart';
import 'package:jmap_dart_client/jmap/core/filter/filter.dart';
import 'package:jmap_dart_client/jmap/core/id.dart';
import 'package:jmap_dart_client/jmap/core/method/response/set_response.dart';
import 'package:jmap_dart_client/jmap/core/state.dart';
import 'package:jmap_dart_client/jmap/mail/email/email.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_filter_condition.dart';
import 'package:model/error_type_handler/set_method_error_handler_mixin.dart';
import 'package:tmail_ui_user/features/base/mixin/handle_error_mixin.dart';
import 'package:tmail_ui_user/features/base/mixin/mail_api_mixin.dart';
import 'package:tmail_ui_user/features/thread/domain/model/email_response.dart';
import '../../../fixtures/account_fixtures.dart';
import '../../../fixtures/session_fixtures.dart';
class TestMailApiMixin with HandleSetErrorMixin, MailAPIMixin {
@override
void handleSetErrors({
SetMethodErrors? notDestroyedError,
SetMethodErrors? notUpdatedError,
SetMethodErrors? notCreatedError,
Set<SetMethodErrorHandler>? notDestroyedHandlers,
Set<SetMethodErrorHandler>? notUpdatedHandlers,
Set<SetMethodErrorHandler>? notCreatedHandlers,
SetMethodErrorHandler? unCatchErrorHandler,
}) {}
@override
Map<Id, SetError> handleSetResponse(List<SetResponse?> listSetResponse) {
throw UnimplementedError();
}
@override
parseErrorForSetResponse(SetResponse? response, Id requestId) {
throw UnimplementedError();
}
}
void main() {
final baseOption = BaseOptions(method: 'POST');
final dio = Dio(baseOption)..options.baseUrl = 'http://domain.com/jmap';
late DioAdapter dioAdapter;
late HttpClient httpClient;
late TestMailApiMixin testMailApiMixin;
final sessionState = State('some-session-state');
final state = State('some-state');
final filter = EmailFilterCondition(text: 'some-text');
final queryState = State('some-query-state');
setUp(() {
dioAdapter = DioAdapter(dio: dio);
httpClient = HttpClient(dio);
testMailApiMixin = TestMailApiMixin();
});
group('mail api mixin test:', () {
group('fetchAllEmail:', () {
Map<String, dynamic> generateRequest({
required Filter filter,
int? position,
}) =>
{
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:apache:james:params:jmap:mail:shares",
],
"methodCalls": [
[
"Email/query",
{
"accountId": AccountFixtures.aliceAccountId.id.value,
if (position != null) "position": position,
"filter": filter.toJson(),
},
"c0"
],
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.id.value,
"#ids": {
"resultOf": "c0",
"name": "Email/query",
"path": "/ids/*"
},
},
"c1"
]
]
};
Map<String, dynamic> generateResponse({
required List<Email> foundEmails,
required List<EmailId> notFoundEmailIds,
}) =>
{
"sessionState": sessionState.value,
"methodResponses": [
[
"Email/query",
{
"accountId": AccountFixtures.aliceAccountId.id.value,
"ids": foundEmails.map((email) => email.id?.id.value).toList()
..addAll(
notFoundEmailIds.map((emailId) => emailId.id.value)),
"queryState": queryState.value,
"canCalculateChanges": true,
"position": 0,
},
"c0"
],
[
"Email/get",
{
"accountId": AccountFixtures.aliceAccountId.id.value,
"state": state.value,
"list": foundEmails.map((email) => email.toJson()).toList(),
"notFound": notFoundEmailIds
.map((emailId) => emailId.id.value)
.toList(),
},
"c1"
]
]
};
test(
'should not add position to request '
'when position is null',
() async {
// arrange
final email = Email(
id: EmailId(Id('someEmailId')),
);
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
foundEmails: [email],
notFoundEmailIds: [],
),
),
data: generateRequest(filter: filter),
);
// act
final result = await testMailApiMixin.fetchAllEmail(
session: SessionFixtures.aliceSession,
accountId: AccountFixtures.aliceAccountId,
httpClient: httpClient,
filter: filter,
);
// assert
expect(
result,
equals(
EmailsResponse(
emailList: [email],
state: state,
notFoundEmailIds: [],
),
),
);
},
);
test(
'should not add position to request '
'when position is 0',
() async {
// arrange
final email = Email(
id: EmailId(Id('someEmailId')),
);
dioAdapter.onPost(
'',
(server) => server.reply(
403,
generateResponse(
foundEmails: [email],
notFoundEmailIds: [],
),
),
data: generateRequest(filter: filter, position: 0),
);
// act & assert
expect(
() => testMailApiMixin.fetchAllEmail(
session: SessionFixtures.aliceSession,
accountId: AccountFixtures.aliceAccountId,
httpClient: httpClient,
filter: filter,
position: 0,
),
throwsA(isA<DioException>()),
);
},
);
test(
'should add position to request '
'when position > 0',
() async {
// arrange
final email = Email(
id: EmailId(Id('someEmailId')),
);
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
foundEmails: [email],
notFoundEmailIds: [],
),
),
data: generateRequest(filter: filter, position: 1),
);
// act
final result = await testMailApiMixin.fetchAllEmail(
session: SessionFixtures.aliceSession,
accountId: AccountFixtures.aliceAccountId,
httpClient: httpClient,
filter: filter,
position: 1,
);
// assert
expect(
result,
equals(
EmailsResponse(
emailList: [email],
state: state,
notFoundEmailIds: [],
),
),
);
},
);
});
});
}
@@ -0,0 +1,101 @@
import 'package:dartz/dartz.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:get/state_manager.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/quick_search_email_state.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/quick_search_email_interactor.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/quick_search_emails_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_sort_order_type.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/search_email_filter.dart';
import '../../../../fixtures/account_fixtures.dart';
import '../../../../fixtures/session_fixtures.dart';
import 'quick_search_emails_extension_test.mocks.dart';
@GenerateNiceMocks([
MockSpec<SearchController>(),
MockSpec<QuickSearchEmailInteractor>(),
])
void main() {
final session = SessionFixtures.aliceSession;
final accountId = AccountFixtures.aliceAccountId;
const ownEmailAddress = 'alice@linagora.com';
const query = 'test';
EmailSortOrderType? sortOrderType;
late MockSearchController searchController;
late MockQuickSearchEmailInteractor quickSearchEmailInteractor;
setUp(() {
quickSearchEmailInteractor = MockQuickSearchEmailInteractor();
searchController = MockSearchController();
when(searchController.quickSearchEmailInteractor)
.thenReturn(quickSearchEmailInteractor);
when(searchController.listFilterOnSuggestionForm)
.thenReturn(<QuickSearchFilter>[].obs);
when(
quickSearchEmailInteractor.execute(
any,
any,
limit: anyNamed('limit'),
sort: anyNamed('sort'),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
),
).thenAnswer((_) => Future.value(Right(QuickSearchEmailSuccess([]))));
});
group('quickSearchEmails', () {
test(
'should invoke quickSearchEmailInteractor.execute() with SearchController\'s sort '
'when sortOrderType is null',
() async {
sortOrderType = null;
when(searchController.searchEmailFilter)
.thenReturn(SearchEmailFilter(sortOrderType: sortOrderType).obs);
await searchController.quickSearchEmails(
session: session,
accountId: accountId,
ownEmailAddress: ownEmailAddress,
query: query,
);
verify(quickSearchEmailInteractor.execute(
session,
accountId,
limit: anyNamed('limit'),
sort: sortOrderType?.getSortOrder().toNullable(),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
)).called(1);
},
);
test(
'should invoke quickSearchEmailInteractor.execute() with SearchController\'s sort '
'when sortOrderType is not null',
() async {
sortOrderType = EmailSortOrderType.oldest;
when(searchController.searchEmailFilter)
.thenReturn(SearchEmailFilter(sortOrderType: sortOrderType).obs);
await searchController.quickSearchEmails(
session: session,
accountId: accountId,
ownEmailAddress: ownEmailAddress,
query: query,
);
verify(quickSearchEmailInteractor.execute(
session,
accountId,
limit: anyNamed('limit'),
sort: sortOrderType?.getSortOrder().toNullable(),
filter: anyNamed('filter'),
properties: anyNamed('properties'),
)).called(1);
},
);
});
}
@@ -57,9 +57,9 @@ void main() {
final dioAdapterHeaders = <String, dynamic>{
'accept': 'application/json;jmapVersion=rfc-8621',
};
final dioAdapter = DioAdapter(dio: dio);
final httpClient = HttpClient(dio);
final threadApi = ThreadAPI(httpClient);
late DioAdapter dioAdapter;
late HttpClient httpClient;
late ThreadAPI threadApi;
final sessionState = State('some-session-state');
final state = State('some-state');
@@ -68,9 +68,15 @@ void main() {
final filter = EmailFilterCondition(text: 'some-text');
final queryState = State('some-query-state');
setUp(() {
dioAdapter = DioAdapter(dio: dio);
httpClient = HttpClient(dio);
threadApi = ThreadAPI(httpClient);
});
group('thread api test:', () {
group('searchEmails:', () {
Map<String, dynamic> generateRequest({required Filter filter}) => {
Map<String, dynamic> generateRequest({required Filter filter, int? position}) => {
"using": [
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
@@ -81,6 +87,7 @@ void main() {
"Email/query",
{
"accountId": AccountFixtures.aliceAccountId.id.value,
if (position != null) "position": position,
"filter": filter.toJson(),
},
"c0"
@@ -267,6 +274,128 @@ void main() {
),
);
});
test(
'should not add position to request '
'when position is null',
() async {
// arrange
final searchEmail = SearchEmail(
id: EmailId(Id('someEmailId')),
searchSnippetSubject: 'searchSnippetSubject',
searchSnippetPreview: 'searchSnippetPreview',
);
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
foundSearchEmails: [searchEmail],
notFoundEmailIds: [],
searchSnippetError: UnknownMethodResponse(),
),
),
data: generateRequest(filter: filter),
);
// act
final result = await threadApi.searchEmails(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
filter: filter,
);
// assert
expect(
result,
equals(
SearchEmailsResponse(
searchSnippets: null,
emailList: [Email(id: searchEmail.id)],
state: state
),
),
);
});
test(
'should not add position to request '
'when position is 0',
() async {
// arrange
final searchEmail = SearchEmail(
id: EmailId(Id('someEmailId')),
searchSnippetSubject: 'searchSnippetSubject',
searchSnippetPreview: 'searchSnippetPreview',
);
dioAdapter.onPost(
'',
(server) => server.reply(
403,
generateResponse(
foundSearchEmails: [searchEmail],
notFoundEmailIds: [],
searchSnippetError: UnknownMethodResponse(),
),
),
data: generateRequest(filter: filter, position: 0),
);
// act & assert
expect(
() => threadApi.searchEmails(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
filter: filter,
position: 0,
),
throwsA(isA<DioException>()),
);
});
test(
'should add position to request '
'when position > 0',
() async {
// arrange
final searchEmail = SearchEmail(
id: EmailId(Id('someEmailId')),
searchSnippetSubject: 'searchSnippetSubject',
searchSnippetPreview: 'searchSnippetPreview',
);
dioAdapter.onPost(
'',
(server) => server.reply(
200,
generateResponse(
foundSearchEmails: [searchEmail],
notFoundEmailIds: [],
searchSnippetError: UnknownMethodResponse(),
),
),
data: generateRequest(filter: filter, position: 1),
);
// act
final result = await threadApi.searchEmails(
SessionFixtures.aliceSession,
AccountFixtures.aliceAccountId,
filter: filter,
position: 1,
);
// assert
expect(
result,
equals(
SearchEmailsResponse(
searchSnippets: null,
emailList: [Email(id: searchEmail.id)],
state: state
),
),
);
});
});
group('sortEmails::test', () {