diff --git a/app/lib/frontend/templates/package.dart b/app/lib/frontend/templates/package.dart index ccad207151..5d279943dd 100644 --- a/app/lib/frontend/templates/package.dart +++ b/app/lib/frontend/templates/package.dart @@ -116,6 +116,7 @@ d.Node renderPkgHeader(PackagePageData data) { packageName: package.name!, publisherId: package.publisherId, published: data.version.created!, + isMaintainerWanted: package.isMaintainerWanted ?? false, isNullSafe: isNullSafe, isDart3Compatible: pkgView.tags.contains(PackageVersionTags.isDart3Compatible), diff --git a/app/lib/frontend/templates/package_misc.dart b/app/lib/frontend/templates/package_misc.dart index 9412b7f397..e8a54ef2ab 100644 --- a/app/lib/frontend/templates/package_misc.dart +++ b/app/lib/frontend/templates/package_misc.dart @@ -35,6 +35,9 @@ final nameMatchBadgeNode = packageBadgeNode( color: 'name-match', ); +/// Renders the maintainer-wanted badged used by package listing and package page. +final maintainerWantedBadgeNode = packageBadgeNode(label: 'Maintainer wanted'); + /// Renders the null-safe badge used by package listing and package page. d.Node nullSafeBadgeNode({String? title}) { return packageBadgeNode( diff --git a/app/lib/frontend/templates/views/pkg/admin_page.dart b/app/lib/frontend/templates/views/pkg/admin_page.dart index 4c2b2f1006..ea4d17afea 100644 --- a/app/lib/frontend/templates/views/pkg/admin_page.dart +++ b/app/lib/frontend/templates/views/pkg/admin_page.dart @@ -200,6 +200,19 @@ d.Node packageAdminPageNode({ ), ), ], + d.a(name: 'maintainer-wanted'), + d.h3(text: 'Maintainer wanted'), + d.markdown( + 'A package that\'s marked as *maintainWanted* will be featured with an ' + 'extra badge on the package page and in the search results.'), + d.div( + classes: ['-pub-form-checkbox-row'], + child: material.checkbox( + id: '-admin-is-maintainer-wanted-checkbox', + label: 'Mark "maintainWanted"', + checked: package.isMaintainerWanted ?? false, + ), + ), _automatedPublishing(package), d.a(name: 'version-retraction'), d.h2(text: 'Version retraction'), diff --git a/app/lib/frontend/templates/views/pkg/header.dart b/app/lib/frontend/templates/views/pkg/header.dart index 2a0398b27e..f392b6172c 100644 --- a/app/lib/frontend/templates/views/pkg/header.dart +++ b/app/lib/frontend/templates/views/pkg/header.dart @@ -12,6 +12,7 @@ d.Node packageHeaderNode({ required String packageName, required String? publisherId, required DateTime published, + required bool isMaintainerWanted, required bool isNullSafe, required bool isDart3Compatible, required bool isDart3Incompatible, @@ -22,6 +23,7 @@ d.Node packageHeaderNode({ d.span(child: d.xAgoTimestamp(published)), d.text(' '), if (publisherId != null) ..._publisher(publisherId), + if (isMaintainerWanted) maintainerWantedBadgeNode, if (isNullSafe && !isDart3Compatible) nullSafeBadgeNode(), if (isDart3Compatible) dart3CompatibleNode, if (isDart3Incompatible) dart3IncompatibleNode, diff --git a/app/lib/frontend/templates/views/pkg/package_list.dart b/app/lib/frontend/templates/views/pkg/package_list.dart index e3e5cd070e..525e325361 100644 --- a/app/lib/frontend/templates/views/pkg/package_list.dart +++ b/app/lib/frontend/templates/views/pkg/package_list.dart @@ -79,6 +79,7 @@ d.Node _packageItem( required bool isNameMatch, }) { final isFlutterFavorite = view.tags.contains(PackageTags.isFlutterFavorite); + final isMaintainerWanted = view.tags.contains(PackageTags.isMaintainerWanted); final isNullSafe = view.tags.contains(PackageVersionTags.isNullSafe); final isDart3Compatible = view.tags.contains(PackageVersionTags.isDart3Compatible); @@ -139,6 +140,7 @@ d.Node _packageItem( child: licenseNode, ), if (isFlutterFavorite) flutterFavoriteBadgeNode, + if (isMaintainerWanted) maintainerWantedBadgeNode, if (isNullSafe && !isDart3Compatible) nullSafeBadgeNode(), if (isDart3Compatible) dart3CompatibleNode, if (isDart3Incompatible) dart3IncompatibleNode, diff --git a/app/lib/package/backend.dart b/app/lib/package/backend.dart index f95cc7ea1c..2a72fff85f 100644 --- a/app/lib/package/backend.dart +++ b/app/lib/package/backend.dart @@ -419,6 +419,7 @@ class PackageBackend { isDiscontinued: p.isDiscontinued, replacedBy: p.replacedBy, isUnlisted: p.isUnlisted, + isMaintainerWanted: p.isMaintainerWanted ?? false, ); } @@ -463,6 +464,13 @@ class PackageBackend { p.isUnlisted = options.isUnlisted!; optionsChanges.add('unlisted'); } + if ((options.isMaintainerWanted ?? false) && + (options.isMaintainerWanted ?? false) != + (p.isMaintainerWanted ?? false)) { + p.updateMaintainerWanted( + isMaintainerWanted: options.isMaintainerWanted ?? false); + optionsChanges.add('maintainerWanted'); + } if (optionsChanges.isEmpty) { return; @@ -471,7 +479,8 @@ class PackageBackend { p.updated = clock.now().toUtc(); _logger.info('Updating $package options: ' 'isDiscontinued: ${p.isDiscontinued} ' - 'isUnlisted: ${p.isUnlisted}'); + 'isUnlisted: ${p.isUnlisted} ' + 'isMaintainerWanted: ${p.isMaintainerWanted}'); tx.insert(p); tx.insert(await AuditLogRecord.packageOptionsUpdated( agent: authenticatedUser, diff --git a/app/lib/package/models.dart b/app/lib/package/models.dart index db264a9880..6f2aefdb9b 100644 --- a/app/lib/package/models.dart +++ b/app/lib/package/models.dart @@ -137,6 +137,16 @@ class Package extends db.ExpandoModel { @db.DateTimeProperty() DateTime? adminDeletedAt; + /// `true` if a package admin wants to advertise that they are looking for new maintainer(s). + /// + /// Note: the value expires and resets to false after a set period (e.g. 6 months after setting it). + @db.BoolProperty(required: false) + bool? isMaintainerWanted; + + /// The timestamp when the [isMaintainerWanted] flag was set. + @db.DateTimeProperty() + DateTime? maintainerWantedStartedAt; + /// Tags that are assigned to this package. /// /// The permissions required to assign a tag typically depends on the tag. @@ -188,6 +198,7 @@ class Package extends db.ExpandoModel { ..isUnlisted = false ..isModerated = false ..isAdminDeleted = false + ..isMaintainerWanted = false ..assignedTags = [] ..deletedVersions = []; } @@ -380,6 +391,7 @@ class Package extends db.ExpandoModel { ], if (isUnlisted) PackageTags.isUnlisted, if (publisherId != null) PackageTags.publisherTag(publisherId!), + if (isMaintainerWanted ?? false) PackageTags.isMaintainerWanted, }; } @@ -419,6 +431,14 @@ class Package extends db.ExpandoModel { adminDeletedAt = isAdminDeleted ? clock.now().toUtc() : null; updated = clock.now().toUtc(); } + + void updateMaintainerWanted({ + required bool isMaintainerWanted, + }) { + this.isMaintainerWanted = isMaintainerWanted; + maintainerWantedStartedAt = isMaintainerWanted ? clock.now().toUtc() : null; + updated = clock.now().toUtc(); + } } /// Describes the various categories of latest releases. diff --git a/app/lib/shared/integrity.dart b/app/lib/shared/integrity.dart index 2e7f69a480..51e6e2334d 100644 --- a/app/lib/shared/integrity.dart +++ b/app/lib/shared/integrity.dart @@ -449,6 +449,11 @@ class IntegrityChecker { isAdminDeleted: p.isAdminDeleted, adminDeletedAt: p.adminDeletedAt, ); + yield* _checkMaintainerWantedFlags( + package: p.name!, + isMaintainerWanted: p.isMaintainerWanted, + maintainerWantedStartedAt: p.maintainerWantedStartedAt, + ); if (p.isModerated) { _packagesWithIsModeratedFlag.add(p.name!); } @@ -1056,3 +1061,18 @@ Stream _checkAdminDeletedFlags({ yield '$kind "$id" has `isAdminDeleted = false` but `adminDeletedAt` is not null.'; } } + +/// Check that `isMaintainerWanted` and `maintainerWantedStartedAt` are consistent. +Stream _checkMaintainerWantedFlags({ + required String package, + required bool? isMaintainerWanted, + required DateTime? maintainerWantedStartedAt, +}) async* { + isMaintainerWanted ??= false; + if (isMaintainerWanted && maintainerWantedStartedAt == null) { + yield 'Package "$package" has `isMaintainerWanted = true` but `maintainerWantedStartedAt` is null.'; + } + if (!isMaintainerWanted && maintainerWantedStartedAt != null) { + yield 'Package "$package" has `isMaintainerWanted = false` but `maintainerWantedStartedAt` is not null.'; + } +} diff --git a/app/lib/tool/test_profile/importer.dart b/app/lib/tool/test_profile/importer.dart index 20fa965c7e..aba58f764d 100644 --- a/app/lib/tool/test_profile/importer.dart +++ b/app/lib/tool/test_profile/importer.dart @@ -161,6 +161,7 @@ Future importProfile({ isDiscontinued: testPackage.isDiscontinued, replacedBy: testPackage.replacedBy, isUnlisted: testPackage.isUnlisted, + isMaintainerWanted: testPackage.isMaintainerWanted, )); if (testPackage.retractedVersions != null) { diff --git a/app/lib/tool/test_profile/models.dart b/app/lib/tool/test_profile/models.dart index 7645337a6f..38f8bc5fdd 100644 --- a/app/lib/tool/test_profile/models.dart +++ b/app/lib/tool/test_profile/models.dart @@ -80,6 +80,7 @@ class TestPackage { final bool? isDiscontinued; final String? replacedBy; final bool? isUnlisted; + final bool? isMaintainerWanted; final bool? isFlutterFavorite; final List? retractedVersions; final int? likeCount; @@ -92,6 +93,7 @@ class TestPackage { this.isDiscontinued, this.replacedBy, this.isUnlisted, + this.isMaintainerWanted, this.isFlutterFavorite, this.retractedVersions, this.likeCount, diff --git a/app/lib/tool/test_profile/models.g.dart b/app/lib/tool/test_profile/models.g.dart index 9467195568..d061cddf83 100644 --- a/app/lib/tool/test_profile/models.g.dart +++ b/app/lib/tool/test_profile/models.g.dart @@ -45,6 +45,7 @@ TestPackage _$TestPackageFromJson(Map json) => TestPackage( isDiscontinued: json['isDiscontinued'] as bool?, replacedBy: json['replacedBy'] as String?, isUnlisted: json['isUnlisted'] as bool?, + isMaintainerWanted: json['isMaintainerWanted'] as bool?, isFlutterFavorite: json['isFlutterFavorite'] as bool?, retractedVersions: (json['retractedVersions'] as List?) ?.map((e) => e as String) @@ -62,6 +63,8 @@ Map _$TestPackageToJson(TestPackage instance) => if (instance.isDiscontinued case final value?) 'isDiscontinued': value, if (instance.replacedBy case final value?) 'replacedBy': value, if (instance.isUnlisted case final value?) 'isUnlisted': value, + if (instance.isMaintainerWanted case final value?) + 'isMaintainerWanted': value, if (instance.isFlutterFavorite case final value?) 'isFlutterFavorite': value, if (instance.retractedVersions case final value?) diff --git a/app/test/frontend/golden/pkg_admin_page.html b/app/test/frontend/golden/pkg_admin_page.html index 7cdfe8ac6b..549a6a7223 100644 --- a/app/test/frontend/golden/pkg_admin_page.html +++ b/app/test/frontend/golden/pkg_admin_page.html @@ -404,6 +404,28 @@

Unlisted

+ +

Maintainer wanted

+

+ A package that's marked as + maintainWanted + will be featured with an extra badge on the package page and in the search results. +

+
+
+
+ +
+ + + +
+
+
+
+ +
+

Automated publishing

diff --git a/app/test/package/api_export/api_exporter_test.dart b/app/test/package/api_export/api_exporter_test.dart index 49548ee6f4..62d18d8aa5 100644 --- a/app/test/package/api_export/api_exporter_test.dart +++ b/app/test/package/api_export/api_exporter_test.dart @@ -140,6 +140,7 @@ Future _testExportedApiSynchronization( 'isDiscontinued': false, 'replacedBy': null, 'isUnlisted': false, + 'isMaintainerWanted': false, }, ); expect( diff --git a/app/test/package/backend_test.dart b/app/test/package/backend_test.dart index 7b76bcdee5..8ccc3dbf14 100644 --- a/app/test/package/backend_test.dart +++ b/app/test/package/backend_test.dart @@ -364,6 +364,7 @@ void main() { expect(p.isDiscontinued, isTrue); expect(p.replacedBy, isNull); expect(p.isUnlisted, isFalse); + expect(p.isMaintainerWanted ?? false, isFalse); }); }); @@ -434,6 +435,7 @@ void main() { expect(p.isDiscontinued, isTrue); expect(p.replacedBy, 'neon'); expect(p.isUnlisted, isFalse); + expect(p.isMaintainerWanted ?? false, isFalse); await packageBackend.updateOptions( 'oxygen', PkgOptions(isDiscontinued: true)); @@ -441,6 +443,7 @@ void main() { expect(p1.isDiscontinued, isTrue); expect(p1.replacedBy, isNull); expect(p1.isUnlisted, isFalse); + expect(p1.isMaintainerWanted ?? false, isFalse); // check audit log record final page = await auditBackend.listRecordsForPackage('oxygen'); @@ -455,6 +458,7 @@ void main() { expect(p2.isDiscontinued, isFalse); expect(p2.replacedBy, isNull); expect(p2.isUnlisted, isFalse); + expect(p2.isMaintainerWanted ?? false, isFalse); }); }); @@ -466,6 +470,19 @@ void main() { expect(p.isDiscontinued, isFalse); expect(p.replacedBy, isNull); expect(p.isUnlisted, isTrue); + expect(p.isMaintainerWanted ?? false, isFalse); + }); + }); + + testWithProfile('maintainerWanted', fn: () async { + await withFakeAuthRequestContext(adminAtPubDevEmail, () async { + await packageBackend.updateOptions( + 'oxygen', PkgOptions(isMaintainerWanted: true)); + final p = (await packageBackend.lookupPackage('oxygen'))!; + expect(p.isDiscontinued, isFalse); + expect(p.replacedBy, isNull); + expect(p.isUnlisted, isFalse); + expect(p.isMaintainerWanted, isTrue); }); }); }); diff --git a/pkg/_pub_shared/lib/data/package_api.dart b/pkg/_pub_shared/lib/data/package_api.dart index 334a13024f..3d06ab6d3b 100644 --- a/pkg/_pub_shared/lib/data/package_api.dart +++ b/pkg/_pub_shared/lib/data/package_api.dart @@ -35,11 +35,13 @@ class PkgOptions { final bool? isDiscontinued; final String? replacedBy; final bool? isUnlisted; + final bool? isMaintainerWanted; PkgOptions({ this.isDiscontinued, this.replacedBy, this.isUnlisted, + this.isMaintainerWanted, }); factory PkgOptions.fromJson(Map json) => diff --git a/pkg/_pub_shared/lib/data/package_api.g.dart b/pkg/_pub_shared/lib/data/package_api.g.dart index 748e39ac67..4ebde3b9bd 100644 --- a/pkg/_pub_shared/lib/data/package_api.g.dart +++ b/pkg/_pub_shared/lib/data/package_api.g.dart @@ -23,6 +23,7 @@ PkgOptions _$PkgOptionsFromJson(Map json) => PkgOptions( isDiscontinued: json['isDiscontinued'] as bool?, replacedBy: json['replacedBy'] as String?, isUnlisted: json['isUnlisted'] as bool?, + isMaintainerWanted: json['isMaintainerWanted'] as bool?, ); Map _$PkgOptionsToJson(PkgOptions instance) => @@ -30,6 +31,7 @@ Map _$PkgOptionsToJson(PkgOptions instance) => 'isDiscontinued': instance.isDiscontinued, 'replacedBy': instance.replacedBy, 'isUnlisted': instance.isUnlisted, + 'isMaintainerWanted': instance.isMaintainerWanted, }; AutomatedPublishingConfig _$AutomatedPublishingConfigFromJson( diff --git a/pkg/_pub_shared/lib/search/tags.dart b/pkg/_pub_shared/lib/search/tags.dart index 944571af45..ae8f2578f3 100644 --- a/pkg/_pub_shared/lib/search/tags.dart +++ b/pkg/_pub_shared/lib/search/tags.dart @@ -33,6 +33,9 @@ abstract class PackageTags { /// Package is marked unlisted, discontinued, or is a legacy package. static const String isUnlisted = 'is:unlisted'; + /// Package is marked maintainerWanted. + static const String isMaintainerWanted = 'is:maintainer-wanted'; + /// Package is shown, regardless of its unlisted status. static const String showUnlisted = 'show:unlisted'; diff --git a/pkg/pub_integration/lib/src/test_browser.dart b/pkg/pub_integration/lib/src/test_browser.dart index 596cf3d1f7..c1966686f6 100644 --- a/pkg/pub_integration/lib/src/test_browser.dart +++ b/pkg/pub_integration/lib/src/test_browser.dart @@ -355,7 +355,7 @@ extension PageExt on Page { /// Returns the [property] value of the first element by [selector]. Future propertyValue(String selector, String property) async { final h = await $(selector); - return await h.propertyValue(property); + return (await h.propertyValue(property)).toString(); } } diff --git a/pkg/pub_integration/test/pkg_admin_page_test.dart b/pkg/pub_integration/test/pkg_admin_page_test.dart index 6ae46031d8..d33285a637 100644 --- a/pkg/pub_integration/test/pkg_admin_page_test.dart +++ b/pkg/pub_integration/test/pkg_admin_page_test.dart @@ -53,8 +53,6 @@ void main() { // github publishing await user.withBrowserPage((page) async { await page.gotoOrigin('/packages/test_pkg/admin'); - await page.takeScreenshots( - prefix: 'package-page/admin-page', selector: 'body'); await page.waitAndClick('#-pkg-admin-automated-github-enabled'); await page.waitForLayout([ @@ -70,6 +68,24 @@ void main() { final value = await page.propertyValue( '#-pkg-admin-automated-github-repository', 'value'); expect(value, githubRepository); + + await page.takeScreenshots( + prefix: 'package-page/admin-page', selector: 'body'); + }); + + // maintainer wanted + await user.withBrowserPage((page) async { + await page.gotoOrigin('/packages/test_pkg/admin'); + final valueBefore = await page.propertyValue( + '#-admin-is-maintainer-wanted-checkbox', 'checked'); + expect(valueBefore, 'false'); + + await page.waitAndClick('#-admin-is-maintainer-wanted-checkbox'); + await page.waitAndClickOnDialogOk(); + await page.reload(); + final valueAfter = await page.propertyValue( + '#-admin-is-maintainer-wanted-checkbox', 'checked'); + expect(valueAfter, 'true'); }); // visit activity log page diff --git a/pkg/web_app/lib/src/admin_pages.dart b/pkg/web_app/lib/src/admin_pages.dart index 816249518d..983da03b5f 100644 --- a/pkg/web_app/lib/src/admin_pages.dart +++ b/pkg/web_app/lib/src/admin_pages.dart @@ -84,6 +84,7 @@ class _PkgAdminWidget { InputElement? _replacedByInput; Element? _replacedByButton; InputElement? _unlistedCheckbox; + InputElement? _maintainerWantedCheckbox; Element? _inviteUploaderButton; Element? _inviteUploaderContent; InputElement? _inviteUploaderInput; @@ -109,6 +110,11 @@ class _PkgAdminWidget { _unlistedCheckbox = document.getElementById('-admin-is-unlisted-checkbox') as InputElement?; _unlistedCheckbox?.onChange.listen((_) => _toggleUnlisted()); + _maintainerWantedCheckbox = + document.getElementById('-admin-is-maintainer-wanted-checkbox') + as InputElement?; + _maintainerWantedCheckbox?.onChange + .listen((_) => _toggleMaintainerWanted()); _inviteUploaderButton = document.getElementById('-pkg-admin-invite-uploader-button'); _inviteUploaderContent = @@ -313,6 +319,30 @@ class _PkgAdminWidget { } } + Future _toggleMaintainerWanted() async { + final oldValue = _maintainerWantedCheckbox!.defaultChecked ?? false; + final newValue = await api_client.rpc( + confirmQuestion: text( + 'Are you sure you want change the "maintainerWanted" status of the package?'), + fn: () async { + final rs = await api_client.client.setPackageOptions( + pageData.pkgData!.package, + PkgOptions( + isMaintainerWanted: !oldValue, + )); + return rs.isUnlisted; + }, + successMessage: text('"maintainerWanted" status changed.'), + onError: (err) => null, + ); + if (newValue == null) { + _maintainerWantedCheckbox!.checked = oldValue; + } else { + _maintainerWantedCheckbox!.defaultChecked = newValue; + _maintainerWantedCheckbox!.checked = newValue; + } + } + Future _setRetracted() async { final version = materialDropdownSelected(_retractPackageVersionInput)?.trim() ?? '';