mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(review): unify review center and bound presentation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
044a2801b9
commit
6978da63a4
71 changed files with 3133 additions and 1725 deletions
|
|
@ -1,113 +1,113 @@
|
|||
{
|
||||
"schema": 1,
|
||||
"description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.",
|
||||
"counts": {"routes":54,"uniqueIds":810,"literalJsxSites":830,"literalJsxValues":984,"navigationDefinitions":29,"forwardingSites":36},
|
||||
"counts": {"routes":54,"uniqueIds":804,"literalJsxSites":810,"literalJsxValues":971,"navigationDefinitions":29,"forwardingSites":36},
|
||||
"routes": [
|
||||
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":258,"export":"LandingPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":134,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":5,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":97,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":59,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":50,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
|
||||
{"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":259},"implementation":{"file":"web/src/pages/landing.tsx","line":258,"export":"LandingPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":265},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":271},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":282},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":291},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":297},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":303},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":319},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":330},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":336},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":345},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":351},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":358},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":365},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":134,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":375},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":5,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":385},"implementation":{"file":"web/src/pages/dashboard.tsx","line":99,"export":"DashboardPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":392},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":59,"export":"MySkillsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":405},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":50,"export":"PublishPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":431},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":438},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":448},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":464},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":482},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":492},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":504},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":511},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":518},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":525},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":532},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":6,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":542},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":549},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":15,"export":"GovernancePage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":556},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":589},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":598},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":31,"export":"ReviewsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":606},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":628},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":635},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":452,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":465,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":651},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":658},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":668},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":678},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":685},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":692},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":707},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false},
|
||||
{"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":713},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":720},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":727},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false},
|
||||
{"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":734},"implementation":null,"guarded":true,"roleProtected":false},
|
||||
{"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":743},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":750},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":757},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":764},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":14,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true},
|
||||
{"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":771},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true}
|
||||
],
|
||||
"definitions": [
|
||||
{"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":90,"ids":["skill.publish.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":91,"ids":["navigation.skills.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":92,"ids":["navigation.github.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":93,"ids":["navigation.suites.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":94,"ids":["navigation.dashboard.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":94,"ids":["navigation.home.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":95,"ids":["skill.publish.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":96,"ids":["navigation.skills.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":97,"ids":["navigation.github.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":98,"ids":["navigation.suites.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":99,"ids":["navigation.dashboard.open"]},
|
||||
{"file":"web/src/features/namespace/namespace-management-nav.tsx","line":26,"ids":["namespace.skills.open"]},
|
||||
{"file":"web/src/features/namespace/namespace-management-nav.tsx","line":27,"ids":["namespace.members.open"]},
|
||||
{"file":"web/src/features/namespace/namespace-management-nav.tsx","line":28,"ids":["namespace.reviews.open"]},
|
||||
{"file":"web/src/features/namespace/namespace-management-nav.tsx","line":29,"ids":["namespace.settings.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":40,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.security.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":42,"ids":["notification.settings.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":48,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":50,"ids":["namespace.list.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":51,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.subscriptions.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":53,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":54,"ids":["review.progress.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":60,"ids":["governance.center.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":61,"ids":["report.queue.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["github.sync.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":86,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.security.open"]}
|
||||
{"file":"web/src/pages/dashboard.tsx","line":41,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":42,"ids":["account.security.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":43,"ids":["notification.settings.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":49,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":50,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":51,"ids":["namespace.list.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":61,"ids":["review.queue.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":62,"ids":["governance.center.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.skills"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.owned.suites"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.tokens.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":91,"ids":["navigation.starred.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.profile.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":93,"ids":["account.security.open"]}
|
||||
],
|
||||
"sites": [
|
||||
{"file":"web/src/app/layout.tsx","line":127,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":140,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/app/layout.tsx","line":160,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]},
|
||||
{"file":"web/src/app/layout.tsx","line":176,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":195,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/app/layout.tsx","line":253,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":254,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":255,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":262,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":263,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":264,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":271,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":272,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":273,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":274,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":281,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":282,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":283,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":132,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":145,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/app/layout.tsx","line":165,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.menu.toggle"]},
|
||||
{"file":"web/src/app/layout.tsx","line":181,"host":"Link","prop":"data-analytics-id","event":"click","ids":["auth.login.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":200,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/app/layout.tsx","line":258,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.skills.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":259,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.publish.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":260,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":267,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.documentation.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":268,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.cli.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":269,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.repository.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":276,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.license.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":277,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.contributing.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":278,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.changelog.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":279,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.conduct.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":286,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.community.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":287,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.privacy.open"]},
|
||||
{"file":"web/src/app/layout.tsx","line":288,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.terms.open"]},
|
||||
{"file":"web/src/features/auth/auth-shell.tsx","line":19,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
|
||||
{"file":"web/src/features/auth/auth-shell.tsx","line":28,"host":"a","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]},
|
||||
{"file":"web/src/features/auth/login-button.tsx","line":71,"host":"Button","prop":"data-analytics-id","event":"click","ids":["auth.oauth.request"]},
|
||||
|
|
@ -199,8 +199,11 @@
|
|||
{"file":"web/src/features/github-sync/github-sync-runs-section.tsx","line":19,"host":"select","prop":"data-analytics-id","event":"change","ids":["github.run.status"]},
|
||||
{"file":"web/src/features/github-sync/github-sync-settings-drawer.tsx","line":14,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.settings.close"]},
|
||||
{"file":"web/src/features/github-sync/github-sync-source-detail.tsx","line":42,"host":"Pagination","prop":"analyticsId","event":"click","ids":["github.source.paginate"]},
|
||||
{"file":"web/src/features/governance/governance-inbox.tsx","line":70,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.promotion.open","governance.report.open","governance.review.open","governance.skill.open"],"conditional":true},
|
||||
{"file":"web/src/features/governance/governance-inbox.tsx","line":69,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.promotion.open","governance.report.open","governance.review.open","governance.skill.open"],"conditional":true},
|
||||
{"file":"web/src/features/governance/governance-notifications.tsx","line":48,"host":"Button","prop":"data-analytics-id","event":"click","ids":["notification.read.request"]},
|
||||
{"file":"web/src/features/governance/search-index-maintenance.tsx","line":29,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.index.rebuild"]},
|
||||
{"file":"web/src/features/governance/search-index-maintenance.tsx","line":31,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["governance.index.rebuild.confirm"]},
|
||||
{"file":"web/src/features/governance/search-index-maintenance.tsx","line":31,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["governance.index.rebuild.cancel"]},
|
||||
{"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":110,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["namespace.member.cancel"]},
|
||||
{"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":122,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["namespace.member.search"]},
|
||||
{"file":"web/src/features/namespace/add-namespace-member-dialog.tsx","line":139,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.member.search"]},
|
||||
|
|
@ -269,13 +272,19 @@
|
|||
{"file":"web/src/features/review/github-sync-review-source.tsx","line":96,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.diff.full"]},
|
||||
{"file":"web/src/features/review/github-sync-review-source.tsx","line":108,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/github-sync-review-source.tsx","line":146,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.commit.external"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":60,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":69,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":81,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":94,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":101,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]},
|
||||
{"file":"web/src/features/review/review-center-filter-form.tsx","line":32,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.filter.kind.interact","review.filter.namespace.interact","review.filter.origin.interact","review.filter.status.interact"],"conditional":true},
|
||||
{"file":"web/src/features/review/review-center-filter-form.tsx","line":34,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.filter.kind.select","review.filter.namespace.select","review.filter.origin.select","review.filter.status.select"],"conditional":true},
|
||||
{"file":"web/src/features/review/review-center-filter-form.tsx","line":55,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.filter.reset"]},
|
||||
{"file":"web/src/features/review/review-center-filter-form.tsx","line":56,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.filter.apply"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":45,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.content.retry"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":86,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.package.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":91,"host":"summary","prop":"data-analytics-id","event":"click","ids":["review.files.expand"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":97,"host":"FileTree","prop":"analyticsId","event":"click","ids":["review.file.open"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":110,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":123,"host":"a","prop":"data-analytics-id","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":132,"host":"FilePreviewDialog","prop":"closeAnalyticsId","event":"click","ids":["review.file.close"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":132,"host":"FilePreviewDialog","prop":"downloadAnalyticsId","event":"click","ids":["review.file.download"]},
|
||||
{"file":"web/src/features/review/review-skill-detail-section.tsx","line":132,"host":"FilePreviewDialog","prop":"copyAnalyticsId","event":"click","ids":["review.file.copy"]},
|
||||
{"file":"web/src/features/search/search-bar.tsx","line":55,"host":"form","prop":"data-analytics-id","event":"submit","ids":[],"conditional":true,"forward":"analyticsId"},
|
||||
{"file":"web/src/features/search/search-bar.tsx","line":67,"host":"button","prop":"data-analytics-id","event":"click","ids":["search.query.clear"],"conditional":true},
|
||||
{"file":"web/src/features/security-audit/security-audit-section.tsx","line":114,"host":"Button","prop":"data-analytics-id","event":"click","ids":["security.findings.toggle"]},
|
||||
|
|
@ -488,19 +497,19 @@
|
|||
{"file":"web/src/pages/admin/namespaces.tsx","line":802,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["admin.namespace.member.role.draft.admin"]},
|
||||
{"file":"web/src/pages/admin/namespaces.tsx","line":805,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.role"]},
|
||||
{"file":"web/src/pages/admin/namespaces.tsx","line":825,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.namespace.member.remove"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":131,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.auth.password.toggle","admin.auth.registration.toggle"],"conditional":true},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":145,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.discard"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":149,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.save"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.auth.confirm"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":158,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.auth.cancel"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":174,"host":"form","prop":"data-analytics-id","event":"submit","ids":["admin.role.rule.create"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":189,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.select"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":218,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.edit"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":226,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.discard"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":227,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.save"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":230,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.disable"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.role.rule.disable.confirm"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":239,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.role.rule.disable.cancel"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":133,"host":"input","prop":"data-analytics-id","event":"change","ids":["admin.auth.password.toggle","admin.auth.registration.toggle"],"conditional":true},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":147,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.discard"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":151,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.auth.save"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":160,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.auth.confirm"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":160,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.auth.cancel"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":176,"host":"form","prop":"data-analytics-id","event":"submit","ids":["admin.role.rule.create"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":191,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.select"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":220,"host":"select","prop":"data-analytics-id","event":"change","ids":["admin.role.rule.role.edit"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":228,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.discard"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":229,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.save"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":232,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.role.rule.disable"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":241,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["admin.role.rule.disable.confirm"]},
|
||||
{"file":"web/src/pages/admin/system-config.tsx","line":241,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["admin.role.rule.disable.cancel"]},
|
||||
{"file":"web/src/pages/admin/users.tsx","line":163,"host":"Input","prop":"data-analytics-id","event":"keydown-enter","ids":["admin.user.search"]},
|
||||
{"file":"web/src/pages/admin/users.tsx","line":171,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.search"]},
|
||||
{"file":"web/src/pages/admin/users.tsx","line":174,"host":"Button","prop":"data-analytics-id","event":"click","ids":["admin.user.clear"]},
|
||||
|
|
@ -532,19 +541,9 @@
|
|||
{"file":"web/src/pages/dashboard/github-sync.tsx","line":53,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.repositories.tab"]},
|
||||
{"file":"web/src/pages/dashboard/github-sync.tsx","line":54,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["github.runs.tab"]},
|
||||
{"file":"web/src/pages/dashboard/github-sync.tsx","line":69,"host":"DialogContent","prop":"closeAnalyticsId","event":"click","ids":["github.source.close"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":102,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.all.tab"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":103,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.review.tab"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":104,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.promotion.tab"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":105,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["governance.report.tab"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":109,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":113,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":117,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":121,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.inbox.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":139,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.notification.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":149,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.activity.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":161,"host":"Button","prop":"data-analytics-id","event":"click","ids":["governance.index.rebuild"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["governance.index.rebuild.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":173,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["governance.index.rebuild.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":32,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.promotion.paginate","governance.report.paginate"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":43,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.notification.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/governance.tsx","line":48,"host":"Pagination","prop":"analyticsId","event":"click","ids":["governance.activity.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":92,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.create.open"]},
|
||||
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":134,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.management.open"]},
|
||||
{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":138,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.namespace.open"]},
|
||||
|
|
@ -612,17 +611,6 @@
|
|||
{"file":"web/src/pages/dashboard/namespace-members.tsx","line":331,"host":"Pagination","prop":"analyticsId","event":"click","ids":["namespace.member.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.member.remove.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-members.tsx","line":335,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.member.remove.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":57,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.namespace.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":97,"host":"Link","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":117,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.status.interact"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":119,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":120,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":121,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.status"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":130,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.namespace.sort.interact"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":134,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":135,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.namespace.sort"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":175,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.user.tab"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":176,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.namespace.sync.tab"]},
|
||||
{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":45,"host":"Button","prop":"data-analytics-id","event":"click","ids":["namespace.archive.request","namespace.delete.request","namespace.freeze.request","namespace.restore.request","namespace.unfreeze.request"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["namespace.archive.confirm","namespace.delete.confirm","namespace.freeze.confirm","namespace.restore.confirm","namespace.unfreeze.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":52,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["namespace.archive.cancel","namespace.delete.cancel","namespace.freeze.cancel","namespace.restore.cancel","namespace.unfreeze.cancel"],"conditional":true},
|
||||
|
|
@ -681,23 +669,23 @@
|
|||
{"file":"web/src/pages/dashboard/reports.tsx","line":210,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["report.dismissed.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["report.archive.confirm","report.dismiss.confirm","report.hide.confirm","report.resolve.confirm"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/reports.tsx","line":224,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["report.archive.cancel","report.dismiss.cancel","report.hide.cancel","report.resolve.cancel"],"conditional":true},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":211,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":233,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":276,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":293,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":317,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":318,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":319,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":320,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":362,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":439,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":453,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":464,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":479,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":508,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":517,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":194,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":205,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.list.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":221,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.content.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":222,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.changes.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":223,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.source.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":224,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.history.tab"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":260,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.suite.retry"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":326,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.security.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":335,"host":"button","prop":"data-analytics-id","event":"click","ids":["review.source.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":361,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":372,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.request"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":387,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.reject.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":398,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.approve.request"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":430,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.approve.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":430,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.approve.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":439,"host":"ConfirmDialog","prop":"confirmAnalyticsId","event":"click","ids":["review.reject.confirm"]},
|
||||
{"file":"web/src/pages/dashboard/review-detail.tsx","line":439,"host":"ConfirmDialog","prop":"cancelAnalyticsId","event":"click","ids":["review.reject.cancel"]},
|
||||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":79,"host":"form","prop":"data-analytics-id","event":"submit","ids":["review.progress.search"]},
|
||||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":100,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.progress.type.interact"]},
|
||||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":104,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.progress.type"]},
|
||||
|
|
@ -715,17 +703,9 @@
|
|||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":262,"host":"Link","prop":"data-analytics-id","event":"click","ids":["skill.resubmit.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":274,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.workspace.open"]},
|
||||
{"file":"web/src/pages/dashboard/review-progress.tsx","line":278,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.history.toggle"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":115,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.list.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":158,"host":"TableRow","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":207,"host":"SelectTrigger","prop":"data-analytics-id","event":"click+keydown-intent","ids":["review.list.sort.interact"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":211,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":212,"host":"SelectItem","prop":"data-analytics-id","event":"radix-item-intent","ids":["review.list.sort"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":221,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.pending.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":227,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.approved.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":233,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.rejected.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":274,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.skill.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":280,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.sync.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":286,"host":"TabsTrigger","prop":"data-analytics-id","event":"click","ids":["review.profile.tab"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":127,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":130,"host":"Button","prop":"data-analytics-id","event":"click","ids":["review.detail.open"]},
|
||||
{"file":"web/src/pages/dashboard/reviews.tsx","line":131,"host":"Pagination","prop":"analyticsId","event":"click","ids":["review.list.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/stars.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]},
|
||||
{"file":"web/src/pages/dashboard/stars.tsx","line":53,"host":"Pagination","prop":"analyticsId","event":"click","ids":["skill.starred.paginate"]},
|
||||
{"file":"web/src/pages/dashboard/subscriptions.tsx","line":42,"host":"SkillCard","prop":"analyticsId","event":"click+keydown-enter-space","ids":["skill.card.open"]},
|
||||
|
|
@ -768,10 +748,10 @@
|
|||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]},
|
||||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]},
|
||||
{"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":107,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":153,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":171,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":222,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":109,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":155,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":173,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/dashboard.tsx","line":224,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"},
|
||||
{"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]},
|
||||
{"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]},
|
||||
{"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This page covers pending-content inspection, decision submission and result veri
|
|||
|
||||
| Operation | Identity and conditions | Entry |
|
||||
| --- | --- | --- |
|
||||
| Review within a team namespace | Namespace Owner or Admin | Review tab in namespace details |
|
||||
| Review within a team namespace | Namespace Owner or Admin | Review entry in namespace details; opens the shared center with this scope selected |
|
||||
| Review across namespaces | Platform Skill administrator (`SKILL_ADMIN`) or super administrator (`SUPER_ADMIN`) | `/dashboard/reviews` |
|
||||
| Track an own submission | Submitter | My Review Progress; read access does not grant approval |
|
||||
| Global promotion and revocation | Submitter and authorized platform reviewer | `/dashboard/promotions`, separate from ordinary version reviews |
|
||||
|
|
@ -15,6 +15,14 @@ Owners and Admins of a team namespace can review their own submissions; super ad
|
|||
|
||||
Ordinary public (`PUBLIC`) or namespace-member-visible (`NAMESPACE_ONLY`) uploads enter scanning and review. Private (`PRIVATE`) uploads are retained first and still require publication confirmation or a separate review submission. A previously published version may remain available while an update is pending.
|
||||
|
||||
## Find Review Requests
|
||||
|
||||
The review center lists authorized Skill, Suite, avatar and visibility requests. Namespace entries open the same queue. The governance center retains reports, global promotions, activity and governance notifications without duplicating ordinary review tasks. User profile reviews remain a separate queue governed by user-management permissions.
|
||||
|
||||
The filter region includes keywords, status, scope, request type and source. Keywords match names, coordinates or submitters. Type and source are independent: a GitHub-synchronized request may be either an initial publication or an update. Platform reviewers can select authorized namespaces they have not joined; namespace Owners and Admins are limited to namespaces they manage.
|
||||
|
||||
Editing filters does not change results until Search is submitted, which returns to page one. Reset restores Pending, all scopes, all types, all sources and empty keywords. Applied filters, sorting and pagination remain in the URL and are restored when returning from details. Retry a failed scope request instead of interpreting it as an empty authorization scope.
|
||||
|
||||
## Inspect Content and Submit a Decision
|
||||
|
||||

|
||||
|
|
@ -22,7 +30,7 @@ Ordinary public (`PUBLIC`) or namespace-member-visible (`NAMESPACE_ONLY`) upload
|
|||
Figure: A decision requires a pending request and current permission. Reading the old version does not verify the current submission.
|
||||
|
||||
1. Open a pending request and verify its submitter, subject, target version, visibility and namespace. GitHub-synchronized items also provide a content revision, source repository and fixed commit. The technical version used for installation appears in technical information.
|
||||
2. Inspect the submitted documentation and file tree in Skill Content. Documentation is fetched when the tab is opened; individual file content is fetched on selection. GitHub updates initially open Changes; initial imports and ordinary Skills open Skill Content. An empty preview caused by an object-storage read failure is not evidence of acceptable content.
|
||||
2. Inspect the effective display name, summary and overview bound to this request, then expand the original package to inspect its files. Neither the old published version nor the original SKILL.md substitutes for custom public-facing content. The right task panel groups request type, scope, version and evidence. Avatar and visibility requests show their own objects; visibility is not a content version number. An empty preview caused by an object-storage read failure is not evidence of acceptable content.
|
||||
3. Check license evidence, the scanning report and findings in Source & Security. Distinguish execution errors, risk findings and unconfigured analyzers. Ordinary version approval is unavailable while scanning is running. Unrecognized licenses or missing evidence require inspection of the specific status and supporting material; they do not confirm permission to use the content.
|
||||
4. For avatar or visibility changes, verify the candidate and requested target. For a Skill Suite, inspect the pending snapshot, Entry Skill and fixed member versions. Approval is unavailable before the snapshot loads. Member approval does not approve the Suite. A Suite definition and a Suite Bundle import operation are separate objects; see [Skill Suites](./skill-suites.html).
|
||||
5. Read previous decisions in Review History, then approve or reject after evidence checks. A rejection must identify the file, problem and correction conditions; approval comments are optional. Switching tabs preserves comments; switching requests clears comments and unconfirmed dialogs. This version has no conditional-approval state.
|
||||
|
|
@ -41,7 +49,13 @@ Online preview limits are listed together below. Exceeding a limit does not mean
|
|||
| Current content loaded online | File exceeds 256KiB | Content is not loaded online |
|
||||
| Baseline download of a removed old file | File exceeds 1MiB | The old file cannot be downloaded from the review page |
|
||||
|
||||
These are frontend/API implementation limits, not review-page settings. Sources: [review detail page](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx) and [review API](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java). `GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` reads metadata and the documentation path without fetching object-storage content; the default is `true`.
|
||||
These are frontend/API implementation limits, not review-page settings. Sources: [review detail page](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx) and [review API](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java). `GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` reads metadata, custom web/package presentation and the documentation path without fetching object-storage content; README or SKILL.md fallback content is loaded on demand; the default is `true`.
|
||||
|
||||
### Understand Changes
|
||||
|
||||
Ordinary updates compare the actual published baseline on the left with the pending version on the right: effective display name, summary and overview, plus added, modified and removed files. Initial publication has no published baseline; the page states this instead of inventing an empty-version comparison. GitHub updates retain fixed source revisions and line-by-line file comparison. Avatar and visibility requests compare their own candidate or target rather than content versions.
|
||||
|
||||
Switching to history cannot bypass inspection: approval stays unavailable when required content, comparison or Suite snapshots cannot be read. Processed requests are read-only. A listed request does not itself grant decision permission. Search index maintenance is in system settings, outside review tasks.
|
||||
|
||||
### Inspect Compliance Declarations
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
| 操作 | 身份与条件 | 入口 |
|
||||
| --- | --- | --- |
|
||||
| 团队命名空间审核 | 该命名空间所有者(Owner)或空间管理员(Admin) | 命名空间详情的“审核”页签 |
|
||||
| 团队命名空间审核 | 该命名空间所有者(Owner)或空间管理员(Admin) | 命名空间详情的“审核”入口,跳转统一审核中心并预选当前范围 |
|
||||
| 跨命名空间审核 | 平台技能管理员(SKILL_ADMIN)或超级管理员(SUPER_ADMIN) | `/dashboard/reviews` |
|
||||
| 跟踪本人申请 | 提交者 | “我的审核进度”;申请可读不代表可以批准 |
|
||||
| 全局提升与撤销 | 提交者及有权平台审核人 | `/dashboard/promotions`,与普通版本审核分别处理 |
|
||||
|
|
@ -15,6 +15,14 @@
|
|||
|
||||
普通公开(PUBLIC)或空间成员可见(NAMESPACE_ONLY)上传进入扫描与审核流程;私有(PRIVATE)上传先保留,仍须确认发布或另行提审。新版审核期间,旧发布版可能继续可用。
|
||||
|
||||
## 查询审核申请
|
||||
|
||||
“审核中心”集中展示当前账号有权读取的技能、套装、头像和可见范围申请。命名空间入口复用同一队列;“治理中心”保留举报、全局提升、治理记录和治理通知,不重复展示普通审核任务。用户资料审核仍为独立队列,按用户管理权限进入。
|
||||
|
||||
筛选区域提供关键词、审核状态、审核范围、申请事项和申请来源。关键词匹配名称、坐标或提交人;申请事项与来源分别筛选,例如 GitHub 同步既可能是首次发布,也可能是版本更新。平台审核人可选择未加入但有权审核的命名空间;命名空间所有者和空间管理员只能选择其管理范围。
|
||||
|
||||
修改条件后点击“查询”才更新结果并返回第一页。“重置”恢复待审核、全部范围、全部事项、全部来源及空关键词。排序、分页和已查询条件保留在地址中,从详情返回时恢复;未点击查询的输入不会改变当前结果。范围读取失败时先重试,不以空范围代替授权结果。
|
||||
|
||||
## 核查内容并提交决定
|
||||
|
||||

|
||||
|
|
@ -22,7 +30,7 @@
|
|||
图:申请仍为待审且具备处理权限时才可作出决定;读取旧版本不等于核对本次申请。
|
||||
|
||||
1. 在待审列表打开申请,核对提交者、对象、目标版本、可见范围及命名空间。GitHub 同步项另有内容修订号、来源仓库和固定 commit;安装使用的技术版本在技术信息中查看。
|
||||
2. 在“技能内容”检查本次说明文档和文件树;进入页签后读取说明,选择文件后读取正文。GitHub 更新默认进入“变更对比”,首次导入和普通技能默认进入“技能内容”。对象存储读取失败时不得以空白预览判定内容合格。
|
||||
2. 在内容页签先核对本次生效的展示名称、简要描述和详细概述,再展开原始技能包核对文件。网页自定义内容必须与本次申请绑定,不能以旧发布版或原始 SKILL.md 替代。右侧任务信息集中展示申请事项、范围、版本和证据状态;头像及可见范围申请按自身对象展示,不能把可见范围当作内容版本号。对象存储读取失败时不得以空白预览判定内容合格。
|
||||
3. 在“来源与安全”核对许可证依据、扫描报告和发现项。区分执行失败、风险发现和分析器未配置。普通版本扫描未结束不能批准;许可证未识别或依据缺失时,须读取具体状态及证据,不能将其解释为许可已确认。
|
||||
4. 若有头像或可见范围变更,核对申请中的候选和目标;若为技能套装(Skill Suite),核对待审快照、入口技能及成员固定版本。快照未加载时批准不可用,成员获批不等于套装获批。套装定义与套装 Bundle 导入操作不是同一对象,见[技能套装](./skill-suites.html)。
|
||||
5. 在“审核记录”读取历史意见,完成证据检查后提交批准或拒绝。拒绝须写明具体文件、问题和修订条件;批准意见可选。切换页签保留意见,切换申请会清空意见和未确认弹窗。本版不提供“附条件批准”状态。
|
||||
|
|
@ -41,7 +49,13 @@
|
|||
| 当前正文在线加载 | 文件超过256KiB | 不在线加载正文 |
|
||||
| 已删除旧文件的基线下载 | 文件超过1MiB | 审核页不能下载该旧文件 |
|
||||
|
||||
这些是前端/接口实现限制,不由审核页面配置;依据[审核详情](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx)及[审核接口](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java)。`GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` 只读元数据及说明路径,不读取对象存储正文;缺省为 `true`。
|
||||
这些是前端/接口实现限制,不由审核页面配置;依据[审核详情](https://github.com/iflytek/skillhub/blob/main/web/src/pages/dashboard/review-detail.tsx)及[审核接口](https://github.com/iflytek/skillhub/blob/main/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java)。`GET /api/v1/reviews/{id}/skill-detail?includeDocumentation=false` 读取元数据、网页或包内自定义展示内容及说明路径,不读取对象存储正文;README 或 SKILL.md 回退正文需按需读取;缺省为 `true`。
|
||||
|
||||
### 理解变更对比
|
||||
|
||||
普通技能更新的“变更对比”以真实已发布基线为左侧,以本次待审版本为右侧,比较有效展示名称、简要描述、详细概述,并列出新增、修改和删除的文件。首次发布没有已发布基线,页面明确说明,不生成虚假的空版本差异。GitHub 更新保留固定来源修订及逐行文件比较;头像和可见范围申请比较各自候选或目标,不生成内容版本差异。
|
||||
|
||||
切换到历史记录不能绕过内容核查:本次正文、比较或套装快照读取失败时,批准保持不可用。已处理申请只读;列表可见不代表当前账号有决定权限。搜索索引维护已移至系统设置,不属于审核任务。
|
||||
|
||||
### 核查合规声明
|
||||
|
||||
|
|
|
|||
31
openspec/changes/unify-review-workbench/design.md
Normal file
31
openspec/changes/unify-review-workbench/design.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# 技术与交付设计
|
||||
|
||||
## 权威设计来源
|
||||
|
||||
用户确认的Figma:列表筛选以v6原位尺寸修正稿为准,详情/右侧/治理以v4统一token稿为准。实现与验收对照这些明确尺寸和状态,不在实现时重新选择风格。
|
||||
|
||||
- 列表:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=79-19170
|
||||
- 状态菜单:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=79-19397
|
||||
- 详情:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=76-7438
|
||||
- 范围详情:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=76-7945
|
||||
- 组件:https://www.figma.com/design/ixPCzrfgZ8V9lX6mWDWBCx?node-id=77-18418
|
||||
|
||||
## 读模型与权限
|
||||
|
||||
扩展GET /reviews,保留旧参数兼容,增加q/reviewKind/originType及status=ALL。reviewKind取FIRST_PUBLISH/VERSION_UPDATE/SUITE_PUBLISH/VISIBILITY_CHANGE/AVATAR_CHANGE;ALL为筛选值而非领域状态。originType取ALL/USER/GITHUB_SYNC。关键词、授权范围、状态和事项在服务端同一查询内过滤后排序分页与计数,不能前端按当前页过滤或枚举全部数据。USER_ADMIN不得自动获取技能审核权限;TEAM Owner/Admin仅授权命名空间集合。
|
||||
|
||||
详情沿用任务授权,新增presentation和comparison读投影;复用当前版本展示解析器,绑定审核任务版本。comparison仅比较真实已发布基线,禁止任取另一待审版本;元数据申请无新版本比较。普通文件只列ADDED/MODIFIED/REMOVED/UNCHANGED,不新增逐行diff实现。原始包和自定义展示内容独立读取。
|
||||
|
||||
## 前端
|
||||
|
||||
查询草稿与已应用条件分离;点击查询统一应用、回第一页并写URL。重置恢复PENDING与其余空/全部范围并查询。列表、详情返回和浏览器前进后退保留有效范围、条件、排序与页码。旧sync队列链接映射来源条件;命名空间旧入口进入同一列表预选slug。保留独立资料审核,不把资料头像当技能头像。
|
||||
|
||||
详情顶部仅任务标题与事项,主区有效简介/概述及原文件,右侧标准Card集中摘要和证据。按任务决定Tab,包内容加载失败不能用已发布旧内容冒充;扫描、头像、许可证和并发决定仍遵守原服务条件。已有任务已处理后只读。
|
||||
|
||||
## 取舍
|
||||
|
||||
只做样式覆盖会保留重复入口与缺失待审内容,因此同时收敛读模型。前端聚合各命名空间分页会导致排序与计数不正确,因此采用服务端授权过滤。新增完整通用工作流引擎或普通zip逐行diff均超出当前需求。
|
||||
|
||||
## 验证
|
||||
|
||||
覆盖筛选查询/重置/URL与分页、平台及TEAM权限隔离、用户资料权限、原文与自定义内容、基线及首次无基线、元数据/套装/GitHub任务、失败重试/已处理/并发、菜单尺寸/键盘/窄屏。使用真实本地Compose、S3依赖和浏览器完成端到端验收;测试矩阵与结果以交付报告链接回写,不把旧截图当新实现验收。
|
||||
13
openspec/changes/unify-review-workbench/proposal.md
Normal file
13
openspec/changes/unify-review-workbench/proposal.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# 统一审核工作台
|
||||
|
||||
## 原因
|
||||
|
||||
审核管理、审核与治理和命名空间审核重复呈现任务,范围与申请事项难以区分。审核详情仅展示原始包文档,无法完整核对版本化的自定义简介和概述;普通变更页仅有合规与历史,未表达展示内容变化。
|
||||
|
||||
## 已确认方向
|
||||
|
||||
统一审核中心和权限内队列。列表取消状态Tab,使用统一筛选区域:关键词、状态、范围、申请事项、来源以及查询/重置。桌面下拉176×32px,菜单同宽、选项32px;手机44px。详情使用已确认的双栏结构、项目token和胶囊内容导航。治理承载举报、全局提升、记录及独立治理通知,命名空间入口预选统一队列范围。
|
||||
|
||||
## 影响
|
||||
|
||||
扩展现有审核读接口和分页查询;不更改审核写入、权限、自审、扫描、发布或技能包内容。不增加数据库迁移与生产依赖。保留旧路由、旧接口消费者和独立用户资料审核。普通版本补展示文案与文件清单比较;GitHub原有逐行差异保持。
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: One authorized review center
|
||||
The system MUST present one review queue for authorized platform reviewers and TEAM owners/admins, limited by their existing review permissions. Namespace entries MUST preselect this same queue. Legacy links and the independent profile-review workflow MUST remain accessible. Governance MUST NOT duplicate the review task queue.
|
||||
|
||||
#### Scenario: A namespace administrator opens the center
|
||||
- **GIVEN** an account manages one TEAM namespace but has no platform skill-review role
|
||||
- **WHEN** it opens the center with no namespace filter
|
||||
- **THEN** only tasks it is already authorized to review are returned and counted
|
||||
- **AND** another namespace filter does not reveal unauthorized tasks
|
||||
|
||||
#### Scenario: A user administrator opens review management
|
||||
- **GIVEN** an account has only USER_ADMIN
|
||||
- **WHEN** it opens review management
|
||||
- **THEN** its profile-review workflow remains available without granting skill-review access
|
||||
|
||||
### Requirement: One complete filter form
|
||||
The queue MUST have no status tabs. One filter region MUST contain keyword, review status, namespace scope, request kind and origin plus Query and Reset. Filtering MUST occur on the server before pagination and counting. Draft conditions MUST not change results before Query; Query MUST apply them together and reset page. Reset MUST restore PENDING and other empty/all defaults. URL and detail return MUST retain applied filters, sorting and page.
|
||||
|
||||
#### Scenario: Query a filtered second page
|
||||
- **GIVEN** a reviewer is browsing a later page and edits several filter fields
|
||||
- **WHEN** Query is submitted
|
||||
- **THEN** all conditions apply together, page becomes the first page, and the server count and rows use identical predicates
|
||||
|
||||
#### Scenario: Restore the queue from a detail page
|
||||
- **GIVEN** a filtered queue and a selected review task
|
||||
- **WHEN** the reviewer returns or uses browser history
|
||||
- **THEN** the same authorized scope, conditions, sort and page are restored
|
||||
|
||||
### Requirement: Consistent compact dropdowns
|
||||
Desktop filter dropdown triggers MUST be 176 by 32 pixels, with matching-width menus, 32-pixel options and 6-pixel menu padding. The mobile filter controls MUST remain 44 pixels high. Keyboard focus and selection MUST remain usable and content MUST not be clipped.
|
||||
|
||||
#### Scenario: Expand a desktop status filter
|
||||
- **WHEN** the status dropdown is opened
|
||||
- **THEN** its menu aligns with and has the same width as the trigger
|
||||
- **AND** option text and selection indicators remain inside their rows
|
||||
|
||||
### Requirement: Review the effective bound presentation
|
||||
The review detail MUST show the effective display name, summary and overview for the task-bound version using the same resolution rules as publication. Original package files MUST remain separately accessible. A failed bound-content read MUST not be replaced by another version's content.
|
||||
|
||||
#### Scenario: Review a custom pending update
|
||||
- **GIVEN** an old published version and a pending version with web custom summary and overview
|
||||
- **WHEN** an authorized reviewer opens the pending task
|
||||
- **THEN** the custom pending content is visible for review and the public version remains unchanged until approval
|
||||
|
||||
### Requirement: Evidence-based changes
|
||||
A normal version update MUST compare the task-bound presentation and package file list against a real published baseline. Missing published baseline MUST not be replaced by another pending version. Visibility and independent avatar requests MUST not pretend to be new content versions. GitHub source evidence and existing file diffs MUST remain available.
|
||||
|
||||
#### Scenario: A first publication has no baseline
|
||||
- **WHEN** its task is viewed
|
||||
- **THEN** the full pending content is reviewable without a fabricated comparison tab
|
||||
|
||||
### Requirement: Task-specific workbench layout
|
||||
The confirmed dual-column detail MUST put readable target content in the main column and concise task/scope/version/evidence in the side card. The neutral segmented navigation MUST show only applicable sections and wrap without internal vertical scrolling on narrow screens. Approval/rejection, permissions, required reasons and processed read-only states MUST preserve existing domain rules.
|
||||
|
||||
#### Scenario: Inspect a visibility expansion
|
||||
- **WHEN** its task is viewed
|
||||
- **THEN** the previous and requested scopes and bound version are clear
|
||||
- **AND** its bound scan evidence is not contradicted by an unrelated generic no-scan message
|
||||
12
openspec/changes/unify-review-workbench/tasks.md
Normal file
12
openspec/changes/unify-review-workbench/tasks.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
## Implementation
|
||||
- [x] Authorized server-side queue filters and count/pagination
|
||||
- [x] Bound effective presentation and real-baseline comparison projection
|
||||
- [x] Unified queue/filter UI and navigation aliases
|
||||
- [x] Workbench layout and task-specific content/evidence/decision states
|
||||
- [x] Bilingual copy, API generation and user documentation
|
||||
|
||||
## Validation
|
||||
- [x] Independent code review and requirement mapping
|
||||
- [x] Targeted server and UI behavior tests, typecheck/lint/build
|
||||
- [ ] Reviewed scenario matrix and real local image/auth/business/browser validation
|
||||
- [ ] Manual-preview handoff with verified boundaries
|
||||
|
|
@ -48,6 +48,14 @@ public class ReviewController extends BaseApiController {
|
|||
this.governanceWorkflowAppService = governanceWorkflowAppService;
|
||||
}
|
||||
|
||||
@GetMapping("/scopes")
|
||||
@Operation(summary = "List namespace scopes the caller is authorized to review")
|
||||
public ApiResponse<List<com.iflytek.skillhub.dto.ReviewScopeResponse>> listScopes(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", governanceWorkflowAppService.listReviewScopes(userId, userNsRoles));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<ReviewTaskResponse> submitReview(@RequestBody ReviewTaskRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
|
|
@ -111,8 +119,11 @@ public class ReviewController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews(@RequestParam String status,
|
||||
public ApiResponse<PageResponse<ReviewTaskResponse>> listReviews(@RequestParam(defaultValue = "PENDING") String status,
|
||||
@RequestParam(required = false) Long namespaceId,
|
||||
@RequestParam(defaultValue = "") String q,
|
||||
@RequestParam(required = false) String reviewKind,
|
||||
@RequestParam(required = false) String originType,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(defaultValue = "DESC") String sortDirection,
|
||||
|
|
@ -120,7 +131,7 @@ public class ReviewController extends BaseApiController {
|
|||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkflowAppService.listReviews(status, namespaceId, page, size, sortDirection, userId, userNsRoles)
|
||||
governanceWorkflowAppService.listReviewQueue(status, namespaceId, q, reviewKind, originType, page, size, sortDirection, userId, userNsRoles)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/** Lightweight namespace option limited to the caller's existing review authority. */
|
||||
public record ReviewScopeResponse(Long id, String slug, String displayName) {}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
||||
public record ReviewSkillDetailResponse(
|
||||
|
|
@ -11,13 +12,27 @@ public record ReviewSkillDetailResponse(
|
|||
String downloadUrl,
|
||||
String activeVersion,
|
||||
AvatarChange avatarChange,
|
||||
VisibilityChange visibilityChange
|
||||
VisibilityChange visibilityChange,
|
||||
Presentation presentation,
|
||||
Comparison comparison
|
||||
) {
|
||||
public ReviewSkillDetailResponse(SkillDetailResponse skill, List<SkillVersionResponse> versions,
|
||||
List<SkillFileResponse> files, String documentationPath, String documentationContent,
|
||||
String downloadUrl, String activeVersion, AvatarChange avatarChange, VisibilityChange visibilityChange) {
|
||||
this(skill, versions, files, documentationPath, documentationContent, downloadUrl, activeVersion,
|
||||
avatarChange, visibilityChange, null, null);
|
||||
}
|
||||
public ReviewSkillDetailResponse(SkillDetailResponse skill, List<SkillVersionResponse> versions,
|
||||
List<SkillFileResponse> files, String documentationPath, String documentationContent,
|
||||
String downloadUrl, String activeVersion, AvatarChange avatarChange) {
|
||||
this(skill, versions, files, documentationPath, documentationContent, downloadUrl, activeVersion, avatarChange, null);
|
||||
}
|
||||
@Schema(name = "ReviewPresentation")
|
||||
public record Presentation(String displayName, String summary, String overview, String overviewSource) {}
|
||||
@Schema(name = "ReviewFileChange")
|
||||
public record FileChange(String path, String changeType) {}
|
||||
@Schema(name = "ReviewComparison")
|
||||
public record Comparison(String baseVersion, Presentation baseline, Presentation current, List<FileChange> files) {}
|
||||
public record VisibilityChange(String previousVisibility, String requestedVisibility,
|
||||
String scanStatus, boolean readyForApproval) {}
|
||||
public record AvatarChange(Long candidateId, String currentAvatarUrl, String candidatePreviewUrl,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,40 @@ public record ReviewTaskResponse(
|
|||
boolean avatarRemoval,
|
||||
String previousVisibility,
|
||||
String requestedVisibility,
|
||||
String originType
|
||||
String originType,
|
||||
String displayName,
|
||||
String summary,
|
||||
String reviewKind,
|
||||
boolean canReview
|
||||
) {
|
||||
public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug,
|
||||
String version, String status, String submittedBy, String submittedByName,
|
||||
String reviewedBy, String reviewedByName, String reviewComment, Instant submittedAt,
|
||||
Instant reviewedAt, String subjectType, Long subjectId, Long subjectVersionId,
|
||||
String subjectSlug, Long avatarCandidateId, boolean avatarRemoval,
|
||||
String previousVisibility, String requestedVisibility, String originType) {
|
||||
this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName,
|
||||
reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, subjectType,
|
||||
subjectId, subjectVersionId, subjectSlug, avatarCandidateId, avatarRemoval,
|
||||
previousVisibility, requestedVisibility, originType, null, null, null, false);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse withReviewPermission(boolean allowed) {
|
||||
return new ReviewTaskResponse(id, skillVersionId, namespace, skillSlug, version, status,
|
||||
submittedBy, submittedByName, reviewedBy, reviewedByName, reviewComment,
|
||||
submittedAt, reviewedAt, subjectType, subjectId, subjectVersionId, subjectSlug,
|
||||
avatarCandidateId, avatarRemoval, previousVisibility, requestedVisibility,
|
||||
originType, displayName, summary, reviewKind, allowed);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse withPresentation(String displayName, String summary, String reviewKind) {
|
||||
return new ReviewTaskResponse(id, skillVersionId, namespace, skillSlug, version, status,
|
||||
submittedBy, submittedByName, reviewedBy, reviewedByName, reviewComment,
|
||||
submittedAt, reviewedAt, subjectType, subjectId, subjectVersionId, subjectSlug,
|
||||
avatarCandidateId, avatarRemoval, previousVisibility, requestedVisibility,
|
||||
originType, displayName, summary, reviewKind, canReview);
|
||||
}
|
||||
|
||||
public ReviewTaskResponse(Long id, Long skillVersionId, String namespace, String skillSlug, String version, String status, String submittedBy, String submittedByName, String reviewedBy, String reviewedByName, String reviewComment, Instant submittedAt, Instant reviewedAt, String subjectType, Long subjectId, Long subjectVersionId, String subjectSlug, String originType) {
|
||||
this(id, skillVersionId, namespace, skillSlug, version, status, submittedBy, submittedByName, reviewedBy, reviewedByName, reviewComment, submittedAt, reviewedAt, subjectType, subjectId, subjectVersionId, subjectSlug, null, false, null, null, originType);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* PostgreSQL read model joining review subjects and immutable version metadata. Direct SQL keeps
|
||||
* authorization, keyword/kind filters, counts and pagination in one database predicate; a bounded
|
||||
* batch fetch then restores the domain tasks for the existing response projection.
|
||||
*/
|
||||
@Repository
|
||||
@Transactional(readOnly = true)
|
||||
public class JpaReviewQueueQueryRepository implements ReviewQueueQueryRepository {
|
||||
// Match SkillMetadata's String.strip, string-only package metadata and UTF-16 length limit.
|
||||
private static final String STRIP_CHARACTERS = "\t\n\u000b\f\r\u001c\u001d\u001e\u001f \u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2008\u2009\u200a\u2028\u2029\u205f\u3000";
|
||||
private static String strip(String expression) {
|
||||
return "btrim(" + expression + ", '" + STRIP_CHARACTERS + "')";
|
||||
}
|
||||
private static String packageText(String key) {
|
||||
String json = "version.parsed_metadata_json #> '{frontmatter,metadata," + key + "}'";
|
||||
return "CASE WHEN jsonb_typeof(" + json + ") = 'string' THEN NULLIF("
|
||||
+ strip("version.parsed_metadata_json #>> '{frontmatter,metadata," + key + "}'") + ", '') END";
|
||||
}
|
||||
private static String displayName(String expression) {
|
||||
String value = strip(expression);
|
||||
// PostgreSQL length counts code points; Java's limit counts supplementary characters twice.
|
||||
String javaLength = "length(" + value + ") + length(regexp_replace(" + value + ", '[^𐀀-]', '', 'g'))";
|
||||
return "CASE WHEN " + javaLength + " <= 200 THEN NULLIF(" + value + ", '') END";
|
||||
}
|
||||
private static final String NAME = "CASE WHEN task.subject_type = 'SUITE_VERSION' "
|
||||
+ "THEN COALESCE(suite_version.display_name, suite.display_name, suite.slug) ELSE COALESCE("
|
||||
+ displayName("version.parsed_metadata_json ->> 'displayNameOverride'") + ", "
|
||||
+ displayName(packageText("display-name")) + ", version.parsed_metadata_json ->> 'name', "
|
||||
+ "skill.display_name, skill.slug) END ";
|
||||
private static final String SUMMARY = "CASE WHEN task.subject_type = 'SUITE_VERSION' "
|
||||
+ "THEN CASE WHEN suite_version.id IS NOT NULL THEN suite_version.summary ELSE suite.summary END "
|
||||
+ "ELSE COALESCE(version.parsed_metadata_json ->> 'shortDescriptionOverride', "
|
||||
+ packageText("short-description") + ", version.parsed_metadata_json ->> 'description', skill.summary) END ";
|
||||
private static final String KIND = """
|
||||
CASE WHEN task.subject_type = 'SUITE_VERSION' THEN 'SUITE_PUBLISH'
|
||||
WHEN task.requested_visibility IS NOT NULL THEN 'VISIBILITY_CHANGE'
|
||||
WHEN task.subject_type = 'SKILL_METADATA' THEN 'AVATAR_CHANGE'
|
||||
WHEN EXISTS (SELECT 1 FROM skill_version previous
|
||||
WHERE previous.skill_id = task.subject_id
|
||||
AND previous.id <> task.subject_version_id
|
||||
AND previous.published_at <= task.submitted_at) THEN 'VERSION_UPDATE'
|
||||
ELSE 'FIRST_PUBLISH' END
|
||||
""";
|
||||
private static final String FROM = """
|
||||
FROM review_task task
|
||||
JOIN namespace ns ON ns.id = task.namespace_id
|
||||
LEFT JOIN skill ON task.subject_type IN ('SKILL_VERSION', 'SKILL_METADATA')
|
||||
AND skill.id = task.subject_id
|
||||
LEFT JOIN skill_suite suite ON task.subject_type = 'SUITE_VERSION' AND suite.id = task.subject_id
|
||||
LEFT JOIN skill_suite_version suite_version ON task.subject_type = 'SUITE_VERSION'
|
||||
AND suite_version.id = task.subject_version_id
|
||||
LEFT JOIN skill_version version ON task.subject_type = 'SKILL_VERSION'
|
||||
AND version.id = task.subject_version_id
|
||||
LEFT JOIN user_account author ON author.id = task.submitted_by
|
||||
""";
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public JpaReviewQueueQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<Entry> list(Filter filter, boolean platformReviewer, Set<Long> managedNamespaceIds,
|
||||
Pageable pageable) {
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
StringBuilder where = new StringBuilder(" WHERE ");
|
||||
if (platformReviewer) {
|
||||
where.append("TRUE");
|
||||
} else if (managedNamespaceIds.isEmpty()) {
|
||||
return Page.empty(pageable);
|
||||
} else {
|
||||
where.append("ns.type = 'TEAM' AND ns.id IN (:managedNamespaceIds)");
|
||||
parameters.put("managedNamespaceIds", managedNamespaceIds);
|
||||
}
|
||||
if (filter.namespaceId() != null) {
|
||||
where.append(" AND task.namespace_id = :namespaceId");
|
||||
parameters.put("namespaceId", filter.namespaceId());
|
||||
}
|
||||
if (filter.status() != null) {
|
||||
where.append(" AND task.status = :status");
|
||||
parameters.put("status", filter.status());
|
||||
}
|
||||
if (filter.originType() != null) {
|
||||
where.append(" AND task.origin_type = :originType");
|
||||
parameters.put("originType", filter.originType());
|
||||
}
|
||||
if (filter.reviewKind() != null) {
|
||||
where.append(" AND (").append(KIND).append(") = :reviewKind");
|
||||
parameters.put("reviewKind", filter.reviewKind());
|
||||
}
|
||||
if (!filter.query().isBlank()) {
|
||||
where.append(" AND (LOWER(").append(NAME).append(") LIKE :query ESCAPE '!' ")
|
||||
.append(" OR LOWER(CONCAT(ns.slug, '/', COALESCE(skill.slug, suite.slug))) LIKE :query ESCAPE '!' ")
|
||||
.append(" OR LOWER(author.display_name) LIKE :query ESCAPE '!' ")
|
||||
.append(" OR LOWER(task.submitted_by) LIKE :query ESCAPE '!')");
|
||||
String value = filter.query().strip().toLowerCase(java.util.Locale.ROOT);
|
||||
if (value.startsWith("@")) value = value.substring(1);
|
||||
parameters.put("query", "%" + value.replace("!", "!!").replace("%", "!%")
|
||||
.replace("_", "!_") + "%");
|
||||
}
|
||||
long total = ((Number) bind(entityManager.createNativeQuery("SELECT COUNT(*) " + FROM + where),
|
||||
parameters).getSingleResult()).longValue();
|
||||
String direction = pageable.getSort().getOrderFor("submittedAt") != null
|
||||
&& pageable.getSort().getOrderFor("submittedAt").isAscending() ? "ASC" : "DESC";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = bind(entityManager.createNativeQuery("SELECT task.id, " + NAME + ", "
|
||||
+ SUMMARY + ", " + KIND + FROM + where
|
||||
+ " ORDER BY task.submitted_at " + direction + ", task.id " + direction), parameters)
|
||||
.setFirstResult(Math.toIntExact(pageable.getOffset())).setMaxResults(pageable.getPageSize())
|
||||
.getResultList();
|
||||
if (rows.isEmpty()) return new PageImpl<>(List.of(), pageable, total);
|
||||
List<Long> ids = rows.stream().map(row -> ((Number) row[0]).longValue()).toList();
|
||||
Map<Long, ReviewTask> tasks = new HashMap<>();
|
||||
entityManager.createQuery("SELECT task FROM ReviewTask task WHERE task.id IN :ids", ReviewTask.class)
|
||||
.setParameter("ids", ids).getResultList().forEach(task -> tasks.put(task.getId(), task));
|
||||
List<Entry> entries = rows.stream().map(row -> new Entry(tasks.get(((Number) row[0]).longValue()),
|
||||
(String) row[1], (String) row[2], (String) row[3])).toList();
|
||||
return new PageImpl<>(entries, pageable, total);
|
||||
}
|
||||
|
||||
private Query bind(Query query, Map<String, Object> parameters) {
|
||||
parameters.forEach(query::setParameter);
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import java.util.Set;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
/** Authorized review-center projection; filters and totals are applied before pagination. */
|
||||
public interface ReviewQueueQueryRepository {
|
||||
Page<Entry> list(Filter filter, boolean platformReviewer, Set<Long> managedNamespaceIds, Pageable pageable);
|
||||
|
||||
record Filter(String status, Long namespaceId, String query, String reviewKind, String originType) {}
|
||||
record Entry(ReviewTask task, String displayName, String summary, String reviewKind) {}
|
||||
}
|
||||
|
|
@ -86,6 +86,18 @@ public class GovernanceWorkflowAppService {
|
|||
return reviewPortalAppService.listReviews(status, namespaceId, page, size, sortDirection, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public List<com.iflytek.skillhub.dto.ReviewScopeResponse> listReviewScopes(
|
||||
String userId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
return reviewPortalAppService.listReviewScopes(userId, userNsRoles);
|
||||
}
|
||||
|
||||
public PageResponse<ReviewTaskResponse> listReviewQueue(String status, Long namespaceId,
|
||||
String query, String reviewKind, String originType, int page, int size,
|
||||
String sortDirection, String userId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
return reviewPortalAppService.listReviewQueue(status, namespaceId, query, reviewKind,
|
||||
originType, page, size, sortDirection, userId, userNsRoles);
|
||||
}
|
||||
|
||||
public PageResponse<ReviewTaskResponse> listSyncReviews(String status,
|
||||
Long namespaceId,
|
||||
int page,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import com.iflytek.skillhub.dto.ReviewTaskResponse;
|
|||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
|
||||
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
|
||||
import com.iflytek.skillhub.repository.ReviewQueueQueryRepository;
|
||||
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
|
@ -36,6 +39,9 @@ import org.springframework.transaction.annotation.Transactional;
|
|||
@Service
|
||||
public class ReviewPortalAppService {
|
||||
|
||||
private final ReviewQueueQueryRepository reviewQueueQueryRepository;
|
||||
private final ReviewPermissionChecker reviewPermissionChecker;
|
||||
|
||||
private final ReviewService reviewService;
|
||||
private final ReviewTaskRepository reviewTaskRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
|
|
@ -54,7 +60,9 @@ public class ReviewPortalAppService {
|
|||
RbacService rbacService,
|
||||
AuditLogService auditLogService,
|
||||
RequestIdAccessor requestIdAccessor,
|
||||
SkillSuiteLifecycleService suiteLifecycleService) {
|
||||
SkillSuiteLifecycleService suiteLifecycleService,
|
||||
ReviewQueueQueryRepository reviewQueueQueryRepository,
|
||||
ReviewPermissionChecker reviewPermissionChecker) {
|
||||
this.reviewService = reviewService;
|
||||
this.reviewTaskRepository = reviewTaskRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
|
|
@ -64,6 +72,8 @@ public class ReviewPortalAppService {
|
|||
this.auditLogService = auditLogService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
this.suiteLifecycleService = suiteLifecycleService;
|
||||
this.reviewQueueQueryRepository = reviewQueueQueryRepository;
|
||||
this.reviewPermissionChecker = reviewPermissionChecker;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
|
@ -161,6 +171,74 @@ public class ReviewPortalAppService {
|
|||
userId, userNsRoles, ReviewOriginType.USER);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<com.iflytek.skillhub.dto.ReviewScopeResponse> listReviewScopes(
|
||||
String userId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
boolean platformReviewer = hasPlatformReviewRole(platformRoles(userId));
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
List<Long> managed = roles.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN)
|
||||
.map(Map.Entry::getKey).toList();
|
||||
if (!platformReviewer && managed.isEmpty()) throw new DomainForbiddenException("review.no_permission");
|
||||
List<Namespace> namespaces = platformReviewer ? namespaceRepository.findAll()
|
||||
: namespaceRepository.findByIdIn(managed);
|
||||
return namespaces.stream()
|
||||
.filter(namespace -> platformReviewer || namespace.getType() == com.iflytek.skillhub.domain.namespace.NamespaceType.TEAM)
|
||||
.sorted(java.util.Comparator.comparing(Namespace::getSlug))
|
||||
.map(namespace -> new com.iflytek.skillhub.dto.ReviewScopeResponse(
|
||||
namespace.getId(), namespace.getSlug(), namespace.getDisplayName())).toList();
|
||||
}
|
||||
|
||||
public PageResponse<ReviewTaskResponse> listReviewQueue(String status, Long namespaceId,
|
||||
String query, String reviewKind, String originType, int page, int size,
|
||||
String sortDirection, String userId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
String normalizedStatus = normalizeFilter(status, Set.of("PENDING", "APPROVED", "REJECTED", "SUPERSEDED"));
|
||||
String normalizedOrigin = normalizeFilter(originType, Set.of("USER", "GITHUB_SYNC"));
|
||||
String normalizedKind = normalizeFilter(reviewKind, Set.of("FIRST_PUBLISH", "VERSION_UPDATE",
|
||||
"SUITE_PUBLISH", "VISIBILITY_CHANGE", "AVATAR_CHANGE"));
|
||||
if (page < 0 || size < 1 || size > 100 || query != null && query.length() > 200) {
|
||||
throw new DomainBadRequestException("error.review.queue.invalidFilter");
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
|
||||
Set<String> platform = platformRoles(userId);
|
||||
boolean platformReviewer = hasPlatformReviewRole(platform);
|
||||
Set<Long> managed = roles.entrySet().stream()
|
||||
.filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN)
|
||||
.map(Map.Entry::getKey).collect(java.util.stream.Collectors.toSet());
|
||||
if (!platformReviewer && managed.isEmpty()) throw new DomainForbiddenException("review.no_permission");
|
||||
if (namespaceId != null) {
|
||||
Namespace namespace = namespaceRepository.findById(namespaceId)
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
|
||||
if (!reviewService.canReviewNamespace(new ReviewTask(0L, namespaceId, userId), userId,
|
||||
namespace.getType(), roles, platform)) throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
Sort.Direction direction;
|
||||
try { direction = Sort.Direction.fromString(sortDirection); }
|
||||
catch (IllegalArgumentException exception) {
|
||||
throw new DomainBadRequestException("error.review.queue.invalidFilter");
|
||||
}
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, "submittedAt").and(Sort.by(direction, "id")));
|
||||
Page<ReviewQueueQueryRepository.Entry> result = reviewQueueQueryRepository.list(
|
||||
new ReviewQueueQueryRepository.Filter(normalizedStatus,
|
||||
namespaceId, query == null ? "" : query, normalizedKind, normalizedOrigin),
|
||||
platformReviewer, managed, pageable);
|
||||
List<ReviewTaskResponse> responses = governanceQueryRepository.getReviewTaskResponses(
|
||||
result.getContent().stream().map(ReviewQueueQueryRepository.Entry::task).toList());
|
||||
List<ReviewTaskResponse> entries = java.util.stream.IntStream.range(0, responses.size())
|
||||
.mapToObj(index -> responses.get(index).withPresentation(result.getContent().get(index).displayName(),
|
||||
result.getContent().get(index).summary(), result.getContent().get(index).reviewKind())).toList();
|
||||
return new PageResponse<>(entries, result.getTotalElements(), result.getNumber(), result.getSize());
|
||||
}
|
||||
|
||||
private String normalizeFilter(String value, Set<String> allowed) {
|
||||
if (value == null || value.isBlank() || "ALL".equalsIgnoreCase(value)) return null;
|
||||
String normalized = value.strip().toUpperCase(java.util.Locale.ROOT);
|
||||
if (!allowed.contains(normalized)) {
|
||||
throw new DomainBadRequestException("error.review.queue.invalidFilter");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public PageResponse<ReviewTaskResponse> listSyncReviews(String status,
|
||||
Long namespaceId,
|
||||
int page,
|
||||
|
|
@ -358,7 +436,8 @@ public class ReviewPortalAppService {
|
|||
platformRoles(userId))) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
return governanceQueryRepository.getReviewTaskResponse(task);
|
||||
return governanceQueryRepository.getReviewTaskResponse(task).withReviewPermission(
|
||||
reviewPermissionChecker.canReview(task, userId, namespace.getType(), normalizeRoles(userNsRoles), platformRoles(userId)));
|
||||
}
|
||||
|
||||
private boolean canViewReview(ReviewTask task, String userId, Map<Long, NamespaceRole> namespaceRoles) {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ public class ReviewSkillDetailAppService {
|
|||
}
|
||||
SkillQueryService.ReviewSkillSnapshotDTO snapshot =
|
||||
skillQueryService.getReviewSkillSnapshot(reviewVersionId(context.task()));
|
||||
return toResponse(reviewId, context, snapshot);
|
||||
return toResponse(reviewId, context, snapshot, true);
|
||||
}
|
||||
|
||||
public ReviewSkillDetailResponse getReviewSkillDetail(Long reviewId,
|
||||
|
|
@ -118,11 +118,11 @@ public class ReviewSkillDetailAppService {
|
|||
SkillQueryService.ReviewSkillSnapshotDTO snapshot =
|
||||
skillQueryService.getReviewSkillSnapshot(reviewVersionId(context.task()), false);
|
||||
|
||||
return toResponse(reviewId, context, snapshot);
|
||||
return toResponse(reviewId, context, snapshot, false);
|
||||
}
|
||||
|
||||
private ReviewSkillDetailResponse toResponse(Long reviewId, ReviewAccessContext context,
|
||||
SkillQueryService.ReviewSkillSnapshotDTO snapshot) {
|
||||
SkillQueryService.ReviewSkillSnapshotDTO snapshot, boolean includeDocumentation) {
|
||||
SkillLifecycleVersionResponse activeVersion = toLifecycleVersion(snapshot.activeVersion());
|
||||
|
||||
SkillDetailResponse skill = new SkillDetailResponse(
|
||||
|
|
@ -184,10 +184,68 @@ public class ReviewSkillDetailAppService {
|
|||
snapshot.documentationContent(),
|
||||
"/api/v1/reviews/" + reviewId + "/download",
|
||||
snapshot.activeVersion().getVersion(),
|
||||
avatarChange(context), visibilityChange(context.task())
|
||||
avatarChange(context), visibilityChange(context.task()),
|
||||
presentation(snapshot), comparison(context, snapshot, includeDocumentation)
|
||||
);
|
||||
}
|
||||
|
||||
private SkillMetadata metadata(SkillVersion version) {
|
||||
if (version.getParsedMetadataJson() == null) return null;
|
||||
try { return metadataReader.readValue(version.getParsedMetadataJson()); }
|
||||
catch (java.io.IOException ignored) { return null; }
|
||||
}
|
||||
|
||||
private ReviewSkillDetailResponse.Presentation presentation(SkillQueryService.ReviewSkillSnapshotDTO snapshot) {
|
||||
SkillMetadata metadata = metadata(snapshot.activeVersion());
|
||||
String name = metadata != null ? metadata.displayName() : snapshot.skill().getDisplayName();
|
||||
String summary = metadata != null ? metadata.shortDescription() : snapshot.skill().getSummary();
|
||||
if (metadata != null && metadata.overviewOverride() != null) {
|
||||
return new ReviewSkillDetailResponse.Presentation(name, summary, metadata.overviewOverride(), "WEB");
|
||||
}
|
||||
if (metadata != null && metadata.packageOverview() != null) {
|
||||
return new ReviewSkillDetailResponse.Presentation(name, summary, metadata.packageOverview(), "PACKAGE");
|
||||
}
|
||||
String source = "README.md".equals(snapshot.documentationPath()) ? "README"
|
||||
: "SKILL.md".equals(snapshot.documentationPath()) ? "SKILL_MD" : "NONE";
|
||||
return new ReviewSkillDetailResponse.Presentation(name, summary, snapshot.documentationContent(), source);
|
||||
}
|
||||
|
||||
private ReviewSkillDetailResponse.Comparison comparison(ReviewAccessContext context,
|
||||
SkillQueryService.ReviewSkillSnapshotDTO snapshot, boolean includeDocumentation) {
|
||||
if (context.task().getSubjectType() != null
|
||||
&& context.task().getSubjectType() != ReviewSubjectType.SKILL_VERSION) return null;
|
||||
// Being the submitter permits reading the bound task, not unrelated private history.
|
||||
if (!context.reviewer() && !snapshot.skill().getOwnerId().equals(context.userId())) return null;
|
||||
// A later publication must never become the alleged baseline of an older review task.
|
||||
SkillVersion baseline = snapshot.versions().stream()
|
||||
.filter(version -> version.getStatus() == com.iflytek.skillhub.domain.skill.SkillVersionStatus.PUBLISHED)
|
||||
.filter(version -> !version.getId().equals(snapshot.activeVersion().getId()))
|
||||
.filter(version -> version.getPublishedAt() != null && context.task().getSubmittedAt() != null
|
||||
&& !version.getPublishedAt().isAfter(context.task().getSubmittedAt()))
|
||||
.max(java.util.Comparator.comparing(SkillVersion::getPublishedAt).thenComparing(SkillVersion::getId))
|
||||
.orElse(null);
|
||||
if (baseline == null) return null;
|
||||
SkillQueryService.ReviewSkillSnapshotDTO previous =
|
||||
skillQueryService.getReviewSkillSnapshot(baseline.getId(), includeDocumentation);
|
||||
Map<String, SkillFile> oldFiles = previous.files().stream()
|
||||
.collect(java.util.stream.Collectors.toMap(SkillFile::getFilePath, file -> file));
|
||||
Map<String, SkillFile> newFiles = snapshot.files().stream()
|
||||
.collect(java.util.stream.Collectors.toMap(SkillFile::getFilePath, file -> file));
|
||||
java.util.SortedSet<String> paths = new java.util.TreeSet<>(oldFiles.keySet());
|
||||
paths.addAll(newFiles.keySet());
|
||||
List<ReviewSkillDetailResponse.FileChange> changes = paths.stream().map(path -> {
|
||||
SkillFile oldFile = oldFiles.get(path);
|
||||
SkillFile newFile = newFiles.get(path);
|
||||
String type = oldFile == null ? "ADDED" : newFile == null ? "REMOVED"
|
||||
: oldFile.getSha256() != null && oldFile.getSha256().equals(newFile.getSha256())
|
||||
&& java.util.Objects.equals(oldFile.getFileSize(), newFile.getFileSize())
|
||||
? "UNCHANGED" : "MODIFIED";
|
||||
return new ReviewSkillDetailResponse.FileChange(path, type);
|
||||
}).toList();
|
||||
return new ReviewSkillDetailResponse.Comparison(baseline.getVersion(), presentation(previous),
|
||||
presentation(snapshot), changes);
|
||||
}
|
||||
|
||||
public SkillDownloadService.DownloadResult downloadReviewPackage(Long reviewId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
|
|
@ -287,7 +345,8 @@ public class ReviewSkillDetailAppService {
|
|||
if (!reviewService.canViewReview(task, userId, namespace.getType(), namespaceRoles, platformRoles)) {
|
||||
throw new DomainForbiddenException("review.no_permission");
|
||||
}
|
||||
return new ReviewAccessContext(task, namespace);
|
||||
return new ReviewAccessContext(task, namespace, userId,
|
||||
reviewService.canReviewNamespace(task, userId, namespace.getType(), namespaceRoles, platformRoles));
|
||||
}
|
||||
|
||||
private SkillFileResponse toFileResponse(SkillFile file) {
|
||||
|
|
@ -310,8 +369,8 @@ public class ReviewSkillDetailAppService {
|
|||
}
|
||||
}
|
||||
return new SkillLifecycleVersionResponse(version.getId(), version.getVersion(), version.getStatus().name(),
|
||||
metadata == null ? null : metadata.displayName(), metadata == null ? null : metadata.description(), version.getGithubSyncRevision());
|
||||
metadata == null ? null : metadata.displayName(), metadata == null ? null : metadata.shortDescription(), version.getGithubSyncRevision());
|
||||
}
|
||||
|
||||
private record ReviewAccessContext(ReviewTask task, Namespace namespace) {}
|
||||
private record ReviewAccessContext(ReviewTask task, Namespace namespace, String userId, boolean reviewer) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -392,3 +392,5 @@ error.githubSync.deepDiscovery.exclusionInvalid=Exclusions must be valid GitHub
|
|||
error.githubSync.deepDiscovery.configurationConflict=Official discovery configuration has changed. Reload the latest configuration before saving
|
||||
|
||||
error.githubSync.deepDiscovery.queryInvalid=Invalid deep discovery query parameters.
|
||||
|
||||
error.review.queue.invalidFilter=Invalid review queue filter or pagination.
|
||||
|
|
|
|||
|
|
@ -335,3 +335,5 @@ error.githubSync.deepDiscovery.exclusionInvalid=Исключения должн
|
|||
error.githubSync.deepDiscovery.configurationConflict=Конфигурация официального поиска изменена. Загрузите актуальную конфигурацию перед сохранением
|
||||
|
||||
error.githubSync.deepDiscovery.queryInvalid=Недопустимые параметры запроса глубокого обнаружения.
|
||||
|
||||
error.review.queue.invalidFilter=Недопустимые параметры фильтра или страницы проверки.
|
||||
|
|
|
|||
|
|
@ -394,3 +394,5 @@ error.githubSync.deepDiscovery.exclusionInvalid=排除项须为有效的GitHub
|
|||
error.githubSync.deepDiscovery.configurationConflict=官方探索配置已被修改,请重新读取最新配置后再保存
|
||||
|
||||
error.githubSync.deepDiscovery.queryInvalid=深度探索查询参数无效。
|
||||
|
||||
error.review.queue.invalidFilter=审核筛选条件或分页参数不合法。
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ class ReviewPortalControllerTest {
|
|||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private com.iflytek.skillhub.repository.ReviewQueueQueryRepository reviewQueueQueryRepository;
|
||||
|
||||
@MockBean
|
||||
private ReviewService reviewService;
|
||||
|
||||
|
|
@ -99,6 +102,26 @@ class ReviewPortalControllerTest {
|
|||
@MockBean
|
||||
private ReviewSkillDetailAppService reviewSkillDetailAppService;
|
||||
|
||||
@Test
|
||||
void reviewScopesIncludesPlatformAuthorizedNamespacesWithoutMembership() throws Exception {
|
||||
Namespace namespace = createNamespace(20L, "outside-membership");
|
||||
stubNamespaceRoles("platform", List.of());
|
||||
given(rbacService.getUserRoleCodes("platform")).willReturn(Set.of("SKILL_ADMIN"));
|
||||
given(namespaceRepository.findAll()).willReturn(List.of(namespace));
|
||||
mockMvc.perform(get("/api/v1/reviews/scopes").with(auth("platform")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data[0].id").value(20))
|
||||
.andExpect(jsonPath("$.data[0].slug").value("outside-membership"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewScopesDoesNotGrantSkillPermissionToUserAdmin() throws Exception {
|
||||
stubNamespaceRoles("profile-admin", List.of());
|
||||
given(rbacService.getUserRoleCodes("profile-admin")).willReturn(Set.of("USER_ADMIN"));
|
||||
mockMvc.perform(get("/api/web/reviews/scopes").with(auth("profile-admin")))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitReview_passesNamespaceRolesToService() throws Exception {
|
||||
ReviewTask task = createReviewTask(1L, 20L, "user-1");
|
||||
|
|
@ -239,12 +262,14 @@ class ReviewPortalControllerTest {
|
|||
1,
|
||||
5,
|
||||
Sort.by(
|
||||
new Sort.Order(Sort.Direction.ASC, "reviewedAt"),
|
||||
new Sort.Order(Sort.Direction.ASC, "submittedAt"),
|
||||
new Sort.Order(Sort.Direction.ASC, "id")
|
||||
)
|
||||
);
|
||||
given(reviewTaskRepository.findByStatus(ReviewTaskStatus.APPROVED, pageable))
|
||||
.willReturn(new PageImpl<>(List.of(), pageable, 0));
|
||||
given(reviewQueueQueryRepository.list(
|
||||
new com.iflytek.skillhub.repository.ReviewQueueQueryRepository.Filter("APPROVED", null, "", null, null),
|
||||
true, Set.of(), pageable)).willReturn(new PageImpl<>(List.of(), pageable, 0));
|
||||
given(governanceQueryRepository.getReviewTaskResponses(List.of())).willReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/v1/reviews")
|
||||
.param("status", "APPROVED")
|
||||
|
|
@ -255,7 +280,9 @@ class ReviewPortalControllerTest {
|
|||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
verify(reviewTaskRepository).findByStatus(ReviewTaskStatus.APPROVED, pageable);
|
||||
verify(reviewQueueQueryRepository).list(
|
||||
new com.iflytek.skillhub.repository.ReviewQueueQueryRepository.Filter("APPROVED", null, "", null, null),
|
||||
true, Set.of(), pageable);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -563,13 +590,16 @@ class ReviewPortalControllerTest {
|
|||
0,
|
||||
20,
|
||||
Sort.by(
|
||||
new Sort.Order(Sort.Direction.DESC, status == ReviewTaskStatus.PENDING ? "submittedAt" : "reviewedAt"),
|
||||
new Sort.Order(Sort.Direction.DESC, "submittedAt"),
|
||||
new Sort.Order(Sort.Direction.DESC, "id")
|
||||
)
|
||||
);
|
||||
|
||||
given(reviewTaskRepository.findByStatus(status, pageable))
|
||||
.willReturn(new PageImpl<>(tasks, pageable, 42));
|
||||
given(reviewQueueQueryRepository.list(
|
||||
new com.iflytek.skillhub.repository.ReviewQueueQueryRepository.Filter(status.name(), null, "", null, null),
|
||||
true, Set.of(), pageable)).willReturn(new PageImpl<>(tasks.stream().map(task ->
|
||||
new com.iflytek.skillhub.repository.ReviewQueueQueryRepository.Entry(task, "Skill", "Summary", "FIRST_PUBLISH"))
|
||||
.toList(), pageable, 42));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN"));
|
||||
tasks.forEach(task -> given(reviewService.canViewReview(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuite;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(JpaReviewQueueQueryRepository.class)
|
||||
@Testcontainers
|
||||
class JpaReviewQueueQueryRepositoryTest {
|
||||
@Container
|
||||
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void postgres(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
|
||||
registry.add("spring.flyway.enabled", () -> true);
|
||||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
|
||||
}
|
||||
|
||||
@Autowired TestEntityManager entities;
|
||||
@Autowired JpaReviewQueueQueryRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void users() {
|
||||
entities.persist(new UserAccount("queue-owner", "Owner", null, null));
|
||||
entities.persist(new UserAccount("queue-author", "Author Name", null, null));
|
||||
entities.flush();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorizationAndTotalAreAppliedBeforePaginationAcrossManagedTeams() {
|
||||
Namespace first = namespace("queue-first", NamespaceType.TEAM);
|
||||
Namespace second = namespace("queue-second", NamespaceType.TEAM);
|
||||
Namespace foreign = namespace("queue-foreign", NamespaceType.TEAM);
|
||||
Namespace catalog = namespace("queue-catalog", NamespaceType.CATALOG);
|
||||
ReviewTask older = task(first, "older", "1.0.0", "2026-01-01T00:00:00Z");
|
||||
ReviewTask newer = task(second, "newer", "1.0.0", "2026-01-02T00:00:00Z");
|
||||
task(foreign, "not-visible", "1.0.0", "2026-01-04T00:00:00Z");
|
||||
task(catalog, "not-a-team", "1.0.0", "2026-01-03T00:00:00Z");
|
||||
var filter = filter("PENDING", null, "", null, null);
|
||||
Set<Long> managed = Set.of(first.getId(), second.getId(), catalog.getId());
|
||||
var page = repository.list(filter, false, managed, page(0, 1));
|
||||
assertThat(page.getTotalElements()).isEqualTo(2);
|
||||
assertThat(page.getContent()).extracting(entry -> entry.task().getId()).containsExactly(newer.getId());
|
||||
assertThat(repository.list(filter, false, managed, page(1, 1)).getContent())
|
||||
.extracting(entry -> entry.task().getId()).containsExactly(older.getId());
|
||||
assertThat(repository.list(filter, false, managed, page(4, 1)).getTotalElements()).isEqualTo(2);
|
||||
assertThat(repository.list(filter, false, Set.of(), page(0, 20))).isEmpty();
|
||||
assertThat(repository.list(filter, true, Set.of(), page(0, 20)).getTotalElements()).isEqualTo(4);
|
||||
assertThat(repository.list(filter("PENDING", foreign.getId(), "", null, null), false, managed, page(0, 20)))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void keywordUsesVersionPresentationCoordinatesAndAuthorAndEscapesWildcards() {
|
||||
Namespace team = namespace("queue-search", NamespaceType.TEAM);
|
||||
ReviewTask match = task(team, "percent-skill", "1.0.0", "2026-01-01T00:00:00Z");
|
||||
entities.getEntityManager().createNativeQuery("""
|
||||
UPDATE skill_version SET parsed_metadata_json = CAST(:metadata AS jsonb) WHERE id = :id
|
||||
""").setParameter("metadata", """
|
||||
{"name":"package-name","description":"package summary","displayNameOverride":"Review 100%_ready",
|
||||
"shortDescriptionOverride":"effective summary"}
|
||||
""").setParameter("id", match.getSkillVersionId()).executeUpdate();
|
||||
task(team, "other-skill", "1.0.0", "2026-01-02T00:00:00Z");
|
||||
var literal = repository.list(filter(null, null, "%_", null, "USER"), true, Set.of(), page(0, 20));
|
||||
assertThat(literal.getContent()).singleElement().satisfies(entry -> {
|
||||
assertThat(entry.task().getId()).isEqualTo(match.getId());
|
||||
assertThat(entry.displayName()).isEqualTo("Review 100%_ready");
|
||||
assertThat(entry.summary()).isEqualTo("effective summary");
|
||||
});
|
||||
assertThat(repository.list(filter(null, null, "@queue-search/percent-skill", null, null), true, Set.of(), page(0, 20)))
|
||||
.hasSize(1);
|
||||
assertThat(repository.list(filter(null, null, "author name", null, null), true, Set.of(), page(0, 20)))
|
||||
.hasSize(2);
|
||||
assertThat(repository.list(filter(null, null, "queue-author", null, null), true, Set.of(), page(0, 20)))
|
||||
.hasSize(2);
|
||||
assertThat(repository.list(filter(null, null, "effective summary", null, null), true, Set.of(), page(0, 20)))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesVersionMetadataAndSuiteRequestsAndFiltersStatusWithStableSort() {
|
||||
Namespace team = namespace("queue-kinds", NamespaceType.TEAM);
|
||||
ReviewTask first = task(team, "first", "1.0.0", "2026-01-02T00:00:00Z");
|
||||
Skill skill = entities.find(Skill.class, first.getSubjectId());
|
||||
SkillVersion published = new SkillVersion(skill.getId(), "0.9.0", "queue-owner");
|
||||
published.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
published.setPublishedAt(Instant.parse("2026-01-01T00:00:00Z"));
|
||||
entities.persistFlushFind(published);
|
||||
SkillSuite suite = entities.persistFlushFind(new SkillSuite(team.getId(), "suite", "Suite", "queue-owner"));
|
||||
entities.persistFlushFind(ReviewTask.forSuiteVersion(null, suite.getId(), team.getId(), "1.0.0", "queue-author"));
|
||||
entities.persistFlushFind(ReviewTask.forSkillMetadata(skill.getId(), team.getId(), "queue-author", null));
|
||||
entities.persistFlushFind(ReviewTask.forVisibility(skill, SkillVisibility.PUBLIC, "queue-author"));
|
||||
ReviewTask rejected = task(team, "rejected", "1.0.0", "2026-01-03T00:00:00Z");
|
||||
rejected.setStatus(ReviewTaskStatus.REJECTED); entities.flush();
|
||||
assertThat(repository.list(filter(null, null, "", "VERSION_UPDATE", null), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.task().getId()).isEqualTo(first.getId()));
|
||||
for (String kind : new String[]{"SUITE_PUBLISH", "AVATAR_CHANGE", "VISIBILITY_CHANGE"}) {
|
||||
assertThat(repository.list(filter(null, null, "", kind, null), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.reviewKind()).isEqualTo(kind));
|
||||
}
|
||||
assertThat(repository.list(filter("REJECTED", null, "", null, null), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.task().getId()).isEqualTo(rejected.getId()));
|
||||
assertThat(repository.list(filter(null, null, "", null, null), true, Set.of(), page(0, 20)).getTotalElements())
|
||||
.isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void githubAndUserOriginsShareQueueButRemainSeparatelyFilterable() {
|
||||
Namespace team = namespace("queue-origins", NamespaceType.TEAM);
|
||||
ReviewTask user = task(team, "user", "1.0.0", "2026-01-01T00:00:00Z");
|
||||
ReviewTask sync = task(team, "sync", "1.0.0", "2026-01-02T00:00:00Z");
|
||||
java.util.UUID run = java.util.UUID.randomUUID();
|
||||
java.util.UUID task = java.util.UUID.randomUUID();
|
||||
java.util.UUID admission = java.util.UUID.randomUUID();
|
||||
java.util.UUID upload = java.util.UUID.randomUUID();
|
||||
java.util.UUID ingestion = java.util.UUID.randomUUID();
|
||||
execute("INSERT INTO github_sync_run (id, namespace_id, kind) VALUES (?1, ?2, 'REPOSITORY_SYNC')", run, team.getId());
|
||||
execute("""
|
||||
INSERT INTO github_sync_task (id, run_id, namespace_id, kind, mode, dedupe_key, snapshot_json)
|
||||
VALUES (?1, ?2, ?3, 'REPOSITORY_SYNC', 'FULL', 'queue-test', CAST('{}' AS jsonb))
|
||||
""", task, run, team.getId());
|
||||
execute("""
|
||||
INSERT INTO github_sync_package_admission
|
||||
(id, task_id, namespace_id, repository_id, commit_sha, skill_path, lease_generation)
|
||||
VALUES (?1, ?2, ?3, 100, ?4, 'skill', 1)
|
||||
""", admission, task, team.getId(), "a".repeat(40));
|
||||
execute("""
|
||||
INSERT INTO github_sync_staged_upload
|
||||
(id, admission_id, object_key, mode, ticket_hash, size_bytes, sha256, status, expires_at)
|
||||
VALUES (?1, ?2, 'queue-test', 'LOCAL_STREAM', ?3, 1, ?3, 'ACCEPTED', CURRENT_TIMESTAMP + INTERVAL '1 day')
|
||||
""", upload, admission, "b".repeat(64));
|
||||
execute("""
|
||||
INSERT INTO github_sync_package_ingestion
|
||||
(id, task_id, admission_id, upload_id, namespace_id, repository_id, commit_sha,
|
||||
skill_path, object_key, size_bytes, sha256, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 100, ?6, 'skill', 'queue-test', 1, ?7, 'VALIDATED')
|
||||
""", ingestion, task, admission, upload, team.getId(), "a".repeat(40), "b".repeat(64));
|
||||
execute("UPDATE review_task SET origin_type = 'GITHUB_SYNC', github_sync_ingestion_id = ?1 WHERE id = ?2",
|
||||
ingestion, sync.getId());
|
||||
entities.clear();
|
||||
assertThat(repository.list(filter("PENDING", null, "", null, null), false, Set.of(team.getId()), page(0, 1)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.task().getId()).isEqualTo(sync.getId()));
|
||||
assertThat(repository.list(filter("PENDING", null, "", null, null), false, Set.of(team.getId()), page(0, 1))
|
||||
.getTotalElements()).isEqualTo(2);
|
||||
assertThat(repository.list(filter(null, null, "", null, "USER"), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.task().getId()).isEqualTo(user.getId()));
|
||||
assertThat(repository.list(filter(null, null, "", null, "GITHUB_SYNC"), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> assertThat(entry.task().getId()).isEqualTo(sync.getId()));
|
||||
}
|
||||
|
||||
private void execute(String sql, Object... parameters) {
|
||||
var query = entities.getEntityManager().createNativeQuery(sql);
|
||||
for (int index = 0; index < parameters.length; index++) query.setParameter(index + 1, parameters[index]);
|
||||
query.executeUpdate();
|
||||
}
|
||||
|
||||
@Test
|
||||
void suiteSearchAndPresentationUseTheTaskBoundVersionInsteadOfPublishedContainer() {
|
||||
Namespace team = namespace("queue-suite-snapshot", NamespaceType.TEAM);
|
||||
SkillSuite suite = entities.persistFlushFind(new SkillSuite(team.getId(), "suite", "Published A", "queue-owner"));
|
||||
var pending = entities.persistFlushFind(new com.iflytek.skillhub.domain.suite.SkillSuiteVersion(
|
||||
suite.getId(), "2.0.0", "Pending B", "Pending summary", SkillVisibility.PUBLIC, "queue-author"));
|
||||
ReviewTask task = entities.persistFlushFind(ReviewTask.forSuiteVersion(
|
||||
pending.getId(), suite.getId(), team.getId(), "2.0.0", "queue-author"));
|
||||
assertThat(repository.list(filter(null, null, "Pending B", null, null), true, Set.of(), page(0, 20)))
|
||||
.singleElement().satisfies(entry -> {
|
||||
assertThat(entry.task().getId()).isEqualTo(task.getId());
|
||||
assertThat(entry.displayName()).isEqualTo("Pending B");
|
||||
assertThat(entry.summary()).isEqualTo("Pending summary");
|
||||
});
|
||||
assertThat(repository.list(filter(null, null, "Published A", null, null), true, Set.of(), page(0, 20))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void packageTextProjectionMatchesSkillMetadataForTypesWhitespaceAndUtf16Limits() throws Exception {
|
||||
Namespace team = namespace("queue-metadata-parity", NamespaceType.TEAM);
|
||||
Object[][] cases = {
|
||||
{123, 456}, {" ", " \t "}, {"\u3000 Trimmed \u2000", "\u2000 Summary \u3000"},
|
||||
{"x".repeat(200), "summary"}, {"x".repeat(201), "summary"},
|
||||
{"😀".repeat(100), "summary"}, {"😀".repeat(101), "summary"}
|
||||
};
|
||||
java.util.Map<Long, com.iflytek.skillhub.domain.skill.metadata.SkillMetadata> expected = new java.util.HashMap<>();
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
for (int index = 0; index < cases.length; index++) {
|
||||
ReviewTask task = task(team, "parity-" + index, "1.0.0", "2026-01-01T00:00:00Z");
|
||||
var metadata = new com.iflytek.skillhub.domain.skill.metadata.SkillMetadata(
|
||||
"package-" + index, "description-" + index, "1.0.0", "body", java.util.Map.of(
|
||||
"metadata", java.util.Map.of("display-name", cases[index][0], "short-description", cases[index][1])));
|
||||
execute("UPDATE skill_version SET parsed_metadata_json = CAST(?1 AS jsonb) WHERE id = ?2",
|
||||
mapper.writeValueAsString(metadata), task.getSkillVersionId());
|
||||
expected.put(task.getId(), metadata);
|
||||
}
|
||||
var entries = repository.list(filter(null, team.getId(), "", null, null), true, Set.of(), page(0, 20));
|
||||
assertThat(entries).hasSize(cases.length);
|
||||
for (var entry : entries) {
|
||||
assertThat(entry.displayName()).isEqualTo(expected.get(entry.task().getId()).displayName());
|
||||
assertThat(entry.summary()).isEqualTo(expected.get(entry.task().getId()).shortDescription());
|
||||
}
|
||||
assertThat(repository.list(filter(null, null, "Trimmed", null, null), true, Set.of(), page(0, 20))).hasSize(1);
|
||||
}
|
||||
|
||||
private Namespace namespace(String slug, NamespaceType type) {
|
||||
Namespace namespace = new Namespace(slug, slug, "queue-owner"); namespace.setType(type);
|
||||
return entities.persistFlushFind(namespace);
|
||||
}
|
||||
|
||||
private ReviewTask task(Namespace namespace, String slug, String number, String submittedAt) {
|
||||
Skill skill = entities.persistFlushFind(new Skill(namespace.getId(), slug, "queue-owner", SkillVisibility.PRIVATE));
|
||||
SkillVersion version = entities.persistFlushFind(new SkillVersion(skill.getId(), number, "queue-owner"));
|
||||
ReviewTask task = new ReviewTask(version.getId(), skill.getId(), namespace.getId(), number, "queue-author");
|
||||
ReflectionTestUtils.setField(task, "submittedAt", Instant.parse(submittedAt));
|
||||
return entities.persistFlushFind(task);
|
||||
}
|
||||
|
||||
private ReviewQueueQueryRepository.Filter filter(String status, Long namespace, String query, String kind, String origin) {
|
||||
return new ReviewQueueQueryRepository.Filter(status, namespace, query, kind, origin);
|
||||
}
|
||||
|
||||
private PageRequest page(int page, int size) {
|
||||
return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "submittedAt").and(Sort.by(Sort.Direction.DESC, "id")));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
|
@ -31,6 +32,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
@ExtendWith(MockitoExtension.class)
|
||||
class ReviewPortalAppServiceTest {
|
||||
|
||||
@Mock private com.iflytek.skillhub.repository.ReviewQueueQueryRepository reviewQueueQueryRepository;
|
||||
@Mock private com.iflytek.skillhub.domain.review.ReviewPermissionChecker reviewPermissionChecker;
|
||||
@Mock private ReviewService reviewService;
|
||||
@Mock private ReviewTaskRepository reviewTaskRepository;
|
||||
@Mock private NamespaceRepository namespaceRepository;
|
||||
|
|
@ -54,7 +57,9 @@ class ReviewPortalAppServiceTest {
|
|||
rbacService,
|
||||
auditLogService,
|
||||
requestIdAccessor,
|
||||
suiteLifecycleService);
|
||||
suiteLifecycleService,
|
||||
reviewQueueQueryRepository,
|
||||
reviewPermissionChecker);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -106,6 +111,76 @@ class ReviewPortalAppServiceTest {
|
|||
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void userAdminDoesNotReceiveSkillReviewQueuePermission() {
|
||||
when(rbacService.getUserRoleCodes("user-admin")).thenReturn(Set.of("USER_ADMIN"));
|
||||
assertThatThrownBy(() -> service.listReviewQueue("PENDING", null, "", null, null,
|
||||
0, 20, "DESC", "user-admin", Map.of()))
|
||||
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
|
||||
verify(reviewQueueQueryRepository, never()).list(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anySet(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamQueuePassesOnlyOwnerAndAdminScopesToDatabaseFiltering() {
|
||||
when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of("USER_ADMIN"));
|
||||
when(reviewQueueQueryRepository.list(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.eq(false), org.mockito.ArgumentMatchers.eq(Set.of(7L, 9L)),
|
||||
org.mockito.ArgumentMatchers.any())).thenReturn(org.springframework.data.domain.Page.empty());
|
||||
when(governanceQueryRepository.getReviewTaskResponses(java.util.List.of())).thenReturn(java.util.List.of());
|
||||
service.listReviewQueue("ALL", null, "name", "ALL", "ALL", 0, 20, "ASC", "team-admin",
|
||||
Map.of(7L, NamespaceRole.OWNER, 9L, NamespaceRole.ADMIN, 11L, NamespaceRole.MEMBER));
|
||||
verify(reviewQueueQueryRepository).list(
|
||||
new com.iflytek.skillhub.repository.ReviewQueueQueryRepository.Filter(null, null, "name", null, null),
|
||||
false, Set.of(7L, 9L), org.springframework.data.domain.PageRequest.of(0, 20,
|
||||
org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.ASC, "submittedAt")
|
||||
.and(org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.ASC, "id"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void readableOwnTaskUsesDomainPermissionForApprovalControls() {
|
||||
ReviewTask task = suiteTask(91L);
|
||||
var namespace = new com.iflytek.skillhub.domain.namespace.Namespace("team", "Team", "owner");
|
||||
when(reviewTaskRepository.findById(91L)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(7L)).thenReturn(Optional.of(namespace));
|
||||
when(rbacService.getUserRoleCodes("author")).thenReturn(Set.of("SKILL_ADMIN"));
|
||||
when(reviewService.canViewReview(task, "author", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN")))
|
||||
.thenReturn(true);
|
||||
when(governanceQueryRepository.getReviewTaskResponse(task)).thenReturn(response(91L));
|
||||
when(reviewPermissionChecker.canReview(task, "author", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN")))
|
||||
.thenReturn(false);
|
||||
assertThat(service.getReviewDetail(91L, "author", Map.of()).canReview()).isFalse();
|
||||
when(reviewPermissionChecker.canReview(task, "author", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN")))
|
||||
.thenReturn(true);
|
||||
assertThat(service.getReviewDetail(91L, "author", Map.of()).canReview()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformReviewScopesIncludeNamespacesWithoutMembershipAndAreSorted() {
|
||||
when(rbacService.getUserRoleCodes("platform")).thenReturn(Set.of("SKILL_ADMIN"));
|
||||
var team = new com.iflytek.skillhub.domain.namespace.Namespace("z-team", "Team", "owner");
|
||||
var global = new com.iflytek.skillhub.domain.namespace.Namespace("global", "Global", "owner");
|
||||
global.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL);
|
||||
when(namespaceRepository.findAll()).thenReturn(java.util.List.of(team, global));
|
||||
assertThat(service.listReviewScopes("platform", Map.of())).extracting("slug")
|
||||
.containsExactly("global", "z-team");
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamReviewScopesExcludeNonTeamsAndUserAdminDoesNotGainAccess() {
|
||||
when(rbacService.getUserRoleCodes("team")).thenReturn(Set.of("USER_ADMIN"));
|
||||
var team = new com.iflytek.skillhub.domain.namespace.Namespace("team", "Team", "owner");
|
||||
team.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.TEAM);
|
||||
var catalog = new com.iflytek.skillhub.domain.namespace.Namespace("catalog", "Catalog", "owner");
|
||||
catalog.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.CATALOG);
|
||||
when(namespaceRepository.findByIdIn(org.mockito.ArgumentMatchers.anyList())).thenReturn(java.util.List.of(team, catalog));
|
||||
assertThat(service.listReviewScopes("team", Map.of(7L, NamespaceRole.OWNER, 9L, NamespaceRole.ADMIN)))
|
||||
.extracting("slug").containsExactly("team");
|
||||
assertThatThrownBy(() -> service.listReviewScopes("team", Map.of()))
|
||||
.isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
private ReviewTask suiteTask(Long id) {
|
||||
ReviewTask task = ReviewTask.forSuiteVersion(31L, 21L, 7L, "1.0.0", "author");
|
||||
setField(task, "id", id);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,76 @@ class ReviewSkillDetailAppServiceTest {
|
|||
assertThat(service.getReviewSkillDetail(42L, "admin", Map.of()).avatarChange().readyForApproval()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewPresentationUsesPendingOverridesAndComparesOnlyPublishedBaseline() {
|
||||
ReviewTask task = createReviewTask(42L, 101L, 20L, "submitter");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
Skill skill = createSkill(101L, 20L, "skill-a");
|
||||
SkillVersion pending = createVersion(101L, 101L, "2.0.0", SkillVersionStatus.PENDING_REVIEW);
|
||||
pending.setParsedMetadataJson("""
|
||||
{"name":"package","description":"原包描述","shortDescriptionOverride":"待审自定义简介",
|
||||
"overviewOverride":"# 待审自定义概述","frontmatter":{"metadata":{"overview":"包内概述"}}}
|
||||
""");
|
||||
SkillVersion published = createVersion(100L, 101L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
published.setParsedMetadataJson("{\"name\":\"old\",\"description\":\"旧简介\"}");
|
||||
published.setPublishedAt(Instant.parse("2020-01-01T00:00:00Z"));
|
||||
SkillVersion future = createVersion(102L, 101L, "3.0.0", SkillVersionStatus.PUBLISHED);
|
||||
setField(future, "publishedAt", Instant.parse("2099-01-01T00:00:00Z"));
|
||||
SkillFile same = new SkillFile(101L, "same.md", 10L, "text/markdown", "same", "same");
|
||||
SkillFile changed = new SkillFile(101L, "changed.md", 12L, "text/markdown", "new", "new");
|
||||
given(reviewTaskRepository.findById(42L)).willReturn(Optional.of(task));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN"));
|
||||
given(reviewService.canViewReview(task, "admin", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN"))).willReturn(true);
|
||||
given(reviewService.canReviewNamespace(task, "admin", namespace.getType(), Map.of(), Set.of("SKILL_ADMIN"))).willReturn(true);
|
||||
given(skillQueryService.getReviewSkillSnapshot(101L, false)).willReturn(
|
||||
new SkillQueryService.ReviewSkillSnapshotDTO(skill, "Owner", pending, future,
|
||||
List.of(future, pending, published), List.of(same, changed,
|
||||
new SkillFile(101L, "added.md", 2L, "text/plain", "added", "added")), "README.md", null));
|
||||
given(skillQueryService.getReviewSkillSnapshot(100L, false)).willReturn(
|
||||
new SkillQueryService.ReviewSkillSnapshotDTO(skill, "Owner", published, published,
|
||||
List.of(published), List.of(new SkillFile(100L, "same.md", 10L, "text/markdown", "same", "old-same"),
|
||||
new SkillFile(100L, "changed.md", 11L, "text/markdown", "old", "old"),
|
||||
new SkillFile(100L, "removed.md", 2L, "text/plain", "removed", "removed")), "README.md", null));
|
||||
|
||||
ReviewSkillDetailResponse response = service.getReviewSkillDetail(42L, "admin", Map.of(), false);
|
||||
|
||||
assertThat(response.skill().summary()).isEqualTo("待审自定义简介");
|
||||
assertThat(response.presentation().summary()).isEqualTo("待审自定义简介");
|
||||
assertThat(response.presentation().overview()).isEqualTo("# 待审自定义概述");
|
||||
assertThat(response.presentation().overviewSource()).isEqualTo("WEB");
|
||||
assertThat(response.documentationContent()).isNull();
|
||||
assertThat(response.comparison().baseVersion()).isEqualTo("1.0.0");
|
||||
assertThat(response.comparison().baseline().summary()).isEqualTo("旧简介");
|
||||
assertThat(response.comparison().files()).extracting("path", "changeType").containsExactly(
|
||||
org.assertj.core.groups.Tuple.tuple("added.md", "ADDED"),
|
||||
org.assertj.core.groups.Tuple.tuple("changed.md", "MODIFIED"),
|
||||
org.assertj.core.groups.Tuple.tuple("removed.md", "REMOVED"),
|
||||
org.assertj.core.groups.Tuple.tuple("same.md", "UNCHANGED"));
|
||||
org.mockito.Mockito.verify(skillQueryService, org.mockito.Mockito.never()).getReviewSkillSnapshot(102L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void readableSubmitterDoesNotGainPrivatePublishedHistory() {
|
||||
ReviewTask task = createReviewTask(42L, 101L, 20L, "submitter");
|
||||
Namespace namespace = createNamespace(20L, "team-a");
|
||||
Skill skill = createSkill(101L, 20L, "skill-a");
|
||||
SkillVersion pending = createVersion(101L, 101L, "2.0.0", SkillVersionStatus.PENDING_REVIEW);
|
||||
pending.setParsedMetadataJson("{\"name\":\"test\",\"description\":\"summary\"}");
|
||||
SkillVersion published = createVersion(100L, 101L, "1.0.0", SkillVersionStatus.PUBLISHED);
|
||||
given(reviewTaskRepository.findById(42L)).willReturn(Optional.of(task));
|
||||
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
|
||||
given(rbacService.getUserRoleCodes("submitter")).willReturn(Set.of());
|
||||
given(reviewService.canViewReview(task, "submitter", namespace.getType(), Map.of(), Set.of())).willReturn(true);
|
||||
given(skillQueryService.getReviewSkillSnapshot(101L, false)).willReturn(
|
||||
new SkillQueryService.ReviewSkillSnapshotDTO(skill, "Owner", pending, published,
|
||||
List.of(pending, published), List.of(), "SKILL.md", null));
|
||||
ReviewSkillDetailResponse response = service.getReviewSkillDetail(42L, "submitter", Map.of(), false);
|
||||
assertThat(response.presentation().overviewSource()).isEqualTo("SKILL_MD");
|
||||
assertThat(response.comparison()).isNull();
|
||||
org.mockito.Mockito.verify(skillQueryService, org.mockito.Mockito.never()).getReviewSkillSnapshot(100L, false);
|
||||
}
|
||||
|
||||
private ReviewTask createReviewTask(Long reviewId, Long versionId, Long namespaceId, String submittedBy) {
|
||||
ReviewTask task = new ReviewTask(versionId, namespaceId, submittedBy);
|
||||
setField(task, "id", reviewId);
|
||||
|
|
|
|||
|
|
@ -665,7 +665,7 @@ public class SkillQueryService {
|
|||
.orElse(null);
|
||||
String documentationPath = documentationFile == null ? null : documentationFile.getFilePath();
|
||||
String documentationContent = !includeDocumentation || documentationFile == null
|
||||
? null : readTextContent(documentationFile);
|
||||
? null : readReviewDocumentation(documentationFile);
|
||||
SkillVersion publishedVersion = versions.stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
|
||||
.findFirst()
|
||||
|
|
@ -907,6 +907,16 @@ public class SkillQueryService {
|
|||
}
|
||||
}
|
||||
|
||||
private String readReviewDocumentation(SkillFile file) {
|
||||
if (file.getFileSize() != null && file.getFileSize() > MAX_REVIEW_PREVIEW_BYTES) return null;
|
||||
try (InputStream content = readFileContent(file)) {
|
||||
byte[] bytes = content.readNBytes(MAX_REVIEW_PREVIEW_BYTES + 1);
|
||||
return bytes.length > MAX_REVIEW_PREVIEW_BYTES ? null : new String(bytes, StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to read review documentation", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String readTextContent(SkillFile file) {
|
||||
try (InputStream inputStream = readFileContent(file)) {
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
|
|
|
|||
|
|
@ -561,6 +561,29 @@ class SkillQueryServiceTest {
|
|||
verify(objectStorageService, never()).exists("other-key");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewDocumentationPreviewRejectsOversizedStreamEvenWhenRecordedSizeIsSmall() throws Exception {
|
||||
Skill skill = new Skill(1L, "sample", "owner", SkillVisibility.PUBLIC);
|
||||
setId(skill, 1L);
|
||||
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
|
||||
setId(version, 2L);
|
||||
SkillFile documentation = new SkillFile(2L, "README.md", 1L, "text/markdown", "hash", "readme-key");
|
||||
when(skillVersionRepository.findById(2L)).thenReturn(Optional.of(version));
|
||||
when(skillRepository.findById(1L)).thenReturn(Optional.of(skill));
|
||||
when(userAccountRepository.findById("owner")).thenReturn(Optional.empty());
|
||||
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(version));
|
||||
when(skillFileRepository.findByVersionId(2L)).thenReturn(List.of(documentation));
|
||||
when(objectStorageService.exists("readme-key")).thenReturn(true);
|
||||
when(objectStorageService.getObject("readme-key"))
|
||||
.thenReturn(new ByteArrayInputStream(new byte[256 * 1024 + 1]));
|
||||
|
||||
var snapshot = service.getReviewSkillSnapshot(2L, true);
|
||||
|
||||
assertEquals("README.md", snapshot.documentationPath());
|
||||
assertNull(snapshot.documentationContent());
|
||||
assertEquals(1, snapshot.files().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testListFiles_ShouldHideEntriesWhoseStorageObjectIsMissing() throws Exception {
|
||||
String namespaceSlug = "test-ns";
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,9 @@ export const suiteApi = {
|
|||
}
|
||||
|
||||
export const reviewApi = {
|
||||
async scopes(): Promise<import('./types').ReviewScope[]> {
|
||||
return fetchJson<import('./types').ReviewScope[]>(`${WEB_API_PREFIX}/reviews/scopes`)
|
||||
},
|
||||
async listSync(params: { status: string; namespaceId?: number; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
|
||||
const result = await client.GET('/api/v1/reviews/sync', {
|
||||
params: { query: { status: params.status, namespaceId: params.namespaceId,
|
||||
|
|
@ -1587,9 +1590,12 @@ export const reviewApi = {
|
|||
async getSyncSource(id: number): Promise<GithubSyncReviewSource> {
|
||||
return fetchJson<GithubSyncReviewSource>(`${WEB_API_PREFIX}/reviews/sync/${id}/source`)
|
||||
},
|
||||
async list(params: { status: string; namespaceId?: number; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
|
||||
async list(params: { status: string; namespaceId?: number; q?: string; reviewKind?: string; originType?: string; page?: number; size?: number; sortDirection?: 'ASC' | 'DESC' }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('status', params.status)
|
||||
if (params.q?.trim()) searchParams.set('q', params.q.trim())
|
||||
if (params.reviewKind && params.reviewKind !== 'ALL') searchParams.set('reviewKind', params.reviewKind)
|
||||
if (params.originType && params.originType !== 'ALL') searchParams.set('originType', params.originType)
|
||||
if (params.namespaceId !== undefined) {
|
||||
searchParams.set('namespaceId', String(params.namespaceId))
|
||||
}
|
||||
|
|
|
|||
125
web/src/api/generated/schema.d.ts
vendored
125
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4957,6 +4957,40 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/reviews/scopes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List namespace scopes the caller is authorized to review */
|
||||
get: operations["listScopes"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/scopes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List namespace scopes the caller is authorized to review */
|
||||
get: operations["listScopes_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/web/reviews/pending": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -8252,6 +8286,10 @@ export interface components {
|
|||
previousVisibility?: string;
|
||||
requestedVisibility?: string;
|
||||
originType?: string;
|
||||
displayName?: string;
|
||||
summary?: string;
|
||||
reviewKind?: string;
|
||||
canReview?: boolean;
|
||||
};
|
||||
ReviewTaskRequest: {
|
||||
/** Format: int64 */
|
||||
|
|
@ -9527,6 +9565,22 @@ export interface components {
|
|||
readyForApproval?: boolean;
|
||||
removal?: boolean;
|
||||
};
|
||||
ReviewComparison: {
|
||||
baseVersion?: string;
|
||||
baseline?: components["schemas"]["ReviewPresentation"];
|
||||
current?: components["schemas"]["ReviewPresentation"];
|
||||
files?: components["schemas"]["ReviewFileChange"][];
|
||||
};
|
||||
ReviewFileChange: {
|
||||
path?: string;
|
||||
changeType?: string;
|
||||
};
|
||||
ReviewPresentation: {
|
||||
displayName?: string;
|
||||
summary?: string;
|
||||
overview?: string;
|
||||
overviewSource?: string;
|
||||
};
|
||||
ReviewSkillDetailResponse: {
|
||||
skill?: components["schemas"]["SkillDetailResponse"];
|
||||
versions?: components["schemas"]["SkillVersionResponse"][];
|
||||
|
|
@ -9537,6 +9591,8 @@ export interface components {
|
|||
activeVersion?: string;
|
||||
avatarChange?: components["schemas"]["AvatarChange"];
|
||||
visibilityChange?: components["schemas"]["VisibilityChange"];
|
||||
presentation?: components["schemas"]["ReviewPresentation"];
|
||||
comparison?: components["schemas"]["ReviewComparison"];
|
||||
};
|
||||
VisibilityChange: {
|
||||
previousVisibility?: string;
|
||||
|
|
@ -9641,6 +9697,21 @@ export interface components {
|
|||
/** Format: int32 */
|
||||
size?: number;
|
||||
};
|
||||
ApiResponseListReviewScopeResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["ReviewScopeResponse"][];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
ReviewScopeResponse: {
|
||||
/** Format: int64 */
|
||||
id?: number;
|
||||
slug?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
ApiResponsePageResponseReviewTaskResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
|
|
@ -15832,9 +15903,12 @@ export interface operations {
|
|||
};
|
||||
listReviews: {
|
||||
parameters: {
|
||||
query: {
|
||||
status: string;
|
||||
query?: {
|
||||
status?: string;
|
||||
namespaceId?: number;
|
||||
q?: string;
|
||||
reviewKind?: string;
|
||||
originType?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortDirection?: string;
|
||||
|
|
@ -15882,9 +15956,12 @@ export interface operations {
|
|||
};
|
||||
listReviews_1: {
|
||||
parameters: {
|
||||
query: {
|
||||
status: string;
|
||||
query?: {
|
||||
status?: string;
|
||||
namespaceId?: number;
|
||||
q?: string;
|
||||
reviewKind?: string;
|
||||
originType?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortDirection?: string;
|
||||
|
|
@ -20864,6 +20941,46 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
listScopes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListReviewScopeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listScopes_1: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseListReviewScopeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listPendingReviews: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
|
|
|||
|
|
@ -620,6 +620,10 @@ export interface SkillDeleteResult {
|
|||
}
|
||||
|
||||
export interface ReviewTask {
|
||||
displayName?: string | null
|
||||
summary?: string | null
|
||||
reviewKind?: 'FIRST_PUBLISH' | 'VERSION_UPDATE' | 'SUITE_PUBLISH' | 'VISIBILITY_CHANGE' | 'AVATAR_CHANGE' | null
|
||||
canReview?: boolean
|
||||
id: number
|
||||
skillVersionId: number | null
|
||||
namespace: string
|
||||
|
|
@ -644,6 +648,8 @@ export interface ReviewTask {
|
|||
originType?: 'USER' | 'GITHUB_SYNC'
|
||||
}
|
||||
|
||||
export type ReviewScope = Required<components['schemas']['ReviewScopeResponse']>
|
||||
|
||||
export interface GithubSyncReviewSource {
|
||||
ingestionId: string
|
||||
runId: string
|
||||
|
|
@ -721,6 +727,8 @@ export interface ReviewProgressPage {
|
|||
}
|
||||
|
||||
export interface ReviewSkillDetail {
|
||||
presentation?: ReviewPresentation | null
|
||||
comparison?: ReviewComparison | null
|
||||
visibilityChange?: components['schemas']['VisibilityChange']
|
||||
skill: SkillDetail
|
||||
versions: SkillVersion[]
|
||||
|
|
@ -732,6 +740,16 @@ export interface ReviewSkillDetail {
|
|||
avatarChange?: ReviewAvatarChange | null
|
||||
}
|
||||
|
||||
export type ReviewPresentation = {
|
||||
[K in keyof Required<components['schemas']['ReviewPresentation']>]: Required<components['schemas']['ReviewPresentation']>[K] | null
|
||||
}
|
||||
|
||||
export type ReviewComparison = Omit<Required<components['schemas']['ReviewComparison']>, 'baseline' | 'current' | 'files'> & {
|
||||
baseline: ReviewPresentation
|
||||
current: ReviewPresentation
|
||||
files: Required<components['schemas']['ReviewFileChange']>[]
|
||||
}
|
||||
|
||||
export interface ReviewAvatarChange {
|
||||
candidateId: number | null
|
||||
currentAvatarUrl: string | null
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ let pathname = '/dashboard'
|
|||
let resolvedPathname = '/dashboard'
|
||||
let roles: string[] = []
|
||||
let canChangePassword = false
|
||||
let namespaceRole: 'OWNER' | 'ADMIN' | 'MEMBER' | undefined
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Outlet: () => <div data-testid="outlet">page-content</div>,
|
||||
|
|
@ -17,6 +18,7 @@ vi.mock('react-i18next', async () => ({
|
|||
...await vi.importActual<typeof import('react-i18next')>('react-i18next'),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en', resolvedLanguage: 'en' } }),
|
||||
}))
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({ useMyNamespaces: () => ({ data: namespaceRole ? [{ type: 'TEAM', currentUserRole: namespaceRole, status: 'ACTIVE', slug: 'design' }] : [] }) }))
|
||||
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ user: { userId: 'test-reader', displayName: 'Reader', platformRoles: roles, canChangePassword }, isLoading: false }) }))
|
||||
vi.mock('@/shared/components/language-switcher', () => ({ LanguageSwitcher: () => null }))
|
||||
vi.mock('@/shared/components/theme-toggle', () => ({ ThemeToggle: () => null }))
|
||||
|
|
@ -26,7 +28,7 @@ vi.mock('@/features/notification/notification-bell', () => ({ NotificationBell:
|
|||
import { APP_FRAME_CLASS_NAME } from '@/shared/lib/layout-frame'
|
||||
import { Layout } from './layout'
|
||||
|
||||
beforeEach(() => { pathname = '/dashboard'; resolvedPathname = pathname; roles = []; canChangePassword = false })
|
||||
beforeEach(() => { pathname = '/dashboard'; resolvedPathname = pathname; roles = []; canChangePassword = false; namespaceRole = undefined })
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('shared console layout composition', () => {
|
||||
|
|
@ -111,6 +113,18 @@ describe('shared console layout composition', () => {
|
|||
expect(within(sidebar).getByText('sidebar.githubSync')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the unified review entry only for authorized namespace managers', () => {
|
||||
const view = render(<Layout />)
|
||||
const sidebar = screen.getByRole('complementary')
|
||||
expect(within(sidebar).queryByRole('link', { name: 'reviewCenter.title' })).toBeNull()
|
||||
namespaceRole = 'OWNER'
|
||||
view.rerender(<Layout />)
|
||||
expect(within(sidebar).getByRole('link', { name: 'reviewCenter.title' }).getAttribute('href')).toBe('/dashboard/reviews')
|
||||
namespaceRole = 'MEMBER'
|
||||
view.rerender(<Layout />)
|
||||
expect(within(sidebar).queryByRole('link', { name: 'reviewCenter.title' })).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a single frame while settings-to-dashboard navigation resolves', () => {
|
||||
pathname = '/dashboard/skills'
|
||||
resolvedPathname = '/settings/profile'
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays'
|
|||
import { syncDocumentLanguage } from '@/shared/lib/document-language'
|
||||
import { DashboardSidebar, SIDEBAR_GROUPS } from '@/pages/dashboard'
|
||||
import { canViewGovernanceCenter } from '@/shared/lib/governance-access'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { canAccessReviewCenter } from '@/features/review/review-paths'
|
||||
import { APP_HEADER_CONTENT_CLASS_NAME, getAppHeaderClassName } from './layout-header-style'
|
||||
import { APP_FRAME_CLASS_NAME } from '@/shared/lib/layout-frame'
|
||||
import { AUTH_ENTRY_PATHS, SPLIT_AUTH_PATHS, getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content'
|
||||
|
|
@ -42,11 +44,14 @@ export function Layout() {
|
|||
const isSplitAuthRoute = SPLIT_AUTH_PATHS.includes(contentLayoutPathname as typeof SPLIT_AUTH_PATHS[number])
|
||||
const showSidebar = mainContentLayout.sidebarRowClassName !== undefined
|
||||
const governanceVisible = canViewGovernanceCenter(user?.platformRoles)
|
||||
const { data: managedNamespaces } = useMyNamespaces(Boolean(user) && showSidebar)
|
||||
const reviewVisible = canAccessReviewCenter(user?.platformRoles, managedNamespaces)
|
||||
const filteredDashboardGroups = SIDEBAR_GROUPS
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => (
|
||||
(!item.admin || governanceVisible)
|
||||
&& (!item.review || reviewVisible)
|
||||
&& (!item.superAdmin || user?.platformRoles?.includes('SUPER_ADMIN'))
|
||||
&& (!item.passwordCapability || user?.canChangePassword === true)
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { createRedirectAuthenticated, createRequireAuth, isSafeAuthReturnTo } fr
|
|||
import { clearDynamicImportReloadGuard, recoverFromDynamicImportError } from '@/shared/lib/dynamic-import-recovery'
|
||||
import { normalizeSearchQuery } from '@/shared/lib/search-query'
|
||||
import { captureInvitationFragment } from '@/shared/lib/namespace-invitation'
|
||||
import { validateReviewSearch } from '@/features/review/review-route-search'
|
||||
|
||||
/**
|
||||
* Central route registry for the SkillHub web app.
|
||||
|
|
@ -598,9 +599,7 @@ const dashboardReviewsRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reviews',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: (search: Record<string, unknown>): { type?: 'skill' | 'profile' | 'sync' } => ({
|
||||
type: search.type === 'skill' || search.type === 'profile' || search.type === 'sync' ? search.type : undefined,
|
||||
}),
|
||||
validateSearch: validateReviewSearch,
|
||||
component: ReviewsPage,
|
||||
})
|
||||
|
||||
|
|
@ -637,6 +636,7 @@ const dashboardReviewDetailRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/reviews/$id',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: validateReviewSearch,
|
||||
component: ReviewDetailPage,
|
||||
})
|
||||
|
||||
|
|
@ -644,6 +644,7 @@ const dashboardNamespaceReviewDetailRoute = createRoute({
|
|||
getParentRoute: () => rootRoute,
|
||||
path: 'dashboard/namespaces/$slug/reviews/$id',
|
||||
beforeLoad: requireAuth,
|
||||
validateSearch: validateReviewSearch,
|
||||
component: NamespaceReviewDetailPage,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GovernanceActivityItem } from '@/api/types'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
|
||||
interface GovernanceActivityProps {
|
||||
items?: GovernanceActivityItem[]
|
||||
|
|
@ -21,13 +20,13 @@ export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps
|
|||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return <Card className="p-10 text-center text-muted-foreground">{t('governance.emptyActivity')}</Card>
|
||||
return <div className="rounded-lg border border-dashed px-5 py-8 text-center text-sm text-muted-foreground">{t('governance.emptyActivity')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border/70">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} className="p-4 space-y-2">
|
||||
<div key={item.id} className="space-y-2 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium">{item.action}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
|
@ -38,7 +37,7 @@ export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps
|
|||
{item.actorDisplayName || item.actorUserId || t('governance.unknownActor')}
|
||||
</div>
|
||||
{item.details ? <div className="text-sm text-foreground break-all">{item.details}</div> : null}
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useNavigate } from '@tanstack/react-router'
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { GovernanceInboxItem } from '@/api/types'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { GOVERNANCE_INBOX_SUBTITLE_CLASS_NAME } from './governance-inbox-style'
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) {
|
|||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return <Card className="p-10 text-center text-muted-foreground">{t('governance.emptyInbox')}</Card>
|
||||
return <div className="rounded-lg border border-dashed px-5 py-8 text-center text-sm text-muted-foreground">{t('governance.emptyInbox')}</div>
|
||||
}
|
||||
|
||||
const openItem = (item: GovernanceInboxItem) => {
|
||||
|
|
@ -49,9 +48,9 @@ export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="divide-y divide-border/70">
|
||||
{items.map((item) => (
|
||||
<Card key={`${item.type}-${item.id}`} className="p-5 space-y-3">
|
||||
<div key={`${item.type}-${item.id}`} className="space-y-3 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -71,7 +70,7 @@ export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) {
|
|||
{t('governance.openItem')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({ hasRole: vi.fn(), mutateAsync: vi.fn(), pending: false, success: vi.fn(), error: vi.fn() }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ hasRole: state.hasRole }) }))
|
||||
vi.mock('./use-governance', () => ({ useRebuildSearchIndex: () => ({ mutateAsync: state.mutateAsync, isPending: state.pending }) }))
|
||||
vi.mock('@/shared/lib/toast', () => ({ toast: { success: state.success, error: state.error } }))
|
||||
|
||||
import { SearchIndexMaintenance } from './search-index-maintenance'
|
||||
|
||||
const action = 'governance.searchRebuildAction'
|
||||
function openConfirmation() {
|
||||
fireEvent.click(screen.getByRole('button', { name: action }))
|
||||
return screen.getByRole('dialog', { name: 'governance.searchRebuildConfirmTitle' })
|
||||
}
|
||||
|
||||
describe('SearchIndexMaintenance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.hasRole.mockReturnValue(true)
|
||||
state.mutateAsync.mockResolvedValue(undefined)
|
||||
state.pending = false
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
it('hides the maintenance action for non-super administrators', () => {
|
||||
state.hasRole.mockReturnValue(false)
|
||||
render(<SearchIndexMaintenance />)
|
||||
expect(state.hasRole).toHaveBeenCalledWith('SUPER_ADMIN')
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(state.mutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires explicit confirmation and allows cancellation without rebuilding', () => {
|
||||
render(<SearchIndexMaintenance />)
|
||||
const dialog = openConfirmation()
|
||||
expect(within(dialog).getByText('governance.searchRebuildConfirmDescription')).toBeTruthy()
|
||||
expect(state.mutateAsync).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'dialog.cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(state.mutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls rebuilding once after confirmation and closes on success', async () => {
|
||||
render(<SearchIndexMaintenance />)
|
||||
fireEvent.click(within(openConfirmation()).getByRole('button', { name: action }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(state.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(state.success).toHaveBeenCalledWith('governance.searchRebuildSuccessTitle', 'governance.searchRebuildSuccessDescription')
|
||||
expect(state.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the confirmation after failure and allows an explicit retry', async () => {
|
||||
state.mutateAsync.mockRejectedValueOnce(new Error('Search service unavailable'))
|
||||
render(<SearchIndexMaintenance />)
|
||||
fireEvent.click(within(openConfirmation()).getByRole('button', { name: action }))
|
||||
await waitFor(() => expect(state.error).toHaveBeenCalledWith('governance.searchRebuildErrorTitle', 'Search service unavailable'))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
await waitFor(() => expect(within(dialog).getByRole('button', { name: action }).hasAttribute('disabled')).toBe(false))
|
||||
expect(state.success).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: action }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(state.mutateAsync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('prevents duplicate confirmation and cancellation while the operation is running', async () => {
|
||||
let finish: () => void = () => undefined
|
||||
state.mutateAsync.mockImplementationOnce(() => new Promise<void>((resolve) => { finish = resolve }))
|
||||
render(<SearchIndexMaintenance />)
|
||||
const dialog = openConfirmation()
|
||||
const confirm = within(dialog).getByRole('button', { name: action })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(state.mutateAsync).toHaveBeenCalledTimes(1)
|
||||
expect(within(dialog).getByRole('button', { name: 'dialog.processing' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(within(dialog).getByRole('button', { name: 'dialog.cancel' }).hasAttribute('disabled')).toBe(true)
|
||||
await act(async () => finish())
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
})
|
||||
|
||||
it('does not open a second operation while the mutation reports pending', () => {
|
||||
state.pending = true
|
||||
render(<SearchIndexMaintenance />)
|
||||
const button = screen.getByRole('button', { name: 'governance.searchRebuildRunning' })
|
||||
expect(button.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(state.mutateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
33
web/src/features/governance/search-index-maintenance.tsx
Normal file
33
web/src/features/governance/search-index-maintenance.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { useRebuildSearchIndex } from './use-governance'
|
||||
|
||||
/** System maintenance remains separate from moderation work. */
|
||||
export function SearchIndexMaintenance() {
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
const [open, setOpen] = useState(false)
|
||||
const mutation = useRebuildSearchIndex()
|
||||
if (!hasRole('SUPER_ADMIN')) return null
|
||||
const rebuild = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync()
|
||||
setOpen(false)
|
||||
toast.success(t('governance.searchRebuildSuccessTitle'), t('governance.searchRebuildSuccessDescription'))
|
||||
} catch (error) {
|
||||
toast.error(t('governance.searchRebuildErrorTitle'), error instanceof Error ? error.message : '')
|
||||
}
|
||||
}
|
||||
return <>
|
||||
<Card className="space-y-4 border-amber-500/20 bg-amber-500/5 p-5">
|
||||
<div><h2 className="text-lg font-semibold">{t('governance.searchMaintenanceTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('governance.searchMaintenanceDescription')}</p></div>
|
||||
<div className="flex flex-wrap items-center gap-3"><Button data-analytics-id="governance.index.rebuild" type="button" variant="outline" disabled={mutation.isPending} onClick={() => setOpen(true)}>{t(mutation.isPending ? 'governance.searchRebuildRunning' : 'governance.searchRebuildAction')}</Button><span className="text-xs text-muted-foreground">{t('governance.searchMaintenanceHint')}</span></div>
|
||||
</Card>
|
||||
<ConfirmDialog closeOnConfirm={false} confirmAnalyticsId="governance.index.rebuild.confirm" cancelAnalyticsId="governance.index.rebuild.cancel" open={open} onOpenChange={setOpen} title={t('governance.searchRebuildConfirmTitle')} description={t('governance.searchRebuildConfirmDescription')} confirmText={t('governance.searchRebuildAction')} onConfirm={rebuild} />
|
||||
</>
|
||||
}
|
||||
62
web/src/features/review/review-center-filter-form.tsx
Normal file
62
web/src/features/review/review-center-filter-form.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useId, type FormEvent } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReviewCenterFilters } from './review-center-filters'
|
||||
import { REVIEW_KINDS, REVIEW_ORIGINS, REVIEW_STATUSES } from './review-center-filters'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
|
||||
interface ReviewFilterOption { value: string; label: string }
|
||||
interface ReviewCenterFilterFormProps {
|
||||
value: ReviewCenterFilters
|
||||
namespaces: ReviewFilterOption[]
|
||||
namespaceLoading?: boolean
|
||||
onChange: (filters: ReviewCenterFilters) => void
|
||||
onApply: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export const REVIEW_FILTER_TRIGGER_CLASS_NAME = 'h-11 w-full rounded-lg bg-background px-3 text-sm md:h-8 md:w-44'
|
||||
export const REVIEW_FILTER_CONTENT_CLASS_NAME = 'w-[var(--radix-select-trigger-width)] min-w-0 max-w-[var(--radix-select-trigger-width)] [&_[role=option]]:min-h-11 [&_[role=option]]:rounded-lg [&_[role=option]]:py-1.5 md:[&_[role=option]]:min-h-8 md:[&_[role=option]]:py-1 [&_[data-radix-select-viewport]]:h-auto [&_[data-radix-select-viewport]]:min-w-0 [&_[data-radix-select-viewport]]:p-1.5'
|
||||
|
||||
export function ReviewCenterFilterForm({ value, namespaces, namespaceLoading, onChange, onApply, onReset }: ReviewCenterFilterFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const setValue = <K extends keyof ReviewCenterFilters>(key: K, next: ReviewCenterFilters[K]) => onChange({ ...value, [key]: next })
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); onApply() }
|
||||
const field = (key: 'status' | 'namespace' | 'reviewKind' | 'originType', options: ReviewFilterOption[]) => (
|
||||
<div className="min-w-0">
|
||||
<label htmlFor={`${id}-${key}`} className="mb-2 block text-xs font-medium text-muted-foreground">{t(`reviewCenter.filters.${key}`)}</label>
|
||||
<Select value={value[key]} onValueChange={(next) => setValue(key, next as ReviewCenterFilters[typeof key])} disabled={key === 'namespace' && namespaceLoading}>
|
||||
<SelectTrigger id={`${id}-${key}`} data-analytics-id={key === 'status' ? 'review.filter.status.interact' : key === 'namespace' ? 'review.filter.namespace.interact' : key === 'reviewKind' ? 'review.filter.kind.interact' : 'review.filter.origin.interact'} className={REVIEW_FILTER_TRIGGER_CLASS_NAME}><SelectValue /></SelectTrigger>
|
||||
<SelectContent sideOffset={8} className={REVIEW_FILTER_CONTENT_CLASS_NAME}>
|
||||
{options.map((option) => <SelectItem key={option.value} value={option.value} title={option.label} data-analytics-id={key === 'status' ? 'review.filter.status.select' : key === 'namespace' ? 'review.filter.namespace.select' : key === 'reviewKind' ? 'review.filter.kind.select' : 'review.filter.origin.select'}><span className="block truncate">{option.label}</span></SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<form onSubmit={submit} className="rounded-[10px] bg-secondary p-5" aria-label={t('reviewCenter.filters.title')}>
|
||||
<h2 className="mb-5 text-[15px] font-semibold">{t('reviewCenter.filters.title')}</h2>
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-4 md:flex md:flex-wrap md:items-end">
|
||||
<div className="col-span-2 min-w-0 md:w-[408px] md:max-w-full">
|
||||
<label htmlFor={`${id}-q`} className="mb-2 block text-xs font-medium text-muted-foreground">{t('reviewCenter.filters.q')}</label>
|
||||
<div className="relative"><Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /><Input id={`${id}-q`} value={value.q} onChange={(event) => setValue('q', event.target.value)} placeholder={t('reviewCenter.filters.searchPlaceholder')} className="h-11 bg-background pl-9 md:h-8" /></div>
|
||||
</div>
|
||||
{field('status', REVIEW_STATUSES.map((status) => ({ value: status, label: t(`reviewCenter.status.${status}`) })))}
|
||||
{field('namespace', [{ value: 'ALL', label: t('reviewCenter.filters.allScopes') }, ...namespaces])}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:flex md:flex-wrap md:items-end">
|
||||
{field('reviewKind', REVIEW_KINDS.map((kind) => ({ value: kind, label: t(`reviewCenter.kind.${kind}`) })))}
|
||||
{field('originType', REVIEW_ORIGINS.map((origin) => ({ value: origin, label: t(`reviewCenter.origin.${origin}`) })))}
|
||||
<div className="col-span-2 mt-2 flex w-full gap-3 md:ml-auto md:mt-0 md:w-auto">
|
||||
<Button data-analytics-id="review.filter.reset" type="button" variant="outline" onClick={onReset} className="h-11 flex-1 bg-background md:h-8 md:w-20 md:flex-none">{t('reviewCenter.filters.reset')}</Button>
|
||||
<Button data-analytics-id="review.filter.apply" type="submit" className="h-11 flex-1 md:h-8 md:w-[100px] md:flex-none">{t('reviewCenter.filters.apply')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
15
web/src/features/review/review-center-filters.test.ts
Normal file
15
web/src/features/review/review-center-filters.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_REVIEW_FILTERS, parseReviewCenterFilters, reviewCenterSearch } from './review-center-filters'
|
||||
|
||||
describe('review center URL state', () => {
|
||||
it('normalizes unsupported values and preserves the legacy sync entry', () => {
|
||||
expect(parseReviewCenterFilters({ status: 'unknown', originType: 'unsafe', reviewKind: 'x', type: 'sync' })).toEqual({ ...DEFAULT_REVIEW_FILTERS, originType: 'GITHUB_SYNC' })
|
||||
})
|
||||
it('round trips non-default conditions and leaves defaults out of the URL', () => {
|
||||
const filters = { ...DEFAULT_REVIEW_FILTERS, status: 'APPROVED' as const, namespace: 'design', q: ' notes ', originType: 'USER' as const }
|
||||
const search = reviewCenterSearch(filters, 2)
|
||||
expect(search).toMatchObject({ q: 'notes', status: 'APPROVED', namespace: 'design', originType: 'USER', page: 2 })
|
||||
expect(parseReviewCenterFilters(search)).toEqual({ ...filters, q: 'notes' })
|
||||
expect(reviewCenterSearch(DEFAULT_REVIEW_FILTERS).status).toBeUndefined()
|
||||
})
|
||||
})
|
||||
46
web/src/features/review/review-center-filters.ts
Normal file
46
web/src/features/review/review-center-filters.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export const REVIEW_STATUSES = ['PENDING', 'APPROVED', 'REJECTED', 'ALL'] as const
|
||||
export const REVIEW_KINDS = ['ALL', 'FIRST_PUBLISH', 'VERSION_UPDATE', 'SUITE_PUBLISH', 'VISIBILITY_CHANGE', 'AVATAR_CHANGE'] as const
|
||||
export const REVIEW_ORIGINS = ['ALL', 'USER', 'GITHUB_SYNC'] as const
|
||||
|
||||
export type ReviewCenterStatus = typeof REVIEW_STATUSES[number]
|
||||
export type ReviewCenterKind = typeof REVIEW_KINDS[number]
|
||||
export type ReviewCenterOrigin = typeof REVIEW_ORIGINS[number]
|
||||
export interface ReviewCenterFilters {
|
||||
q: string
|
||||
status: ReviewCenterStatus
|
||||
namespace: string
|
||||
reviewKind: ReviewCenterKind
|
||||
originType: ReviewCenterOrigin
|
||||
sortDirection: 'ASC' | 'DESC'
|
||||
}
|
||||
|
||||
export const DEFAULT_REVIEW_FILTERS: ReviewCenterFilters = {
|
||||
q: '', status: 'PENDING', namespace: 'ALL', reviewKind: 'ALL', originType: 'ALL', sortDirection: 'DESC',
|
||||
}
|
||||
|
||||
function choice<T extends string>(value: unknown, options: readonly T[], fallback: T): T {
|
||||
return options.find((option) => option === value) ?? fallback
|
||||
}
|
||||
|
||||
export function parseReviewCenterFilters(search: Record<string, unknown>): ReviewCenterFilters {
|
||||
return {
|
||||
q: typeof search.q === 'string' ? search.q : '',
|
||||
status: choice(search.status, REVIEW_STATUSES, 'PENDING'),
|
||||
namespace: typeof search.namespace === 'string' && search.namespace.trim() ? search.namespace : 'ALL',
|
||||
reviewKind: choice(search.reviewKind, REVIEW_KINDS, 'ALL'),
|
||||
originType: choice(search.originType, REVIEW_ORIGINS, search.type === 'sync' ? 'GITHUB_SYNC' : 'ALL'),
|
||||
sortDirection: search.sortDirection === 'ASC' ? 'ASC' : 'DESC',
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewCenterSearch(filters: ReviewCenterFilters, page = 0) {
|
||||
return {
|
||||
q: filters.q.trim() || undefined,
|
||||
status: filters.status === 'PENDING' ? undefined : filters.status,
|
||||
namespace: filters.namespace === 'ALL' ? undefined : filters.namespace,
|
||||
reviewKind: filters.reviewKind === 'ALL' ? undefined : filters.reviewKind,
|
||||
originType: filters.originType === 'ALL' ? undefined : filters.originType,
|
||||
sortDirection: filters.sortDirection === 'DESC' ? undefined : filters.sortDirection,
|
||||
page: page > 0 ? page : undefined,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SkillVersion } from '@/api/types'
|
||||
import { ReviewComplianceDiffPanel } from './review-compliance-diff-panel'
|
||||
import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
|
|
@ -37,6 +37,11 @@ function createVersion(overrides: Partial<SkillVersion> = {}): SkillVersion {
|
|||
}
|
||||
|
||||
describe('ReviewComplianceDiffPanel', () => {
|
||||
it('never uses a different unpublished version as the published baseline', () => {
|
||||
const pending = createVersion({ id: 2, version: '1.1.0', status: 'PENDING_REVIEW' })
|
||||
expect(pickBaseVersion([pending, createVersion({ status: 'REJECTED' })], '1.1.0')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a clickable diff summary and item details', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ReviewComplianceDiffPanel
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ function pickBaseVersion(versions: SkillVersion[], activeVersion: string) {
|
|||
return right.id - left.id
|
||||
})[0]
|
||||
}
|
||||
return versions.find((version) => version.version !== activeVersion) ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, className, concealVersionIdentifiers = false }: ReviewComplianceDiffPanelProps) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ReviewSkillDetail } from '@/api/types'
|
||||
import { ReviewPresentationComparison } from './review-presentation-comparison'
|
||||
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/features/skill/markdown-renderer', () => ({ MarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div> }))
|
||||
|
||||
function detail(): ReviewSkillDetail {
|
||||
return {
|
||||
skill: { id: 1, slug: 'meeting-notes', namespace: 'team', displayName: 'Meeting notes', visibility: 'PUBLIC', status: 'ACTIVE', downloadCount: 0, starCount: 0, ratingCount: 0, hidden: false, canManageLifecycle: false, canSubmitPromotion: false, canInteract: false, canReport: false, resolutionMode: 'REVIEW_TASK' },
|
||||
versions: [], files: [], downloadUrl: null, activeVersion: '1.1.0',
|
||||
presentation: { displayName: 'Meeting notes', summary: 'New summary', overview: 'New overview', overviewSource: 'WEB' },
|
||||
comparison: {
|
||||
baseVersion: '1.0.0',
|
||||
baseline: { displayName: 'Meeting notes', summary: 'Original summary', overview: 'Original overview', overviewSource: 'WEB' },
|
||||
current: { displayName: 'Meeting notes', summary: 'New summary', overview: 'New overview', overviewSource: 'WEB' },
|
||||
files: [{ path: 'SKILL.md', changeType: 'MODIFIED' }, { path: 'README.md', changeType: 'UNCHANGED' }, { path: 'removed.txt', changeType: 'REMOVED' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ReviewPresentationComparison', () => {
|
||||
it('compares the server-selected published baseline with the bound review snapshot and file manifest', () => {
|
||||
const html = renderToStaticMarkup(<ReviewPresentationComparison detail={detail()} />)
|
||||
expect(html).toContain('1.0.0')
|
||||
expect(html).toContain('1.1.0')
|
||||
expect(html).toContain('Original summary')
|
||||
expect(html).toContain('New summary')
|
||||
expect(html).toContain('Original overview')
|
||||
expect(html).toContain('New overview')
|
||||
expect(html).toContain('reviewWorkbench.fileMODIFIED')
|
||||
expect(html).toContain('reviewWorkbench.fileUNCHANGED')
|
||||
expect(html).toContain('reviewWorkbench.fileREMOVED')
|
||||
})
|
||||
|
||||
it('does not describe unread fallback documentation as unchanged', () => {
|
||||
const value = detail()
|
||||
value.comparison!.baseline.overview = null
|
||||
value.comparison!.baseline.overviewSource = 'README'
|
||||
value.comparison!.current.overview = null
|
||||
value.comparison!.current.overviewSource = 'README'
|
||||
const html = renderToStaticMarkup(<ReviewPresentationComparison detail={value} />)
|
||||
expect(html).toContain('reviewWorkbench.unavailableComparison')
|
||||
expect(html.match(/reviewWorkbench.overviewUnavailable/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not invent a baseline from other pending versions', () => {
|
||||
const value = detail()
|
||||
value.comparison = null
|
||||
const html = renderToStaticMarkup(<ReviewPresentationComparison detail={value} />)
|
||||
expect(html).toContain('reviewWorkbench.noBaseline')
|
||||
expect(html).not.toContain('1.0.0')
|
||||
})
|
||||
})
|
||||
65
web/src/features/review/review-presentation-comparison.tsx
Normal file
65
web/src/features/review/review-presentation-comparison.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReviewSkillDetail } from '@/api/types'
|
||||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
|
||||
interface ReviewPresentationComparisonProps {
|
||||
detail: ReviewSkillDetail
|
||||
}
|
||||
|
||||
/** Compares only snapshots authorized and selected by the review endpoint. */
|
||||
export function ReviewPresentationComparison({ detail }: ReviewPresentationComparisonProps) {
|
||||
const { t } = useTranslation()
|
||||
const comparison = detail.comparison
|
||||
if (!comparison) return <p className="text-sm text-muted-foreground">{t('reviewWorkbench.noBaseline')}</p>
|
||||
|
||||
const fields = [
|
||||
{ key: 'displayName', title: t('publish.displayName'), markdown: false },
|
||||
{ key: 'summary', title: t('reviewWorkbench.summary'), markdown: false },
|
||||
{ key: 'overview', title: t('reviewWorkbench.overview'), markdown: true },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<section className="space-y-6" aria-label={t('reviewWorkbench.presentationChanges')}>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-md bg-secondary/70 p-4 text-sm">
|
||||
<div><p className="text-xs text-muted-foreground">{t('reviewWorkbench.baseline')}</p><p className="mt-1 break-all font-medium">{comparison.baseVersion}</p></div>
|
||||
<div><p className="text-xs text-muted-foreground">{t('reviewWorkbench.pending')}</p><p className="mt-1 break-all font-medium">{detail.activeVersion}</p></div>
|
||||
</div>
|
||||
{fields.map(({ key, title, markdown }) => {
|
||||
const before = comparison.baseline[key]
|
||||
const after = comparison.current[key]
|
||||
const unavailable = markdown && ((!before && comparison.baseline.overviewSource !== 'NONE') || (!after && comparison.current.overviewSource !== 'NONE'))
|
||||
const changed = before !== after
|
||||
return (
|
||||
<section key={key} className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs text-muted-foreground">{t(unavailable ? 'reviewWorkbench.unavailableComparison' : changed ? 'reviewWorkbench.modified' : 'reviewWorkbench.unchanged')}</span>
|
||||
</div>
|
||||
<Card className="grid min-w-0 divide-y divide-border/60 overflow-hidden md:grid-cols-2 md:divide-x md:divide-y-0">
|
||||
{([{ label: 'before', value: before, snapshot: comparison.baseline }, { label: 'after', value: after, snapshot: comparison.current }] as const).map(({ label, value, snapshot }) => (
|
||||
<div key={label} className="min-w-0 space-y-3 p-5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t(`reviewWorkbench.${label}`)}</p>
|
||||
{value ? markdown ? <MarkdownRenderer content={value} /> : <p className="whitespace-pre-wrap break-words text-sm leading-6">{value}</p>
|
||||
: <p className="text-sm text-muted-foreground">{t(markdown && snapshot.overviewSource !== 'NONE' ? 'reviewWorkbench.overviewUnavailable' : 'reviewWorkbench.notProvided')}</p>}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-base font-semibold">{t('reviewWorkbench.fileChanges')}</h3>
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/60">
|
||||
{comparison.files.map((file) => (
|
||||
<div key={file.path} className="flex min-w-0 flex-wrap items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<span className="min-w-0 break-all font-mono text-xs">{file.path}</span>
|
||||
<span className="shrink-0 rounded-full bg-secondary px-2.5 py-1 text-xs">{t(`reviewWorkbench.file${file.changeType}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
{comparison.files.length === 0 && <p className="p-4 text-sm text-muted-foreground">{t('skillDetail.noFiles')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
23
web/src/features/review/review-route-search.test.ts
Normal file
23
web/src/features/review/review-route-search.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildReviewReturnPath, validateReviewSearch } from './review-route-search'
|
||||
|
||||
describe('review queue route context', () => {
|
||||
it('preserves applied scope, filters, sort and page on a detail return', () => {
|
||||
const url = new URL(buildReviewReturnPath({ namespace: 'product-design', q: '会议 & action', status: 'ALL', reviewKind: 'VERSION_UPDATE', originType: 'USER', sortDirection: 'ASC', page: '3' }), 'https://example.test')
|
||||
expect(url.pathname).toBe('/dashboard/reviews')
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual({ namespace: 'product-design', q: '会议 & action', status: 'ALL', reviewKind: 'VERSION_UPDATE', originType: 'USER', sortDirection: 'ASC', page: '3' })
|
||||
})
|
||||
it('maps legacy namespace and sync links without arbitrary redirects', () => {
|
||||
const url = new URL(buildReviewReturnPath({ type: 'sync', returnTo: 'https://untrusted.test' }, 'engineering'), 'https://example.test')
|
||||
expect(url.pathname).toBe('/dashboard/reviews')
|
||||
expect(url.searchParams.get('namespace')).toBe('engineering')
|
||||
expect(url.searchParams.get('originType')).toBe('GITHUB_SYNC')
|
||||
expect(url.searchParams.has('returnTo')).toBe(false)
|
||||
})
|
||||
it.each([-1, 1.5, 'NaN', Infinity, ''])('rejects invalid page %s', (page) => {
|
||||
expect(validateReviewSearch({ page }).page).toBeUndefined()
|
||||
})
|
||||
it('does not pass unknown filters to consumers', () => {
|
||||
expect(validateReviewSearch({ status: 'WITHDRAWN', originType: 'foreign', extra: 'ignored' })).toEqual({ q: undefined, status: undefined, namespace: undefined, reviewKind: undefined, originType: undefined, sortDirection: undefined, page: undefined, type: undefined })
|
||||
})
|
||||
})
|
||||
31
web/src/features/review/review-route-search.ts
Normal file
31
web/src/features/review/review-route-search.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { parseReviewCenterFilters, reviewCenterSearch, type ReviewCenterStatus, type ReviewCenterKind, type ReviewCenterOrigin } from './review-center-filters'
|
||||
|
||||
export interface ReviewRouteSearch {
|
||||
q?: string
|
||||
namespace?: string
|
||||
status?: ReviewCenterStatus
|
||||
reviewKind?: ReviewCenterKind
|
||||
originType?: ReviewCenterOrigin
|
||||
sortDirection?: 'ASC' | 'DESC'
|
||||
page?: number
|
||||
type?: 'skill' | 'profile' | 'sync'
|
||||
}
|
||||
|
||||
/** Preserve queue context while keeping detail returns on a fixed, trusted route. */
|
||||
export function validateReviewSearch(search: Record<string, unknown>): ReviewRouteSearch {
|
||||
const candidate = typeof search.page === 'string' && search.page.trim() ? Number(search.page) : search.page
|
||||
const page = typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate > 0 ? candidate : 0
|
||||
return {
|
||||
...reviewCenterSearch(parseReviewCenterFilters(search), page),
|
||||
type: search.type === 'skill' || search.type === 'profile' || search.type === 'sync' ? search.type : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildReviewReturnPath(search: Record<string, unknown>, namespaceSlug?: string): string {
|
||||
const normalized = validateReviewSearch({ ...search, namespace: search.namespace ?? namespaceSlug })
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(normalized)) {
|
||||
if (value !== undefined) params.set(key, String(value))
|
||||
}
|
||||
return `/dashboard/reviews${params.size ? `?${params.toString()}` : ''}`
|
||||
}
|
||||
|
|
@ -68,6 +68,21 @@ function createDetail(): ReviewSkillDetail {
|
|||
}
|
||||
|
||||
describe('ReviewSkillDetailSection', () => {
|
||||
it('shows the effective review presentation before the original package files', () => {
|
||||
const detail = createDetail()
|
||||
detail.skill.summary = 'Old public summary'
|
||||
detail.presentation = { displayName: 'Pending display name', summary: 'Custom pending summary', overview: '# Custom pending overview', overviewSource: 'WEB' }
|
||||
detail.documentationContent = '# Original README content'
|
||||
const html = renderToStaticMarkup(<ReviewSkillDetailSection detail={detail} />)
|
||||
expect(html).toContain('Custom pending summary')
|
||||
expect(html).toContain('Custom pending overview')
|
||||
expect(html).not.toContain('Old public summary')
|
||||
expect(html).toContain('Original README content')
|
||||
expect(html.indexOf('Custom pending overview')).toBeLessThan(html.indexOf('Original README content'))
|
||||
expect(html).toContain('reviewWorkbench.originalFiles')
|
||||
expect(html).toContain('<details')
|
||||
})
|
||||
|
||||
it('renders the download link, file tree, and review-bound documentation together', () => {
|
||||
const html = renderToStaticMarkup(<ReviewSkillDetailSection detail={createDetail()} />)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ import type { FileTreeNode } from '@/features/skill/file-tree-builder'
|
|||
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
|
||||
import { ComplianceSnapshotPanel } from '@/features/skill/compliance-snapshot-panel'
|
||||
import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel'
|
||||
import { buttonVariants } from '@/shared/ui/button'
|
||||
import { Button, buttonVariants } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { getReviewDownloadHref, getReviewSkillDocumentation, isActiveReviewVersion } from './review-skill-detail'
|
||||
import { useReviewFile } from './use-review-file'
|
||||
import { ReviewPresentationComparison } from './review-presentation-comparison'
|
||||
|
||||
interface ReviewSkillDetailSectionProps {
|
||||
detail?: ReviewSkillDetail
|
||||
|
|
@ -22,13 +23,17 @@ interface ReviewSkillDetailSectionProps {
|
|||
documentationTooLarge?: boolean
|
||||
documentationLoading?: boolean
|
||||
documentationError?: boolean
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId, documentationTooLarge = false,
|
||||
documentationLoading = false, documentationError = false }: ReviewSkillDetailSectionProps) {
|
||||
documentationLoading = false, documentationError = false, onRetry }: ReviewSkillDetailSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [previewNode, setPreviewNode] = useState<FileTreeNode | null>(null)
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||
const originalFile = useReviewFile(reviewId, detail?.documentationPath ?? null, filesExpanded && !detail?.documentationContent && !documentationTooLarge)
|
||||
|
||||
const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile(
|
||||
reviewId,
|
||||
previewNode?.path || null,
|
||||
|
|
@ -37,10 +42,13 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
)
|
||||
|
||||
if (isLoading) return <Card className="h-56 animate-shimmer" />
|
||||
if (hasError) return <Card className="p-6 text-sm text-destructive">{t('review.skillDetailError')}</Card>
|
||||
if (hasError) return <Card className="space-y-3 p-5 text-sm" role="alert"><p className="text-destructive">{t('review.skillDetailError')}</p>{onRetry && <Button data-analytics-id="review.content.retry" size="sm" variant="outline" onClick={onRetry}>{t('reviewWorkbench.retry')}</Button>}</Card>
|
||||
if (!detail) return null
|
||||
|
||||
const documentation = getReviewSkillDocumentation(detail)
|
||||
const documentation = getReviewSkillDocumentation({ ...detail, documentationContent: detail.documentationContent ?? originalFile.data })
|
||||
const presentation = detail.presentation
|
||||
const summary = presentation?.summary ?? detail.skill.summary
|
||||
const overview = presentation?.overview ?? ((presentation?.overviewSource === 'README' || presentation?.overviewSource === 'SKILL_MD' || !presentation) ? detail.documentationContent : null)
|
||||
const downloadHref = getReviewDownloadHref(detail)
|
||||
const handleDownloadFile = () => {
|
||||
if (!previewNode || !reviewId) return
|
||||
|
|
@ -54,14 +62,34 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-6" aria-label={t('reviewWorkbench.displayContent')}>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold">{t('reviewWorkbench.displayContent')}</h2>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('reviewWorkbench.boundContent', { version: detail.activeVersion })}</p>
|
||||
</div>
|
||||
<div className="space-y-3 border-b border-border/60 pb-6">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{t('reviewWorkbench.summary')}</h3>
|
||||
<p className="whitespace-pre-wrap break-words text-base leading-7">{summary || t('reviewWorkbench.notProvided')}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{t('reviewWorkbench.overview')}</h3>
|
||||
{documentationLoading && !overview ? <div className="h-28 animate-shimmer rounded-lg" role="status" aria-label={t('review.scanLoading')} />
|
||||
: documentationError && !overview ? <div role="alert" className="space-y-3"><p className="text-sm text-destructive">{t('review.documentationLoadFailed')}</p>{onRetry && <Button size="sm" variant="outline" onClick={onRetry}>{t('reviewWorkbench.retry')}</Button>}</div>
|
||||
: overview ? <MarkdownRenderer content={overview} />
|
||||
: <p className="text-sm text-muted-foreground">{t(documentationTooLarge ? 'review.documentationTooLarge' : 'reviewWorkbench.notProvided')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-4 border-t border-border/60 pt-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t('review.skillDetailDescription', { skill: `${detail.skill.namespace}/${detail.skill.slug}` })}</p>
|
||||
<div><h2 className="text-base font-semibold">{t('reviewWorkbench.packageFiles')}</h2><p className="mt-1 text-xs leading-5 text-muted-foreground">{t('reviewWorkbench.originalFiles')}</p></div>
|
||||
{downloadHref && <a data-analytics-id="review.package.download" className={buttonVariants({ variant: 'outline', size: 'sm', className: 'px-2.5' })} href={buildApiUrl(downloadHref)}>
|
||||
{t('review.downloadSkillZip')}
|
||||
</a>}
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-3 lg:grid-cols-[minmax(220px,260px)_minmax(0,1fr)]">
|
||||
<details className="group" onToggle={(event) => setFilesExpanded(event.currentTarget.open)}>
|
||||
<summary data-analytics-id="review.files.expand" className="cursor-pointer py-2 text-sm font-medium text-foreground underline-offset-4 hover:underline">{t('reviewWorkbench.browseFiles')}</summary>
|
||||
<div className="mt-3 grid min-w-0 gap-3 xl:grid-cols-[minmax(160px,200px)_minmax(0,1fr)]">
|
||||
<Card className="min-w-0 p-3">
|
||||
<h3 className="mb-2 text-sm font-semibold">{t('fileTree.title')} <span className="font-normal text-muted-foreground">{detail.files.length}</span></h3>
|
||||
{detail.files.length > 0 ? (
|
||||
|
|
@ -72,11 +100,12 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
</Card>
|
||||
<Card className="min-w-0 p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('review.documentationTitle')}</h3>
|
||||
{documentationLoading ? (
|
||||
{documentationLoading || originalFile.isLoading ? (
|
||||
<div className="h-24 animate-shimmer rounded-lg" />
|
||||
) : documentationError ? (
|
||||
) : documentationError || originalFile.error ? (
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p className="text-destructive">{t('review.documentationLoadFailed')}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => void originalFile.refetch?.()}>{t('reviewWorkbench.retry')}</Button>
|
||||
{detail.documentationPath && reviewId && (
|
||||
<a data-analytics-id="review.file.download" className="text-primary underline" href={buildApiUrl(`${WEB_API_PREFIX}/reviews/${reviewId}/file?path=${encodeURIComponent(detail.documentationPath)}`)} download>
|
||||
{t('filePreview.downloadFile')}
|
||||
|
|
@ -98,6 +127,8 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
) : <p className="text-sm text-muted-foreground">{t('review.noDocumentation')}</p>}
|
||||
</Card>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<FilePreviewDialog closeAnalyticsId="review.file.close" downloadAnalyticsId="review.file.download"
|
||||
open={previewDialogOpen}
|
||||
onOpenChange={setPreviewDialogOpen}
|
||||
|
|
@ -112,14 +143,14 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId
|
|||
)
|
||||
}
|
||||
|
||||
export function ReviewSkillChangesSection({ detail, isLoading, hasError, concealTechnicalVersions = false }: Omit<ReviewSkillDetailSectionProps, 'reviewId'> & { concealTechnicalVersions?: boolean }) {
|
||||
export function ReviewSkillChangesSection({ detail, isLoading, hasError, onRetry, concealTechnicalVersions = false }: Omit<ReviewSkillDetailSectionProps, 'reviewId'> & { concealTechnicalVersions?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
if (isLoading) return <Card className="h-40 animate-shimmer" />
|
||||
if (hasError) return <Card className="p-6 text-sm text-destructive">{t('review.skillDetailError')}</Card>
|
||||
if (hasError) return <Card className="space-y-3 p-5 text-sm" role="alert"><p className="text-destructive">{t('review.skillDetailError')}</p>{onRetry && <Button size="sm" variant="outline" onClick={onRetry}>{t('reviewWorkbench.retry')}</Button>}</Card>
|
||||
if (!detail) return null
|
||||
|
||||
const pendingVersion = detail.versions.find((version) => version.version === detail.activeVersion) ?? null
|
||||
const baseVersion = pickBaseVersion(detail.versions, detail.activeVersion ?? '')
|
||||
const baseVersion = detail.comparison ? detail.versions.find((version) => version.version === detail.comparison?.baseVersion) ?? null : pickBaseVersion(detail.versions, detail.activeVersion ?? '')
|
||||
const versionHistory = (
|
||||
<Card className="space-y-3 p-4">
|
||||
<h3 className="text-base font-semibold">{t('skillDetail.tabVersions')}</h3>
|
||||
|
|
@ -140,14 +171,18 @@ export function ReviewSkillChangesSection({ detail, isLoading, hasError, conceal
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ReviewComplianceDiffPanel baseVersion={baseVersion} pendingVersion={pendingVersion}
|
||||
concealVersionIdentifiers={concealTechnicalVersions} />
|
||||
{(!concealTechnicalVersions || detail.comparison) && <ReviewPresentationComparison detail={detail} />}
|
||||
<details className="rounded-md border border-border/60 p-4 text-sm">
|
||||
<summary className="cursor-pointer font-medium">{t('review.complianceChanges')}</summary>
|
||||
<div className="mt-4"><ReviewComplianceDiffPanel baseVersion={baseVersion} pendingVersion={pendingVersion}
|
||||
concealVersionIdentifiers={concealTechnicalVersions} /></div>
|
||||
</details>
|
||||
{concealTechnicalVersions ? (
|
||||
<details className="rounded-md border border-border bg-card p-4">
|
||||
<summary className="cursor-pointer text-sm font-medium">{t('review.technicalDetails')}</summary>
|
||||
<div className="mt-4">{versionHistory}</div>
|
||||
</details>
|
||||
) : versionHistory}
|
||||
) : <details className="rounded-md border border-border/60 p-4 text-sm"><summary className="cursor-pointer font-medium">{t('skillDetail.tabVersions')}</summary><div className="mt-4">{versionHistory}</div></details>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
57
web/src/features/review/review-task-summary.tsx
Normal file
57
web/src/features/review/review-task-summary.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ReviewSkillDetail, ReviewTask } from '@/api/types'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
|
||||
export function reviewRequestKey(review: ReviewTask, hasBaseline: boolean) {
|
||||
if (review.subjectType === 'SUITE_VERSION') return 'suite'
|
||||
if (review.requestedVisibility) return 'visibility'
|
||||
if (review.subjectType === 'SKILL_METADATA') return review.avatarRemoval ? 'avatarRemoval' : 'avatar'
|
||||
if (review.originType === 'GITHUB_SYNC') return 'sync'
|
||||
if (review.reviewKind === 'VERSION_UPDATE') return 'update'
|
||||
if (review.reviewKind === 'FIRST_PUBLISH') return 'publish'
|
||||
return hasBaseline ? 'update' : 'publish'
|
||||
}
|
||||
|
||||
export function ReviewTaskStatus({ status }: { status: ReviewTask['status'] }) {
|
||||
const { t } = useTranslation()
|
||||
const color = status === 'APPROVED' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400'
|
||||
: status === 'REJECTED' ? 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400'
|
||||
: status === 'PENDING' ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
: 'border-border/60 bg-secondary text-muted-foreground'
|
||||
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-medium ${color}`}>{t(status === 'PENDING' ? 'review.statusPending' : status === 'APPROVED' ? 'review.statusApproved' : status === 'REJECTED' ? 'review.statusRejected' : 'review.statusSuperseded')}</span>
|
||||
}
|
||||
|
||||
export function ReviewTaskSummary({ review, detail, hasBaseline, submittedAt, children }: {
|
||||
review: ReviewTask
|
||||
detail?: ReviewSkillDetail
|
||||
hasBaseline: boolean
|
||||
submittedAt: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const kind = reviewRequestKey(review, hasBaseline)
|
||||
const metadataOnly = review.subjectType === 'SKILL_METADATA' && !review.requestedVisibility
|
||||
const rows = [
|
||||
{ label: t('reviewWorkbench.request'), value: t(`reviewWorkbench.kind.${kind}`) },
|
||||
...(!metadataOnly ? [{ label: t(review.requestedVisibility ? 'reviewWorkbench.boundVersion' : 'review.version'), value: detail?.activeVersion ?? review.version ?? '—' }] : []),
|
||||
...(review.requestedVisibility ? [{ label: t('skillVisibility.title'), value: `${t(`skillVisibility.${review.previousVisibility}`)} → ${t(`skillVisibility.${review.requestedVisibility}`)}` }]
|
||||
: detail ? [{ label: t('skillVisibility.title'), value: t(`skillVisibility.${detail.skill.visibility}`) }] : []),
|
||||
{ label: t('review.submitter'), value: review.submittedByName || review.submittedBy },
|
||||
{ label: t('review.submitTime'), value: submittedAt },
|
||||
]
|
||||
return (
|
||||
<aside className="min-w-0 self-start xl:sticky xl:top-20" aria-label={t('reviewWorkbench.requestSummary')}>
|
||||
<Card className="space-y-5 p-5">
|
||||
<h2 className="text-lg font-semibold">{t('reviewWorkbench.requestSummary')}</h2>
|
||||
<dl className="space-y-4">
|
||||
{rows.map(({ label, value }) => <div key={label} className="grid grid-cols-[4rem_minmax(0,1fr)] gap-3 text-sm">
|
||||
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 break-words leading-5 [overflow-wrap:anywhere]">{value}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
{children && <div className="space-y-4 border-t border-border/60 pt-5">{children}</div>}
|
||||
</Card>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -114,3 +114,31 @@ describe('review list cache behavior', () => {
|
|||
client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
describe('unified review filters', () => {
|
||||
it('sends keyword, request type and source to the server and isolates their cache entries', async () => {
|
||||
const list = vi.spyOn(reviewApi, 'list').mockResolvedValue({ items: [], total: 0, page: 0, size: 20 })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const initial = { q: 'notes', reviewKind: 'VERSION_UPDATE', originType: 'USER' }
|
||||
const hook = renderHook((filters) => useReviewList('ALL', undefined, 0, 20, 'DESC', true, filters), { initialProps: initial, wrapper: queryWrapper(client) })
|
||||
await waitFor(() => expect(hook.result.current.isSuccess).toBe(true))
|
||||
expect(list).toHaveBeenLastCalledWith({ status: 'ALL', namespaceId: undefined, page: 0, size: 20, sortDirection: 'DESC', ...initial })
|
||||
hook.rerender({ ...initial, originType: 'GITHUB_SYNC' })
|
||||
await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
|
||||
expect(client.getQueryCache().getAll()).toHaveLength(2)
|
||||
hook.unmount()
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
it('encodes server-side filters without losing spaces, Unicode or namespace scope', async () => {
|
||||
const request = vi.fn().mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { items: [], total: 0, page: 0, size: 20 } }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', request)
|
||||
await reviewApi.list({ status: 'ALL', namespaceId: 4, q: ' 中文 / A&B ', reviewKind: 'VISIBILITY_CHANGE', originType: 'GITHUB_SYNC' })
|
||||
const url = new URL(String(request.mock.calls[0][0]), 'http://localhost')
|
||||
expect(url.searchParams.get('q')).toBe('中文 / A&B')
|
||||
expect(url.searchParams.get('status')).toBe('ALL')
|
||||
expect(url.searchParams.get('namespaceId')).toBe('4')
|
||||
expect(url.searchParams.get('reviewKind')).toBe('VISIBILITY_CHANGE')
|
||||
expect(url.searchParams.get('originType')).toBe('GITHUB_SYNC')
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { reviewApi } from '@/api/client'
|
||||
|
||||
export interface ReviewListFilters { q?: string; reviewKind?: string; originType?: string }
|
||||
|
||||
/**
|
||||
* Loads review tasks filtered by status and optional namespace scope.
|
||||
* Returns paginated response with totalElements / totalPages.
|
||||
|
|
@ -11,8 +13,9 @@ async function getReviewList(
|
|||
page = 0,
|
||||
size = 20,
|
||||
sortDirection: 'ASC' | 'DESC' = 'DESC',
|
||||
filters?: ReviewListFilters,
|
||||
) {
|
||||
const response = await reviewApi.list({ status, namespaceId, page, size, sortDirection })
|
||||
const response = await reviewApi.list({ status, namespaceId, page, size, sortDirection, ...filters })
|
||||
return {
|
||||
...response,
|
||||
totalElements: response.total,
|
||||
|
|
@ -30,10 +33,11 @@ export function useReviewList(
|
|||
size = 20,
|
||||
sortDirection: 'ASC' | 'DESC' = 'DESC',
|
||||
enabled = true,
|
||||
filters?: ReviewListFilters,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['reviews', status, namespaceId, page, size, sortDirection],
|
||||
queryFn: () => getReviewList(status, namespaceId, page, size, sortDirection),
|
||||
queryKey: ['reviews', status, namespaceId, page, size, sortDirection, ...(filters ? [filters] : [])],
|
||||
queryFn: () => getReviewList(status, namespaceId, page, size, sortDirection, filters),
|
||||
staleTime: 0,
|
||||
enabled: enabled && (namespaceId === undefined || namespaceId > 0),
|
||||
})
|
||||
|
|
|
|||
45
web/src/features/review/use-review-scopes.test.tsx
Normal file
45
web/src/features/review/use-review-scopes.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { reviewApi } from '@/api/client'
|
||||
import { clearSessionScopedQueries } from '@/features/notification/notification-session'
|
||||
import { useReviewScopes } from './use-review-scopes'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{children}</QueryClientProvider>
|
||||
}
|
||||
describe('authorized review scopes', () => {
|
||||
it('returns server-authorized scopes without consulting namespace membership', async () => {
|
||||
const scopes = [{ id: 23, slug: 'not-joined', displayName: 'Unjoined namespace' }]
|
||||
const request = vi.spyOn(reviewApi, 'scopes').mockResolvedValue(scopes)
|
||||
const { result } = renderHook(() => useReviewScopes(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data).toEqual(scopes)
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('clears the previous account scopes before another account reads them', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
client.setQueryData(['reviews', 'scopes'], [{ id: 23, slug: 'account-a-private', displayName: 'Account A' }])
|
||||
clearSessionScopedQueries(client)
|
||||
vi.spyOn(reviewApi, 'scopes').mockResolvedValue([{ id: 42, slug: 'account-b', displayName: 'Account B' }])
|
||||
const { result } = renderHook(() => useReviewScopes(), { wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> })
|
||||
expect(result.current.data).toBeUndefined()
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(result.current.data?.[0].id).toBe(42)
|
||||
client.clear()
|
||||
})
|
||||
it('does not request skill scopes in a profile-only workflow', () => {
|
||||
const request = vi.spyOn(reviewApi, 'scopes')
|
||||
renderHook(() => useReviewScopes(false), { wrapper })
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
it('keeps request failures visible instead of substituting member namespaces', async () => {
|
||||
vi.spyOn(reviewApi, 'scopes').mockRejectedValue(new Error('scope failed'))
|
||||
const { result } = renderHook(() => useReviewScopes(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
})
|
||||
7
web/src/features/review/use-review-scopes.ts
Normal file
7
web/src/features/review/use-review-scopes.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
import { reviewApi } from '@/api/client'
|
||||
|
||||
/** Server-authorized scopes, including namespaces a platform reviewer has not joined. */
|
||||
export function useReviewScopes(enabled = true) {
|
||||
return useQuery({ queryKey: ['reviews', 'scopes'], queryFn: () => reviewApi.scopes(), enabled, staleTime: 0 })
|
||||
}
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
"tokens": "API Tokens",
|
||||
"reviewProgress": "Review Progress",
|
||||
"admin": "Admin",
|
||||
"governance": "Governance",
|
||||
"governance": "Governance center",
|
||||
"reports": "Reports",
|
||||
"githubSync": "Skill Sync",
|
||||
"deepDiscovery": "Deep Discovery"
|
||||
|
|
@ -1568,7 +1568,7 @@
|
|||
"rejectedFeedbackTitle": "Review Rejected",
|
||||
"rejectedFeedbackLabel": "Reviewer feedback",
|
||||
"rejectedFeedbackFallback": "No rejection feedback was provided.",
|
||||
"governance": "Governance",
|
||||
"governance": "Governance center",
|
||||
"processing": "Processing...",
|
||||
"archiveSkill": "Archive Skill",
|
||||
"unarchiveSkill": "Restore Skill",
|
||||
|
|
@ -2237,7 +2237,7 @@
|
|||
"stars": "Starred",
|
||||
"subscriptions": "My Subscriptions",
|
||||
"reviewProgress": "My Review Progress",
|
||||
"reviews": "Review Management",
|
||||
"reviews": "Review center",
|
||||
"promotions": "Promotion Management",
|
||||
"reports": "Report Management",
|
||||
"users": "User Management",
|
||||
|
|
@ -3487,5 +3487,135 @@
|
|||
"title": "Subscribed skill version yanked",
|
||||
"description": "{{skillName}}{{versionSuffix}} version was yanked."
|
||||
}
|
||||
},
|
||||
"reviewCenter": {
|
||||
"title": "Review center",
|
||||
"description": "Find and process requests within your authorized scope.",
|
||||
"profileDescription": "Review user profile changes. Skill and suite requests use the review queue.",
|
||||
"profileQueue": "Profile reviews",
|
||||
"skillQueue": "Skill and suite reviews",
|
||||
"governanceTitle": "Governance center",
|
||||
"governanceDescription": "Handle reports and global promotion requests, and read governance records. Publishing, avatar and visibility requests belong in the review center.",
|
||||
"openingScope": "Opening this namespace in the review center…",
|
||||
"unknownObject": "Unnamed resource",
|
||||
"loading": "Loading requests…",
|
||||
"scopeFailed": "Unable to load the review scope. Try again.",
|
||||
"noPermission": "This account does not have review permission.",
|
||||
"retry": "Reload",
|
||||
"loadFailed": "Unable to load review requests. Your filters have been kept.",
|
||||
"results": "Results",
|
||||
"resultsCount": "Results · {{count}} requests",
|
||||
"empty": "No matching requests",
|
||||
"emptyDescription": "Adjust the conditions or reset the filters and search again.",
|
||||
"sortLabel": "Submission order",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first",
|
||||
"open": "View",
|
||||
"pageSummary": "{{total}} requests · Page {{page}}",
|
||||
"governanceReviewLink": "Open review center",
|
||||
"governancePendingPromotions": "Pending global promotions",
|
||||
"governancePendingReports": "Pending reports",
|
||||
"governanceActivity": "Recent decisions",
|
||||
"governanceSearchMoved": "Search index maintenance is now in system settings.",
|
||||
"filters": {
|
||||
"title": "Filter requests",
|
||||
"q": "Keywords",
|
||||
"status": "Review status",
|
||||
"namespace": "Review scope",
|
||||
"reviewKind": "Request type",
|
||||
"originType": "Source",
|
||||
"searchPlaceholder": "Search name, coordinate or submitter",
|
||||
"allScopes": "All authorized scopes",
|
||||
"apply": "Search",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"status": {
|
||||
"ALL": "All statuses",
|
||||
"PENDING": "Pending",
|
||||
"APPROVED": "Approved",
|
||||
"REJECTED": "Rejected",
|
||||
"SUPERSEDED": "Superseded"
|
||||
},
|
||||
"kind": {
|
||||
"ALL": "All request types",
|
||||
"FIRST_PUBLISH": "First publication",
|
||||
"VERSION_UPDATE": "Version update",
|
||||
"SUITE_PUBLISH": "Suite publication",
|
||||
"VISIBILITY_CHANGE": "Expand visibility",
|
||||
"AVATAR_CHANGE": "Replace avatar",
|
||||
"AVATAR_REMOVAL": "Remove avatar",
|
||||
"SKILL_VERSION": "Skill version request"
|
||||
},
|
||||
"origin": {
|
||||
"ALL": "All sources",
|
||||
"USER": "User submission",
|
||||
"GITHUB_SYNC": "GitHub sync"
|
||||
},
|
||||
"columns": {
|
||||
"object": "Resource",
|
||||
"kind": "Request",
|
||||
"source": "Source / Namespace",
|
||||
"submitter": "Submitter / Time",
|
||||
"action": "Action"
|
||||
},
|
||||
"viewAll": "View all",
|
||||
"pageEmpty": "This page has no requests. Return to the first page.",
|
||||
"firstPage": "First page",
|
||||
"governanceNotifications": "Governance notifications and outcomes",
|
||||
"notificationReadFailed": "Could not mark governance notification as read"
|
||||
},
|
||||
"reviewWorkbench": {
|
||||
"displayContent": "Presentation for this version",
|
||||
"boundContent": "This content is fixed to version {{version}} bound to the review. Inspect the original package files separately.",
|
||||
"summary": "Short description",
|
||||
"overview": "Detailed overview",
|
||||
"packageFiles": "Original package files",
|
||||
"originalFiles": "The uploaded files are unchanged and reviewed separately from the presentation.",
|
||||
"browseFiles": "Browse files",
|
||||
"retry": "Reload",
|
||||
"requestSummary": "Review request",
|
||||
"request": "Request",
|
||||
"boundVersion": "Bound version",
|
||||
"checks": "Review evidence",
|
||||
"scanBoundary": "Scanning provides evidence; content, purpose and requested visibility still require human review.",
|
||||
"boundScan": "Bound-version scan evidence",
|
||||
"boundScanDescription": "This visibility request uses its bound version’s scan snapshot. The scan status does not mean visibility has already changed.",
|
||||
"contentNavigation": "Review content navigation",
|
||||
"viewOnly": "Read only: this account cannot process this request.",
|
||||
"presentationChanges": "Presentation and file changes",
|
||||
"baseline": "Published baseline",
|
||||
"pending": "Under review",
|
||||
"modified": "Modified",
|
||||
"unchanged": "Unchanged",
|
||||
"unavailableComparison": "Comparison incomplete",
|
||||
"notProvided": "No content provided.",
|
||||
"overviewUnavailable": "The full overview could not be read. This does not establish that it is empty or unchanged.",
|
||||
"noBaseline": "No published baseline is available for this review.",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"fileChanges": "Package file changes",
|
||||
"fileADDED": "Added",
|
||||
"fileMODIFIED": "Modified",
|
||||
"fileREMOVED": "Removed",
|
||||
"fileUNCHANGED": "Unchanged",
|
||||
"kind": {
|
||||
"publish": "First publication",
|
||||
"update": "Version update",
|
||||
"visibility": "Expand visibility",
|
||||
"avatar": "Replace skill avatar",
|
||||
"avatarRemoval": "Remove skill avatar",
|
||||
"suite": "Publish suite",
|
||||
"sync": "GitHub sync"
|
||||
},
|
||||
"approve": {
|
||||
"publish": "Approve publication",
|
||||
"update": "Approve update",
|
||||
"visibility": "Approve visibility change",
|
||||
"avatar": "Approve avatar change",
|
||||
"avatarRemoval": "Approve avatar removal",
|
||||
"suite": "Approve suite version",
|
||||
"sync": "Approve sync update"
|
||||
},
|
||||
"approveContentUnavailable": "Pending content is still loading or cannot be verified. Approval is unavailable."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -548,7 +548,7 @@
|
|||
"tokens": "API 토큰",
|
||||
"reviewProgress": "검토 진행 상황",
|
||||
"admin": "관리",
|
||||
"governance": "거버넌스",
|
||||
"governance": "거버넌스 센터",
|
||||
"reports": "신고",
|
||||
"githubSync": "스킬 동기화",
|
||||
"deepDiscovery": "심층 탐색"
|
||||
|
|
@ -2517,7 +2517,7 @@
|
|||
"stars": "즐겨찾기됨",
|
||||
"subscriptions": "내 구독",
|
||||
"reviewProgress": "내 검토 진행 상황",
|
||||
"reviews": "검토 관리",
|
||||
"reviews": "검토 센터",
|
||||
"promotions": "승격 관리",
|
||||
"reports": "신고 관리",
|
||||
"users": "사용자 관리",
|
||||
|
|
@ -3767,5 +3767,135 @@
|
|||
"title": "구독한 스킬 버전 배포 철회",
|
||||
"description": "{{skillName}}{{versionSuffix}} 버전의 배포가 철회되었습니다."
|
||||
}
|
||||
},
|
||||
"reviewCenter": {
|
||||
"title": "검토 센터",
|
||||
"description": "권한이 있는 범위에서 신청을 검색하고 처리합니다.",
|
||||
"profileDescription": "사용자 프로필 변경 사항을 검토합니다. 스킬 및 스킬 모음 신청은 검토 대기열에서 처리합니다.",
|
||||
"profileQueue": "프로필 검토",
|
||||
"skillQueue": "스킬 및 스킬 모음 검토",
|
||||
"governanceTitle": "거버넌스 센터",
|
||||
"governanceDescription": "신고와 전역 승격 신청을 처리하고 의사 결정 기록을 조회합니다. 게시, 아바타 및 공개 범위 신청은 검토 센터에서 처리합니다.",
|
||||
"openingScope": "검토 센터에서 이 네임스페이스를 여는 중…",
|
||||
"unknownObject": "이름 없는 리소스",
|
||||
"loading": "신청을 불러오는 중…",
|
||||
"scopeFailed": "검토 범위를 불러오지 못했습니다. 다시 시도하세요.",
|
||||
"noPermission": "이 계정에는 검토 권한이 없습니다.",
|
||||
"retry": "다시 불러오기",
|
||||
"loadFailed": "검토 신청을 불러오지 못했습니다. 필터는 유지됩니다.",
|
||||
"results": "검색 결과",
|
||||
"resultsCount": "검색 결과 · 신청 {{count}}건",
|
||||
"empty": "조건에 맞는 신청이 없습니다",
|
||||
"emptyDescription": "조건을 변경하거나 필터를 초기화한 후 다시 검색하세요.",
|
||||
"sortLabel": "제출 순서",
|
||||
"newest": "최신 제출순",
|
||||
"oldest": "오래된 제출순",
|
||||
"open": "보기",
|
||||
"pageSummary": "신청 {{total}}건 · {{page}}페이지",
|
||||
"governanceReviewLink": "검토 센터 열기",
|
||||
"governancePendingPromotions": "대기 중인 전역 승격",
|
||||
"governancePendingReports": "대기 중인 신고",
|
||||
"governanceActivity": "최근 의사 결정",
|
||||
"governanceSearchMoved": "검색 인덱스 유지 관리는 시스템 설정으로 이동했습니다.",
|
||||
"filters": {
|
||||
"title": "신청 필터",
|
||||
"q": "검색어",
|
||||
"status": "검토 상태",
|
||||
"namespace": "검토 범위",
|
||||
"reviewKind": "신청 유형",
|
||||
"originType": "출처",
|
||||
"searchPlaceholder": "이름, 좌표 또는 제출자 검색",
|
||||
"allScopes": "권한이 있는 모든 범위",
|
||||
"apply": "검색",
|
||||
"reset": "초기화"
|
||||
},
|
||||
"status": {
|
||||
"ALL": "모든 상태",
|
||||
"PENDING": "검토 대기",
|
||||
"APPROVED": "승인됨",
|
||||
"REJECTED": "거부됨",
|
||||
"SUPERSEDED": "대체됨"
|
||||
},
|
||||
"kind": {
|
||||
"ALL": "모든 신청 유형",
|
||||
"FIRST_PUBLISH": "최초 게시",
|
||||
"VERSION_UPDATE": "버전 업데이트",
|
||||
"SUITE_PUBLISH": "스킬 모음 게시",
|
||||
"VISIBILITY_CHANGE": "공개 범위 확대",
|
||||
"AVATAR_CHANGE": "아바타 변경",
|
||||
"AVATAR_REMOVAL": "아바타 삭제",
|
||||
"SKILL_VERSION": "스킬 버전 신청"
|
||||
},
|
||||
"origin": {
|
||||
"ALL": "모든 출처",
|
||||
"USER": "사용자 제출",
|
||||
"GITHUB_SYNC": "GitHub 동기화"
|
||||
},
|
||||
"columns": {
|
||||
"object": "리소스",
|
||||
"kind": "신청",
|
||||
"source": "출처 / 네임스페이스",
|
||||
"submitter": "제출자 / 시간",
|
||||
"action": "작업"
|
||||
},
|
||||
"viewAll": "모두 보기",
|
||||
"pageEmpty": "이 페이지에는 신청이 없습니다. 첫 페이지로 돌아가세요.",
|
||||
"firstPage": "첫 페이지",
|
||||
"governanceNotifications": "거버넌스 알림 및 처리 결과",
|
||||
"notificationReadFailed": "거버넌스 알림을 읽음으로 표시하지 못했습니다"
|
||||
},
|
||||
"reviewWorkbench": {
|
||||
"displayContent": "이 버전의 표시 정보",
|
||||
"boundContent": "이 내용은 검토에 연결된 {{version}} 버전을 기준으로 고정됩니다. 원본 패키지 파일은 별도로 확인합니다.",
|
||||
"summary": "간단한 설명",
|
||||
"overview": "상세 개요",
|
||||
"packageFiles": "원본 패키지 파일",
|
||||
"originalFiles": "업로드된 파일은 변경되지 않으며 표시 정보와 별도로 검토합니다.",
|
||||
"browseFiles": "파일 살펴보기",
|
||||
"retry": "다시 불러오기",
|
||||
"requestSummary": "검토 신청",
|
||||
"request": "신청",
|
||||
"boundVersion": "연결된 버전",
|
||||
"checks": "검토 근거",
|
||||
"scanBoundary": "검사는 검토 근거를 제공합니다. 내용, 용도 및 요청된 공개 범위는 사람이 검토해야 합니다.",
|
||||
"boundScan": "연결된 버전의 검사 근거",
|
||||
"boundScanDescription": "이 공개 범위 신청에는 연결된 버전의 검사 스냅샷이 사용됩니다. 검사 상태가 공개 범위 변경 완료를 의미하지는 않습니다.",
|
||||
"contentNavigation": "검토 내용 탐색",
|
||||
"viewOnly": "읽기 전용: 이 계정은 신청을 처리할 수 없습니다.",
|
||||
"presentationChanges": "표시 정보 및 파일 변경 사항",
|
||||
"baseline": "게시된 기준 버전",
|
||||
"pending": "검토 중",
|
||||
"modified": "변경됨",
|
||||
"unchanged": "변경 없음",
|
||||
"unavailableComparison": "비교 미완료",
|
||||
"notProvided": "제공된 내용이 없습니다.",
|
||||
"overviewUnavailable": "전체 개요를 읽지 못했습니다. 내용이 비어 있거나 변경되지 않았다는 의미는 아닙니다.",
|
||||
"noBaseline": "이 검토에는 게시된 기준 버전이 없습니다.",
|
||||
"before": "변경 전",
|
||||
"after": "변경 후",
|
||||
"fileChanges": "패키지 파일 변경 사항",
|
||||
"fileADDED": "추가됨",
|
||||
"fileMODIFIED": "변경됨",
|
||||
"fileREMOVED": "삭제됨",
|
||||
"fileUNCHANGED": "변경 없음",
|
||||
"kind": {
|
||||
"publish": "최초 게시",
|
||||
"update": "버전 업데이트",
|
||||
"visibility": "공개 범위 확대",
|
||||
"avatar": "스킬 아바타 변경",
|
||||
"avatarRemoval": "스킬 아바타 삭제",
|
||||
"suite": "스킬 모음 게시",
|
||||
"sync": "GitHub 동기화"
|
||||
},
|
||||
"approve": {
|
||||
"publish": "게시 승인",
|
||||
"update": "업데이트 승인",
|
||||
"visibility": "공개 범위 변경 승인",
|
||||
"avatar": "아바타 변경 승인",
|
||||
"avatarRemoval": "아바타 삭제 승인",
|
||||
"suite": "스킬 모음 버전 승인",
|
||||
"sync": "동기화 업데이트 승인"
|
||||
},
|
||||
"approveContentUnavailable": "검토할 내용을 불러오는 중이거나 확인할 수 없습니다. 현재 승인할 수 없습니다."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -511,7 +511,7 @@
|
|||
"tokens": "Токены API",
|
||||
"reviewProgress": "Статус проверки",
|
||||
"admin": "Админ",
|
||||
"governance": "Управление",
|
||||
"governance": "Центр управления",
|
||||
"reports": "Жалобы",
|
||||
"githubSync": "Синхронизация навыков",
|
||||
"deepDiscovery": "Глубокий поиск"
|
||||
|
|
@ -2800,7 +2800,7 @@
|
|||
"stars": "Избранное",
|
||||
"subscriptions": "Мои подписки",
|
||||
"reviewProgress": "Мои проверки",
|
||||
"reviews": "Управление ревью",
|
||||
"reviews": "Центр проверки",
|
||||
"promotions": "Управление продвижением",
|
||||
"reports": "Управление жалобами",
|
||||
"users": "Пользователи",
|
||||
|
|
@ -3491,5 +3491,135 @@
|
|||
"title": "Версия подписанного навыка отозвана",
|
||||
"description": "Версия {{skillName}}{{versionSuffix}} отозвана."
|
||||
}
|
||||
},
|
||||
"reviewCenter": {
|
||||
"title": "Центр проверки",
|
||||
"description": "Поиск и обработка заявок в пределах доступных полномочий.",
|
||||
"profileDescription": "Проверка изменений профиля пользователя. Заявки на навыки и наборы находятся в очереди проверки.",
|
||||
"profileQueue": "Проверки профилей",
|
||||
"skillQueue": "Проверки навыков и наборов",
|
||||
"governanceTitle": "Центр управления",
|
||||
"governanceDescription": "Обработка жалоб и заявок на глобальное продвижение, просмотр журнала решений. Заявки на публикацию, аватар и видимость находятся в центре проверки.",
|
||||
"openingScope": "Открытие пространства имён в центре проверки…",
|
||||
"unknownObject": "Ресурс без названия",
|
||||
"loading": "Загрузка заявок…",
|
||||
"scopeFailed": "Не удалось загрузить область проверки. Повторите попытку.",
|
||||
"noPermission": "У этой учётной записи нет прав на проверку.",
|
||||
"retry": "Перезагрузить",
|
||||
"loadFailed": "Не удалось загрузить заявки на проверку. Фильтры сохранены.",
|
||||
"results": "Результаты",
|
||||
"resultsCount": "Результаты · заявок: {{count}}",
|
||||
"empty": "Нет подходящих заявок",
|
||||
"emptyDescription": "Измените условия или сбросьте фильтры и повторите поиск.",
|
||||
"sortLabel": "Порядок подачи",
|
||||
"newest": "Сначала новые",
|
||||
"oldest": "Сначала старые",
|
||||
"open": "Просмотреть",
|
||||
"pageSummary": "Заявок: {{total}} · Страница {{page}}",
|
||||
"governanceReviewLink": "Открыть центр проверки",
|
||||
"governancePendingPromotions": "Глобальные продвижения на рассмотрении",
|
||||
"governancePendingReports": "Жалобы на рассмотрении",
|
||||
"governanceActivity": "Последние решения",
|
||||
"governanceSearchMoved": "Обслуживание поискового индекса перенесено в системные настройки.",
|
||||
"filters": {
|
||||
"title": "Фильтр заявок",
|
||||
"q": "Ключевые слова",
|
||||
"status": "Статус проверки",
|
||||
"namespace": "Область проверки",
|
||||
"reviewKind": "Тип заявки",
|
||||
"originType": "Источник",
|
||||
"searchPlaceholder": "Название, координаты или отправитель",
|
||||
"allScopes": "Все доступные области",
|
||||
"apply": "Поиск",
|
||||
"reset": "Сбросить"
|
||||
},
|
||||
"status": {
|
||||
"ALL": "Все статусы",
|
||||
"PENDING": "Ожидает проверки",
|
||||
"APPROVED": "Одобрено",
|
||||
"REJECTED": "Отклонено",
|
||||
"SUPERSEDED": "Заменено"
|
||||
},
|
||||
"kind": {
|
||||
"ALL": "Все типы заявок",
|
||||
"FIRST_PUBLISH": "Первая публикация",
|
||||
"VERSION_UPDATE": "Обновление версии",
|
||||
"SUITE_PUBLISH": "Публикация набора",
|
||||
"VISIBILITY_CHANGE": "Расширение видимости",
|
||||
"AVATAR_CHANGE": "Замена аватара",
|
||||
"AVATAR_REMOVAL": "Удаление аватара",
|
||||
"SKILL_VERSION": "Заявка на версию навыка"
|
||||
},
|
||||
"origin": {
|
||||
"ALL": "Все источники",
|
||||
"USER": "От пользователя",
|
||||
"GITHUB_SYNC": "Синхронизация GitHub"
|
||||
},
|
||||
"columns": {
|
||||
"object": "Ресурс",
|
||||
"kind": "Заявка",
|
||||
"source": "Источник / Пространство имён",
|
||||
"submitter": "Отправитель / Время",
|
||||
"action": "Действие"
|
||||
},
|
||||
"viewAll": "Просмотреть все",
|
||||
"pageEmpty": "На этой странице нет заявок. Вернитесь на первую страницу.",
|
||||
"firstPage": "Первая страница",
|
||||
"governanceNotifications": "Уведомления и результаты управления",
|
||||
"notificationReadFailed": "Не удалось отметить уведомление управления как прочитанное"
|
||||
},
|
||||
"reviewWorkbench": {
|
||||
"displayContent": "Представление этой версии",
|
||||
"boundContent": "Содержимое закреплено за версией {{version}}, связанной с проверкой. Исходные файлы пакета проверяются отдельно.",
|
||||
"summary": "Краткое описание",
|
||||
"overview": "Подробный обзор",
|
||||
"packageFiles": "Исходные файлы пакета",
|
||||
"originalFiles": "Загруженные файлы не изменены и проверяются отдельно от описания.",
|
||||
"browseFiles": "Просмотреть файлы",
|
||||
"retry": "Перезагрузить",
|
||||
"requestSummary": "Заявка на проверку",
|
||||
"request": "Заявка",
|
||||
"boundVersion": "Связанная версия",
|
||||
"checks": "Материалы проверки",
|
||||
"scanBoundary": "Сканирование предоставляет материалы для проверки; содержимое, назначение и запрашиваемая видимость требуют проверки человеком.",
|
||||
"boundScan": "Результат сканирования связанной версии",
|
||||
"boundScanDescription": "Эта заявка на изменение видимости использует снимок сканирования связанной версии. Статус сканирования не означает, что видимость уже изменена.",
|
||||
"contentNavigation": "Разделы содержимого проверки",
|
||||
"viewOnly": "Только чтение: эта учётная запись не может обработать заявку.",
|
||||
"presentationChanges": "Изменения описания и файлов",
|
||||
"baseline": "Опубликованная базовая версия",
|
||||
"pending": "На проверке",
|
||||
"modified": "Изменено",
|
||||
"unchanged": "Без изменений",
|
||||
"unavailableComparison": "Неполное сравнение",
|
||||
"notProvided": "Содержимое не предоставлено.",
|
||||
"overviewUnavailable": "Не удалось прочитать полный обзор. Это не означает, что он пуст или не изменён.",
|
||||
"noBaseline": "Для этой проверки нет опубликованной базовой версии.",
|
||||
"before": "До",
|
||||
"after": "После",
|
||||
"fileChanges": "Изменения файлов пакета",
|
||||
"fileADDED": "Добавлен",
|
||||
"fileMODIFIED": "Изменён",
|
||||
"fileREMOVED": "Удалён",
|
||||
"fileUNCHANGED": "Без изменений",
|
||||
"kind": {
|
||||
"publish": "Первая публикация",
|
||||
"update": "Обновление версии",
|
||||
"visibility": "Расширение видимости",
|
||||
"avatar": "Замена аватара навыка",
|
||||
"avatarRemoval": "Удаление аватара навыка",
|
||||
"suite": "Публикация набора",
|
||||
"sync": "Синхронизация GitHub"
|
||||
},
|
||||
"approve": {
|
||||
"publish": "Одобрить публикацию",
|
||||
"update": "Одобрить обновление",
|
||||
"visibility": "Одобрить изменение видимости",
|
||||
"avatar": "Одобрить замену аватара",
|
||||
"avatarRemoval": "Одобрить удаление аватара",
|
||||
"suite": "Одобрить версию набора",
|
||||
"sync": "Одобрить обновление синхронизации"
|
||||
},
|
||||
"approveContentUnavailable": "Содержимое заявки ещё загружается или не может быть проверено. Одобрение недоступно."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@
|
|||
},
|
||||
"highlights": {
|
||||
"selfHosted": "自托管 Registry",
|
||||
"governance": "审核与治理",
|
||||
"governance": "治理中心",
|
||||
"multiClient": "Agent、CLI、Web 接入",
|
||||
"traceable": "版本可追溯"
|
||||
},
|
||||
|
|
@ -486,7 +486,7 @@
|
|||
"mySkillsPreviewEmpty": "你还没有发布任何技能",
|
||||
"credentials": "访问凭证",
|
||||
"openTokens": "查看 API Tokens",
|
||||
"governanceTitle": "审核与治理",
|
||||
"governanceTitle": "治理中心",
|
||||
"viewGovernance": "打开治理中心",
|
||||
"viewPromotions": "查看提升审核",
|
||||
"reportsTitle": "举报管理",
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
"tokens": "Token 凭证",
|
||||
"reviewProgress": "我的审核进度",
|
||||
"admin": "管理员",
|
||||
"governance": "审核与治理",
|
||||
"governance": "治理中心",
|
||||
"reports": "举报管理",
|
||||
"githubSync": "技能同步",
|
||||
"deepDiscovery": "深度探索"
|
||||
|
|
@ -2235,7 +2235,7 @@
|
|||
"stars": "我的收藏",
|
||||
"subscriptions": "我的订阅",
|
||||
"reviewProgress": "我的审核进度",
|
||||
"reviews": "审核管理",
|
||||
"reviews": "审核中心",
|
||||
"promotions": "推广管理",
|
||||
"reports": "举报管理",
|
||||
"users": "用户管理",
|
||||
|
|
@ -3485,5 +3485,135 @@
|
|||
"title": "订阅技能版本撤回",
|
||||
"description": "{{skillName}}{{versionSuffix}} 版本已撤回。"
|
||||
}
|
||||
},
|
||||
"reviewCenter": {
|
||||
"title": "审核中心",
|
||||
"description": "查看并处理当前账号可审核的申请。",
|
||||
"profileDescription": "审核用户资料变更,技能与套装申请由审核队列处理。",
|
||||
"profileQueue": "用户资料审核",
|
||||
"skillQueue": "技能与套装审核",
|
||||
"governanceTitle": "治理中心",
|
||||
"governanceDescription": "处理举报和全局提升申请,查阅治理记录。技能发布、头像和可见范围申请统一进入审核中心。",
|
||||
"openingScope": "正在打开当前命名空间的审核队列…",
|
||||
"unknownObject": "未命名对象",
|
||||
"loading": "正在读取申请…",
|
||||
"scopeFailed": "无法读取审核范围,请重试。",
|
||||
"noPermission": "当前账号没有审核权限。",
|
||||
"retry": "重新加载",
|
||||
"loadFailed": "无法读取审核申请,当前筛选条件已保留。",
|
||||
"results": "查询结果",
|
||||
"resultsCount": "查询结果 · {{count}} 项",
|
||||
"empty": "没有符合条件的申请",
|
||||
"emptyDescription": "可以调整条件或重置筛选后重新查询。",
|
||||
"sortLabel": "提交时间排序",
|
||||
"newest": "最新提交",
|
||||
"oldest": "最早提交",
|
||||
"open": "查看",
|
||||
"pageSummary": "共 {{total}} 项 · 第 {{page}} 页",
|
||||
"governanceReviewLink": "前往审核中心",
|
||||
"governancePendingPromotions": "待处理全局提升",
|
||||
"governancePendingReports": "待处理举报",
|
||||
"governanceActivity": "最近处理记录",
|
||||
"governanceSearchMoved": "搜索索引维护已移至系统设置。",
|
||||
"filters": {
|
||||
"title": "筛选条件",
|
||||
"q": "关键词",
|
||||
"status": "审核状态",
|
||||
"namespace": "审核范围",
|
||||
"reviewKind": "申请事项",
|
||||
"originType": "申请来源",
|
||||
"searchPlaceholder": "搜索名称、坐标或提交人",
|
||||
"allScopes": "全部可审核范围",
|
||||
"apply": "查询",
|
||||
"reset": "重置"
|
||||
},
|
||||
"status": {
|
||||
"ALL": "全部状态",
|
||||
"PENDING": "待审核",
|
||||
"APPROVED": "已批准",
|
||||
"REJECTED": "已拒绝",
|
||||
"SUPERSEDED": "已替代"
|
||||
},
|
||||
"kind": {
|
||||
"ALL": "全部事项",
|
||||
"FIRST_PUBLISH": "首次发布",
|
||||
"VERSION_UPDATE": "版本更新",
|
||||
"SUITE_PUBLISH": "套装发布",
|
||||
"VISIBILITY_CHANGE": "扩大可见范围",
|
||||
"AVATAR_CHANGE": "头像替换",
|
||||
"AVATAR_REMOVAL": "头像移除",
|
||||
"SKILL_VERSION": "技能版本申请"
|
||||
},
|
||||
"origin": {
|
||||
"ALL": "全部来源",
|
||||
"USER": "用户提交",
|
||||
"GITHUB_SYNC": "GitHub 同步"
|
||||
},
|
||||
"columns": {
|
||||
"object": "申请对象",
|
||||
"kind": "申请事项",
|
||||
"source": "来源 / 命名空间",
|
||||
"submitter": "提交人 / 时间",
|
||||
"action": "操作"
|
||||
},
|
||||
"viewAll": "查看全部",
|
||||
"pageEmpty": "当前页没有申请,请返回第一页。",
|
||||
"firstPage": "返回第一页",
|
||||
"governanceNotifications": "治理通知与处置结果",
|
||||
"notificationReadFailed": "标记治理通知已读失败"
|
||||
},
|
||||
"reviewWorkbench": {
|
||||
"displayContent": "本次版本的展示内容",
|
||||
"boundContent": "以下内容固定对应本任务绑定的版本 {{version}},与技能包原文件分别核查。",
|
||||
"summary": "简要描述",
|
||||
"overview": "详细概述",
|
||||
"packageFiles": "技能包原文件",
|
||||
"originalFiles": "保持上传原文,与展示介绍分别核查。",
|
||||
"browseFiles": "浏览文件",
|
||||
"retry": "重新加载",
|
||||
"requestSummary": "本次申请",
|
||||
"request": "申请事项",
|
||||
"boundVersion": "绑定版本",
|
||||
"checks": "核查项目",
|
||||
"scanBoundary": "扫描结果仅作为核查证据,仍需人工核对内容、用途与申请范围。",
|
||||
"boundScan": "绑定版本的扫描证据",
|
||||
"boundScanDescription": "本次范围申请使用绑定版本的扫描快照;该状态不代表可见范围已经扩大。",
|
||||
"contentNavigation": "审核内容导航",
|
||||
"viewOnly": "仅查看:当前账号无权处理本任务。",
|
||||
"presentationChanges": "本次展示与文件变化",
|
||||
"baseline": "已发布基线",
|
||||
"pending": "本次待审",
|
||||
"modified": "已修改",
|
||||
"unchanged": "未变化",
|
||||
"unavailableComparison": "无法完整比较",
|
||||
"notProvided": "未提供内容。",
|
||||
"overviewUnavailable": "概述未能完整读取,不能据此判断内容为空或未变化。",
|
||||
"noBaseline": "没有可用的已发布基线,当前任务不提供版本对照。",
|
||||
"before": "修改前",
|
||||
"after": "修改后",
|
||||
"fileChanges": "包内文件变化",
|
||||
"fileADDED": "新增",
|
||||
"fileMODIFIED": "修改",
|
||||
"fileREMOVED": "删除",
|
||||
"fileUNCHANGED": "未变化",
|
||||
"kind": {
|
||||
"publish": "首次发布",
|
||||
"update": "版本更新",
|
||||
"visibility": "扩大可见范围",
|
||||
"avatar": "替换技能头像",
|
||||
"avatarRemoval": "移除技能头像",
|
||||
"suite": "套装发布",
|
||||
"sync": "GitHub 同步"
|
||||
},
|
||||
"approve": {
|
||||
"publish": "批准发布",
|
||||
"update": "批准本次更新",
|
||||
"visibility": "批准范围变更",
|
||||
"avatar": "批准头像变更",
|
||||
"avatarRemoval": "批准头像移除",
|
||||
"suite": "批准套装版本",
|
||||
"sync": "批准同步更新"
|
||||
},
|
||||
"approveContentUnavailable": "待审内容尚未完成加载或无法核对,暂不能批准。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const api = vi.hoisted(() => ({
|
|||
updateAvatarReview: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ hasRole: () => false }) }))
|
||||
vi.mock('@/api/client', () => ({
|
||||
systemConfigApi: api,
|
||||
ApiError: class ApiError extends Error {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Input } from '@/shared/ui/input'
|
|||
import { Label } from '@/shared/ui/label'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { AvatarReviewSettingsPanel } from '@/features/skill/avatar-review-settings'
|
||||
import { SearchIndexMaintenance } from '@/features/governance/search-index-maintenance'
|
||||
|
||||
export function SystemConfigPage() {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -107,6 +108,7 @@ export function SystemConfigPage() {
|
|||
{error ? <p role="alert" className="rounded-lg bg-destructive/10 px-4 py-3 text-sm text-destructive">{error}</p> : null}
|
||||
{message ? <p role="status" className="rounded-lg bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700">{message}</p> : null}
|
||||
<AvatarReviewSettingsPanel />
|
||||
<SearchIndexMaintenance />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('systemConfig.localAuth')}</CardTitle>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface SidebarItem {
|
|||
label: string
|
||||
to: string
|
||||
analyticsId?: string
|
||||
review?: boolean
|
||||
admin?: boolean
|
||||
superAdmin?: boolean
|
||||
passwordCapability?: boolean
|
||||
|
|
@ -57,8 +58,8 @@ export const SIDEBAR_GROUPS: SidebarGroup[] = [
|
|||
{
|
||||
label: 'sidebar.admin',
|
||||
items: [
|
||||
{ key: 'governance', icon: Shield, label: 'sidebar.governance', to: '/dashboard/governance', analyticsId: 'governance.center.open', admin: true },
|
||||
{ key: 'reports', icon: Flag, label: 'sidebar.reports', to: '/dashboard/reports', analyticsId: 'report.queue.open', admin: true },
|
||||
{ key: 'reviewCenter', icon: Shield, label: 'reviewCenter.title', to: '/dashboard/reviews', analyticsId: 'review.queue.open', review: true },
|
||||
{ key: 'governance', icon: Flag, label: 'sidebar.governance', to: '/dashboard/governance', analyticsId: 'governance.center.open', admin: true },
|
||||
{ key: 'githubSync', icon: Github, label: 'sidebar.githubSync', to: '/dashboard/github-sync', analyticsId: 'github.sync.open', superAdmin: true },
|
||||
],
|
||||
},
|
||||
|
|
@ -67,11 +68,12 @@ export const SIDEBAR_GROUPS: SidebarGroup[] = [
|
|||
// Flatten for backward compatibility with layout.tsx
|
||||
const SIDEBAR_NAV_ITEMS = SIDEBAR_GROUPS.flatMap((g) => g.items)
|
||||
|
||||
export const SIDEBAR_NAV = SIDEBAR_NAV_ITEMS.map(({ key, icon, label, to, admin, superAdmin, passwordCapability }) => ({
|
||||
export const SIDEBAR_NAV = SIDEBAR_NAV_ITEMS.map(({ key, icon, label, to, review, admin, superAdmin, passwordCapability }) => ({
|
||||
key,
|
||||
icon,
|
||||
label,
|
||||
to,
|
||||
review,
|
||||
admin,
|
||||
superAdmin,
|
||||
passwordCapability,
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
import { createElement, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasRole: vi.fn(),
|
||||
summary: vi.fn(),
|
||||
inbox: vi.fn(),
|
||||
activity: vi.fn(),
|
||||
notifications: vi.fn(),
|
||||
rebuildSearchIndex: vi.fn(),
|
||||
markRead: vi.fn(),
|
||||
totalPages: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: mocks.hasRole }),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: ({ title, subtitle }: { title: string; subtitle: string }) =>
|
||||
createElement('header', null, createElement('h1', null, title), createElement('p', null, subtitle)),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/confirm-dialog', () => ({
|
||||
ConfirmDialog: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: ({ page, totalPages }: { page: number; totalPages: number }) =>
|
||||
createElement('div', null, `pagination:${page}/${totalPages}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/button', () => ({
|
||||
Button: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: ReactNode }) => children,
|
||||
TabsContent: ({ children, value }: { children: ReactNode; value: string }) =>
|
||||
value === 'ALL' ? createElement('div', { 'data-tab': value }, children) : null,
|
||||
TabsList: ({ children }: { children: ReactNode }) => children,
|
||||
TabsTrigger: ({ children }: { children: ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-inbox', () => ({
|
||||
GovernanceInbox: ({ items, isLoading }: { items?: Array<{ id: string }>; isLoading: boolean }) =>
|
||||
createElement('div', null, `governance-inbox:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-activity', () => ({
|
||||
GovernanceActivity: ({ items, isLoading }: { items?: Array<{ id: string }>; isLoading: boolean }) =>
|
||||
createElement('div', null, `governance-activity:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-notifications', () => ({
|
||||
GovernanceNotifications: ({
|
||||
items,
|
||||
isLoading,
|
||||
}: {
|
||||
items?: Array<{ id: string }>
|
||||
isLoading: boolean
|
||||
}) => createElement('div', null, `governance-notifications:${isLoading ? 'loading' : items?.length ?? 0}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/governance-pagination', () => ({
|
||||
getGovernanceTotalPages: (total: number, size: number) => {
|
||||
if (total <= 0 || size <= 0) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(total / size))
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/governance/use-governance', () => ({
|
||||
GOVERNANCE_PAGE_SIZE: 20,
|
||||
useGovernanceActivity: () => mocks.activity(),
|
||||
useGovernanceInbox: () => mocks.inbox(),
|
||||
useGovernanceNotifications: () => mocks.notifications(),
|
||||
useRebuildSearchIndex: () => mocks.rebuildSearchIndex(),
|
||||
useGovernanceSummary: () => mocks.summary(),
|
||||
useMarkGovernanceNotificationRead: () => mocks.markRead(),
|
||||
}))
|
||||
|
||||
import { GovernancePage } from './governance'
|
||||
|
||||
describe('GovernancePage', () => {
|
||||
beforeEach(() => {
|
||||
mocks.hasRole.mockReturnValue(false)
|
||||
mocks.summary.mockReturnValue({
|
||||
data: {
|
||||
pendingReviews: 11,
|
||||
pendingPromotions: 22,
|
||||
pendingReports: 33,
|
||||
unreadNotifications: 44,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.inbox.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'inbox-1' }, { id: 'inbox-2' }],
|
||||
total: 40,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.activity.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'activity-1' }, { id: 'activity-2' }, { id: 'activity-3' }],
|
||||
total: 60,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.notifications.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'notification-1' }],
|
||||
total: 21,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.rebuildSearchIndex.mockReturnValue({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
})
|
||||
mocks.markRead.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
})
|
||||
mocks.totalPages.mockClear()
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof GovernancePage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders the summary cards and core governance sections', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('governance.title')
|
||||
expect(html).toContain('governance.subtitle')
|
||||
expect(html).toContain('governance.pendingReviews')
|
||||
expect(html).toContain('11')
|
||||
expect(html).toContain('governance.pendingPromotions')
|
||||
expect(html).toContain('22')
|
||||
expect(html).toContain('governance.pendingReports')
|
||||
expect(html).toContain('33')
|
||||
expect(html).toContain('governance.unreadNotifications')
|
||||
expect(html).toContain('44')
|
||||
expect(html).toContain('governance.inboxTitle')
|
||||
expect(html).toContain('governance.notificationsTitle')
|
||||
expect(html).toContain('governance.activityTitle')
|
||||
expect(html).toContain('governance-inbox:2')
|
||||
expect(html).toContain('governance-notifications:1')
|
||||
expect(html).toContain('governance-activity:3')
|
||||
})
|
||||
|
||||
it('shows pagination only when a section has more than one page', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('pagination:0/2')
|
||||
expect(html).toContain('pagination:0/3')
|
||||
expect(html.match(/pagination:0\/\d+/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('hides all pagination when each section fits on one page', () => {
|
||||
mocks.inbox.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'inbox-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.activity.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'activity-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
mocks.notifications.mockReturnValue({
|
||||
data: {
|
||||
items: [{ id: 'notification-1' }],
|
||||
total: 0,
|
||||
size: 20,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).not.toContain('pagination:')
|
||||
})
|
||||
|
||||
it('hides the search maintenance area for non-super-admin users', () => {
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).not.toContain('governance.searchMaintenanceTitle')
|
||||
expect(html).not.toContain('governance.searchRebuildAction')
|
||||
})
|
||||
|
||||
it('shows the search maintenance area for super admins', () => {
|
||||
mocks.hasRole.mockReturnValue(true)
|
||||
|
||||
const html = renderToStaticMarkup(createElement(GovernancePage))
|
||||
|
||||
expect(html).toContain('governance.searchMaintenanceTitle')
|
||||
expect(html).toContain('governance.searchMaintenanceDescription')
|
||||
expect(html).toContain('governance.searchRebuildAction')
|
||||
expect(html).toContain('governance.searchMaintenanceHint')
|
||||
})
|
||||
})
|
||||
50
web/src/pages/dashboard/governance.test.tsx
Normal file
50
web/src/pages/dashboard/governance.test.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
const mocks = vi.hoisted(() => ({ inbox: vi.fn(), activity: vi.fn(), refetch: vi.fn(), notifications: vi.fn(), markRead: vi.fn() }))
|
||||
vi.mock('@tanstack/react-router', () => ({ Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a> }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('@/features/governance/use-governance', () => ({ GOVERNANCE_PAGE_SIZE: 10, useGovernanceInbox: (...args: unknown[]) => mocks.inbox(...args), useGovernanceActivity: (...args: unknown[]) => mocks.activity(...args), useGovernanceNotifications: (...args: unknown[]) => mocks.notifications(...args), useMarkGovernanceNotificationRead: () => ({ mutate: mocks.markRead, isPending: false }) }))
|
||||
vi.mock('@/features/governance/governance-inbox', () => ({ GovernanceInbox: ({ items }: { items?: { title: string }[] }) => <div>{items?.map((item) => <p key={item.title}>{item.title}</p>)}</div> }))
|
||||
vi.mock('@/features/governance/governance-notifications', () => ({ GovernanceNotifications: ({ onMarkRead }: { onMarkRead: (id: number) => void }) => <button onClick={() => onMarkRead(8)}>mark-governance-read</button> }))
|
||||
vi.mock('@/features/governance/governance-activity', () => ({ GovernanceActivity: () => <p>activity-list</p> }))
|
||||
import { GovernancePage } from './governance'
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.inbox.mockImplementation((type: string) => ({ data: { items: [{ title: type }], total: 22, size: 10 }, isLoading: false, refetch: mocks.refetch }))
|
||||
mocks.notifications.mockReturnValue({ data: { items: [], total: 0, size: 10 }, isLoading: false, refetch: mocks.refetch })
|
||||
mocks.activity.mockReturnValue({ data: { items: [], total: 0, size: 10 }, isLoading: false, refetch: mocks.refetch })
|
||||
})
|
||||
describe('governance responsibility boundary', () => {
|
||||
it('preserves the separate governance notification feed and mark-read action', () => {
|
||||
render(<GovernancePage />)
|
||||
expect(screen.getByText('reviewCenter.governanceNotifications').closest('details')?.open).toBe(false)
|
||||
fireEvent.click(screen.getByText('mark-governance-read'))
|
||||
expect(mocks.markRead).toHaveBeenCalledWith(8, expect.objectContaining({ onError: expect.any(Function) }))
|
||||
expect(mocks.notifications).toHaveBeenCalledWith(0, 10)
|
||||
})
|
||||
it('requests only report and promotion queues and links to the one review center', () => {
|
||||
render(<GovernancePage />)
|
||||
expect(mocks.inbox.mock.calls.map((call) => call[0])).toEqual(['REPORT', 'PROMOTION'])
|
||||
expect(screen.getByText('REPORT')).toBeTruthy()
|
||||
expect(screen.getByText('PROMOTION')).toBeTruthy()
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'reviewCenter.governanceReviewLink' }).getAttribute('href')).toBe('/dashboard/reviews')
|
||||
expect(screen.queryByText('governance.searchMaintenanceTitle')).toBeNull()
|
||||
})
|
||||
it('keeps report and promotion pagination independent', () => {
|
||||
render(<GovernancePage />)
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'pagination.next' })[0])
|
||||
expect(mocks.inbox).toHaveBeenCalledWith('REPORT', 1, 10)
|
||||
expect(mocks.inbox).toHaveBeenLastCalledWith('PROMOTION', 0, 10)
|
||||
})
|
||||
it('provides a retry for failed governance data instead of showing an empty queue', () => {
|
||||
mocks.inbox.mockImplementation((type: string) => type === 'REPORT' ? { isError: true, refetch: mocks.refetch } : { data: { items: [], total: 0, size: 10 } })
|
||||
render(<GovernancePage />)
|
||||
expect(screen.getByRole('alert').textContent).toContain('reviewCenter.loadFailed')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.retry' }))
|
||||
expect(mocks.refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,185 +1,51 @@
|
|||
import { useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { GovernanceInbox } from '@/features/governance/governance-inbox'
|
||||
import { GovernanceActivity } from '@/features/governance/governance-activity'
|
||||
import { GovernanceNotifications } from '@/features/governance/governance-notifications'
|
||||
import { toast } from '@/shared/lib/toast'
|
||||
import { GovernanceActivity } from '@/features/governance/governance-activity'
|
||||
import { getGovernanceTotalPages } from '@/features/governance/governance-pagination'
|
||||
import {
|
||||
GOVERNANCE_PAGE_SIZE,
|
||||
useGovernanceActivity,
|
||||
useGovernanceInbox,
|
||||
useGovernanceNotifications,
|
||||
useRebuildSearchIndex,
|
||||
useGovernanceSummary,
|
||||
useMarkGovernanceNotificationRead,
|
||||
} from '@/features/governance/use-governance'
|
||||
|
||||
type GovernanceInboxTab = 'ALL' | 'REVIEW' | 'PROMOTION' | 'REPORT'
|
||||
|
||||
/**
|
||||
* Dashboard page that aggregates governance summary counts, inbox queues, notifications, and
|
||||
* recent moderation activity.
|
||||
*/
|
||||
function SummaryCard({ label, value }: { label: string; value?: number }) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="mt-3 text-3xl font-bold font-heading">{value ?? 0}</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
import { GOVERNANCE_PAGE_SIZE, useGovernanceActivity, useGovernanceInbox, useGovernanceNotifications, useMarkGovernanceNotificationRead } from '@/features/governance/use-governance'
|
||||
|
||||
/** Governance handles reports and promotions; review requests have one dedicated queue. */
|
||||
export function GovernancePage() {
|
||||
const { t } = useTranslation()
|
||||
const { hasRole } = useAuth()
|
||||
const [inboxType, setInboxType] = useState<GovernanceInboxTab>('ALL')
|
||||
const [inboxPage, setInboxPage] = useState(0)
|
||||
const [reportPage, setReportPage] = useState(0)
|
||||
const [promotionPage, setPromotionPage] = useState(0)
|
||||
const [activityPage, setActivityPage] = useState(0)
|
||||
const [notificationsPage, setNotificationsPage] = useState(0)
|
||||
const [rebuildDialogOpen, setRebuildDialogOpen] = useState(false)
|
||||
const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary()
|
||||
const { data: inboxPageData, isLoading: isInboxLoading } = useGovernanceInbox(
|
||||
inboxType === 'ALL' ? undefined : inboxType,
|
||||
inboxPage,
|
||||
GOVERNANCE_PAGE_SIZE,
|
||||
)
|
||||
const { data: activityPageData, isLoading: isActivityLoading } = useGovernanceActivity(activityPage, GOVERNANCE_PAGE_SIZE)
|
||||
const { data: notificationsPageData, isLoading: isNotificationsLoading } = useGovernanceNotifications(
|
||||
notificationsPage,
|
||||
GOVERNANCE_PAGE_SIZE,
|
||||
)
|
||||
const markReadMutation = useMarkGovernanceNotificationRead()
|
||||
const rebuildSearchIndexMutation = useRebuildSearchIndex()
|
||||
|
||||
const canRebuildSearchIndex = hasRole('SUPER_ADMIN')
|
||||
const inboxTotalPages = getGovernanceTotalPages(inboxPageData?.total ?? 0, inboxPageData?.size ?? GOVERNANCE_PAGE_SIZE)
|
||||
const activityTotalPages = getGovernanceTotalPages(activityPageData?.total ?? 0, activityPageData?.size ?? GOVERNANCE_PAGE_SIZE)
|
||||
const notificationsTotalPages = getGovernanceTotalPages(notificationsPageData?.total ?? 0, notificationsPageData?.size ?? GOVERNANCE_PAGE_SIZE)
|
||||
|
||||
const handleRebuildSearchIndex = async () => {
|
||||
try {
|
||||
await rebuildSearchIndexMutation.mutateAsync()
|
||||
toast.success(t('governance.searchRebuildSuccessTitle'), t('governance.searchRebuildSuccessDescription'))
|
||||
} catch (error) {
|
||||
toast.error(t('governance.searchRebuildErrorTitle'), error instanceof Error ? error.message : '')
|
||||
throw error
|
||||
}
|
||||
const [notificationPage, setNotificationPage] = useState(0)
|
||||
const notifications = useGovernanceNotifications(notificationPage, GOVERNANCE_PAGE_SIZE)
|
||||
const markRead = useMarkGovernanceNotificationRead()
|
||||
const markNotificationRead = (id: number) => markRead.mutate(id, { onError: (error) => toast.error(t('reviewCenter.notificationReadFailed'), error instanceof Error ? error.message : '') })
|
||||
const reports = useGovernanceInbox('REPORT', reportPage, GOVERNANCE_PAGE_SIZE)
|
||||
const promotions = useGovernanceInbox('PROMOTION', promotionPage, GOVERNANCE_PAGE_SIZE)
|
||||
const activity = useGovernanceActivity(activityPage, GOVERNANCE_PAGE_SIZE)
|
||||
const section = (kind: 'REPORT' | 'PROMOTION', query: typeof reports, page: number, setPage: (page: number) => void) => {
|
||||
const totalPages = getGovernanceTotalPages(query.data?.total ?? 0, query.data?.size ?? GOVERNANCE_PAGE_SIZE)
|
||||
return <section className="space-y-4" aria-label={t(kind === 'REPORT' ? 'reviewCenter.governancePendingReports' : 'reviewCenter.governancePendingPromotions')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="text-xl font-semibold">{t(kind === 'REPORT' ? 'reviewCenter.governancePendingReports' : 'reviewCenter.governancePendingPromotions')}</h2><Link to={kind === 'REPORT' ? '/dashboard/reports' : '/dashboard/promotions'} className="text-sm font-medium underline-offset-4 hover:underline">{t('reviewCenter.viewAll')}</Link></div>
|
||||
{query.isError ? <div role="alert" className="space-y-3 rounded-lg border p-5"><p className="text-sm text-destructive">{t('reviewCenter.loadFailed')}</p><Button variant="outline" onClick={() => void query.refetch()}>{t('reviewCenter.retry')}</Button></div> : <GovernanceInbox items={query.data?.items} isLoading={query.isLoading} />}
|
||||
{totalPages > 1 && <Pagination analyticsId={kind === 'REPORT' ? 'governance.report.paginate' : 'governance.promotion.paginate'} page={page} totalPages={totalPages} onPageChange={setPage} />}
|
||||
</section>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('governance.title')} subtitle={t('governance.subtitle')} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<SummaryCard label={t('governance.pendingReviews')} value={isSummaryLoading ? undefined : summary?.pendingReviews} />
|
||||
<SummaryCard label={t('governance.pendingPromotions')} value={isSummaryLoading ? undefined : summary?.pendingPromotions} />
|
||||
<SummaryCard label={t('governance.pendingReports')} value={isSummaryLoading ? undefined : summary?.pendingReports} />
|
||||
<SummaryCard label={t('governance.unreadNotifications')} value={isSummaryLoading ? undefined : summary?.unreadNotifications} />
|
||||
return <div className="space-y-9">
|
||||
<DashboardPageHeader title={t('reviewCenter.governanceTitle')} subtitle={t('reviewCenter.governanceDescription')} actions={<Link to="/dashboard/reviews" className="inline-flex min-h-9 items-center rounded-lg border border-border/60 px-3 text-sm hover:bg-secondary">{t('reviewCenter.governanceReviewLink')}</Link>} />
|
||||
{section('REPORT', reports, reportPage, setReportPage)}
|
||||
{section('PROMOTION', promotions, promotionPage, setPromotionPage)}
|
||||
<details className="rounded-lg border border-border p-5">
|
||||
<summary className="cursor-pointer text-lg font-semibold">{t('reviewCenter.governanceNotifications')}</summary>
|
||||
<div className="mt-5 space-y-4">
|
||||
{notifications.isError ? <div role="alert" className="space-y-3"><p className="text-sm text-destructive">{t('reviewCenter.loadFailed')}</p><Button variant="outline" onClick={() => void notifications.refetch()}>{t('reviewCenter.retry')}</Button></div> : <GovernanceNotifications items={notifications.data?.items} isLoading={notifications.isLoading} onMarkRead={markNotificationRead} isMarkingRead={markRead.isPending} />}
|
||||
{getGovernanceTotalPages(notifications.data?.total ?? 0, notifications.data?.size ?? GOVERNANCE_PAGE_SIZE) > 1 && <Pagination analyticsId="governance.notification.paginate" page={notificationPage} totalPages={getGovernanceTotalPages(notifications.data?.total ?? 0, notifications.data?.size ?? GOVERNANCE_PAGE_SIZE)} onPageChange={setNotificationPage} />}
|
||||
</div>
|
||||
|
||||
<Card className="p-5 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold font-heading">{t('governance.inboxTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('governance.inboxSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue="ALL"
|
||||
onValueChange={(value) => {
|
||||
setInboxType(value as GovernanceInboxTab)
|
||||
setInboxPage(0)
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger data-analytics-id="governance.all.tab" value="ALL">{t('governance.tabAll')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="governance.review.tab" value="REVIEW">{t('governance.tabReview')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="governance.promotion.tab" value="PROMOTION">{t('governance.tabPromotion')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="governance.report.tab" value="REPORT">{t('governance.tabReport')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="ALL" className="mt-6">
|
||||
<GovernanceInbox items={inboxPageData?.items} isLoading={isInboxLoading} />
|
||||
{inboxTotalPages > 1 ? <Pagination analyticsId="governance.inbox.paginate" page={inboxPage} totalPages={inboxTotalPages} onPageChange={setInboxPage} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="REVIEW" className="mt-6">
|
||||
<GovernanceInbox items={inboxPageData?.items} isLoading={isInboxLoading} />
|
||||
{inboxTotalPages > 1 ? <Pagination analyticsId="governance.inbox.paginate" page={inboxPage} totalPages={inboxTotalPages} onPageChange={setInboxPage} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="PROMOTION" className="mt-6">
|
||||
<GovernanceInbox items={inboxPageData?.items} isLoading={isInboxLoading} />
|
||||
{inboxTotalPages > 1 ? <Pagination analyticsId="governance.inbox.paginate" page={inboxPage} totalPages={inboxTotalPages} onPageChange={setInboxPage} /> : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="REPORT" className="mt-6">
|
||||
<GovernanceInbox items={inboxPageData?.items} isLoading={isInboxLoading} />
|
||||
{inboxTotalPages > 1 ? <Pagination analyticsId="governance.inbox.paginate" page={inboxPage} totalPages={inboxTotalPages} onPageChange={setInboxPage} /> : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card className="p-5 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold font-heading">{t('governance.notificationsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('governance.notificationsSubtitle')}</p>
|
||||
</div>
|
||||
<GovernanceNotifications
|
||||
items={notificationsPageData?.items}
|
||||
isLoading={isNotificationsLoading}
|
||||
onMarkRead={(id) => markReadMutation.mutate(id)}
|
||||
isMarkingRead={markReadMutation.isPending}
|
||||
/>
|
||||
{notificationsTotalPages > 1 ? (
|
||||
<Pagination analyticsId="governance.notification.paginate" page={notificationsPage} totalPages={notificationsTotalPages} onPageChange={setNotificationsPage} />
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card className="p-5 space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold font-heading">{t('governance.activityTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('governance.activitySubtitle')}</p>
|
||||
</div>
|
||||
<GovernanceActivity items={activityPageData?.items} isLoading={isActivityLoading} />
|
||||
{activityTotalPages > 1 ? <Pagination analyticsId="governance.activity.paginate" page={activityPage} totalPages={activityTotalPages} onPageChange={setActivityPage} /> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{canRebuildSearchIndex ? (
|
||||
<>
|
||||
<Card className="p-5 space-y-4 border border-amber-500/20 bg-amber-500/5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold font-heading">{t('governance.searchMaintenanceTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('governance.searchMaintenanceDescription')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button data-analytics-id="governance.index.rebuild"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRebuildDialogOpen(true)}
|
||||
disabled={rebuildSearchIndexMutation.isPending}
|
||||
>
|
||||
{rebuildSearchIndexMutation.isPending ? t('governance.searchRebuildRunning') : t('governance.searchRebuildAction')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{t('governance.searchMaintenanceHint')}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog confirmAnalyticsId="governance.index.rebuild.confirm" cancelAnalyticsId="governance.index.rebuild.cancel"
|
||||
open={rebuildDialogOpen}
|
||||
onOpenChange={setRebuildDialogOpen}
|
||||
title={t('governance.searchRebuildConfirmTitle')}
|
||||
description={t('governance.searchRebuildConfirmDescription')}
|
||||
confirmText={t('governance.searchRebuildAction')}
|
||||
onConfirm={handleRebuildSearchIndex}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
</details>
|
||||
<section className="space-y-4 border-t border-border pt-7"><h2 className="text-xl font-semibold">{t('reviewCenter.governanceActivity')}</h2>
|
||||
{activity.isError ? <div role="alert" className="space-y-3"><p className="text-sm text-destructive">{t('reviewCenter.loadFailed')}</p><Button variant="outline" onClick={() => void activity.refetch()}>{t('reviewCenter.retry')}</Button></div> : <GovernanceActivity items={activity.data?.items} isLoading={activity.isLoading} />}
|
||||
{getGovernanceTotalPages(activity.data?.total ?? 0, activity.data?.size ?? GOVERNANCE_PAGE_SIZE) > 1 && <Pagination analyticsId="governance.activity.paginate" page={activityPage} totalPages={getGovernanceTotalPages(activity.data?.total ?? 0, activity.data?.size ?? GOVERNANCE_PAGE_SIZE)} onPageChange={setActivityPage} />}
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children, to }: { children: unknown; to: string }) => createElement('a', { href: to }, children as string),
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ slug: 'test-ns' }),
|
||||
useSearch: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/github-sync-review-queue', () => ({
|
||||
GithubSyncReviewQueue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
|
||||
paginationProps.push(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
const useNamespaceDetailMock = vi.fn()
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useNamespaceDetail: (...args: unknown[]) => useNamespaceDetailMock(...args),
|
||||
}))
|
||||
|
||||
const useReviewListMock = vi.fn()
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-header', () => ({
|
||||
NamespaceHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/namespace/namespace-management-nav', () => ({ NamespaceManagementBack: () => createElement('a', { href: '/dashboard/namespaces' }, 'back'), NamespaceManagementNav: ({ slug, active }: { slug: string; active: string }) => createElement('nav', { 'data-slug': slug, 'data-active': active }) }))
|
||||
|
||||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
|
||||
describe('NamespaceReviewsPage', () => {
|
||||
function createReviewItem(id: number) {
|
||||
return {
|
||||
id,
|
||||
namespace: 'demo-ns',
|
||||
skillSlug: `skill-${id}`,
|
||||
version: '1.0.0',
|
||||
submittedBy: 'user-1',
|
||||
submittedByName: 'User 1',
|
||||
submittedAt: '2026-04-01T12:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paginationProps.length = 0
|
||||
useNamespaceDetailMock.mockReset()
|
||||
useReviewListMock.mockReset()
|
||||
|
||||
useNamespaceDetailMock.mockReturnValue({
|
||||
data: {
|
||||
id: 100,
|
||||
slug: 'test-ns',
|
||||
displayName: 'Test Namespace',
|
||||
type: 'CUSTOM',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(1)],
|
||||
totalElements: 11,
|
||||
totalPages: 2,
|
||||
page,
|
||||
size: 10,
|
||||
total: 11,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof NamespaceReviewsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('uses a status filter instead of nested review tabs', () => {
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
expect(html).toContain('nsReviews.statusLabel')
|
||||
expect(html).toContain('nsReviews.tabPending')
|
||||
expect(html).toContain('nsReviews.sortLabel')
|
||||
expect(html).not.toContain('role="tab"')
|
||||
})
|
||||
|
||||
it('renders pagination for namespace review list when totalPages > 1', () => {
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
expect(html).toContain('data-active="reviews"')
|
||||
expect(html).toContain('data-slug="test-ns"')
|
||||
expect(html).toContain('nsReviews.pageSummary')
|
||||
expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1')
|
||||
expect(html).toContain('nsReviews.openReview')
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(2)
|
||||
})
|
||||
|
||||
it('does not render pagination when there is only one page', () => {
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(2)],
|
||||
totalElements: 1,
|
||||
totalPages: 1,
|
||||
page,
|
||||
size: 10,
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
||||
renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
expect(paginationProps).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not enable review queries before namespace detail resolves', () => {
|
||||
useNamespaceDetailMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
|
||||
expect(useReviewListMock).toHaveBeenCalled()
|
||||
for (const call of useReviewListMock.mock.calls) {
|
||||
expect(call[5]).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('lists avatar-only tasks in the same queue with their existing review detail link', () => {
|
||||
useReviewListMock.mockReturnValue({ data: { items: [{ ...createReviewItem(9), subjectType: 'SKILL_METADATA', version: null, avatarRemoval: true }], totalElements: 1, totalPages: 1 }, isLoading: false })
|
||||
const html = renderToStaticMarkup(createElement(NamespaceReviewsPage))
|
||||
expect(html).toContain('review.avatarRemoval')
|
||||
expect(html).toContain('/dashboard/namespaces/test-ns/reviews/9')
|
||||
expect(html).not.toContain('skillAvatar.reviewTitle')
|
||||
})
|
||||
|
||||
})
|
||||
20
web/src/pages/dashboard/namespace-reviews.test.tsx
Normal file
20
web/src/pages/dashboard/namespace-reviews.test.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
const mocks = vi.hoisted(() => ({ navigate: vi.fn(), queue: 'sync' as string | undefined }))
|
||||
vi.mock('@tanstack/react-router', () => ({ useParams: () => ({ slug: 'design' }), useSearch: () => ({ queue: mocks.queue }), useNavigate: () => mocks.navigate }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
import { NamespaceReviewsPage } from './namespace-reviews'
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks() })
|
||||
describe('namespace review compatibility entry', () => {
|
||||
it('opens the shared queue with the namespace and legacy sync filter', () => {
|
||||
render(<NamespaceReviewsPage />)
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/dashboard/reviews', search: { namespace: 'design', originType: 'GITHUB_SYNC' }, replace: true })
|
||||
})
|
||||
it('does not create another namespace-specific review list', () => {
|
||||
mocks.queue = undefined
|
||||
const view = render(<NamespaceReviewsPage />)
|
||||
expect(view.container.querySelector('table')).toBeNull()
|
||||
expect(mocks.navigate).toHaveBeenCalledWith({ to: '/dashboard/reviews', search: { namespace: 'design', originType: undefined }, replace: true })
|
||||
})
|
||||
})
|
||||
|
|
@ -1,188 +1,15 @@
|
|||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { useNamespaceDetail } from '@/shared/hooks/use-namespace-queries'
|
||||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
import { GithubSyncReviewQueue } from '@/features/review/github-sync-review-queue'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import { NamespaceManagementBack, NamespaceManagementNav } from '@/features/namespace/namespace-management-nav'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { NamespaceHeader } from '@/features/namespace/namespace-header'
|
||||
|
||||
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
type TimeSortDirection = 'ASC' | 'DESC'
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0
|
||||
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
REJECTED: 0,
|
||||
})
|
||||
const [activeStatus, setActiveStatus] = useState<ReviewStatus>('PENDING')
|
||||
const [sortDirection, setSortDirection] = useState<TimeSortDirection>('DESC')
|
||||
const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING')
|
||||
const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED')
|
||||
const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED')
|
||||
|
||||
const changePage = (status: ReviewStatus, nextPage: number) => {
|
||||
setPages((current) => ({ ...current, [status]: nextPage }))
|
||||
}
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
setSortDirection(value as TimeSortDirection)
|
||||
setPages({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
REJECTED: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const renderPagination = (status: ReviewStatus, totalElements: number, totalPages: number) => {
|
||||
if (totalPages <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentPage = pages[status]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 px-5 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<p>{t('nsReviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
|
||||
<Pagination analyticsId="review.namespace.paginate" page={currentPage} totalPages={totalPages} onPageChange={(nextPage) => changePage(status, nextPage)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderItems = (query: typeof pending, status: ReviewStatus) => {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-16 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const list = query.data?.items
|
||||
if (!list || list.length === 0) {
|
||||
return <Card className="p-10 text-center text-muted-foreground">{t('nsReviews.empty')}</Card>
|
||||
}
|
||||
return (
|
||||
<Card className="overflow-hidden divide-y divide-border/40">
|
||||
{list.map((review) => (
|
||||
<div key={review.id} className="p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="font-semibold font-heading">{review.namespace}/{review.skillSlug}</div>
|
||||
<div className="text-sm text-muted-foreground">{review.subjectType === 'SKILL_METADATA' ? t(review.requestedVisibility ? 'skillVisibility.title' : review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : t('nsReviews.version', { version: review.version })}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatLocalDateTime(
|
||||
status === 'PENDING' ? review.submittedAt : review.reviewedAt ?? review.submittedAt,
|
||||
i18n.language,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{review.reviewComment ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{review.reviewComment}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Link data-analytics-id="review.detail.open"
|
||||
to={buildNamespaceReviewDetailPath(slug, review.id)}
|
||||
className="inline-flex items-center rounded-md border border-border/60 px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{t('nsReviews.openReview')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={t('nsReviews.title')}>
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="w-full max-w-48">
|
||||
<label htmlFor="namespace-review-status" className="mb-2 block text-sm font-medium text-muted-foreground">{t('nsReviews.statusLabel')}</label>
|
||||
<Select value={activeStatus} onValueChange={(value) => setActiveStatus(value as ReviewStatus)}>
|
||||
<SelectTrigger data-analytics-id="review.namespace.status.interact" id="namespace-review-status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="review.namespace.status" value="PENDING">{t('nsReviews.tabPending')}</SelectItem>
|
||||
<SelectItem data-analytics-id="review.namespace.status" value="APPROVED">{t('nsReviews.tabApproved')}</SelectItem>
|
||||
<SelectItem data-analytics-id="review.namespace.status" value="REJECTED">{t('nsReviews.tabRejected')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full max-w-48">
|
||||
<label htmlFor="namespace-review-sort" className="mb-2 block text-sm font-medium text-muted-foreground">
|
||||
{t('nsReviews.sortLabel')}
|
||||
</label>
|
||||
<Select value={sortDirection} onValueChange={handleSortChange}>
|
||||
<SelectTrigger data-analytics-id="review.namespace.sort.interact" id="namespace-review-sort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="review.namespace.sort" value="DESC">{t('nsReviews.sortNewest')}</SelectItem>
|
||||
<SelectItem data-analytics-id="review.namespace.sort" value="ASC">{t('nsReviews.sortOldest')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{renderItems(activeStatus === 'PENDING' ? pending : activeStatus === 'APPROVED' ? approved : rejected, activeStatus)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Compatibility entry: a namespace selects the shared queue's scope, not another workbench. */
|
||||
export function NamespaceReviewsPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ from: '/dashboard/namespaces/$slug/reviews' })
|
||||
const queueType = search.queue ?? 'user'
|
||||
const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' })
|
||||
const { data: namespace } = useNamespaceDetail(slug)
|
||||
const readOnlyMessage = namespace?.type === 'GLOBAL'
|
||||
? t('nsReviews.globalReadOnly')
|
||||
: namespace?.status === 'FROZEN'
|
||||
? t('nsReviews.frozenReadOnly')
|
||||
: namespace?.status === 'ARCHIVED'
|
||||
? t('nsReviews.archivedReadOnly')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<NamespaceManagementBack />
|
||||
{namespace ? <NamespaceHeader namespace={namespace} /> : null}
|
||||
{readOnlyMessage ? (
|
||||
<Card className="border-border/50 bg-secondary/40 p-4 text-sm text-muted-foreground">
|
||||
{readOnlyMessage}
|
||||
</Card>
|
||||
) : null}
|
||||
<NamespaceManagementNav slug={slug} active="reviews" />
|
||||
<Tabs value={queueType} onValueChange={(value) => void navigate({
|
||||
to: '/dashboard/namespaces/$slug/reviews', params: { slug },
|
||||
search: { queue: value === 'sync' ? 'sync' : undefined }, replace: true,
|
||||
})}>
|
||||
<TabsList>
|
||||
<TabsTrigger data-analytics-id="review.namespace.user.tab" value="user">{t('reviews.typeSkill')}</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="review.namespace.sync.tab" value="sync">{t('reviews.typeSync')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="user" className="mt-5">
|
||||
<ReviewListSection namespaceId={namespace?.id} slug={slug} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sync" className="mt-5">
|
||||
<GithubSyncReviewQueue namespaceId={namespace?.id} namespaceSlug={slug}
|
||||
enabled={queueType === 'sync' && !!namespace?.id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
const search = useSearch({ from: '/dashboard/namespaces/$slug/reviews' })
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
void navigate({ to: '/dashboard/reviews', search: { namespace: slug, originType: search.queue === 'sync' ? 'GITHUB_SYNC' : undefined }, replace: true })
|
||||
}, [navigate, slug, search.queue])
|
||||
return <p className="py-6 text-sm text-muted-foreground" role="status">{t('reviewCenter.openingScope')}</p>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReviewSkillDetail } from '@/api/types'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const route = vi.hoisted(() => ({ id: '13' }))
|
||||
const route = vi.hoisted(() => ({ id: '13', search: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearch: () => route.search,
|
||||
useParams: (options?: { from?: string }) => (
|
||||
options?.from === '/dashboard/namespaces/$slug/reviews/$id'
|
||||
? { id: route.id, slug: 'team-alpha' }
|
||||
|
|
@ -55,6 +57,7 @@ const useReviewDetailMock = vi.fn<() => unknown>(() => ({
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
|
|
@ -66,7 +69,7 @@ const useReviewDetailMock = vi.fn<() => unknown>(() => ({
|
|||
isLoading: false,
|
||||
}))
|
||||
|
||||
const useReviewSkillDetailMock = vi.fn<() => unknown>(() => ({
|
||||
const useReviewSkillDetailMock = vi.fn<(...args: unknown[]) => unknown>(() => ({
|
||||
data: {
|
||||
skill: {
|
||||
id: 1,
|
||||
|
|
@ -115,6 +118,7 @@ const useReviewAttemptsMock = vi.fn<() => unknown>(() => ({
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
}],
|
||||
|
|
@ -134,7 +138,7 @@ vi.mock('@/shared/components/confirm-dialog', () => ({
|
|||
|
||||
vi.mock('@/features/review/use-review-detail', () => ({
|
||||
useReviewDetail: () => useReviewDetailMock(),
|
||||
useReviewSkillDetail: () => useReviewSkillDetailMock(),
|
||||
useReviewSkillDetail: (...args: unknown[]) => useReviewSkillDetailMock(...args),
|
||||
useReviewAttempts: () => useReviewAttemptsMock(),
|
||||
useGithubSyncReviewSource: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
useApproveReview: () => ({
|
||||
|
|
@ -153,7 +157,7 @@ vi.mock('@/features/auth/use-auth', () => ({
|
|||
}))
|
||||
|
||||
// Mock hooks used directly by the review-detail page for file browser sidebar
|
||||
const reviewFileMock = vi.fn((_taskId?: number, _path?: string | null, _enabled?: boolean) => ({ data: null, isLoading: false, error: null }))
|
||||
const reviewFileMock = vi.fn<(_taskId?: number, _path?: string | null, _enabled?: boolean) => { data: string | null; isLoading: boolean; error: Error | null }>(() => ({ data: null, isLoading: false, error: null }))
|
||||
vi.mock('@/features/review/use-review-file', () => ({
|
||||
useReviewFile: (...args: [number?, (string | null)?, boolean?]) => reviewFileMock(...args),
|
||||
}))
|
||||
|
|
@ -168,8 +172,9 @@ import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail'
|
|||
describe('ReviewDetailPage', () => {
|
||||
beforeEach(() => {
|
||||
route.id = '13'
|
||||
route.search = {}
|
||||
navigateMock.mockReset()
|
||||
reviewFileMock.mockClear()
|
||||
reviewFileMock.mockReset().mockReturnValue({ data: null, isLoading: false, error: null })
|
||||
userMock.platformRoles = ['SKILL_ADMIN']
|
||||
useReviewDetailMock.mockReset()
|
||||
useReviewSkillDetailMock.mockReset()
|
||||
|
|
@ -184,6 +189,7 @@ describe('ReviewDetailPage', () => {
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
}],
|
||||
|
|
@ -197,6 +203,7 @@ describe('ReviewDetailPage', () => {
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
|
|
@ -280,27 +287,29 @@ describe('ReviewDetailPage', () => {
|
|||
useSuiteDetailMock.mockReturnValue({ data: undefined, isLoading: false, refetch })
|
||||
|
||||
const view = render(<ReviewDetailPage />)
|
||||
expect((screen.getByRole('button', { name: 'review.approve' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'suite.reviewSnapshotRetry' }))
|
||||
expect(refetch).toHaveBeenCalledOnce()
|
||||
|
||||
useSuiteDetailMock.mockReturnValue({ data: { members: [] }, isLoading: false, refetch })
|
||||
view.rerender(<ReviewDetailPage />)
|
||||
expect((screen.getByRole('button', { name: 'review.approve' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }) as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the shared review overview and content tab in a single-column flow', () => {
|
||||
it('renders a compact task header, pill navigation and a right-hand request summary', () => {
|
||||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
|
||||
expect(html).toContain('flex min-h-[calc(100dvh-5.25rem)] w-full flex-col')
|
||||
expect(html).not.toContain('max-w-[1440px]')
|
||||
expect(html).toContain('review.tabContent')
|
||||
expect(html).toContain('review.tabChanges')
|
||||
expect(html).not.toContain('review.tabChanges')
|
||||
expect(html).toContain('review.tabHistory')
|
||||
expect(html).toContain('sticky bottom-0')
|
||||
expect(html).toContain('review.commentLabel')
|
||||
expect(html).toContain('review.approve')
|
||||
expect(html).not.toContain('<aside')
|
||||
expect(html).toContain('reviewWorkbench.approve.')
|
||||
expect(html).toContain('<aside')
|
||||
expect(html).toContain('reviewWorkbench.requestSummary')
|
||||
expect(html).not.toContain('overflow-x-auto')
|
||||
})
|
||||
|
||||
it('renders not-found state when the review record is missing', () => {
|
||||
|
|
@ -322,6 +331,7 @@ describe('ReviewDetailPage', () => {
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
|
|
@ -335,11 +345,11 @@ describe('ReviewDetailPage', () => {
|
|||
|
||||
const html = renderToStaticMarkup(<NamespaceReviewDetailPage />)
|
||||
|
||||
expect(html).toContain('review.detail')
|
||||
expect(html).toContain('reviewWorkbench.requestSummary')
|
||||
expect(html).toContain('demo-skill')
|
||||
})
|
||||
|
||||
it('redirects namespace reviews opened through the global route for namespace operators', () => {
|
||||
it('keeps authorized namespace tasks on the unified detail route for namespace operators', () => {
|
||||
userMock.platformRoles = []
|
||||
useReviewDetailMock.mockReturnValue({
|
||||
data: {
|
||||
|
|
@ -348,6 +358,7 @@ describe('ReviewDetailPage', () => {
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
|
|
@ -361,7 +372,8 @@ describe('ReviewDetailPage', () => {
|
|||
|
||||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
|
||||
expect(html).toBe('')
|
||||
expect(html).toContain('reviewWorkbench.requestSummary')
|
||||
expect(navigateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows not-found state when the namespace route slug does not match the review namespace', () => {
|
||||
|
|
@ -372,6 +384,7 @@ describe('ReviewDetailPage', () => {
|
|||
skillSlug: 'demo-skill',
|
||||
version: '1.2.0',
|
||||
status: 'PENDING',
|
||||
canReview: true,
|
||||
submittedBy: 'local-admin',
|
||||
submittedByName: 'Local Admin',
|
||||
submittedAt: '2026-03-19T00:00:00Z',
|
||||
|
|
@ -440,7 +453,8 @@ describe('ReviewDetailPage', () => {
|
|||
function metadataDetail(readyForApproval = true, removal = false) {
|
||||
useReviewDetailMock.mockReturnValue({ data: {
|
||||
id: 13, namespace: 'global', skillSlug: 'demo-skill', subjectType: 'SKILL_METADATA',
|
||||
skillVersionId: null, version: null, status: 'PENDING', submittedBy: 'owner', submittedAt: '2026-03-19T00:00:00Z',
|
||||
skillVersionId: null, version: null, status: 'PENDING',
|
||||
canReview: true, submittedBy: 'owner', submittedAt: '2026-03-19T00:00:00Z',
|
||||
}, isLoading: false })
|
||||
useReviewSkillDetailMock.mockReturnValue({ data: {
|
||||
skill: { id: 1, displayName: 'Demo Skill', slug: 'demo-skill', namespace: 'global' },
|
||||
|
|
@ -454,10 +468,10 @@ describe('ReviewDetailPage', () => {
|
|||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
expect(html.match(/<h2[^>]*>review.avatarUpdate<\/h2>/g)).toHaveLength(1)
|
||||
expect(html).not.toMatch(/<p\b[^>]*>(?:(?!<\/p>)[\s\S])*<div\b/)
|
||||
expect(html).toContain('review.metadataChange')
|
||||
expect(html).toContain('reviewWorkbench.kind.avatar')
|
||||
expect(html).toContain('review.currentAvatar')
|
||||
expect(html).toContain('src="/candidate"')
|
||||
expect(html).toContain('review.approve')
|
||||
expect(html).toContain('reviewWorkbench.approve.')
|
||||
expect(html).not.toContain('review.downloadSkillZip')
|
||||
expect(html).not.toContain('review.activeReviewVersion')
|
||||
expect(html).not.toContain('review.skillDetailTitle')
|
||||
|
|
@ -469,7 +483,7 @@ describe('ReviewDetailPage', () => {
|
|||
metadataDetail(false)
|
||||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
expect(html).toContain('review.approveDisabledAvatar')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>review.approve<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>reviewWorkbench.approve.avatar<\/button>/)
|
||||
expect(html).toContain('review.reject')
|
||||
})
|
||||
|
||||
|
|
@ -487,7 +501,7 @@ describe('ReviewDetailPage', () => {
|
|||
useReviewSkillDetailMock.mockReturnValue({ ...detail, data: { ...detail.data, avatarChange: { ...detail.data.avatarChange, machineStatus: 'BLOCKED' } } })
|
||||
const html = renderToStaticMarkup(<ReviewDetailPage />)
|
||||
expect(html).toContain('review.avatarOverrideReason')
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>review.approve<\/button>/)
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>reviewWorkbench.approve.avatar<\/button>/)
|
||||
})
|
||||
|
||||
it('keeps visibility changes and their approval gate inside the unified detail tabs', () => {
|
||||
|
|
@ -500,7 +514,7 @@ describe('ReviewDetailPage', () => {
|
|||
} })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByText('skillVisibility.PRIVATE → skillVisibility.PUBLIC')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'review.approve' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('tab', { name: 'review.tabSourceSecurity' })).toBeTruthy()
|
||||
expect(screen.getByLabelText('review.commentLabel')).toBeTruthy()
|
||||
})
|
||||
|
|
@ -512,7 +526,7 @@ describe('ReviewDetailPage', () => {
|
|||
avatarChange: { ...detail.data.avatarChange, machineStatus: 'BLOCKED' },
|
||||
} })
|
||||
render(<ReviewDetailPage />)
|
||||
const approve = screen.getByRole('button', { name: 'review.approve' })
|
||||
const approve = screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ })
|
||||
expect(approve.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.change(screen.getByLabelText('review.commentLabel'), { target: { value: 'Reviewed the candidate and accept the exception.' } })
|
||||
expect(approve.hasAttribute('disabled')).toBe(false)
|
||||
|
|
@ -520,4 +534,95 @@ describe('ReviewDetailPage', () => {
|
|||
expect(screen.getByRole('dialog').textContent).toBe('review.approveTitle')
|
||||
})
|
||||
|
||||
it('uses the task permission flag for a read-only author rather than showing review actions', () => {
|
||||
const snapshot = useReviewDetailMock() as { data: Record<string, unknown> }
|
||||
useReviewDetailMock.mockReturnValue({ ...snapshot, data: { ...snapshot.data, canReview: false } })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByText('reviewWorkbench.viewOnly')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^reviewWorkbench\.approve\./ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'review.reject' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows effective custom presentation and a changes tab only with the endpoint baseline', () => {
|
||||
const snapshot = useReviewSkillDetailMock() as { data: ReviewSkillDetail }
|
||||
const current = { displayName: 'Pending customer presentation', summary: 'Pending custom summary', overview: '# Pending custom overview', overviewSource: 'WEB' as const }
|
||||
useReviewSkillDetailMock.mockReturnValue({ ...snapshot, data: { ...snapshot.data, presentation: current,
|
||||
comparison: { baseVersion: '1.0.0', baseline: { ...current, summary: 'Published summary' }, current, files: [] },
|
||||
} })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Pending customer presentation' }).textContent).toBe('Pending customer presentation')
|
||||
expect(screen.getByText('Pending custom summary')).toBeTruthy()
|
||||
expect(screen.getByRole('tab', { name: 'review.tabChanges' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'review.tabChanges' }))
|
||||
expect(screen.getByText('Published summary')).toBeTruthy()
|
||||
expect(screen.getByText('1.0.0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps approval blocked after a failed comparison when switching to history', () => {
|
||||
const snapshot = useReviewSkillDetailMock() as { data: ReviewSkillDetail }
|
||||
const current = { displayName: 'Pending', summary: 'Pending summary', overview: '# Pending', overviewSource: 'WEB' }
|
||||
const data = { ...snapshot.data, presentation: current, comparison: { baseVersion: '1.0.0', baseline: current, current, files: [] } }
|
||||
let comparisonFailed = false
|
||||
useReviewSkillDetailMock.mockImplementation((_id, _enabled, includeDocumentation) => includeDocumentation
|
||||
? { data: undefined, isLoading: false, error: comparisonFailed ? new Error('comparison failed') : null }
|
||||
: { ...snapshot, data })
|
||||
const { rerender } = render(<ReviewDetailPage />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'review.tabChanges' }))
|
||||
comparisonFailed = true
|
||||
rerender(<ReviewDetailPage />)
|
||||
expect(screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'review.tabHistory' }))
|
||||
expect(screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks approval while required fallback presentation cannot be read and retains the error', () => {
|
||||
const snapshot = useReviewSkillDetailMock() as { data: ReviewSkillDetail }
|
||||
useReviewSkillDetailMock.mockReturnValue({ ...snapshot, data: { ...snapshot.data, documentationContent: null,
|
||||
presentation: { displayName: 'Pending', summary: 'Pending summary', overview: null, overviewSource: 'README' },
|
||||
} })
|
||||
reviewFileMock.mockReturnValue({ data: null, isLoading: false, error: new Error('unavailable') })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getAllByText('review.documentationLoadFailed').length).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'review.tabHistory' }))
|
||||
expect(screen.getByRole('button', { name: /^reviewWorkbench\.approve\./ }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show contradictory generic scan evidence for a visibility snapshot', () => {
|
||||
metadataDetail()
|
||||
const task = useReviewDetailMock() as { data: Record<string, unknown> }
|
||||
const detail = useReviewSkillDetailMock() as { data: Record<string, unknown> }
|
||||
useReviewDetailMock.mockReturnValue({ ...task, data: { ...task.data, previousVisibility: 'PRIVATE', requestedVisibility: 'PUBLIC' } })
|
||||
useReviewSkillDetailMock.mockReturnValue({ ...detail, data: { ...detail.data, avatarChange: null,
|
||||
visibilityChange: { previousVisibility: 'PRIVATE', requestedVisibility: 'PUBLIC', scanStatus: 'PASSED', readyForApproval: true },
|
||||
} })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByText('skillVisibility.scanPASSED')).toBeTruthy()
|
||||
expect(screen.queryByText('review.scanUnavailable')).toBeNull()
|
||||
expect(screen.queryByRole('tab', { name: 'review.tabChanges' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'reviewWorkbench.approve.visibility' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('preserves approved task content but removes executable decisions', () => {
|
||||
const task = useReviewDetailMock() as { data: Record<string, unknown> }
|
||||
useReviewDetailMock.mockReturnValue({ ...task, data: { ...task.data, status: 'APPROVED', reviewedAt: '2026-03-20T00:00:00Z' } })
|
||||
render(<ReviewDetailPage />)
|
||||
expect(screen.getByRole('tab', { name: 'review.tabContent' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: /^reviewWorkbench\.approve\./ })).toBeNull()
|
||||
expect(screen.queryByLabelText('review.commentLabel')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns to the unified queue with the original filter and page context', () => {
|
||||
route.search = { namespace: 'team-alpha', q: 'pending summary', status: 'REJECTED', page: 2 }
|
||||
render(<ReviewDetailPage />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'review.backToList' }))
|
||||
const target = navigateMock.mock.calls.at(-1)?.[0]?.to as string
|
||||
expect(target).toContain('/dashboard/reviews?')
|
||||
const query = new URL(target, 'http://localhost').searchParams
|
||||
expect(query.get('namespace')).toBe('team-alpha')
|
||||
expect(query.get('q')).toBe('pending summary')
|
||||
expect(query.get('status')).toBe('REJECTED')
|
||||
expect(query.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from '@tanstack/react-router'
|
||||
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import {
|
||||
buildGlobalReviewsPath,
|
||||
buildNamespaceReviewDetailPath,
|
||||
buildNamespaceReviewsPath,
|
||||
canAccessGlobalReviewCenter,
|
||||
} from '@/features/review/review-paths'
|
||||
import { buildReviewReturnPath } from '@/features/review/review-route-search'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { ReviewTaskStatus, ReviewTaskSummary, reviewRequestKey } from '@/features/review/review-task-summary'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Textarea } from '@/shared/ui/textarea'
|
||||
|
|
@ -54,14 +50,12 @@ function ReviewDetailScreen({
|
|||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { user } = useAuth()
|
||||
|
||||
const { data: review, isLoading } = useReviewDetail(taskId)
|
||||
const isSuiteReview = review?.subjectType === 'SUITE_VERSION'
|
||||
const isMetadataReview = review?.subjectType === 'SKILL_METADATA' && !review.requestedVisibility
|
||||
const isGithubSyncReview = review?.originType === 'GITHUB_SYNC'
|
||||
const returnTo = isGithubSyncReview
|
||||
? `${backTo}?${namespaceSlug ? 'queue' : 'type'}=sync` : backTo
|
||||
const returnTo = backTo
|
||||
const { data: githubSource, isLoading: isLoadingGithubSource, isError: isGithubSourceError } =
|
||||
useGithubSyncReviewSource(taskId, isGithubSyncReview)
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||
|
|
@ -77,19 +71,26 @@ function ReviewDetailScreen({
|
|||
data: reviewSkillDetail,
|
||||
isLoading: isLoadingReviewSkillDetail,
|
||||
error: reviewSkillDetailError,
|
||||
refetch: refetchReviewSkillDetail,
|
||||
} = useReviewSkillDetail(taskId, Boolean(review) && !isSuiteReview, false)
|
||||
const hasBaseline = Boolean(!isMetadataReview && !review?.requestedVisibility && !isSuiteReview
|
||||
&& (isGithubSyncReview ? githubSource?.baseVersionId : reviewSkillDetail?.comparison))
|
||||
const availableTab = activeTab === 'changes' && !hasBaseline ? 'content' : activeTab
|
||||
const comparisonQuery = useReviewSkillDetail(taskId, Boolean(review) && hasBaseline && availableTab === 'changes', true)
|
||||
const overviewNeedsDocumentation = !reviewSkillDetail?.presentation || ['README', 'SKILL_MD'].includes(reviewSkillDetail.presentation.overviewSource ?? 'NONE')
|
||||
const documentationFile = reviewSkillDetail?.files.find(
|
||||
(file) => file.filePath === reviewSkillDetail.documentationPath,
|
||||
)
|
||||
const documentationTooLarge = Boolean(documentationFile && documentationFile.fileSize > 256 * 1024)
|
||||
const shouldLoadDocumentation = !isMetadataReview && activeTab === 'content' && Boolean(reviewSkillDetail?.documentationPath)
|
||||
const shouldLoadDocumentation = !isMetadataReview && overviewNeedsDocumentation && availableTab === 'content' && Boolean(reviewSkillDetail?.documentationPath)
|
||||
&& !documentationTooLarge && (!isGithubSyncReview || Boolean(githubSource) || isGithubSourceError)
|
||||
const {
|
||||
data: documentationContent,
|
||||
isLoading: isLoadingDocumentation,
|
||||
error: documentationError,
|
||||
refetch: refetchDocumentation,
|
||||
} = useReviewFile(taskId, reviewSkillDetail?.documentationPath ?? null, shouldLoadDocumentation)
|
||||
const contentDetail = reviewSkillDetail && documentationContent !== undefined
|
||||
const contentDetail = reviewSkillDetail && typeof documentationContent === 'string'
|
||||
? { ...reviewSkillDetail, documentationContent }
|
||||
: reviewSkillDetail
|
||||
const activeReviewVersion = reviewSkillDetail?.versions?.find(
|
||||
|
|
@ -97,10 +98,9 @@ function ReviewDetailScreen({
|
|||
)
|
||||
const isApprovalBlockedByScanning = !isMetadataReview && activeReviewVersion?.status === 'SCANNING'
|
||||
const skillId = reviewSkillDetail?.skill?.id
|
||||
const versionId = reviewSkillDetail?.versions?.find((version) => version.version === review?.version)?.id
|
||||
?? review?.skillVersionId ?? undefined
|
||||
const versionId = review?.skillVersionId ?? activeReviewVersion?.id
|
||||
const { data: securityAudits, isLoading: isLoadingSecurityAudits, isError: isSecurityAuditsError } =
|
||||
useSecurityAudits(isMetadataReview ? undefined : skillId, isMetadataReview ? undefined : versionId)
|
||||
useSecurityAudits(isMetadataReview || review?.requestedVisibility ? undefined : skillId, isMetadataReview || review?.requestedVisibility ? undefined : versionId)
|
||||
const { data: reviewSuiteDetail, isLoading: isLoadingReviewSuiteDetail, refetch: refetchReviewSuiteDetail } = useSuiteDetail(
|
||||
review?.namespace || '',
|
||||
review?.subjectSlug || '',
|
||||
|
|
@ -131,36 +131,22 @@ function ReviewDetailScreen({
|
|||
const [approveDialog, setApproveDialog] = useState(false)
|
||||
const [rejectDialog, setRejectDialog] = useState(false)
|
||||
useEffect(() => setSelectedTab(null), [taskId])
|
||||
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
|
||||
const shouldRedirectToNamespaceRoute =
|
||||
!namespaceSlug &&
|
||||
!!review &&
|
||||
review.namespace !== 'global' &&
|
||||
!hasGlobalReviewAccess
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return formatLocalDateTime(dateString, i18n.language)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirectToNamespaceRoute || !review) {
|
||||
return
|
||||
}
|
||||
|
||||
void navigate({
|
||||
to: buildNamespaceReviewDetailPath(review.namespace, review.id),
|
||||
replace: true,
|
||||
})
|
||||
}, [navigate, review, shouldRedirectToNamespaceRoute])
|
||||
|
||||
const isApprovalBlockedByAvatar = reviewSkillDetail?.avatarChange?.readyForApproval === false || reviewSkillDetail?.visibilityChange?.readyForApproval === false
|
||||
const isAvatarOverrideReasonRequired = reviewSkillDetail?.avatarChange?.machineStatus === 'BLOCKED' && !comment.trim()
|
||||
const isApprovalBlocked = isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired
|
||||
const requiresDocumentationVerification = !isMetadataReview && overviewNeedsDocumentation && Boolean(reviewSkillDetail?.documentationPath)
|
||||
const isDocumentationUnverified = requiresDocumentationVerification && (documentationTooLarge || isLoadingDocumentation || Boolean(documentationError)
|
||||
|| (typeof documentationContent !== 'string' && typeof reviewSkillDetail?.documentationContent !== 'string'))
|
||||
const isComparisonUnverified = hasBaseline && (comparisonQuery.isLoading || Boolean(comparisonQuery.error))
|
||||
const isApprovalBlocked = isDocumentationUnverified || isComparisonUnverified || isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired
|
||||
|| (isSuiteReview ? !reviewSuiteDetail : isLoadingReviewSkillDetail || !!reviewSkillDetailError || !reviewSkillDetail)
|
||||
|| (isGithubSyncReview && !githubSource)
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (isApprovalBlocked) {
|
||||
if (isApprovalBlocked || review?.canReview !== true || review.status !== 'PENDING') {
|
||||
return
|
||||
}
|
||||
if (needsLicenseBasis && !comment.trim()) {
|
||||
|
|
@ -171,6 +157,7 @@ function ReviewDetailScreen({
|
|||
}
|
||||
|
||||
const handleReject = async () => {
|
||||
if (review?.canReview !== true || review.status !== 'PENDING') return
|
||||
if (!comment.trim()) {
|
||||
toast.error(t('review.rejectReasonRequired'))
|
||||
return
|
||||
|
|
@ -187,10 +174,6 @@ function ReviewDetailScreen({
|
|||
)
|
||||
}
|
||||
|
||||
if (shouldRedirectToNamespaceRoute) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!review) {
|
||||
return (
|
||||
<div className="text-center py-20 animate-fade-up">
|
||||
|
|
@ -218,114 +201,29 @@ function ReviewDetailScreen({
|
|||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100dvh-5.25rem)] w-full flex-col gap-4 animate-fade-up">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
{t(isSuiteReview ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')} · {t('review.detail')} · {t('review.id')}: {review.id}
|
||||
</p>
|
||||
<h1 className="break-words text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{reviewSkillDetail?.skill.displayName || review.subjectSlug || review.skillSlug || t('review.detail')}
|
||||
</h1>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">
|
||||
{review.namespace}/{review.subjectSlug || review.skillSlug}
|
||||
</p>
|
||||
</div>
|
||||
<Button data-analytics-id="review.list.open" variant="outline" size="sm" className="px-2.5" onClick={() => navigate({ to: returnTo })}>
|
||||
{t('review.backToList')}
|
||||
<div className="space-y-4">
|
||||
<Button data-analytics-id="review.list.open" variant="link" size="sm" className="h-auto px-0 py-0 text-xs" onClick={() => navigate({ to: returnTo })}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />{t('review.backToList')}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
<h1 className="break-words text-[27px] font-medium leading-9 tracking-[-0.8px]">{reviewSkillDetail?.presentation?.displayName || reviewSkillDetail?.skill.displayName || review.subjectSlug || review.skillSlug || t('review.detail')}</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-medium">{t(`reviewWorkbench.kind.${reviewRequestKey(review, hasBaseline)}`)}</span>
|
||||
<ReviewTaskStatus status={review.status} />
|
||||
<span className="min-w-0 break-all text-xs text-muted-foreground">@{review.namespace}/{review.subjectSlug || review.skillSlug}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="space-y-3 p-4">
|
||||
{reviewSkillDetail?.skill.summary && (
|
||||
<p className="max-w-3xl whitespace-pre-wrap text-sm leading-6 text-foreground/80">{reviewSkillDetail.skill.summary}</p>
|
||||
)}
|
||||
<dl className={`grid gap-x-4 gap-y-3 text-xs sm:grid-cols-2 ${isGithubSyncReview ? 'lg:grid-cols-5' : 'lg:grid-cols-4'}`}>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground">{t('review.status')}</dt>
|
||||
<dd className="mt-1 font-semibold">
|
||||
<span className={review.status === 'APPROVED' ? 'text-emerald-600' : review.status === 'REJECTED' ? 'text-destructive' : 'text-amber-600'}>
|
||||
{t(review.status === 'PENDING' ? 'review.statusPending' : review.status === 'APPROVED' ? 'review.statusApproved' : review.status === 'REJECTED' ? 'review.statusRejected' : 'review.statusSuperseded')}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
{isGithubSyncReview && (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground">{t('review.githubRevision')}</dt>
|
||||
<dd className="mt-1 font-semibold">{githubSource ? t('review.githubRevisionValue', { count: githubSource.syncRevision }) : '—'}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground">{isGithubSyncReview ? t('review.githubRepository') : t('review.version')}</dt>
|
||||
<dd className="mt-1 break-all font-medium">
|
||||
{isGithubSyncReview ? githubSource ? `${githubSource.ownerLogin}/${githubSource.repositoryName}` : '—' : review.requestedVisibility ? t('skillVisibility.title') : isMetadataReview ? t('review.metadataChange') : review.version}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground">{isGithubSyncReview ? t('review.githubCommit') : t('review.submitter')}</dt>
|
||||
<dd className="mt-1 break-all font-medium">
|
||||
{isGithubSyncReview ? githubSource?.commitSha.slice(0, 10) || '—' : review.submittedByName || review.submittedBy}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted-foreground">{t('review.submitTime')}</dt>
|
||||
<dd className="mt-1 font-medium">{formatDate(review.submittedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{!isSuiteReview && !isMetadataReview && (
|
||||
<div className="flex flex-wrap gap-2 border-t pt-3 text-xs">
|
||||
<button data-analytics-id="review.security.open" type="button" onClick={() => setSelectedTab('source')}
|
||||
className="inline-flex min-h-8 flex-wrap items-center gap-2 rounded border border-border bg-muted/20 px-2.5 py-1 text-left hover:bg-muted/50">
|
||||
<span className="font-medium">{t('review.scanSummary')}</span>
|
||||
{isLoadingReviewSkillDetail || (skillId && versionId && isLoadingSecurityAudits)
|
||||
? <span className="text-muted-foreground">{t('review.scanLoading')}</span>
|
||||
: reviewSkillDetailError || isSecurityAuditsError
|
||||
? <span className="text-destructive">{t('review.scanLoadFailed')}</span>
|
||||
: !skillId || !versionId ? <span className="text-muted-foreground">{t('review.scanUnavailable')}</span>
|
||||
: securityAudits?.length ? securityAudits.map((audit) => (
|
||||
<span key={audit.id} className="inline-flex items-center gap-1">
|
||||
<span className="font-mono text-muted-foreground">{audit.scannerType}</span>
|
||||
<VerdictBadge displayState={getSecurityAuditDisplayState(audit, activeReviewVersion?.status)} compact />
|
||||
</span>
|
||||
)) : <span className="text-muted-foreground">{t(activeReviewVersion?.status === 'SCANNING' ? 'review.scanPending'
|
||||
: activeReviewVersion?.status === 'SCAN_FAILED' ? 'review.scanFailed' : 'review.scanUnavailable')}</span>}
|
||||
</button>
|
||||
{isGithubSyncReview && (
|
||||
<button data-analytics-id="review.security.open" type="button" onClick={() => setSelectedTab('source')}
|
||||
className="inline-flex min-h-8 items-center gap-2 rounded border border-border bg-muted/20 px-2.5 py-1 text-left hover:bg-muted/50">
|
||||
<span className="font-medium">{t('review.githubLicenseStatus')}</span>
|
||||
<span className={githubSource?.evidence.status !== 'RECOGNIZED' ? 'text-amber-700 dark:text-amber-400' : 'text-emerald-700 dark:text-emerald-400'}>
|
||||
{githubSource ? t(`review.githubLicense${githubSource.evidence.status}`, { defaultValue: githubSource.evidence.status })
|
||||
: t(isGithubSourceError ? 'review.githubSourceError' : 'review.scanLoading')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isGithubSyncReview && (
|
||||
<details className="border-t pt-4 text-sm">
|
||||
<summary className="cursor-pointer font-medium">{t('review.technicalDetails')}</summary>
|
||||
<dl className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div><dt className="text-muted-foreground">{t('review.version')}</dt><dd className="break-all font-mono">{review.version}</dd></div>
|
||||
<div><dt className="text-muted-foreground">{t('review.submitter')}</dt><dd className="break-all">{review.submittedByName || review.submittedBy}</dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setSelectedTab} className="min-w-0 space-y-4">
|
||||
<TabsList className="flex w-full gap-4 overflow-x-auto">
|
||||
<TabsTrigger data-analytics-id="review.content.tab" value="content" className="py-2 text-xs">{t('review.tabContent')}</TabsTrigger>
|
||||
{!isSuiteReview && !isMetadataReview && <TabsTrigger data-analytics-id="review.changes.tab" value="changes" className="py-2 text-xs">{t('review.tabChanges')}</TabsTrigger>}
|
||||
{!isSuiteReview && !isMetadataReview && <TabsTrigger data-analytics-id="review.source.tab" value="source" className="py-2 text-xs">{t('review.tabSourceSecurity')}</TabsTrigger>}
|
||||
<TabsTrigger data-analytics-id="review.history.tab" value="history" className="py-2 text-xs">{t('review.tabHistory')}</TabsTrigger>
|
||||
<div className="grid min-w-0 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_268px]">
|
||||
<div className="order-2 min-w-0 xl:order-1">
|
||||
<Tabs value={availableTab} onValueChange={setSelectedTab} className="min-w-0 space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2 gap-1 rounded-2xl border-0 bg-secondary/70 p-1 sm:inline-flex sm:w-auto sm:flex-wrap sm:rounded-full" aria-label={t('reviewWorkbench.contentNavigation')}>
|
||||
<TabsTrigger data-analytics-id="review.content.tab" value="content" className="mb-0 min-h-9 rounded-xl border-0 px-4 py-2 font-normal data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:shadow-sm sm:rounded-full">{t('review.tabContent')}</TabsTrigger>
|
||||
{hasBaseline && <TabsTrigger data-analytics-id="review.changes.tab" value="changes" className="mb-0 min-h-9 rounded-xl border-0 px-4 py-2 font-normal data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:shadow-sm sm:rounded-full">{t('review.tabChanges')}</TabsTrigger>}
|
||||
{!isSuiteReview && !isMetadataReview && <TabsTrigger data-analytics-id="review.source.tab" value="source" className="mb-0 min-h-9 rounded-xl border-0 px-4 py-2 font-normal data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:shadow-sm sm:rounded-full">{t('review.tabSourceSecurity')}</TabsTrigger>}
|
||||
<TabsTrigger data-analytics-id="review.history.tab" value="history" className="mb-0 min-h-9 rounded-xl border-0 px-4 py-2 font-normal data-[state=active]:bg-background data-[state=active]:font-medium data-[state=active]:shadow-sm sm:rounded-full">{t('review.tabHistory')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="content" className="space-y-4">
|
||||
{review.requestedVisibility && <section className="rounded-xl border p-4 space-y-2">
|
||||
<h2 className="font-semibold">{t('skillVisibility.title')}</h2>
|
||||
<p>{t(`skillVisibility.${review.previousVisibility}`)} → {t(`skillVisibility.${review.requestedVisibility}`)}</p>
|
||||
{reviewSkillDetail?.visibilityChange && <p>{t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`)}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t('skillVisibility.expanding')}</p>
|
||||
</section>}
|
||||
{reviewSkillDetail?.avatarChange && <ReviewAvatarChangeSection change={reviewSkillDetail.avatarChange} name={reviewSkillDetail.skill.displayName || review.skillSlug || ''} />}
|
||||
|
||||
{isSuiteReview && (
|
||||
|
|
@ -373,7 +271,8 @@ function ReviewDetailScreen({
|
|||
hasError={Boolean(reviewSkillDetailError)}
|
||||
documentationLoading={shouldLoadDocumentation && isLoadingDocumentation}
|
||||
documentationError={Boolean(shouldLoadDocumentation && documentationError)}
|
||||
documentationTooLarge={documentationTooLarge} reviewId={taskId} />
|
||||
documentationTooLarge={documentationTooLarge} reviewId={taskId}
|
||||
onRetry={() => { void refetchReviewSkillDetail?.(); void refetchDocumentation?.() }} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="changes" className="space-y-4">
|
||||
|
|
@ -385,16 +284,17 @@ function ReviewDetailScreen({
|
|||
{!isSuiteReview && !isMetadataReview && (
|
||||
isGithubSyncReview ? (
|
||||
<details className="rounded border bg-card p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">{t('review.complianceChanges')}</summary>
|
||||
<div className="mt-3"><ReviewSkillChangesSection detail={reviewSkillDetail} isLoading={isLoadingReviewSkillDetail}
|
||||
hasError={Boolean(reviewSkillDetailError)} concealTechnicalVersions /></div>
|
||||
<summary className="cursor-pointer font-medium">{t('reviewWorkbench.presentationChanges')}</summary>
|
||||
<div className="mt-3"><ReviewSkillChangesSection detail={comparisonQuery.data ?? reviewSkillDetail} isLoading={comparisonQuery.isLoading}
|
||||
hasError={Boolean(comparisonQuery.error)} onRetry={() => void comparisonQuery.refetch?.()} concealTechnicalVersions /></div>
|
||||
</details>
|
||||
) : <ReviewSkillChangesSection detail={reviewSkillDetail} isLoading={isLoadingReviewSkillDetail}
|
||||
hasError={Boolean(reviewSkillDetailError)} />
|
||||
) : <ReviewSkillChangesSection detail={comparisonQuery.data ?? reviewSkillDetail} isLoading={comparisonQuery.isLoading}
|
||||
hasError={Boolean(comparisonQuery.error)} onRetry={() => void comparisonQuery.refetch?.()} />
|
||||
)}
|
||||
</TabsContent>
|
||||
{!isSuiteReview && !isMetadataReview && <TabsContent value="source" className="space-y-4">
|
||||
{skillId && versionId ? <SecurityAuditSection skillId={skillId} versionId={versionId}
|
||||
{review.requestedVisibility ? <Card className="space-y-3 p-5"><h2 className="text-lg font-semibold">{t('reviewWorkbench.boundScan')}</h2><p className="text-sm">{reviewSkillDetail?.visibilityChange ? t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`) : t('review.scanLoadFailed')}</p><p className="text-xs text-muted-foreground">{t('reviewWorkbench.boundScanDescription')}</p></Card>
|
||||
: skillId && versionId ? <SecurityAuditSection skillId={skillId} versionId={versionId}
|
||||
versionStatus={activeReviewVersion?.status} showEmptyState compact />
|
||||
: <Card className="p-4 text-sm text-muted-foreground">{t('review.scanUnavailable')}</Card>}
|
||||
{isGithubSyncReview && (isLoadingGithubSource ? <Card className="h-32 animate-shimmer" />
|
||||
|
|
@ -418,9 +318,29 @@ function ReviewDetailScreen({
|
|||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="order-1 min-w-0 xl:order-2">
|
||||
<ReviewTaskSummary review={review} detail={reviewSkillDetail} hasBaseline={hasBaseline} submittedAt={formatDate(review.submittedAt)}>
|
||||
{!isSuiteReview && !isMetadataReview && <div className="space-y-3">
|
||||
<h3 className="text-base font-semibold">{t('reviewWorkbench.checks')}</h3>
|
||||
<button data-analytics-id="review.security.open" type="button" onClick={() => setSelectedTab('source')} className="block w-full space-y-2 text-left text-sm underline-offset-4 hover:underline">
|
||||
<span className="block font-medium">{t('review.scanSummary')}</span>
|
||||
{review.requestedVisibility
|
||||
? <span className="block text-xs text-muted-foreground">{reviewSkillDetail?.visibilityChange ? t(`skillVisibility.scan${reviewSkillDetail.visibilityChange.scanStatus}`) : t('review.scanLoadFailed')}</span>
|
||||
: isLoadingReviewSkillDetail || (skillId && versionId && isLoadingSecurityAudits) ? <span className="block text-xs text-muted-foreground">{t('review.scanLoading')}</span>
|
||||
: reviewSkillDetailError || isSecurityAuditsError ? <span className="block text-xs text-destructive">{t('review.scanLoadFailed')}</span>
|
||||
: securityAudits?.length ? <span className="flex flex-wrap gap-2">{securityAudits.map((audit) => <VerdictBadge key={audit.id} displayState={getSecurityAuditDisplayState(audit, activeReviewVersion?.status)} compact />)}</span>
|
||||
: <span className="block text-xs text-muted-foreground">{t(activeReviewVersion?.status === 'SCANNING' ? 'review.scanPending' : activeReviewVersion?.status === 'SCAN_FAILED' ? 'review.scanFailed' : 'review.scanUnavailable')}</span>}
|
||||
</button>
|
||||
{isGithubSyncReview && <button type="button" data-analytics-id="review.source.open" onClick={() => setSelectedTab('source')} className="space-y-1 text-left text-sm underline-offset-4 hover:underline"><span className="block font-medium">{t('review.githubLicenseStatus')}</span><span className="block text-xs text-muted-foreground">{githubSource ? t(`review.githubLicense${githubSource.evidence.status}`, { defaultValue: githubSource.evidence.status }) : t(isGithubSourceError ? 'review.githubSourceError' : 'review.scanLoading')}</span></button>}
|
||||
<p className="text-xs leading-5 text-muted-foreground">{t('reviewWorkbench.scanBoundary')}</p>
|
||||
</div>}
|
||||
</ReviewTaskSummary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-20 -mx-3 mt-auto border-t border-border bg-background px-3 py-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-5px_16px_-12px_rgb(0_0_0/0.28)] sm:-mx-4 sm:px-4">
|
||||
{review.status === 'PENDING' ? (
|
||||
{review.status === 'PENDING' && review.canReview === true ? (
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-end">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label htmlFor="comment" className="text-xs font-medium">
|
||||
|
|
@ -431,29 +351,17 @@ function ReviewDetailScreen({
|
|||
placeholder={t('review.commentPlaceholder')}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={1}
|
||||
className="min-h-9 resize-y text-xs"
|
||||
rows={3}
|
||||
className="min-h-[100px] resize-y text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button data-analytics-id="review.approve.request"
|
||||
size="sm"
|
||||
className="px-2.5"
|
||||
onClick={() => {
|
||||
if (isApprovalBlocked) {
|
||||
return
|
||||
}
|
||||
setApproveDialog(true)
|
||||
}}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending || isApprovalBlocked}
|
||||
>
|
||||
{t('review.approve')}
|
||||
</Button>
|
||||
|
||||
{!showRejectForm ? (
|
||||
<Button data-analytics-id="review.reject.open"
|
||||
size="sm"
|
||||
className="px-2.5"
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
onClick={() => setShowRejectForm(true)}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending}
|
||||
>
|
||||
|
|
@ -487,9 +395,23 @@ function ReviewDetailScreen({
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button data-analytics-id="review.approve.request"
|
||||
size="sm"
|
||||
className="px-2.5"
|
||||
onClick={() => {
|
||||
if (isApprovalBlocked) {
|
||||
return
|
||||
}
|
||||
setApproveDialog(true)
|
||||
}}
|
||||
disabled={approveMutation.isPending || rejectMutation.isPending || isApprovalBlocked}
|
||||
>
|
||||
{t(`reviewWorkbench.approve.${reviewRequestKey(review, hasBaseline)}`)}
|
||||
</Button>
|
||||
</div>
|
||||
{(isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired || needsLicenseBasis || (showRejectForm && !comment.trim())) && (
|
||||
{(isDocumentationUnverified || isComparisonUnverified || isApprovalBlockedByScanning || isApprovalBlockedByAvatar || isAvatarOverrideReasonRequired || needsLicenseBasis || (showRejectForm && !comment.trim())) && (
|
||||
<div className="text-xs lg:max-w-52" role="status">
|
||||
{(isDocumentationUnverified || isComparisonUnverified) && <p className="text-muted-foreground">{t('reviewWorkbench.approveContentUnavailable')}</p>}
|
||||
{isApprovalBlockedByScanning && <p className="text-muted-foreground">{t('review.approveDisabledScanning')}</p>}
|
||||
{isAvatarOverrideReasonRequired && <p className="text-muted-foreground">{t('review.avatarOverrideReason')}</p>}
|
||||
{isApprovalBlockedByAvatar && <p className="text-muted-foreground">{t('review.approveDisabledAvatar')}</p>}
|
||||
|
|
@ -500,7 +422,7 @@ function ReviewDetailScreen({
|
|||
</div>
|
||||
) : <div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-semibold">{t('review.reviewResult')}:</span>
|
||||
<span>{t(review.status === 'APPROVED' ? 'review.statusApproved' : review.status === 'REJECTED' ? 'review.statusRejected' : 'review.statusSuperseded')}</span>
|
||||
<span>{review.status === 'PENDING' ? t('reviewWorkbench.viewOnly') : t(review.status === 'APPROVED' ? 'review.statusApproved' : review.status === 'REJECTED' ? 'review.statusRejected' : 'review.statusSuperseded')}</span>
|
||||
{review.reviewComment && <span className="min-w-0 break-words text-muted-foreground">{review.reviewComment}</span>}
|
||||
</div>}
|
||||
</div>
|
||||
|
|
@ -529,24 +451,26 @@ function ReviewDetailScreen({
|
|||
|
||||
export function ReviewDetailPage() {
|
||||
const { id } = useParams({ from: '/dashboard/reviews/$id' })
|
||||
const search = useSearch({ from: '/dashboard/reviews/$id' })
|
||||
|
||||
return (
|
||||
<ReviewDetailScreen
|
||||
key={id}
|
||||
taskId={Number(id)}
|
||||
backTo={buildGlobalReviewsPath()}
|
||||
backTo={buildReviewReturnPath(search)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NamespaceReviewDetailPage() {
|
||||
const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' })
|
||||
const search = useSearch({ from: '/dashboard/namespaces/$slug/reviews/$id' })
|
||||
|
||||
return (
|
||||
<ReviewDetailScreen
|
||||
key={`${slug}/${id}`}
|
||||
taskId={Number(id)}
|
||||
backTo={buildNamespaceReviewsPath(slug)}
|
||||
backTo={buildReviewReturnPath(search, slug)}
|
||||
namespaceSlug={slug}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
|
||||
const useSearchMock = vi.fn()
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearch: () => useSearchMock(),
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
FileCheck2: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/ui/card', () => ({
|
||||
Card: ({ children }: { children: unknown }) => children,
|
||||
CardContent: ({ children }: { children: unknown }) => children,
|
||||
CardDescription: ({ children }: { children: unknown }) => children,
|
||||
CardHeader: ({ children }: { children: unknown }) => children,
|
||||
CardTitle: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/select', () => ({
|
||||
Select: ({ children }: { children: unknown }) => children,
|
||||
SelectContent: ({ children }: { children: unknown }) => children,
|
||||
SelectItem: ({ children }: { children: unknown }) => children,
|
||||
SelectTrigger: ({ children }: { children: unknown }) => children,
|
||||
SelectValue: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/tabs', () => ({
|
||||
Tabs: ({ children }: { children: unknown }) => children,
|
||||
TabsContent: ({ children }: { children: unknown }) => children,
|
||||
TabsList: ({ children }: { children: unknown }) => children,
|
||||
TabsTrigger: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/table', () => ({
|
||||
Table: ({ children }: { children: unknown }) => children,
|
||||
TableBody: ({ children }: { children: unknown }) => children,
|
||||
TableCell: ({ children }: { children: unknown }) => children,
|
||||
TableHead: ({ children }: { children: unknown }) => children,
|
||||
TableHeader: ({ children }: { children: unknown }) => children,
|
||||
TableRow: ({ children }: { children: unknown }) => children,
|
||||
}))
|
||||
|
||||
const paginationProps: Array<{ page: number; totalPages: number; onPageChange: (page: number) => void }> = []
|
||||
vi.mock('@/shared/components/pagination', () => ({
|
||||
Pagination: (props: { page: number; totalPages: number; onPageChange: (page: number) => void }) => {
|
||||
paginationProps.push(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
const useReviewListMock = vi.fn()
|
||||
vi.mock('@/features/review/use-review-list', () => ({
|
||||
useReviewList: (...args: unknown[]) => useReviewListMock(...args),
|
||||
}))
|
||||
|
||||
const hasRoleMock = vi.fn()
|
||||
const userMock = { platformRoles: ['SKILL_ADMIN'] }
|
||||
vi.mock('@/features/auth/use-auth', () => ({
|
||||
useAuth: () => ({ hasRole: hasRoleMock, user: userMock }),
|
||||
}))
|
||||
|
||||
const useMyNamespacesMock = vi.fn()
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => useMyNamespacesMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/components/dashboard-page-header', () => ({
|
||||
DashboardPageHeader: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/lib/date-time', () => ({
|
||||
formatLocalDateTime: (v: string) => v,
|
||||
}))
|
||||
|
||||
vi.mock('./profile-review-table', () => ({
|
||||
ProfileReviewTable: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/review/github-sync-review-queue', () => ({
|
||||
GithubSyncReviewQueue: () => null,
|
||||
}))
|
||||
|
||||
import { ReviewsPage } from './reviews'
|
||||
|
||||
describe('ReviewsPage', () => {
|
||||
function createReviewItem(id: number) {
|
||||
return {
|
||||
id,
|
||||
namespace: 'demo',
|
||||
skillSlug: `skill-${id}`,
|
||||
version: '1.0.0',
|
||||
submittedBy: 'user-1',
|
||||
submittedByName: 'User 1',
|
||||
submittedAt: '2026-04-01T12:00:00Z',
|
||||
reviewedBy: null,
|
||||
reviewedByName: null,
|
||||
reviewedAt: null,
|
||||
reviewComment: null,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
paginationProps.length = 0
|
||||
hasRoleMock.mockReset()
|
||||
useReviewListMock.mockReset()
|
||||
useMyNamespacesMock.mockReset()
|
||||
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN')
|
||||
userMock.platformRoles = ['SKILL_ADMIN']
|
||||
useMyNamespacesMock.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
})
|
||||
useSearchMock.mockReturnValue({})
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
|
||||
if (status === 'PENDING') {
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(1)],
|
||||
totalElements: 21,
|
||||
totalPages: 2,
|
||||
page,
|
||||
size: 20,
|
||||
total: 21,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
}
|
||||
|
||||
return { data: null, isLoading: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('exports a named component function', () => {
|
||||
expect(typeof ReviewsPage).toBe('function')
|
||||
})
|
||||
|
||||
it('renders pagination for pending reviews when totalPages > 1', () => {
|
||||
const html = renderToStaticMarkup(createElement(ReviewsPage))
|
||||
|
||||
expect(html).toContain('reviews.pageSummary')
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(2)
|
||||
})
|
||||
|
||||
it('renders disabled-style pagination when there is only one page', () => {
|
||||
useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => {
|
||||
if (!enabled || status !== 'PENDING') {
|
||||
return { data: null, isLoading: false }
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
items: [createReviewItem(2)],
|
||||
totalElements: 1,
|
||||
totalPages: 1,
|
||||
page,
|
||||
size: 20,
|
||||
total: 1,
|
||||
},
|
||||
isLoading: false,
|
||||
}
|
||||
})
|
||||
|
||||
renderToStaticMarkup(createElement(ReviewsPage))
|
||||
|
||||
expect(paginationProps).toHaveLength(1)
|
||||
expect(paginationProps[0]?.page).toBe(0)
|
||||
expect(paginationProps[0]?.totalPages).toBe(1)
|
||||
})
|
||||
|
||||
it('uses the profile review tab when the review type search param requests it', () => {
|
||||
hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN')
|
||||
userMock.platformRoles = ['SUPER_ADMIN']
|
||||
useSearchMock.mockReturnValue({ type: 'profile' })
|
||||
|
||||
renderToStaticMarkup(createElement(ReviewsPage))
|
||||
|
||||
expect(useReviewListMock).toHaveBeenCalled()
|
||||
expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
164
web/src/pages/dashboard/reviews.test.tsx
Normal file
164
web/src/pages/dashboard/reviews.test.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Children, isValidElement, type ReactNode } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ManagedNamespace } from '@/api/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
search: {} as Record<string, unknown>, navigate: vi.fn(), list: vi.fn(), refetch: vi.fn(), namespaces: [] as ManagedNamespace[], roles: ['SKILL_ADMIN'], scopes: undefined as { id: number; slug: string; displayName: string }[] | undefined, scopeError: false,
|
||||
}))
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
useNavigate: () => mocks.navigate, useSearch: () => mocks.search,
|
||||
Link: ({ children, to }: { children: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }) }))
|
||||
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ hasRole: (role: string) => mocks.roles.includes(role) }) }))
|
||||
vi.mock('@/shared/hooks/use-namespace-queries', () => ({
|
||||
useMyNamespaces: () => ({ data: mocks.namespaces, isLoading: false }),
|
||||
useNamespaceDetail: (slug: string) => ({ data: mocks.namespaces.find((namespace) => namespace.slug === slug), isLoading: false }),
|
||||
}))
|
||||
vi.mock('@/features/review/use-review-scopes', () => ({ useReviewScopes: () => ({ data: mocks.scopeError ? undefined : mocks.scopes ?? mocks.namespaces, isLoading: false, isError: mocks.scopeError, refetch: mocks.refetch }) }))
|
||||
vi.mock('@/features/review/use-review-list', () => ({ useReviewList: (...args: unknown[]) => mocks.list(...args) }))
|
||||
vi.mock('@/features/skill/skill-avatar', () => ({ SkillAvatar: () => <span /> }))
|
||||
vi.mock('./profile-review-table', () => ({ ProfileReviewTable: () => <div>profile-review-table</div> }))
|
||||
vi.mock('@/shared/ui/select', () => {
|
||||
function descendants(children: ReactNode): Array<{ value?: string; id?: string; className?: string; children?: ReactNode }> {
|
||||
return Children.toArray(children).flatMap((child) => isValidElement<{ value?: string; id?: string; className?: string; children?: ReactNode }>(child) ? [child.props, ...descendants(child.props.children)] : [])
|
||||
}
|
||||
return {
|
||||
Select: ({ value, onValueChange, children }: { value: string; onValueChange: (value: string) => void; children: ReactNode }) => {
|
||||
const props = descendants(children)
|
||||
const trigger = props.find((element) => element.id)
|
||||
return <select id={trigger?.id} aria-label={trigger?.id ? undefined : 'sort'} value={value} onChange={(event) => onValueChange(event.target.value)}>{props.filter((element) => element.value).map((element) => <option key={element.value} value={element.value}>{descendants(element.children).length ? descendants(element.children).map((child) => typeof child.children === 'string' ? child.children : '').join('') : element.children}</option>)}</select>
|
||||
},
|
||||
SelectContent: ({ children }: { children: ReactNode }) => children,
|
||||
SelectItem: ({ children }: { children: ReactNode }) => children,
|
||||
SelectTrigger: ({ children }: { children: ReactNode }) => children,
|
||||
SelectValue: () => null,
|
||||
}
|
||||
})
|
||||
import { ReviewsPage, reviewTaskKind } from './reviews'
|
||||
import type { ReviewTask } from '@/api/types'
|
||||
|
||||
function lastFilters() { return mocks.list.mock.calls.at(-1) }
|
||||
function renderPage() { return render(<ReviewsPage />) }
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.search = {}
|
||||
mocks.roles = ['SKILL_ADMIN']
|
||||
mocks.namespaces = []
|
||||
mocks.scopes = undefined
|
||||
mocks.scopeError = false
|
||||
mocks.list.mockImplementation(() => ({ data: { items: [{ id: 1, namespace: 'design', skillSlug: 'notes', displayName: 'Meeting notes', version: '1.0.0', status: 'PENDING', submittedBy: 'author', submittedAt: '2026-11-05T10:00:00Z', reviewKind: 'VERSION_UPDATE' }], total: 21, totalElements: 21, totalPages: 2 }, isLoading: false, refetch: mocks.refetch }))
|
||||
})
|
||||
|
||||
describe('unified review center', () => {
|
||||
it('does not infer a version update or replace its primary request kind with attached avatar changes', () => {
|
||||
expect(reviewTaskKind({ subjectType: 'SKILL_VERSION' } as ReviewTask)).toBe('SKILL_VERSION')
|
||||
expect(reviewTaskKind({ subjectType: 'SKILL_METADATA', requestedVisibility: 'PUBLIC', avatarRemoval: true } as ReviewTask)).toBe('VISIBILITY_CHANGE')
|
||||
mocks.list.mockReturnValue({ data: { items: [{ id: 2, subjectType: 'SKILL_VERSION', reviewKind: 'VERSION_UPDATE', avatarRemoval: true, status: 'PENDING', submittedAt: '2026-11-05T10:00:00Z' }], totalElements: 1, totalPages: 1 }, refetch: mocks.refetch })
|
||||
renderPage()
|
||||
expect(screen.getAllByText('reviewCenter.kind.VERSION_UPDATE').filter((element) => element.tagName !== 'OPTION')).toHaveLength(2)
|
||||
expect(screen.queryByText('reviewCenter.kind.AVATAR_REMOVAL')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers a first-page action for an empty historical page rather than claiming no matching requests', () => {
|
||||
mocks.search = { page: 2 }
|
||||
mocks.list.mockReturnValue({ data: { items: [], totalElements: 1, totalPages: 1 }, refetch: mocks.refetch })
|
||||
renderPage()
|
||||
expect(screen.getByText('reviewCenter.pageEmpty')).toBeTruthy()
|
||||
expect(screen.queryByText('reviewCenter.empty')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.firstPage' }))
|
||||
expect(lastFilters()?.[2]).toBe(0)
|
||||
})
|
||||
|
||||
it('uses authorized scopes even when a platform reviewer is not a member', () => {
|
||||
mocks.scopes = [{ id: 90, slug: 'foreign', displayName: 'Foreign team' }]
|
||||
mocks.search = { namespace: 'foreign' }
|
||||
renderPage()
|
||||
expect(screen.getByRole('option', { name: 'Foreign team' })).toBeTruthy()
|
||||
expect(lastFilters()?.[1]).toBe(90)
|
||||
expect(lastFilters()?.[5]).toBe(true)
|
||||
})
|
||||
|
||||
it('does not run an unrestricted query when the authorized scope request fails', () => {
|
||||
mocks.scopeError = true
|
||||
renderPage()
|
||||
expect(lastFilters()?.[5]).toBe(false)
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('uses one request queue with no status or source tabs', () => {
|
||||
renderPage()
|
||||
expect(screen.queryByRole('tab')).toBeNull()
|
||||
expect(screen.getByRole('form', { name: 'reviewCenter.filters.title' })).toBeTruthy()
|
||||
expect(screen.getAllByText('Meeting notes')).toHaveLength(2)
|
||||
expect(lastFilters()?.slice(0, 6)).toEqual(['PENDING', undefined, 0, 20, 'DESC', true])
|
||||
})
|
||||
|
||||
it('keeps draft changes out of the query until Search then resets the page', () => {
|
||||
mocks.search = { page: 2 }
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByLabelText('reviewCenter.filters.q'), { target: { value: ' notes ' } })
|
||||
fireEvent.change(screen.getByLabelText('reviewCenter.filters.originType'), { target: { value: 'GITHUB_SYNC' } })
|
||||
expect(lastFilters()?.[2]).toBe(2)
|
||||
expect(lastFilters()?.[6]).toEqual({ q: '', reviewKind: 'ALL', originType: 'ALL' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.filters.apply' }))
|
||||
expect(lastFilters()?.[2]).toBe(0)
|
||||
expect(lastFilters()?.[6]).toEqual({ q: 'notes', reviewKind: 'ALL', originType: 'GITHUB_SYNC' })
|
||||
expect(mocks.navigate).toHaveBeenLastCalledWith(expect.objectContaining({ search: expect.objectContaining({ q: 'notes', originType: 'GITHUB_SYNC', page: undefined }) }))
|
||||
})
|
||||
|
||||
it('reset restores pending requests, clears conditions and refreshes identical queries', () => {
|
||||
mocks.search = { q: 'notes', status: 'REJECTED', originType: 'USER', page: 2 }
|
||||
renderPage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.filters.reset' }))
|
||||
expect(lastFilters()?.slice(0, 6)).toEqual(['PENDING', undefined, 0, 20, 'DESC', true])
|
||||
expect(lastFilters()?.[6]).toEqual({ q: '', reviewKind: 'ALL', originType: 'ALL' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.filters.reset' }))
|
||||
expect(mocks.refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('paginates using applied values even while another search is being drafted', () => {
|
||||
renderPage()
|
||||
fireEvent.change(screen.getByLabelText('reviewCenter.filters.q'), { target: { value: 'unsubmitted' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /pagination.next/i }))
|
||||
expect(lastFilters()?.[2]).toBe(1)
|
||||
expect(lastFilters()?.[6]).toEqual({ q: '', reviewKind: 'ALL', originType: 'ALL' })
|
||||
})
|
||||
|
||||
it('allows a team owner to use the same center but does not treat USER_ADMIN as a skill reviewer', () => {
|
||||
mocks.roles = ['USER_ADMIN']
|
||||
const first = renderPage()
|
||||
expect(screen.getByText('profile-review-table')).toBeTruthy()
|
||||
expect(lastFilters()?.[5]).toBe(false)
|
||||
first.unmount()
|
||||
mocks.roles = []
|
||||
mocks.namespaces = [{ id: 4, slug: 'design', displayName: 'Design', type: 'TEAM', currentUserRole: 'OWNER' } as ManagedNamespace]
|
||||
mocks.search = { namespace: 'design' }
|
||||
renderPage()
|
||||
expect(screen.getByRole('form')).toBeTruthy()
|
||||
expect(lastFilters()?.[1]).toBe(4)
|
||||
expect(lastFilters()?.[5]).toBe(true)
|
||||
})
|
||||
|
||||
it('does not present a failed request as an empty result', () => {
|
||||
mocks.list.mockReturnValue({ isError: true, isLoading: false, refetch: mocks.refetch })
|
||||
renderPage()
|
||||
expect(screen.getByRole('alert').textContent).toContain('reviewCenter.loadFailed')
|
||||
expect(screen.queryByText('reviewCenter.empty')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reviewCenter.retry' }))
|
||||
expect(mocks.refetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('restores applied filter state when the URL changes', () => {
|
||||
const view = renderPage()
|
||||
mocks.search = { q: 'historic', status: 'APPROVED', page: 1 }
|
||||
view.rerender(<ReviewsPage />)
|
||||
expect(lastFilters()?.[0]).toBe('APPROVED')
|
||||
expect(lastFilters()?.[2]).toBe(1)
|
||||
expect(screen.getByLabelText<HTMLInputElement>('reviewCenter.filters.q').value).toBe('historic')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,310 +1,136 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { FileCheck2 } from 'lucide-react'
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
|
||||
import {
|
||||
buildNamespaceReviewsPath,
|
||||
canAccessGlobalReviewCenter,
|
||||
getPreferredNamespaceReviewEntry,
|
||||
} from '@/features/review/review-paths'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/shared/ui/table'
|
||||
import { canManageNamespaceReviews } from '@/features/review/review-paths'
|
||||
import { ReviewCenterFilterForm } from '@/features/review/review-center-filter-form'
|
||||
import { DEFAULT_REVIEW_FILTERS, parseReviewCenterFilters, reviewCenterSearch, type ReviewCenterFilters } from '@/features/review/review-center-filters'
|
||||
import { useReviewScopes } from '@/features/review/use-review-scopes'
|
||||
import { useReviewList } from '@/features/review/use-review-list'
|
||||
import { GithubSyncReviewQueue } from '@/features/review/github-sync-review-queue'
|
||||
import { SkillAvatar } from '@/features/skill/skill-avatar'
|
||||
import type { ReviewTask } from '@/api/types'
|
||||
import { useAuth } from '@/features/auth/use-auth'
|
||||
import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
|
||||
import { Pagination } from '@/shared/components/pagination'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card } from '@/shared/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { formatLocalDateTime } from '@/shared/lib/date-time'
|
||||
import { ProfileReviewTable } from './profile-review-table'
|
||||
|
||||
type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
type TimeSortDirection = 'ASC' | 'DESC'
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Dashboard review queue page. Each tab materializes one review status because
|
||||
* the moderation workflow treats pending, approved, and rejected queues as
|
||||
* distinct operator views rather than one filterable table.
|
||||
*/
|
||||
export function reviewTaskKind(review: ReviewTask): string {
|
||||
if (review.reviewKind) return review.reviewKind
|
||||
if (review.subjectType === 'SUITE_VERSION') return 'SUITE_PUBLISH'
|
||||
if (review.subjectType === 'SKILL_METADATA') return review.requestedVisibility ? 'VISIBILITY_CHANGE' : 'AVATAR_CHANGE'
|
||||
return 'SKILL_VERSION'
|
||||
}
|
||||
|
||||
/** One authorized queue, regardless of submission source or namespace entry point. */
|
||||
export function ReviewsPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ from: '/dashboard/reviews' })
|
||||
const { hasRole, user } = useAuth()
|
||||
const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces()
|
||||
const [pages, setPages] = useState<Record<ReviewStatus, number>>({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
REJECTED: 0,
|
||||
})
|
||||
const [activeStatus, setActiveStatus] = useState<ReviewStatus>('PENDING')
|
||||
const [sortDirection, setSortDirection] = useState<TimeSortDirection>('DESC')
|
||||
const { hasRole } = useAuth()
|
||||
const namespacesQuery = useMyNamespaces()
|
||||
const namespaces = namespacesQuery.data ?? []
|
||||
const routeFilters = parseReviewCenterFilters(search)
|
||||
const [draft, setDraft] = useState<ReviewCenterFilters>(routeFilters)
|
||||
const [applied, setApplied] = useState<ReviewCenterFilters>(routeFilters)
|
||||
const [page, setPage] = useState(search.page ?? 0)
|
||||
const { q: routeQ, status: routeStatus, namespace: routeNamespace, reviewKind: routeKind, originType: routeOrigin, sortDirection: routeSort } = routeFilters
|
||||
const routePage = search.page ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
const next = { q: routeQ, status: routeStatus, namespace: routeNamespace, reviewKind: routeKind, originType: routeOrigin, sortDirection: routeSort }
|
||||
setApplied(next)
|
||||
setDraft(next)
|
||||
setPage(routePage)
|
||||
}, [routeQ, routeStatus, routeNamespace, routeKind, routeOrigin, routeSort, routePage])
|
||||
|
||||
const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN')
|
||||
const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles)
|
||||
const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces)
|
||||
const showTypeTabs = isSkillAdmin
|
||||
const managedNamespaces = namespaces.filter((namespace) => namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole))
|
||||
const canReviewSkills = isSkillAdmin || managedNamespaces.length > 0
|
||||
const profileMode = isUserAdmin && (search.type === 'profile' || (!canReviewSkills && !namespacesQuery.isLoading))
|
||||
const scopes = useReviewScopes(canReviewSkills && !profileMode)
|
||||
const scope = scopes.data?.find((item) => item.slug === applied.namespace)
|
||||
const scopePending = !scopes.data || (applied.namespace !== 'ALL' && !scope)
|
||||
const query = useReviewList(applied.status, applied.namespace === 'ALL' ? undefined : scope?.id,
|
||||
page, PAGE_SIZE, applied.sortDirection, canReviewSkills && !profileMode && !scopePending,
|
||||
{ q: applied.q.trim(), reviewKind: applied.reviewKind, originType: applied.originType })
|
||||
const options = (scopes.data ?? []).map((item) => ({ value: item.slug, label: item.displayName || item.slug }))
|
||||
|
||||
// Determine default top-level tab
|
||||
const defaultType = isSkillAdmin ? 'skill' : 'profile'
|
||||
const requestedType = search.type === 'skill' || search.type === 'sync'
|
||||
|| (search.type === 'profile' && isUserAdmin) ? search.type : undefined
|
||||
const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType
|
||||
const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill'
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGlobalReviewAccess || isLoadingNamespaces) {
|
||||
return
|
||||
}
|
||||
|
||||
if (namespaceReviewEntry) {
|
||||
void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
void navigate({ to: '/dashboard', replace: true })
|
||||
}, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate])
|
||||
|
||||
const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING')
|
||||
const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED')
|
||||
const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED')
|
||||
|
||||
const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language)
|
||||
|
||||
const handleRowClick = (reviewId: number) => {
|
||||
navigate({ to: `/dashboard/reviews/${reviewId}` })
|
||||
const apply = (filters: ReviewCenterFilters, nextPage = 0) => {
|
||||
const normalized = { ...filters, q: filters.q.trim() }
|
||||
setApplied(normalized)
|
||||
setDraft(normalized)
|
||||
setPage(nextPage)
|
||||
void navigate({ to: '/dashboard/reviews', search: reviewCenterSearch(normalized, nextPage) })
|
||||
// A repeated query or reset must refresh the same key rather than silently reuse a stale result.
|
||||
if (!scopePending && JSON.stringify(normalized) === JSON.stringify(applied) && nextPage === page) void query.refetch()
|
||||
}
|
||||
|
||||
function changePage(status: ReviewStatus, nextPage: number) {
|
||||
setPages((current) => ({ ...current, [status]: nextPage }))
|
||||
const openReview = (review: ReviewTask) => {
|
||||
void navigate({ to: '/dashboard/reviews/$id', params: { id: String(review.id) }, search: reviewCenterSearch(applied, page) })
|
||||
}
|
||||
|
||||
function handleSortChange(value: string) {
|
||||
setSortDirection(value as TimeSortDirection)
|
||||
setPages({
|
||||
PENDING: 0,
|
||||
APPROVED: 0,
|
||||
REJECTED: 0,
|
||||
})
|
||||
}
|
||||
|
||||
function handleTypeChange(value: string) {
|
||||
void navigate({
|
||||
to: '/dashboard/reviews',
|
||||
search: { type: value === 'profile' ? 'profile' : value === 'sync' ? 'sync' : 'skill' },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) {
|
||||
const currentPage = pages[status]
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 px-6 py-4 text-sm text-muted-foreground md:flex-row md:items-center md:justify-between">
|
||||
<p>{t('reviews.pageSummary', { total: totalElements, page: currentPage + 1 })}</p>
|
||||
<Pagination analyticsId="review.list.paginate" page={currentPage} totalPages={Math.max(totalPages, 1)} onPageChange={(nextPage) => changePage(status, nextPage)} />
|
||||
const formatDate = (value: string) => formatLocalDateTime(value, i18n.language)
|
||||
const renderObject = (review: ReviewTask) => {
|
||||
const slug = review.subjectSlug || review.skillSlug || ''
|
||||
const name = review.displayName || slug
|
||||
return <div className="flex min-w-0 gap-3">
|
||||
<SkillAvatar name={name} seed={`${review.namespace}/${slug}`} size="h-8 w-8 text-xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-semibold">{name || t('reviewCenter.unknownObject')}</p>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">@{review.namespace}/{slug}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{t(review.subjectType === 'SUITE_VERSION' ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')}{review.version ? ` · ${review.version}` : ''}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the table rendering logic in one local helper so the per-status tabs
|
||||
* stay declarative while still allowing columns to differ by workflow state.
|
||||
*/
|
||||
const renderReviewTable = (query: typeof pendingQuery, status: ReviewStatus) => {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-16 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const reviews = query.data?.items
|
||||
if (!reviews || reviews.length === 0) {
|
||||
return <div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-muted-foreground">{t('reviews.empty')}</div>
|
||||
}
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/60">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/35">
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colSkill')}</TableHead>
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colVersion')}</TableHead>
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colSubmitter')}</TableHead>
|
||||
{status === 'PENDING' ? (
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colSubmitTime')}</TableHead>
|
||||
) : (
|
||||
<>
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colReviewer')}</TableHead>
|
||||
<TableHead className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('reviews.colReviewTime')}</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reviews.map((review) => (
|
||||
<TableRow data-analytics-id="review.detail.open"
|
||||
key={review.id}
|
||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||||
onClick={() => handleRowClick(review.id)}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<span className="mr-2 rounded-full bg-secondary px-2 py-0.5 text-xs">
|
||||
{t(review.subjectType === 'SUITE_VERSION' ? 'suite.resourceTypeSuite' : 'suite.resourceTypeSkill')}
|
||||
</span>
|
||||
{review.namespace}/{review.subjectSlug || review.skillSlug}
|
||||
</TableCell>
|
||||
<TableCell>{review.subjectType === 'SKILL_METADATA' ? t(review.requestedVisibility ? 'skillVisibility.title' : review.avatarRemoval ? 'review.avatarRemoval' : 'review.avatarUpdate') : review.version}</TableCell>
|
||||
<TableCell>{review.submittedByName || review.submittedBy}</TableCell>
|
||||
{status === 'PENDING' ? (
|
||||
<TableCell>{formatDate(review.submittedAt)}</TableCell>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{review.reviewedByName || review.reviewedBy || '-'}</TableCell>
|
||||
<TableCell>{review.reviewedAt ? formatDate(review.reviewedAt) : '-'}</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{query.data && renderPagination(status, query.data.totalElements, query.data.totalPages)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSkillReviewContent() {
|
||||
return (
|
||||
<Card className="glass-strong overflow-hidden border-border/60 shadow-sm hover:shadow-sm">
|
||||
<div className="h-1 bg-gradient-to-r from-slate-900 via-blue-700 to-sky-500" />
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-secondary/80 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-secondary-foreground">
|
||||
<FileCheck2 className="h-3.5 w-3.5" />
|
||||
{t('reviews.typeSkill')}
|
||||
</div>
|
||||
<CardTitle>{t('reviews.typeSkill')}</CardTitle>
|
||||
<CardDescription>{t('reviews.subtitle')}</CardDescription>
|
||||
</div>
|
||||
<div className="w-full max-w-48">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t('reviews.sortLabel')}
|
||||
</p>
|
||||
<Select value={sortDirection} onValueChange={handleSortChange}>
|
||||
<SelectTrigger data-analytics-id="review.list.sort.interact">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem data-analytics-id="review.list.sort" value="DESC">{t('reviews.sortNewest')}</SelectItem>
|
||||
<SelectItem data-analytics-id="review.list.sort" value="ASC">{t('reviews.sortOldest')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Tabs value={activeStatus} onValueChange={(v) => setActiveStatus(v as ReviewStatus)}>
|
||||
<TabsList className="gap-4 rounded-xl border-b-0 bg-muted/70 p-1 shadow-none">
|
||||
<TabsTrigger data-analytics-id="review.pending.tab"
|
||||
value="PENDING"
|
||||
className="mb-0 rounded-lg border-b-0 px-4 py-2.5 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.tabPending')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="review.approved.tab"
|
||||
value="APPROVED"
|
||||
className="mb-0 rounded-lg border-b-0 px-4 py-2.5 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.tabApproved')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="review.rejected.tab"
|
||||
value="REJECTED"
|
||||
className="mb-0 rounded-lg border-b-0 px-4 py-2.5 data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.tabRejected')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="PENDING" className="mt-6">
|
||||
{renderReviewTable(pendingQuery, 'PENDING')}
|
||||
</TabsContent>
|
||||
<TabsContent value="APPROVED" className="mt-6">
|
||||
{renderReviewTable(approvedQuery, 'APPROVED')}
|
||||
</TabsContent>
|
||||
<TabsContent value="REJECTED" className="mt-6">
|
||||
{renderReviewTable(rejectedQuery, 'REJECTED')}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasGlobalReviewAccess) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Loading...
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('reviews.title')} subtitle={t('reviews.subtitle')} />
|
||||
|
||||
|
||||
{showTypeTabs ? (
|
||||
<Tabs value={activeType} onValueChange={handleTypeChange}>
|
||||
<TabsList className="gap-2 rounded-2xl border-b-0 bg-muted/80 p-1 shadow-sm">
|
||||
<TabsTrigger data-analytics-id="review.skill.tab"
|
||||
value="skill"
|
||||
className="mb-0 rounded-xl border-b-0 px-5 py-3 text-base font-semibold data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.typeSkill')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger data-analytics-id="review.sync.tab"
|
||||
value="sync"
|
||||
className="mb-0 rounded-xl border-b-0 px-5 py-3 text-base font-semibold data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.typeSync')}
|
||||
</TabsTrigger>
|
||||
{isUserAdmin && <TabsTrigger data-analytics-id="review.profile.tab"
|
||||
value="profile"
|
||||
className="mb-0 rounded-xl border-b-0 px-5 py-3 text-base font-semibold data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground"
|
||||
>
|
||||
{t('reviews.typeProfile')}
|
||||
</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="skill" className="mt-6">
|
||||
{renderSkillReviewContent()}
|
||||
</TabsContent>
|
||||
<TabsContent value="sync" className="mt-6">
|
||||
<GithubSyncReviewQueue enabled={activeType === 'sync'} />
|
||||
</TabsContent>
|
||||
{isUserAdmin && <TabsContent value="profile" className="mt-6">
|
||||
<ProfileReviewTable />
|
||||
</TabsContent>}
|
||||
</Tabs>
|
||||
) : isSkillAdmin ? (
|
||||
renderSkillReviewContent()
|
||||
) : isUserAdmin ? (
|
||||
<ProfileReviewTable />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderMetadata = (review: ReviewTask) => {
|
||||
const kind = reviewTaskKind(review)
|
||||
return <>
|
||||
<span className="inline-flex rounded-full bg-secondary px-2.5 py-1 text-xs font-medium">{t(kind === 'AVATAR_CHANGE' && review.avatarRemoval ? 'reviewCenter.kind.AVATAR_REMOVAL' : `reviewCenter.kind.${kind}`)}</span>
|
||||
{applied.status !== 'PENDING' && <span className="mt-1 block text-xs text-muted-foreground">{t(`reviewCenter.status.${review.status}`)}</span>}
|
||||
</>
|
||||
}
|
||||
|
||||
if (namespacesQuery.isLoading && !isSkillAdmin) {
|
||||
return <div className="space-y-6"><DashboardPageHeader title={t('reviewCenter.title')} /><div className="h-40 animate-shimmer rounded-lg" aria-label={t('reviewCenter.loading')} /></div>
|
||||
}
|
||||
if (!canReviewSkills && !isUserAdmin) {
|
||||
return <div className="space-y-6"><DashboardPageHeader title={t('reviewCenter.title')} /><Card className="p-6" role="alert"><p>{t(namespacesQuery.isError ? 'reviewCenter.scopeFailed' : 'reviewCenter.noPermission')}</p>{namespacesQuery.isError && <Button type="button" variant="outline" className="mt-3" onClick={() => void namespacesQuery.refetch()}>{t('reviewCenter.retry')}</Button>}</Card></div>
|
||||
}
|
||||
|
||||
return <div className="space-y-7">
|
||||
<DashboardPageHeader title={t('reviewCenter.title')} subtitle={t(profileMode ? 'reviewCenter.profileDescription' : 'reviewCenter.description')}
|
||||
actions={isUserAdmin && canReviewSkills ? <Link to="/dashboard/reviews" search={profileMode ? {} : { type: 'profile' }} className="inline-flex min-h-9 items-center rounded-lg border border-border/60 px-3 text-sm hover:bg-secondary">{t(profileMode ? 'reviewCenter.skillQueue' : 'reviewCenter.profileQueue')}</Link> : undefined} />
|
||||
{profileMode ? <ProfileReviewTable /> : <>
|
||||
<ReviewCenterFilterForm value={draft} namespaces={options} namespaceLoading={scopes.isLoading || scopes.isError} onChange={setDraft} onApply={() => apply(draft)} onReset={() => apply({ ...DEFAULT_REVIEW_FILTERS })} />
|
||||
{scopes.isError && <div role="alert" className="flex flex-wrap items-center gap-3 text-sm"><p className="text-destructive">{t('reviewCenter.scopeFailed')}</p><Button type="button" size="sm" variant="outline" onClick={() => void scopes.refetch()}>{t('reviewCenter.retry')}</Button></div>}
|
||||
<div className="space-y-4" aria-busy={query.isFetching}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-medium">{query.isLoading || scopePending ? t('reviewCenter.loading') : query.isError ? t('reviewCenter.results') : t('reviewCenter.resultsCount', { count: query.data?.totalElements ?? 0 })}</h2>
|
||||
<Select value={applied.sortDirection} onValueChange={(sortDirection) => apply({ ...applied, sortDirection: sortDirection === 'ASC' ? 'ASC' : 'DESC' })}>
|
||||
<SelectTrigger aria-label={t('reviewCenter.sortLabel')} className="h-9 w-36 border-0 bg-transparent text-xs shadow-none"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="min-w-0 w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"><SelectItem value="DESC">{t('reviewCenter.newest')}</SelectItem><SelectItem value="ASC">{t('reviewCenter.oldest')}</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!scopes.isLoading && !scopes.isError && applied.namespace !== 'ALL' && !scope ? <Card className="space-y-3 p-6" role="alert"><p>{t('reviewCenter.scopeFailed')}</p><Button type="button" variant="outline" onClick={() => void scopes.refetch()}>{t('reviewCenter.retry')}</Button></Card>
|
||||
: scopes.isError ? null : query.isError ? <Card className="space-y-3 p-6" role="alert"><p>{t('reviewCenter.loadFailed')}</p><Button type="button" variant="outline" onClick={() => void query.refetch()}>{t('reviewCenter.retry')}</Button></Card>
|
||||
: query.isLoading || scopePending ? <div className="space-y-3" aria-label={t('reviewCenter.loading')}>{Array.from({ length: 4 }, (_, index) => <div key={index} className="h-20 animate-shimmer rounded-lg" />)}</div>
|
||||
: !query.data?.items.length ? <Card className="space-y-3 p-8 text-center"><p className="font-medium">{t(query.data?.totalElements && page > 0 ? 'reviewCenter.pageEmpty' : 'reviewCenter.empty')}</p><p className="text-sm text-muted-foreground">{t('reviewCenter.emptyDescription')}</p><Button type="button" variant="outline" onClick={() => query.data?.totalElements && page > 0 ? apply(applied, 0) : apply({ ...DEFAULT_REVIEW_FILTERS })}>{t(query.data?.totalElements && page > 0 ? 'reviewCenter.firstPage' : 'reviewCenter.filters.reset')}</Button></Card>
|
||||
: <>
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full min-w-[700px] table-fixed text-left">
|
||||
<thead className="bg-secondary text-xs font-medium text-muted-foreground"><tr><th className="w-[41%] rounded-l-lg px-4 py-3">{t('reviewCenter.columns.object')}</th><th className="w-[16%] px-4 py-3">{t('reviewCenter.columns.kind')}</th><th className="w-[20%] px-4 py-3">{t('reviewCenter.columns.source')}</th><th className="w-[17%] px-4 py-3">{t('reviewCenter.columns.submitter')}</th><th className="w-[6%] rounded-r-lg px-2 py-3"><span className="sr-only">{t('reviewCenter.columns.action')}</span></th></tr></thead>
|
||||
<tbody className="divide-y divide-border/70">{query.data.items.map((review) => <tr key={review.id} className="hover:bg-secondary/70"><td className="px-4 py-5 align-top">{renderObject(review)}</td><td className="px-4 py-5 align-top">{renderMetadata(review)}</td><td className="px-4 py-5 align-top"><p className="text-sm">{t(`reviewCenter.origin.${review.originType || 'USER'}`)}</p><p className="mt-1 break-all text-xs text-muted-foreground">{review.namespace}</p></td><td className="px-4 py-5 align-top"><p className="break-words text-sm">{review.submittedByName || review.submittedBy}</p><p className="mt-1 text-xs text-muted-foreground">{formatDate(review.submittedAt)}</p></td><td className="px-2 py-5 align-top"><Button data-analytics-id="review.detail.open" variant="link" className="h-8 px-0 text-xs" onClick={() => openReview(review)}>{t('reviewCenter.open')}</Button></td></tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="divide-y divide-border md:hidden">{query.data.items.map((review) => <article key={review.id} className="space-y-4 py-5">{renderObject(review)}<div>{renderMetadata(review)}</div><div className="flex items-end justify-between gap-4"><p className="min-w-0 break-words text-xs text-muted-foreground">{review.submittedByName || review.submittedBy} · {t(`reviewCenter.origin.${review.originType || 'USER'}`)}<span className="mt-1 block">{formatDate(review.submittedAt)}</span></p><Button data-analytics-id="review.detail.open" variant="link" className="shrink-0" onClick={() => openReview(review)}>{t('reviewCenter.open')}</Button></div></article>)}</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 text-xs text-muted-foreground"><p>{t('reviewCenter.pageSummary', { total: query.data.totalElements, page: page + 1 })}</p><Pagination analyticsId="review.list.paginate" page={page} totalPages={Math.max(query.data.totalPages, 1)} onPageChange={(nextPage) => apply(applied, nextPage)} /></div>
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ describe('UserMenu navigation', () => {
|
|||
)
|
||||
|
||||
expect(html).toContain('href="/dashboard/reviews"')
|
||||
expect(html).toContain('user.menu.reviews')
|
||||
expect(html).toContain('reviewCenter.title')
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
) : null}
|
||||
{canReview ? (
|
||||
<Link data-analytics-id="review.queue.open" to="/dashboard/reviews" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviews')}
|
||||
{t('reviewCenter.title')}
|
||||
</Link>
|
||||
) : null}
|
||||
{canReview || isUserAdmin || isAuditor || isSuperAdmin ? <div className="-mx-1 my-1 h-px bg-muted" /> : null}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue