From 166d7e0c81447b785e717107930363e6c524f5e5 Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Tue, 9 Sep 2025 16:47:33 +0200 Subject: [PATCH] New /my-liked-packages page (behind experimental). --- app/lib/frontend/handlers/account.dart | 43 +++++++++++++++++- app/lib/frontend/handlers/experimental.dart | 5 ++- app/lib/frontend/templates/admin.dart | 44 +++++++++++++++---- app/lib/frontend/templates/layout.dart | 12 +++-- .../templates/views/shared/site_header.dart | 10 ++++- app/test/frontend/templates_test.dart | 2 + pkg/pub_integration/test/like_test.dart | 5 +++ 7 files changed, 104 insertions(+), 17 deletions(-) diff --git a/app/lib/frontend/handlers/account.dart b/app/lib/frontend/handlers/account.dart index 6c67c2b43f..d1a206edd3 100644 --- a/app/lib/frontend/handlers/account.dart +++ b/app/lib/frontend/handlers/account.dart @@ -3,8 +3,11 @@ // BSD-style license that can be found in the LICENSE file. import 'package:_pub_shared/data/account_api.dart'; +import 'package:_pub_shared/search/search_form.dart'; +import 'package:_pub_shared/search/tags.dart'; import 'package:clock/clock.dart'; import 'package:pub_dev/frontend/handlers/cache_control.dart'; +import 'package:pub_dev/package/search_adapter.dart'; import 'package:shelf/shelf.dart' as shelf; import '../../account/backend.dart'; @@ -311,7 +314,7 @@ Future accountPackagesPageHandler(shelf.Request request) async { return htmlResponse(html); } -/// Handles requests for GET my-liked-packages +/// Handles requests for GET /my-liked-packages Future accountMyLikedPackagesPageHandler( shelf.Request request, ) async { @@ -323,11 +326,49 @@ Future accountMyLikedPackagesPageHandler( final user = (await accountBackend.lookupUserById( requestContext.authenticatedUserId!, ))!; + + // Redirect in case of empty search query. + if (request.requestedUri.query == 'q=') { + return redirectResponse(request.requestedUri.path); + } + + if (requestContext.experimentalFlags.useMyLikedSearch) { + // redirect to the search page when any search or pagination is present + final searchForm = SearchForm.parse(request.requestedUri.queryParameters); + if (searchForm.isNotEmpty) { + final redirectForm = searchForm.addRequiredTagIfAbsent( + AccountTag.isLikedByMe, + ); + return redirectResponse( + redirectForm.toSearchLink(page: searchForm.currentPage), + ); + } + + final appliedSearchForm = SearchForm().toggleRequiredTag( + AccountTag.isLikedByMe, + ); + final searchResult = await searchAdapter.search( + appliedSearchForm, + // Do not apply rate limit here. + rateLimitKey: null, + ); + final html = renderMyLikedPackagesPage( + user: user, + userSessionData: requestContext.sessionData!, + likes: null, + searchForm: appliedSearchForm, + searchResult: searchResult, + ); + return htmlResponse(html); + } + final likes = await likeBackend.listPackageLikes(user.userId); final html = renderMyLikedPackagesPage( user: user, userSessionData: requestContext.sessionData!, likes: likes, + searchForm: null, + searchResult: null, ); return htmlResponse(html); } diff --git a/app/lib/frontend/handlers/experimental.dart b/app/lib/frontend/handlers/experimental.dart index df68fa0213..90253dad0e 100644 --- a/app/lib/frontend/handlers/experimental.dart +++ b/app/lib/frontend/handlers/experimental.dart @@ -10,11 +10,14 @@ typedef PublicFlag = ({String name, String description}); const _publicFlags = { (name: 'example', description: 'Short description'), + ( + name: 'my-liked-search', + description: 'New "My liked packages" page and search.', + ), }; final _allFlags = { 'dark-as-default', - 'my-liked-search', ..._publicFlags.map((x) => x.name), }; diff --git a/app/lib/frontend/templates/admin.dart b/app/lib/frontend/templates/admin.dart index f7a4e1ff3d..0df3c290aa 100644 --- a/app/lib/frontend/templates/admin.dart +++ b/app/lib/frontend/templates/admin.dart @@ -3,6 +3,9 @@ // BSD-style license that can be found in the LICENSE file. import 'package:_pub_shared/data/page_data.dart'; +import 'package:_pub_shared/search/search_form.dart'; +import 'package:pub_dev/frontend/templates/listing.dart'; +import 'package:pub_dev/package/search_adapter.dart'; import '../../account/models.dart' show LikeData, User, SessionData; import '../../audit/models.dart'; @@ -97,16 +100,38 @@ String renderAccountPackagesPage({ String renderMyLikedPackagesPage({ required User user, required SessionData userSessionData, - required List likes, + required List? likes, + required SearchForm? searchForm, + required SearchResultPage? searchResult, }) { - final resultCount = likes.isNotEmpty - ? d.p( - text: - 'You like ${likes.length} ${likes.length == 1 ? 'package' : 'packages'}.', - ) - : d.p(text: 'You have not liked any packages yet.'); - - final tabContent = d.fragment([resultCount, likedPackageListNode(likes)]); + late d.Node tabContent; + if (likes != null) { + final resultCount = likes.isNotEmpty + ? d.p( + text: + 'You like ${likes.length} ${likes.length == 1 ? 'package' : 'packages'}.', + ) + : d.p(text: 'You have not liked any packages yet.'); + + tabContent = d.fragment([resultCount, likedPackageListNode(likes)]); + } else { + final infoNode = listingInfo( + searchForm: searchForm!, + totalCount: searchResult!.totalCount, + title: 'My liked packages', + messageFromBackend: searchResult.errorMessage, + ); + final listNode = packageList(searchResult); + final pagination = searchResult.hasHit + ? paginationNode(PageLinks(searchForm, searchResult.totalCount)) + : null; + tabContent = d.fragment([ + infoNode, + listNode, + if (pagination != null) pagination, + ]); + } + final content = renderDetailPage( headerNode: _accountDetailHeader(user, userSessionData), tabs: [ @@ -129,6 +154,7 @@ String renderMyLikedPackagesPage({ noIndex: true, mainClasses: [wideHeaderDetailPageClassName], pageData: PageData(sessionAware: true), + searchForm: searchForm, ); } diff --git a/app/lib/frontend/templates/layout.dart b/app/lib/frontend/templates/layout.dart index 631e6d6f3a..fed7aa2239 100644 --- a/app/lib/frontend/templates/layout.dart +++ b/app/lib/frontend/templates/layout.dart @@ -35,8 +35,8 @@ enum PageType { /// Whether to show a wide/tall search banner at the top of the page, /// otherwise only show a top-navigation search input. -bool showSearchBanner(PageType type) => - type != PageType.account && +bool showSearchBanner(PageType type, SearchForm? searchForm) => + (type != PageType.account || searchForm != null) && type != PageType.package && type != PageType.standalone; @@ -99,7 +99,11 @@ String renderLayoutPage( ? null : pageDataJsonCodec.encode(pageData.toJson()), bodyClasses: bodyClasses, - siteHeader: siteHeaderNode(pageType: type, userSession: session), + siteHeader: siteHeaderNode( + pageType: type, + userSession: session, + searchForm: searchForm, + ), announcementBanner: announcementBannerHtml == null ? null : d.unsafeRawHtml(announcementBannerHtml), @@ -108,7 +112,7 @@ String renderLayoutPage( : hex .encode(sha1.convert(utf8.encode(announcementBannerHtml)).bytes) .substring(0, 16), - searchBanner: showSearchBanner(type) + searchBanner: showSearchBanner(type, searchForm) ? _renderSearchBanner(type: type, searchForm: searchForm) : null, isLanding: type == PageType.landing, diff --git a/app/lib/frontend/templates/views/shared/site_header.dart b/app/lib/frontend/templates/views/shared/site_header.dart index 12f8669845..0c44ee1a7b 100644 --- a/app/lib/frontend/templates/views/shared/site_header.dart +++ b/app/lib/frontend/templates/views/shared/site_header.dart @@ -2,6 +2,8 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:_pub_shared/search/search_form.dart'; + import '../../../../account/models.dart' show SessionData; import '../../../../shared/urls.dart' as urls; import '../../../dom/dom.dart' as d; @@ -10,7 +12,11 @@ import '../../_consts.dart'; import '../../layout.dart' show PageType, showSearchBanner; /// Creates the site header and navigation node. -d.Node siteHeaderNode({required PageType pageType, SessionData? userSession}) { +d.Node siteHeaderNode({ + required PageType pageType, + required SessionData? userSession, + required SearchForm? searchForm, +}) { return d.div( classes: ['site-header'], children: [ @@ -31,7 +37,7 @@ d.Node siteHeaderNode({required PageType pageType, SessionData? userSession}) { ), d.div(classes: ['site-header-space']), d.div(classes: ['site-header-mask']), - if (!showSearchBanner(pageType)) + if (!showSearchBanner(pageType, searchForm)) d.div( classes: ['site-header-search'], child: d.form( diff --git a/app/test/frontend/templates_test.dart b/app/test/frontend/templates_test.dart index 6a4125f1a2..4eae34e297 100644 --- a/app/test/frontend/templates_test.dart +++ b/app/test/frontend/templates_test.dart @@ -880,6 +880,8 @@ void main() { LikeData(package: 'super_package', created: liked1), LikeData(package: 'another_package', created: liked2), ], + searchForm: null, + searchResult: null, ); expectGoldenFile( html, diff --git a/pkg/pub_integration/test/like_test.dart b/pkg/pub_integration/test/like_test.dart index 2afd4a9906..8d8b5a38e2 100644 --- a/pkg/pub_integration/test/like_test.dart +++ b/pkg/pub_integration/test/like_test.dart @@ -88,6 +88,11 @@ void main() { await Future.delayed(Duration(milliseconds: 200)); expect(await getCountLabels(), ['1', '1', '']); + // checking /my-liked-packages - with the one liked package + await page.gotoOrigin('/my-liked-packages'); + final info = await listingPageInfo(page); + expect(info.packageNames.toSet(), {'test_pkg'}); + // checking search with my-liked packages - with the one liked package await page.gotoOrigin('/packages?q=pkg+is:liked-by-me'); final info2 = await listingPageInfo(page);