diff --git a/.phan/stubs/wpcom-stubs.php b/.phan/stubs/wpcom-stubs.php index 51836f781e3f..3154c881cfd3 100644 --- a/.phan/stubs/wpcom-stubs.php +++ b/.phan/stubs/wpcom-stubs.php @@ -4,7 +4,7 @@ * `bin/teamcity-builds/jetpack-stubs/stub-defs.php` and regenerate the stubs * by triggering the Jetpack Staging → Update WPCOM Stubs job in TeamCity. * - * Stubs automatically generated from WordPress.com commit b9cdd5655544ee93dbd0e64d835f467279c727e0. + * Stubs automatically generated from WordPress.com commit 0079ea4c26d955b4cfca4d3eb035a2ec06f0bd68. */ namespace { @@ -1701,6 +1701,14 @@ function get_blog_subscriptions_aggregate_count(?int $blog_id = null, $post_term function is_message_templates_enabled($blog_id = 0) { } + /** + * @param \WP_Post $post + * @param array $items + * @return array> + */ + function render_messages_for_networks(\WP_Post $post, array $items): array + { + } /** * @param \WP_Post $post * @param string $network diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18936cf37583..bf61e334fba9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -558,8 +558,8 @@ importers: specifier: ^7 version: 7.29.2 '@wordpress/admin-ui': - specifier: 1.12.0 - version: 1.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + specifier: 2.0.0 + version: 2.0.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) '@wordpress/browserslist-config': specifier: 6.44.0 version: 6.44.0 @@ -1831,6 +1831,109 @@ importers: projects/packages/account-protection: {} + projects/packages/activity-log: + dependencies: + '@automattic/jetpack-analytics': + specifier: workspace:* + version: link:../../js-packages/analytics + '@automattic/jetpack-components': + specifier: workspace:* + version: link:../../js-packages/components + '@automattic/jetpack-connection': + specifier: workspace:* + version: link:../../js-packages/connection + '@automattic/ui': + specifier: 1.0.2 + version: 1.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.90.8 + version: 5.90.8(react@18.3.1) + '@wordpress/api-fetch': + specifier: 7.44.0 + version: 7.44.0 + '@wordpress/components': + specifier: 32.6.0 + version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.44.0 + version: 7.44.0(react@18.3.1) + '@wordpress/data': + specifier: 10.44.0 + version: 10.44.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.1.0 + version: 14.1.0(@types/react@18.3.28)(react@18.3.1)(stylelint@17.7.0) + '@wordpress/date': + specifier: 5.44.0 + version: 5.44.0 + '@wordpress/element': + specifier: 6.44.0 + version: 6.44.0 + '@wordpress/i18n': + specifier: 6.17.0 + version: 6.17.0 + '@wordpress/icons': + specifier: 12.2.0 + version: 12.2.0(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 + date-fns: + specifier: 4.1.0 + version: 4.1.0 + fast-deep-equal: + specifier: 3.1.3 + version: 3.1.3 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@automattic/babel-plugin-replace-textdomain': + specifier: workspace:* + version: link:../../js-packages/babel-plugin-replace-textdomain + '@automattic/jetpack-base-styles': + specifier: workspace:* + version: link:../../js-packages/base-styles + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config + '@babel/core': + specifier: 7.29.0 + version: 7.29.0 + '@babel/preset-env': + specifier: 7.29.2 + version: 7.29.2(@babel/core@7.29.0) + '@babel/runtime': + specifier: 7.29.2 + version: 7.29.2 + '@types/react': + specifier: 18.3.28 + version: 18.3.28 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 + '@wordpress/browserslist-config': + specifier: 6.44.0 + version: 6.44.0 + concurrently: + specifier: 9.2.1 + version: 9.2.1 + sass-embedded: + specifier: 1.97.3 + version: 1.97.3 + sass-loader: + specifier: 16.0.5 + version: 16.0.5(sass-embedded@1.97.3)(webpack@5.105.2) + webpack: + specifier: 5.105.2 + version: 5.105.2(webpack-cli@6.0.1) + webpack-cli: + specifier: 6.0.1 + version: 6.0.1(webpack@5.105.2) + projects/packages/admin-ui: devDependencies: '@automattic/jetpack-base-styles': @@ -2411,8 +2514,8 @@ importers: specifier: 0.16.0 version: 0.16.0 '@wordpress/admin-ui': - specifier: 1.12.0 - version: 1.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + specifier: 2.0.0 + version: 2.0.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) '@wordpress/base-styles': specifier: 6.20.0 version: 6.20.0 @@ -2595,8 +2698,8 @@ importers: specifier: 6.44.0 version: 6.44.0 '@wordpress/build': - specifier: 0.12.0 - version: 0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2) + specifier: 0.13.0 + version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2) '@wordpress/date': specifier: 5.44.0 version: 5.44.0 @@ -3494,8 +3597,8 @@ importers: specifier: 7.29.0 version: 7.29.0 '@wordpress/build': - specifier: 0.12.0 - version: 0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + specifier: 0.13.0 + version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: 4.28.2 version: 4.28.2 @@ -4279,8 +4382,8 @@ importers: specifier: ^4.40.0 version: 4.44.0 '@wordpress/admin-ui': - specifier: ^1.9.0 - version: 1.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + specifier: 2.0.0 + version: 2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) '@wordpress/boot': specifier: ^0.11.0 version: 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) @@ -4801,6 +4904,9 @@ importers: '@wordpress/theme': specifier: 0.11.0 version: 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) clsx: specifier: 2.1.1 version: 2.1.1 @@ -5497,6 +5603,9 @@ importers: '@wordpress/icons': specifier: 12.2.0 version: 12.2.0(react@18.3.1) + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) '@wordpress/url': specifier: 4.44.0 version: 4.44.0 @@ -7035,6 +7144,23 @@ packages: '@types/react': optional: true + '@base-ui/react@1.4.1': + resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + '@base-ui/utils@0.2.7': resolution: {integrity: sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==} peerDependencies: @@ -7045,6 +7171,16 @@ packages: '@types/react': optional: true + '@base-ui/utils@0.2.8': + resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -10326,12 +10462,22 @@ packages: resolution: {integrity: sha512-VewBVprbT10DnsIbIamtBXz5jVlwI+nRroXkYsRbYJq63h/dHkD2nnOObIbIdFfMi5m33fwcs1a3v93vqs8WMQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/a11y@4.45.0': + resolution: {integrity: sha512-KOgdBsZP34nAi+UfrhIAZDt2I1ZDb3DXAgIeQk7QxTIc9OlQKMNfrYwPG0jidgfKwmjFxh8vV8HbZcBzTD29Rw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/admin-ui@1.12.0': resolution: {integrity: sha512-CVTvE2jLTP71vBliAhOrvlMoOG1o1TdyoCL5gmw0Uswuj/qhqK3f1Y1adz7hAWiR9o7H9SoPYf+qg6pbZJVyaQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} peerDependencies: react: ^18.0.0 + '@wordpress/admin-ui@2.0.0': + resolution: {integrity: sha512-ZX6qExEPkdVzkv8gSsfU1NJU9EXdNbWCaPfck1Qo2bMcJWhb1n4zoITkfT+zAPqj3N/Fd2q8a8NOYxh2e/7fDw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/annotations@3.44.0': resolution: {integrity: sha512-0ie+k+sdIMu+HjTevXNR9Y+5rOtOkjKkV0w7VovU7wVvxRlg4dBQclnpww0QMivuhJNoFFvwym+3NjBn+N/JsA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10364,6 +10510,10 @@ packages: resolution: {integrity: sha512-Dsug4Zxz2xOFtK6CGThKYXwCqC9Yztw2STKQzwztrX4yW+o6iDbzkxpcwdDhsaVJs0Jt9A4LmJpZPh+pUozzLA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/base-styles@7.0.0': + resolution: {integrity: sha512-Q0BbZzfeYbQZKHnyNT4RF8RGVugN5jStGtpRKhBYQW7ut7sS61LbbpP7jR0D0sDPYoEEC8jKZQSZwSM23B4jow==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/blob@4.44.0': resolution: {integrity: sha512-MR5neg3nI4VNo7Oyd6XB0mh0AfWBuAkrQPSymQHayBQ1DEng8ZBo0EpuRV+f+Bf7yVW1KEmG+o9X1qg2gvTu6Q==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10403,15 +10553,15 @@ packages: resolution: {integrity: sha512-lYtkO7U7ok9RfRBIHWvVWXhcOys6cQuLfwFr1bGuPTE6+LmVHmRyniMnImZlG8Jb3XE4pvH8gXT1ecXogpDI2Q==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} - '@wordpress/build@0.12.0': - resolution: {integrity: sha512-PCwxVXEKGVjwZRRVGhl6jaiOXX4y3ENsUj7UFKFPC9Nna6ov9YOQvhM+1+87Wvqqlze9jAOAMU0cuUQ+WmntjQ==} + '@wordpress/build@0.13.0': + resolution: {integrity: sha512-a442H7Kh1hW1b9UH8DZzqLaxYAspe84/dWDRyep1R3YZFx2TcMCBs1tAF96xzvli5pN83PAN/gscv2DfNYBHyw==} engines: {node: '>=20.10.0', npm: '>=10.2.3'} hasBin: true peerDependencies: - '@wordpress/boot': '>=0.3.0' + '@wordpress/boot': '>=0.3.0 <1.0.0' '@wordpress/private-apis': ^1.0.0 - '@wordpress/route': '>=0.2.0' - '@wordpress/theme': '>=0.3.0' + '@wordpress/route': '>=0.2.0 <1.0.0' + '@wordpress/theme': '>=0.8.0 <1.0.0' peerDependenciesMeta: '@wordpress/boot': optional: true @@ -10436,12 +10586,25 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 + '@wordpress/components@33.0.0': + resolution: {integrity: sha512-VeLDtfz8612bdRqgQiSMtIIEGDi4ZByj0XUvjT7E6RVLgczQyV9DTpGOPyL6PbTyAluIx6hjt9bzsaC+bM6G+w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/compose@7.44.0': resolution: {integrity: sha512-NlMSR+sqEkHppjUM3irJhB0PLaWYoAgWFa7BL6xb94ciWxr4C5CIB0pSCXW8B0WNBPgS7q/xCeJGKGSfLkBgIQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} peerDependencies: react: ^18.0.0 + '@wordpress/compose@7.45.0': + resolution: {integrity: sha512-/keWdRFUe7bnzh2ZtOYLexknpj0K0G56WFw7RLZehl54a9EmzjYjAODBOF9DB3c07pJuNuy7c5QgqMPi0cqLlw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/core-data@7.44.0': resolution: {integrity: sha512-SBT/wiprxlo15QUwxKWH0t9RMvPu1TPgdd7+kPqqg79uUbkebs2P70Q3vBbQ6OdfEC4Mz7MGFeLlAr0uGT6KJQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10461,6 +10624,12 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/data@10.45.0': + resolution: {integrity: sha512-OR/uMpcEbCh1aBkbzateXffNrL829M+N92qtuD+Gt08Mey129WIEVR9kBC2Tf02VtXs644OKZD6cz77KlxH8XA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/dataviews@14.1.0': resolution: {integrity: sha512-RDnCbbgNEcTJiLscqn7pN0r9toEI3Pt3L2mvLHrMjMYR8aqdouYwPldM96Sa4j+DZLf+122hQ7wBvYwyn9C4Kw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10471,6 +10640,10 @@ packages: resolution: {integrity: sha512-8TUnhQKqjnMyQij1dQgVtpiJ5luRueCgu9iXGUwfoYfS6YmTS8u7lACVxn+LtWwGuJNSeZS4Dghsq5DgeW6sUQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/date@5.45.0': + resolution: {integrity: sha512-34v3hCxn68kYzWs8bhuAt8cfMxdFX9ukKn3a3FB+tAJXpxafnPCcZoWfJHn4I8hepCbreFrf3UiGdA+id2kQ4A==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dependency-extraction-webpack-plugin@6.44.0': resolution: {integrity: sha512-bc6PfIUW//FxDu7DOuUoq2/oIQL2u8U33oDArFukTmyzf1fBWSIYKc2rpD3t3JMaWmnoiorlKgDpaFXfI6dCuA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10481,14 +10654,26 @@ packages: resolution: {integrity: sha512-Yb2kPVP3vJnuJ87sQqWqt/QzRglEkXL6IJ1TnSyXKv7Jqke2Bh2UmSGLFn86e3ZHIbGkzRUYb5ZPGzaePPrQFQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/deprecated@4.45.0': + resolution: {integrity: sha512-qer/fk/lgmmisb8/hj1xZtsbJbZhCoOblhyxI2k7RRul7rQDdk+fm28LJYV+eIF0ldSVX30f4dmz1pvcVHQEEg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dom-ready@4.44.0': resolution: {integrity: sha512-YSiDpmelYLgFu0/Mki9OogEDO5t8Dr1pZnJU/RYRC7aawWGxidgNr0hael+9jO6pLAd+3LiAEV5cAvLg0V1pZQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dom-ready@4.45.0': + resolution: {integrity: sha512-0lFImpg9DGXcGCDQePdoU8haz7QYsKOFXUMTpRvi/Te38LFXzgZtOUBQbY8fRBlLxrgrj4FsAIc7bzdLn73wNQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dom@4.44.0': resolution: {integrity: sha512-W8uzlz83q73qO3fxl1Qcm69KvZqiXtcebEiXntO2lAyOtA5k/C3rbSwpGdTlgxFbQvg+SKbux17ZyztcB2p33Q==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/dom@4.45.0': + resolution: {integrity: sha512-6RObr/KEZS1FnZwpcDAsKlJ3qw2KLF5+A/LsxlM9fSWDGSO05CEaTp+VmWgx9pwjQWbPEa7N73ijEy8cCNSZWA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/e2e-test-utils-playwright@1.44.0': resolution: {integrity: sha512-iUKHGH8TjW1s0cpkcHF6y/APOmy4YnwBfzdBNCITK4+4fuSZnTV7vZyzBU3adthGcBSMGQ9w8MTE2AzGLtlG3w==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10514,10 +10699,18 @@ packages: resolution: {integrity: sha512-kVCRSwGMPFu7oBcAzN0VzwFQw3mwctUb/TEHkGeG5An1Uus6olruGJyvFwkHNtO9WRCdTXXunUaSk0CIA9+Wig==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/element@6.45.0': + resolution: {integrity: sha512-WFrGNPEnj8uE+XhFW9NVbxvqraYpConaEokLv9IszFYVfyg8juXSQcHOAfEnxjC08HBPfVcayr2igu/XUgGOAw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/escape-html@3.44.0': resolution: {integrity: sha512-nAEshSe6IYFr3G8sfY8o9pYNTRKvxocQ3DXs3KUesmdaEtrtJSlDmrMOI3FIgaYfv1PP6d+cDZpsygp6IZGo2w==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/escape-html@3.45.0': + resolution: {integrity: sha512-IW4mnA+65XKhABuBkwrQNAlbq97luC6ZIBfdSq0Tkq+AFPqE1lJTMlLo7iBkTpsHsBLyznViPXultq40fz8L7w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/eslint-plugin@25.0.0': resolution: {integrity: sha512-GYOPtbsibtFWmvFHm4ZBKUM16SREBcvpVHUuQLfh2s/CQtTP9kbC25XHmIdp0by77i2FDFvEtVGHo7aswoiRCA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10575,15 +10768,28 @@ packages: resolution: {integrity: sha512-6p2vFvoFaovqnKFnIoy6Kib2XJhTwaJ1VhMXp4tM2PhSLnFMXVm1TpcHeX/kH7E6sWKJACBrDR6FH2nGYMk5dA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/hooks@4.45.0': + resolution: {integrity: sha512-+gOlu8TdohqL1INQNxS/7CxhM4T4MuYnKietWV9zWDmNQV2ysM0SdamNk5pWERJ4w0yY9XhtMBcwR/piJtePZg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/html-entities@4.44.0': resolution: {integrity: sha512-Vejleo4VvES7Ec4qX6p74DL8M6P15p0Law9+A8Wp4Vu8wg4TLtTNZE4Hfet1YoXwY9t6czty+KGISZpEG3Y7RA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/html-entities@4.45.0': + resolution: {integrity: sha512-7W95xaOv4UgMSWlEmyO7YkBsUae3QlQu3GKENVH7Pt/osbJGSPInAJ1ruO4oeUwGPygWOL7b7IzRsgTNP0M/Wg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/i18n@6.17.0': resolution: {integrity: sha512-v1SLBweg7CRzQ+5+WSC1U93i8h9d3AoB0YBvMsd6gWI5vO8Zh4YKlEMexvrHQC++WN83egwqux84fWEdeU0MUA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} hasBin: true + '@wordpress/i18n@6.18.0': + resolution: {integrity: sha512-6dYCih4wUwi7Csu4RNfHiAKkgWhpSQdl8YthvQUF59Sfsoia3RCdtd4K2l7W4f18ldFA/RXjShMjvSexWy6OyQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + hasBin: true + '@wordpress/icons@10.32.0': resolution: {integrity: sha512-1WvJdT361X1LnetYBpBWUjAVXZzl+pBdIwHbYRAp8ej47EI/igPmNxmq81nFd40s8fer/9qtipielcqSI6H2rA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10596,6 +10802,12 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/icons@13.0.0': + resolution: {integrity: sha512-+CLbvNdzMUHxQK5I6gFdHb3X6EVAH6SOSIj0xtMWm6PZO+Nnf7tXHfNBuxqTnGfxT5grtfb6D3A9ZMBU+Tpv+Q==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/image-cropper@1.8.0': resolution: {integrity: sha512-Y297q++8o9YRRy8qn9c8pPLPa3EzZleV5hBaeeL1+NtolcJZL3lq9foByfmxTIZIeKtrAlUaQii2RWn2nibyjw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10622,6 +10834,10 @@ packages: resolution: {integrity: sha512-TTqNqi3yYD/aKVouTkm6xCbFsG2w2XAnODNrobY2y3k+6Cka7iIEVqLJU9lG5pl7+SYXd9RE1N5UPlQTO3Qczg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/is-shallow-equal@5.45.0': + resolution: {integrity: sha512-saamGjAuhZOiFOyznsriPGrO8GRDremImMO4q92qjQqmDqssC+FRDQnwr9D8BaedSnVvUDcriGeYBObEEnIJ2A==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/jest-console@8.44.0': resolution: {integrity: sha512-2Dawx6Qh2zr0ZlFByFmvkfCukb6CzCrCFnTnHImdiwlQ7wKcmTaIR3QPomJg6fTxiwgBiWn9yeiO7N97vJ59eg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10638,6 +10854,10 @@ packages: resolution: {integrity: sha512-dt8lfiTxnw9QqlS0DhvSOw4HbB4tlwv0/M++nEVYjpnIXIOsuH9/HYyHWhzIbSR2mw8S6TG6I4jktmKi/zemUA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/keycodes@4.45.0': + resolution: {integrity: sha512-N+Wp572xZovLM45cYo6HfUNTQNDfEqakAYIOcY8bUqA2iFelN6AUkNfUIkIxmrE0EqkQAQ5odES03g8ym7e1IA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/latex-to-mathml@1.12.0': resolution: {integrity: sha512-osmcIXqNNQIR5AkDFxATXoBuBPrMKWTsGVGSBfnnWzJNdFRBsZSIv9HlFFJVuvwEKQMYha11rbRFFRiKgKN/gg==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10703,14 +10923,28 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/primitives@4.45.0': + resolution: {integrity: sha512-x+i6EKUvz96EkUb2KuBTLNGm8d5+ZS0FYjUEnIhp5dtWxjMe8dJT6LS+n363vg+K28LVvjptiTAaByccnNKc9w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/priority-queue@3.44.0': resolution: {integrity: sha512-L1BaCwWz/kMr8FMWITZ+Z/RgF7UiX0bikn5XOHGqiEh/3dLLBpCLItK51FA7lejvW1+t5EQf6rcSmeUEkIz1YQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/priority-queue@3.45.0': + resolution: {integrity: sha512-0sIX2PRPzo5nk252f60xpPj3/BUZxEOLcabCC7FuvQDYPGZrRyS6Dy0vDDzozZxHGuUYCT65t8ubBwXx37wXCw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/private-apis@1.44.0': resolution: {integrity: sha512-fTR1HRshYIrN4yau/Z+zxY+oRFnJz/LS8XGeXx43PT5O4B25+4kO41ApdS9FG56erg8HqUB6HoqDUcReT5pzlQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/private-apis@1.45.0': + resolution: {integrity: sha512-UjhIDpoyKKUghPM0tkqd5Whsuk4kqfAfhb5VYGoEYtunDs0rB8IxgFO7hE0PhimHL74QVgaJOlprRZVRCCoQ6w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/react-i18n@4.43.0': resolution: {integrity: sha512-NASm8oHzEtZsHeqR4vnM27/j//zbojJUefZ4LMXCp2LlCWn63dJMYb8JVMOJtp9rYDwKx0UzztBnVsoN/4H2lA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10721,6 +10955,12 @@ packages: peerDependencies: redux: '>=4' + '@wordpress/redux-routine@5.45.0': + resolution: {integrity: sha512-6ShpBns4jIBFXrYFBcKA5pnFm/kjr1SqFvLj5DwLgMV61eI3Rr9LyZwIzNR2BGg067ryxu4W172Uqjke/mZjcQ==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + redux: '>=4' + '@wordpress/reusable-blocks@5.44.0': resolution: {integrity: sha512-9dpae0P0sZiCjsJz9Zk+MhjHIllURzD3e72XLzjnRh0r/tmoQt1OJKUyAfDZCM1YS7SgvMkTPlpCgiB+M9c3jw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10734,12 +10974,24 @@ packages: peerDependencies: react: ^18.0.0 + '@wordpress/rich-text@7.45.0': + resolution: {integrity: sha512-C5+JQqNzA3fiQq0hN9pQPKsjcwO/fczouHqubq3847kAUrClROqqI1GJHE34WLl1Vp+/tWQuBkIjQ/95olKteA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/route@0.10.0': resolution: {integrity: sha512-rNXo4cq+yPlkFzC/bQjZW8qQpaNgh1nAeUVefc4Si079C79pC0JhnXKqPOq4Iy28oJZyRjfdLdFV1FdLhHfmzA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} peerDependencies: react: ^18.0.0 + '@wordpress/route@0.11.0': + resolution: {integrity: sha512-3P02OKhI6yTlxHo1mLg8l8QNGsKi+e63ICP0KkaMZl5aMumfjzpZr9/fH0+uyuRdS5m2uu3BVMVa0J4QG6gMyg==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + '@wordpress/router@1.44.0': resolution: {integrity: sha512-o7ksSoxHMhX+hHyTSHbJtr4jJep7fuwNU0jRwfmGTLNGWb8m1/wB7l+WcP/KNwcM/jomi2aa/ggsm9bT+yAypw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10783,6 +11035,17 @@ packages: stylelint: optional: true + '@wordpress/theme@0.12.0': + resolution: {integrity: sha512-AmEVO0B+kI9tsxkLnna/S+7yi+EPCMTuaPqagje7pnlXeDfykVQfeDeWJfU+QvhcqHXCySn89vvw1Ihep0rj7w==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + stylelint: '>=16.8.2' + peerDependenciesMeta: + stylelint: + optional: true + '@wordpress/token-list@3.44.0': resolution: {integrity: sha512-+96NDDOC6vA/DQnRk/fnnmLylnZXEpMctklNOdztgpdwrXSsM+LoPoksaOYrmswPUxayzlHPBBbO/5rZ72g7zQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10794,10 +11057,21 @@ packages: react: ^18.0.0 react-dom: ^18.0.0 + '@wordpress/ui@0.12.0': + resolution: {integrity: sha512-n/xfyagM90CcikLtlvNcjsFZtpt1wTpboOZPyCp9wqF6akAyJ4SUg9hXb/UA7pC8JqGe1Dg/hXJnFn/td8pvRA==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + '@wordpress/undo-manager@1.44.0': resolution: {integrity: sha512-NVMR35nMQc7DkCjQvkt13sd+cYtNsmwyaXJ0H2ENe23ndzRXoNKKLSgN03FzFQ73IlePbAHyasyEyLCc1hDRsw==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/undo-manager@1.45.0': + resolution: {integrity: sha512-BqclZIPjzBYIjLqLZFihs+Ce+w+yBQuj44VYSrRDOj56AbMtwmClIUqgIVBZAe2En/2ncixTTWOZG9KluvEXfA==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/upload-media@0.29.0': resolution: {integrity: sha512-ruMjLJGYWC5uSzzYKM+xkmXwpB1C6Ud69VNoupblpUmoG5amcI7I9e7gnQa8oJ0zHIkxFA50/9aHs4C0rsSQPA==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -10827,6 +11101,10 @@ packages: resolution: {integrity: sha512-avxdbIYhDuUh2qi2oiq7KeqYOVv2RubqV8UI/Q7bctZSFSXJE8RQGSR/W2YjABeyWBIjlyX/U5lOxVs2PIfy/w==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/warning@3.45.0': + resolution: {integrity: sha512-NQ9tAhPdwhfceVIzWra1rbumvgAFAEDTgZlWsX880zLiq1F8JTwBouwW6wfIhA3XLcY6Yj7cBBYLa8vnNiDZDw==} + engines: {node: '>=18.12.0', npm: '>=8.19.2'} + '@wordpress/widgets@4.44.0': resolution: {integrity: sha512-wYTWr6/CBip7ZMNwwiV/UyB5mi7W4wR8IU8HcZKwxg/H+Nmwb8MKnVurmAKuxHZvbsmlzLVuBLDqzA5yL1XfmQ==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} @@ -17413,10 +17691,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -19104,6 +19384,33 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1) + '@base-ui/react@1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.8(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@date-fns/tz': 1.4.1 + '@types/react': 18.3.28 + date-fns: 4.1.0 + + '@base-ui/react@1.4.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + '@base-ui/utils@0.2.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 @@ -19124,6 +19431,26 @@ snapshots: reselect: 5.1.1 use-sync-external-store: 1.6.0(react@18.3.1) + '@base-ui/utils@0.2.8(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + + '@base-ui/utils@0.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@18.3.1) + '@bcoe/v8-coverage@0.2.3': {} '@blazediff/core@1.9.1': {} @@ -23134,6 +23461,11 @@ snapshots: '@wordpress/dom-ready': 4.44.0 '@wordpress/i18n': 6.17.0 + '@wordpress/a11y@4.45.0': + dependencies: + '@wordpress/dom-ready': 4.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/admin-ui@1.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': dependencies: '@wordpress/base-styles': 6.20.0 @@ -23170,6 +23502,40 @@ snapshots: - stylelint - supports-color + '@wordpress/admin-ui@2.0.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': + dependencies: + '@wordpress/components': 33.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/element': 6.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/private-apis': 1.45.0 + '@wordpress/route': 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/ui': 0.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + clsx: 2.1.1 + react: 18.3.1 + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - react-dom + - stylelint + - supports-color + + '@wordpress/admin-ui@2.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': + dependencies: + '@wordpress/components': 33.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/element': 6.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/private-apis': 1.45.0 + '@wordpress/route': 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/ui': 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + clsx: 2.1.1 + react: 18.3.1 + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - '@types/react' + - react-dom + - stylelint + - supports-color + '@wordpress/annotations@3.44.0(react@18.3.1)': dependencies: '@wordpress/data': 10.44.0(react@18.3.1) @@ -23210,6 +23576,8 @@ snapshots: '@wordpress/base-styles@6.20.0': {} + '@wordpress/base-styles@7.0.0': {} + '@wordpress/blob@4.44.0': {} '@wordpress/block-editor@15.17.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': @@ -23671,7 +24039,7 @@ snapshots: '@wordpress/browserslist-config@6.44.0': {} - '@wordpress/build@0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.4.27(postcss@8.5.10) @@ -23697,7 +24065,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.4.27(postcss@8.5.10) @@ -23845,6 +24213,63 @@ snapshots: - '@emotion/is-prop-valid' - supports-color + '@wordpress/components@33.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@ariakit/react': 0.4.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@date-fns/utc': 2.1.1 + '@emotion/cache': 11.14.0 + '@emotion/css': 11.13.5 + '@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1) + '@emotion/serialize': 1.3.3 + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1) + '@emotion/utils': 1.4.2 + '@floating-ui/react-dom': 2.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/gradient-parser': 1.1.0 + '@types/highlight-words-core': 1.2.1 + '@types/react': 18.3.28 + '@use-gesture/react': 10.3.1(react@18.3.1) + '@wordpress/a11y': 4.45.0 + '@wordpress/base-styles': 7.0.0 + '@wordpress/compose': 7.45.0(react@18.3.1) + '@wordpress/date': 5.45.0 + '@wordpress/deprecated': 4.45.0 + '@wordpress/dom': 4.45.0 + '@wordpress/element': 6.45.0 + '@wordpress/escape-html': 3.45.0 + '@wordpress/hooks': 4.45.0 + '@wordpress/html-entities': 4.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/icons': 13.0.0(react@18.3.1) + '@wordpress/is-shallow-equal': 5.45.0 + '@wordpress/keycodes': 4.45.0 + '@wordpress/primitives': 4.45.0(react@18.3.1) + '@wordpress/private-apis': 1.45.0 + '@wordpress/rich-text': 7.45.0(react@18.3.1) + '@wordpress/warning': 3.45.0 + change-case: 4.1.2 + clsx: 2.1.1 + colord: 2.9.3 + csstype: 3.2.3 + date-fns: 3.6.0 + deepmerge: 4.3.1 + fast-deep-equal: 3.1.3 + framer-motion: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + gradient-parser: 1.1.1 + highlight-words-core: 1.2.3 + is-plain-object: 5.0.0 + memize: 2.1.1 + path-to-regexp: 6.3.0 + re-resizable: 6.11.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-colorful: 5.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker: 9.14.0(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + remove-accents: 0.5.0 + uuid: 9.0.1(patch_hash=87a713b75995ed86c0ecd0cadfd1b9f85092ca16fdff7132ff98b62fc3cf2db0) + transitivePeerDependencies: + - '@emotion/is-prop-valid' + - supports-color + '@wordpress/compose@7.44.0(react@18.3.1)': dependencies: '@types/mousetrap': 1.6.15 @@ -23860,6 +24285,21 @@ snapshots: react: 18.3.1 use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/compose@7.45.0(react@18.3.1)': + dependencies: + '@types/mousetrap': 1.6.15 + '@wordpress/deprecated': 4.45.0 + '@wordpress/dom': 4.45.0 + '@wordpress/element': 6.45.0 + '@wordpress/is-shallow-equal': 5.45.0 + '@wordpress/keycodes': 4.45.0 + '@wordpress/priority-queue': 3.45.0 + '@wordpress/undo-manager': 1.45.0 + change-case: 4.1.2 + mousetrap: 1.6.5 + react: 18.3.1 + use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/core-data@7.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': dependencies: '@wordpress/api-fetch': 7.44.0 @@ -23981,6 +24421,24 @@ snapshots: rememo: 4.0.2 use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/data@10.45.0(react@18.3.1)': + dependencies: + '@wordpress/compose': 7.45.0(react@18.3.1) + '@wordpress/deprecated': 4.45.0 + '@wordpress/element': 6.45.0 + '@wordpress/is-shallow-equal': 5.45.0 + '@wordpress/priority-queue': 3.45.0 + '@wordpress/private-apis': 1.45.0 + '@wordpress/redux-routine': 5.45.0(redux@5.0.1) + deepmerge: 4.3.1 + equivalent-key-map: 0.2.2 + is-plain-object: 5.0.0 + is-promise: 4.0.0 + react: 18.3.1 + redux: 5.0.1 + rememo: 4.0.2 + use-memo-one: 1.1.3(react@18.3.1) + '@wordpress/dataviews@14.1.0(@types/react@18.3.28)(react@18.3.1)(stylelint@17.7.0)': dependencies: '@ariakit/react': 0.4.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24089,6 +24547,12 @@ snapshots: moment: 2.30.1 moment-timezone: 0.5.48 + '@wordpress/date@5.45.0': + dependencies: + '@wordpress/deprecated': 4.45.0 + moment: 2.30.1 + moment-timezone: 0.5.48 + '@wordpress/dependency-extraction-webpack-plugin@6.44.0(webpack@5.105.2)': dependencies: json2php: 0.0.7 @@ -24098,12 +24562,22 @@ snapshots: dependencies: '@wordpress/hooks': 4.44.0 + '@wordpress/deprecated@4.45.0': + dependencies: + '@wordpress/hooks': 4.45.0 + '@wordpress/dom-ready@4.44.0': {} + '@wordpress/dom-ready@4.45.0': {} + '@wordpress/dom@4.44.0': dependencies: '@wordpress/deprecated': 4.44.0 + '@wordpress/dom@4.45.0': + dependencies: + '@wordpress/deprecated': 4.45.0 + '@wordpress/e2e-test-utils-playwright@1.44.0(@playwright/test@1.58.2)(@types/node@24.12.2)': dependencies: '@playwright/test': 1.58.2 @@ -24461,8 +24935,20 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@wordpress/element@6.45.0': + dependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@wordpress/escape-html': 3.45.0 + change-case: 4.1.2 + is-plain-object: 5.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@wordpress/escape-html@3.44.0': {} + '@wordpress/escape-html@3.45.0': {} + '@wordpress/eslint-plugin@25.0.0(@babel/core@7.29.0)(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint-plugin-import@2.32.0)(eslint-plugin-jest@29.15.0(eslint@9.39.4)(jest@30.3.0)(typescript@5.9.3))(eslint-plugin-jsdoc@62.8.0(eslint@9.39.4))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4))(eslint-plugin-playwright@2.10.0(eslint@9.39.4))(eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(wp-prettier@3.0.3))(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4))(eslint-plugin-react@7.37.5(eslint@9.39.4))(eslint@9.39.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0(typescript@5.9.3))(typescript@5.9.3)(wp-prettier@3.0.3)': dependencies: '@babel/core': 7.29.0 @@ -24748,8 +25234,12 @@ snapshots: '@wordpress/hooks@4.44.0': {} + '@wordpress/hooks@4.45.0': {} + '@wordpress/html-entities@4.44.0': {} + '@wordpress/html-entities@4.45.0': {} + '@wordpress/i18n@6.17.0': dependencies: '@tannin/sprintf': 1.3.3 @@ -24758,6 +25248,14 @@ snapshots: memize: 2.1.1 tannin: 1.2.0 + '@wordpress/i18n@6.18.0': + dependencies: + '@tannin/sprintf': 1.3.3 + '@wordpress/hooks': 4.45.0 + gettext-parser: 1.4.0 + memize: 2.1.1 + tannin: 1.2.0 + '@wordpress/icons@10.32.0(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 @@ -24772,6 +25270,13 @@ snapshots: change-case: 4.1.2 react: 18.3.1 + '@wordpress/icons@13.0.0(react@18.3.1)': + dependencies: + '@wordpress/element': 6.45.0 + '@wordpress/primitives': 4.45.0(react@18.3.1) + change-case: 4.1.2 + react: 18.3.1 + '@wordpress/image-cropper@1.8.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/components': 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -24847,6 +25352,8 @@ snapshots: '@wordpress/is-shallow-equal@5.44.0': {} + '@wordpress/is-shallow-equal@5.45.0': {} + '@wordpress/jest-console@8.44.0(jest@30.3.0)': dependencies: jest: 30.3.0 @@ -24864,6 +25371,10 @@ snapshots: dependencies: '@wordpress/i18n': 6.17.0 + '@wordpress/keycodes@4.45.0': + dependencies: + '@wordpress/i18n': 6.18.0 + '@wordpress/latex-to-mathml@1.12.0': dependencies: temml: 0.10.34 @@ -25232,12 +25743,24 @@ snapshots: clsx: 2.1.1 react: 18.3.1 + '@wordpress/primitives@4.45.0(react@18.3.1)': + dependencies: + '@wordpress/element': 6.45.0 + clsx: 2.1.1 + react: 18.3.1 + '@wordpress/priority-queue@3.44.0': dependencies: requestidlecallback: 0.3.0 + '@wordpress/priority-queue@3.45.0': + dependencies: + requestidlecallback: 0.3.0 + '@wordpress/private-apis@1.44.0': {} + '@wordpress/private-apis@1.45.0': {} + '@wordpress/react-i18n@4.43.0': dependencies: '@wordpress/element': 6.44.0 @@ -25251,6 +25774,13 @@ snapshots: redux: 5.0.1 rungen: 0.3.2 + '@wordpress/redux-routine@5.45.0(redux@5.0.1)': + dependencies: + is-plain-object: 5.0.0 + is-promise: 4.0.0 + redux: 5.0.1 + rungen: 0.3.2 + '@wordpress/reusable-blocks@5.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': dependencies: '@wordpress/base-styles': 6.20.0 @@ -25336,6 +25866,22 @@ snapshots: memize: 2.1.1 react: 18.3.1 + '@wordpress/rich-text@7.45.0(react@18.3.1)': + dependencies: + '@wordpress/a11y': 4.45.0 + '@wordpress/compose': 7.45.0(react@18.3.1) + '@wordpress/data': 10.45.0(react@18.3.1) + '@wordpress/deprecated': 4.45.0 + '@wordpress/dom': 4.45.0 + '@wordpress/element': 6.45.0 + '@wordpress/escape-html': 3.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/keycodes': 4.45.0 + '@wordpress/private-apis': 1.45.0 + colord: 2.9.3 + memize: 2.1.1 + react: 18.3.1 + '@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.161.5 @@ -25345,6 +25891,15 @@ snapshots: transitivePeerDependencies: - react-dom + '@wordpress/route@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/history': 1.161.5 + '@tanstack/react-router': 1.167.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/private-apis': 1.45.0 + react: 18.3.1 + transitivePeerDependencies: + - react-dom + '@wordpress/router@1.44.0(react@18.3.1)': dependencies: '@wordpress/compose': 7.44.0(react@18.3.1) @@ -25427,6 +25982,17 @@ snapshots: optionalDependencies: stylelint: 17.7.0 + '@wordpress/theme@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': + dependencies: + '@wordpress/element': 6.45.0 + '@wordpress/private-apis': 1.45.0 + colorjs.io: 0.6.1 + memize: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + stylelint: 17.7.0 + '@wordpress/token-list@3.44.0': {} '@wordpress/ui@0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': @@ -25473,10 +26039,58 @@ snapshots: - '@types/react' - stylelint + '@wordpress/ui@0.12.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': + dependencies: + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@date-fns/tz': 1.4.1 + '@wordpress/a11y': 4.45.0 + '@wordpress/compose': 7.45.0(react@18.3.1) + '@wordpress/element': 6.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/icons': 13.0.0(react@18.3.1) + '@wordpress/keycodes': 4.45.0 + '@wordpress/primitives': 4.45.0(react@18.3.1) + '@wordpress/private-apis': 1.45.0 + '@wordpress/theme': 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + clsx: 2.1.1 + date-fns: 4.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@types/react' + - stylelint + + '@wordpress/ui@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': + dependencies: + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@date-fns/tz': 1.4.1 + '@wordpress/a11y': 4.45.0 + '@wordpress/compose': 7.45.0(react@18.3.1) + '@wordpress/element': 6.45.0 + '@wordpress/i18n': 6.18.0 + '@wordpress/icons': 13.0.0(react@18.3.1) + '@wordpress/keycodes': 4.45.0 + '@wordpress/primitives': 4.45.0(react@18.3.1) + '@wordpress/private-apis': 1.45.0 + '@wordpress/theme': 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + clsx: 2.1.1 + date-fns: 4.1.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@types/react' + - stylelint + '@wordpress/undo-manager@1.44.0': dependencies: '@wordpress/is-shallow-equal': 5.44.0 + '@wordpress/undo-manager@1.45.0': + dependencies: + '@wordpress/is-shallow-equal': 5.45.0 + '@wordpress/upload-media@0.29.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/blob': 4.44.0 @@ -25567,6 +26181,8 @@ snapshots: '@wordpress/warning@3.44.0': {} + '@wordpress/warning@3.45.0': {} + '@wordpress/widgets@4.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0)': dependencies: '@wordpress/api-fetch': 7.44.0 diff --git a/projects/js-packages/base-styles/admin-page-layout.scss b/projects/js-packages/base-styles/admin-page-layout.scss index e201ac4e3df9..d27b68ee658f 100644 --- a/projects/js-packages/base-styles/admin-page-layout.scss +++ b/projects/js-packages/base-styles/admin-page-layout.scss @@ -18,8 +18,19 @@ // root: .jp-admin-page (added unconditionally by the // component from // @automattic/jetpack-components) -// @wordpress/admin-ui: .admin-ui-page, .admin-ui-page__header +// admin-ui's : .jp-admin-page__page (className passed through +// by ; admin-ui +// 2.0.0 forwards it onto +// the page's outer node) +// admin-ui's header:
element rendered by admin-ui's +// inside `.jp-admin-page__page` (no class hooks — +// we anchor structurally to the HTML5 element) // : .jetpack-footer +// Tabs wrapper convention: .jp-admin-page-tabs (consumer-applied div that +// wraps `@wordpress/ui` +// `Tabs.List`; see the +// "Tabs strip" section +// below) // Implementation note — `postcss-custom-properties` with `preserve: false`: // several consumer packages in this monorepo (publicize, videopress, search, @@ -172,10 +183,16 @@ $jp-breakpoint-mobile: 782px; } // ── admin-ui Page internals ─────────────────────────────────────── - // admin-ui ships `.admin-ui-page` with `height: 100%`, which fills its + // admin-ui ships its page node with `height: 100%`, which fills its // parent's computed height. Paired with our flex chain, the element // becomes a viewport-fitted flex column. - .admin-ui-page { + + // admin-ui 2.0.0 moved internals to CSS Modules (no `.admin-ui-page*` + // class hooks anymore). We pass `className="jp-admin-page__page"` from + // ; admin-ui forwards it onto the page's outer node. The + // header is a stable `
` element directly inside that node, so + // we anchor to `> header` instead of a class. + .jp-admin-page__page { flex: 1 1 auto; min-height: 0; min-width: 0; @@ -183,20 +200,20 @@ $jp-breakpoint-mobile: 782px; flex-direction: column; } - .admin-ui-page__header { + .jp-admin-page__page > header { flex-shrink: 0; // pinned at top of the flex column. } // The scrollable middle. does not pass `hasPadding` to - // admin-ui's , so `.admin-ui-page__content` is not rendered — - // children drop in as direct descendants of `.admin-ui-page`. Target + // admin-ui's , so admin-ui's content wrapper is not rendered — + // children drop in as direct descendants of the page node. Target // anything that isn't the header or the footer. // `overflow: auto` (both axes) lets any child wider than the column // (dashboard grids with fixed column widths, wide tables, `100vw` // descendants) scroll horizontally inside the middle instead of // dragging the whole window into a horizontal scrollbar. - .admin-ui-page > :not(.admin-ui-page__header):not(.jetpack-footer) { + .jp-admin-page__page > :not(header):not(.jetpack-footer) { flex: 1 1 auto; min-height: 0; min-width: 0; diff --git a/projects/js-packages/base-styles/changelog/add-admin-page-tabs-mixin-rules b/projects/js-packages/base-styles/changelog/add-admin-page-tabs-mixin-rules new file mode 100644 index 000000000000..cc9070a786ab --- /dev/null +++ b/projects/js-packages/base-styles/changelog/add-admin-page-tabs-mixin-rules @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +admin-page-layout mixin: style hooks for `@wordpress/ui` Tabs hosted in an AdminPage (sticky `.jp-admin-page-tabs` wrapper, inline-padding alignment for tab buttons, header bottom-border/padding suppression when tabs are present). diff --git a/projects/js-packages/base-styles/changelog/admin-page-layout-flex-children b/projects/js-packages/base-styles/changelog/admin-page-layout-flex-children new file mode 100644 index 000000000000..321f2c02d5ef --- /dev/null +++ b/projects/js-packages/base-styles/changelog/admin-page-layout-flex-children @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +admin-page-layout mixin: extend the flex chain into AdminPage's outer Container/Col so DataViews-style consumers can fill their bounded slot and let their own internal scroll handle the table body. diff --git a/projects/js-packages/base-styles/changelog/jetpack-page-layout-admin-ui-2x b/projects/js-packages/base-styles/changelog/jetpack-page-layout-admin-ui-2x new file mode 100644 index 000000000000..92437e5b3891 --- /dev/null +++ b/projects/js-packages/base-styles/changelog/jetpack-page-layout-admin-ui-2x @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +admin-page-layout mixin: anchor selectors to the new `.jp-admin-page__page` className and the rendered `
` element, replacing the `.admin-ui-page*` global classes that admin-ui 2.0.0 dropped when it moved to CSS Modules. Restores the viewport-fitted scroll chain on every consumer (Boost, Protect, VideoPress, Search, Newsletter, Publicize, Backup, Jetpack network admin). diff --git a/projects/js-packages/components/changelog/add-toggle-control-aria-label b/projects/js-packages/components/changelog/add-toggle-control-aria-label new file mode 100644 index 000000000000..817947c288fa --- /dev/null +++ b/projects/js-packages/components/changelog/add-toggle-control-aria-label @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +ToggleControl: forward the `aria-label` prop to the underlying checkbox so consumers can label toggles that have no visible label. diff --git a/projects/js-packages/components/changelog/admin-page-add-unwrapped-prop b/projects/js-packages/components/changelog/admin-page-add-unwrapped-prop new file mode 100644 index 000000000000..9fbe04636dba --- /dev/null +++ b/projects/js-packages/components/changelog/admin-page-add-unwrapped-prop @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +AdminPage: add `unwrapped` prop to render children directly inside the admin-ui Page, skipping the default Container/Col grid wrap. Use for full-bleed pages (DataViews-based admin surfaces) that own their own scroll/layout model. diff --git a/projects/js-packages/components/changelog/jetpack-page-layout-admin-ui-2x b/projects/js-packages/components/changelog/jetpack-page-layout-admin-ui-2x new file mode 100644 index 000000000000..8a45decc53b0 --- /dev/null +++ b/projects/js-packages/components/changelog/jetpack-page-layout-admin-ui-2x @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +AdminPage: pass a stable `jp-admin-page__page` className to admin-ui's Page so layout overrides survive admin-ui 2.0.0's switch to CSS Modules; pin the header heading level to `

` and center the new `visual` slot to keep the Jetpack logo aligned with the title. diff --git a/projects/js-packages/components/changelog/update-admin-ui b/projects/js-packages/components/changelog/update-admin-ui new file mode 100644 index 000000000000..1f65e020eaef --- /dev/null +++ b/projects/js-packages/components/changelog/update-admin-ui @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +AdminPage: Update to @wordpress/admin-ui 2.0.0 and use the new `visual` prop to render the Jetpack logo alongside the page title. diff --git a/projects/js-packages/components/components/admin-page/index.tsx b/projects/js-packages/components/components/admin-page/index.tsx index 85db4371e324..4fa00a0342ee 100644 --- a/projects/js-packages/components/components/admin-page/index.tsx +++ b/projects/js-packages/components/components/admin-page/index.tsx @@ -1,9 +1,5 @@ import restApi from '@automattic/jetpack-api'; import { Page } from '@wordpress/admin-ui'; -import '@wordpress/admin-ui/build-style/style.css'; -import { - __experimentalHStack as HStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis -} from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import clsx from 'clsx'; import { useEffect, useCallback } from 'react'; @@ -41,6 +37,7 @@ const AdminPage: FC< AdminPageProps > = ( { breadcrumbs, tabs, showBottomBorder = true, + unwrapped = false, } ) => { useEffect( () => { restApi.setApiRoot( apiRoot ); @@ -73,31 +70,27 @@ const AdminPage: FC< AdminPageProps > = ( { } }, [] ); - // Compose the title with logo for the admin-ui Page header. - // Page's Header wraps this in an

tag, so we just pass the content directly. - const composedTitle = title ? ( - - { logo || } - { title } - - ) : undefined; - // When title or breadcrumbs are provided, use admin-ui Page for the full page layout. - if ( showHeader && ( composedTitle || breadcrumbs ) ) { + if ( showHeader && ( title || breadcrumbs ) ) { return (
} breadcrumbs={ breadcrumbs } - title={ composedTitle } + title={ title } subTitle={ subTitle } actions={ actions } showSidebarToggle={ false } > { tabs } - - { children } - + { unwrapped ? ( + children + ) : ( + + { children } + + ) } { showFooter && }
diff --git a/projects/js-packages/components/components/admin-page/style.module.scss b/projects/js-packages/components/components/admin-page/style.module.scss index 588fa35e4fe3..0a58c92e8e5b 100644 --- a/projects/js-packages/components/components/admin-page/style.module.scss +++ b/projects/js-packages/components/components/admin-page/style.module.scss @@ -13,23 +13,36 @@ // or when showBottomBorder is false. // Ideally admin-ui would expose a prop or CSS custom property for this: // https://github.com/WordPress/gutenberg/issues/75428 - &.without-bottom-border :global(.admin-ui-page__header) { + + // Anchor: `.jp-admin-page__page` is the className we pass to admin-ui's + // ; admin-ui 2.0.0 renders the header as a stable
element + // directly inside that page node — no class hooks anymore. + &.without-bottom-border :global(.jp-admin-page__page > header) { border-bottom: none; } // Disable sticky header until we make it work better across all pages. // JETPACK-1386 - :global(.admin-ui-page__header) { + :global(.jp-admin-page__page > header) { position: relative; z-index: 1; } // Normalize admin headers: implementation of admin-ui needs to // comprehend old wp-admin floating containers, such as Hello Dolly. - :global(.admin-ui-page) { + :global(.jp-admin-page__page) { clear: both; } + // admin-ui 2.0.0's header `visual` slot is a 24px grid box but does not + // center its contents. Our JetpackLogo is 20px tall, so without this it + // pins to the top-left of the cell and looks misaligned vs. the title. + // admin-ui ships the slot as `
+ + +
-

{ children } ); diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 9f887ea0bc79..f1ea2e0643a9 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -18,7 +18,7 @@ class Search_Blocks { * Reserved query params that must not be parsed as filter keys. Mirrors * `RESERVED_PARAMS` in store/url-state.js. */ - const RESERVED_QUERY_PARAMS = array( 's', 'orderby' ); + const RESERVED_QUERY_PARAMS = array( 's', 'orderby', 'min_price', 'max_price' ); /** * Template slug used for the Jetpack Search page template. @@ -396,10 +396,12 @@ public static function build_seed_state( array $filter_configs ): array { // derives it from the raw URL params, so a URL that carried only // unregistered `?foo[]=bar` params (e.g. from another plugin) would // leave isLoading=true after gating emptied activeFilters — and since - // the JS `initialize()` only fires a search when `searchQuery` or - // `hasActiveFilters` is truthy, neither would fire, the spinner would + // the JS `initialize()` only fires a search when `searchQuery`, + // `hasActiveFilters`, or `priceRange` is truthy, the spinner would // never clear. - $state['isLoading'] = '' !== $state['searchQuery'] || ! empty( $state['activeFilters'] ); + $state['isLoading'] = '' !== $state['searchQuery'] + || ! empty( $state['activeFilters'] ) + || null !== $state['priceRange']; return $state; } @@ -490,6 +492,7 @@ public static function build_initial_state() { $site_id = class_exists( Helper::class ) ? Helper::get_wpcom_site_id() : 0; $search_query = function_exists( 'get_search_query' ) ? (string) get_search_query() : ''; $active_filters = static::parse_url_filters(); + $price_range = static::parse_url_price_range(); return array( // Connection / routing config. @@ -514,6 +517,7 @@ public static function build_initial_state() { 'searchQuery' => $search_query, 'sortOrder' => static::parse_url_sort(), 'activeFilters' => $active_filters, + 'priceRange' => $price_range, // filterConfigs: each filter-checkbox block's render.php merges its // own entry here. Shape: { [filterKey]: { filterKey, filterType, @@ -532,7 +536,7 @@ public static function build_initial_state() { // search query or filter selection so the no-results block stays // hidden between first paint and JS hydrating the initial fetch — // otherwise a "No results found" flash appears on deep links. - 'isLoading' => '' !== $search_query || ! empty( $active_filters ), + 'isLoading' => '' !== $search_query || ! empty( $active_filters ) || null !== $price_range, 'isLoadingMore' => false, 'hasError' => false, @@ -585,6 +589,65 @@ protected static function parse_url_sort(): string { return in_array( $orderby, array( 'newest', 'oldest' ), true ) ? $orderby : 'relevance'; } + /** + * Parse the price range from the URL, mirroring the contract in + * src/search-blocks/store/url-state.js. Either bound may be null for a + * half-open range; non-numeric or negative values yield null so a + * garbage URL can't drive the API into producing zero results. + * + * Returns null when neither bound is set, so callers can early-out + * without checking individual fields. + * + * @return array{min: float|null, max: float|null}|null + */ + protected static function parse_url_price_range(): ?array { + // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- read-only URL state; coerced to float in parse_price_bound() which discards any non-numeric input. + $min = self::parse_price_bound( $_GET['min_price'] ?? null ); + $max = self::parse_price_bound( $_GET['max_price'] ?? null ); + // phpcs:enable + + if ( null === $min && null === $max ) { + return null; + } + // Both bounds present but inverted (min > max) yields an empty ES + // `range` clause that returns zero results silently. Treat the URL + // as garbage and bail so the page renders a normal (unfiltered) + // search rather than a guaranteed-empty one. Mirrors the same + // rejection in store/url-state.js. + if ( null !== $min && null !== $max && $min > $max ) { + return null; + } + return array( + 'min' => $min, + 'max' => $max, + ); + } + + /** + * Coerce a single price-range URL value into a finite, non-negative float. + * + * @param mixed $raw Raw value pulled from $_GET. + * @return float|null + */ + private static function parse_price_bound( $raw ): ?float { + if ( null === $raw || '' === $raw || ! is_scalar( $raw ) ) { + return null; + } + // `is_numeric` rejects partially-numeric strings like "1.5.3" that + // the (float) cast would silently extract as 1.5 — JS's Number() + // returns NaN for the same input, so without this gate the PHP + // initial render and JS hydration disagree on parsed value. + $raw = wp_unslash( $raw ); + if ( ! is_numeric( $raw ) ) { + return null; + } + $num = (float) $raw; + if ( ! is_finite( $num ) || $num < 0 ) { + return null; + } + return $num; + } + /** * Parse flat filter selections from the current request URL. * diff --git a/projects/packages/search/src/search-blocks/store/api.js b/projects/packages/search/src/search-blocks/store/api.js index 5fa9bf62a3e3..4ac1f4c16bb0 100644 --- a/projects/packages/search/src/search-blocks/store/api.js +++ b/projects/packages/search/src/search-blocks/store/api.js @@ -84,10 +84,67 @@ export function resolveFilterFields( config ) { filterField: 'author_login', bucketFormat: 'slash', }; + case 'wc_stock_status': + // Reads WooCommerce-populated postmeta indexed under + // `meta._stock_status.value` with a `.raw` keyword subfield via + // the `meta_str_template` dynamic mapping. The terms-aggs + // whitelist (`meta\..*\.value\.raw` in + // class-wpcom-search-engine.php) admits this exact path. The + // `wc_*` prefix on the filterType marks the dependency on + // WC-populated meta even though no WC-side code is involved at + // query time. + return { + aggField: 'meta._stock_status.value.raw', + filterField: 'meta._stock_status.value.raw', + bucketFormat: 'plain', + }; + case 'wc_rating': + // Reads WC's per-product `_wc_average_rating` meta. Aggregation + // uses a histogram (range aggs aren't whitelisted on the + // WPCOM v1.3 search API — `[aggs:range] is not whitelisted`) + // with `interval: 1, offset: 0.5` so bucket boundaries fall on + // half-integers, mirroring WC's `ROUND(avg_rating, 0)` star + // cutoffs from FilterData::get_rating_counts. Filter clauses + // use `range` per selected star, OR'd via `bool.should`. + // `buildAggregations` and `buildFilterClause` special-case this + // filterType because the standard `terms` agg / `term` clause + // don't apply. + return { + aggField: 'meta._wc_average_rating.double', + filterField: 'meta._wc_average_rating.double', + bucketFormat: 'plain', + }; } return { aggField: null, filterField: null, bucketFormat: 'plain' }; } +/** + * Star buckets for `wc_rating` filter clauses. Mirrors WC's + * `ROUND(avg_rating, 0)` semantics: star=5 covers avg ∈ [4.5, ∞), + * star=4 covers [3.5, 4.5), down to star=1 covering [0.5, 1.5). + * + * Products with `_wc_average_rating` < 0.5 fall into a histogram + * bucket at -0.5 with no corresponding star entry — they're returned + * in unfiltered results but cannot be selected via the rating filter. + * This matches WC's own `ROUND(avg_rating, 0)` filter UI which has + * no "0-star" option either; when the front-end filter block lands + * (DSGWOO-equivalent on the Search 3.0 side) it will surface only the + * 1–5 entries here. + * + * Aggregation uses a histogram (range aggs aren't whitelisted on the + * WPCOM v1.3 search API). `histogramKey` is the bucket key the + * histogram emits for that star band when called with `interval: 1` + * and `offset: 0.5` — useful for response-side bucket → star + * projection. + */ +export const WC_RATING_RANGES = [ + { key: '1', from: 0.5, to: 1.5, histogramKey: 0.5 }, + { key: '2', from: 1.5, to: 2.5, histogramKey: 1.5 }, + { key: '3', from: 2.5, to: 3.5, histogramKey: 2.5 }, + { key: '4', from: 3.5, to: 4.5, histogramKey: 3.5 }, + { key: '5', from: 4.5, histogramKey: 4.5 }, +]; + /** * Build ES aggregation requests from the filterConfigs registered by each * filter-checkbox block's render.php. @@ -106,6 +163,23 @@ export function resolveFilterFields( config ) { export function buildAggregations( filterConfigs ) { const aggregations = {}; for ( const [ filterKey, config ] of Object.entries( filterConfigs ?? {} ) ) { + // Rating gets a histogram aggregation. `range` aggs aren't + // whitelisted on the v1.3 API; histogram interval=1 with offset=0.5 + // produces buckets keyed at .5 boundaries, mirroring WC's + // ROUND(avg_rating) star buckets. + if ( config?.filterType === 'wc_rating' ) { + const { aggField: ratingField } = resolveFilterFields( config ); + aggregations[ filterKey ] = { + histogram: { + field: ratingField, + interval: 1, + offset: 0.5, + min_doc_count: 0, + }, + }; + continue; + } + const { aggField } = resolveFilterFields( config ); if ( ! aggField ) { continue; @@ -144,7 +218,30 @@ export function buildFilterClause( activeFilters, filterConfigs ) { if ( ! Array.isArray( values ) || values.length === 0 ) { continue; } - const { filterField } = resolveFilterFields( filterConfigs?.[ filterKey ] ); + const config = filterConfigs?.[ filterKey ]; + + // Rating: each selected star level maps to a range clause on + // the rating field resolved from the config; multiple selections OR. + if ( config?.filterType === 'wc_rating' ) { + const { filterField: ratingField } = resolveFilterFields( config ); + const ranges = values + .map( value => WC_RATING_RANGES.find( r => r.key === String( value ) ) ) + .filter( Boolean ) + .map( r => { + const range = { gte: r.from }; + if ( r.to !== undefined ) { + range.lt = r.to; + } + return { range: { [ ratingField ]: range } }; + } ); + if ( ranges.length === 0 ) { + continue; + } + must.push( ranges.length === 1 ? ranges[ 0 ] : { bool: { should: ranges } } ); + continue; + } + + const { filterField } = resolveFilterFields( config ); if ( ! filterField ) { continue; } @@ -169,6 +266,11 @@ export function buildFilterClause( activeFilters, filterConfigs ) { * @param {object} [opts.activeFilters] - { [filterKey]: string[] } selected filters. * @param {object} [opts.filterConfigs] - { [filterKey]: FilterConfig } registered filters. * @param {string} [opts.homeUrl] - Home URL; required for private WPcom sites. + * @param {object|null} [opts.priceRange] - `{ min, max }` numeric range against the + * `wc.price` ES field. Either bound may be null + * for a half-open range. Read by future product + * filter blocks driven by `min_price` / `max_price` + * URL params. * @return {string} Full URL to call. */ export function buildSearchUrl( { @@ -182,6 +284,7 @@ export function buildSearchUrl( { activeFilters = {}, filterConfigs = {}, homeUrl = '', + priceRange = null, } ) { // `qss.encode()` runs `encodeURIComponent` on every value, so we pass the // raw query here. The instant-search code double-encodes (pre-encodes @@ -201,7 +304,26 @@ export function buildSearchUrl( { params.aggregations = aggregations; } - const filter = buildFilterClause( activeFilters, filterConfigs ); + // `buildFilterClause` returns either `{ bool: { must: [...] } }` or + // `undefined` — the spread below relies on that shape contract. + let filter = buildFilterClause( activeFilters, filterConfigs ); + if ( priceRange && ( priceRange.min != null || priceRange.max != null ) ) { + const range = {}; + if ( priceRange.min != null ) { + range.gte = priceRange.min; + } + if ( priceRange.max != null ) { + range.lte = priceRange.max; + } + const rangeClause = { range: { 'wc.price': range } }; + // Build a fresh wrapper rather than mutating the object returned by + // `buildFilterClause` — safe today because that helper always returns + // a freshly constructed object, but the non-mutating shape stays + // correct if memoisation or caching is added later. + filter = filter + ? { bool: { must: [ ...filter.bool.must, rangeClause ] } } + : { bool: { must: [ rangeClause ] } }; + } if ( filter ) { params.filter = filter; } diff --git a/projects/packages/search/src/search-blocks/store/index.js b/projects/packages/search/src/search-blocks/store/index.js index be5a06c3199d..4a5b05f0d9cc 100644 --- a/projects/packages/search/src/search-blocks/store/index.js +++ b/projects/packages/search/src/search-blocks/store/index.js @@ -33,6 +33,7 @@ function* fetchResults( pageHandle ) { homeUrl: state.homeUrl, activeFilters: state.activeFilters, filterConfigs: state.filterConfigs, + priceRange: state.priceRange, } ); const response = yield fetch( url, { headers: state.isPrivateSite ? { 'X-WP-Nonce': state.nonce } : {}, @@ -347,6 +348,7 @@ const { state, actions } = store( NAMESPACE, { searchQuery: state.searchQuery, sortOrder: state.sortOrder, activeFilters: state.activeFilters, + priceRange: state.priceRange, } ); }, @@ -356,10 +358,13 @@ const { state, actions } = store( NAMESPACE, { * @yield {Promise} search action. */ *handlePopState() { - const { searchQuery, sortOrder, activeFilters } = readStateFromUrl( state.filterConfigs ); + const { searchQuery, sortOrder, activeFilters, priceRange } = readStateFromUrl( + state.filterConfigs + ); state.searchQuery = searchQuery; state.sortOrder = sortOrder; state.activeFilters = activeFilters; + state.priceRange = priceRange; yield actions.search( { syncUrl: false } ); }, @@ -457,9 +462,13 @@ const { state, actions } = store( NAMESPACE, { } initialized = true; window.addEventListener( 'popstate', actions.handlePopState ); - if ( state.searchQuery || state.hasActiveFilters ) { + if ( state.searchQuery || state.hasActiveFilters || state.priceRange ) { // The URL already carries this query — don't push a duplicate // history entry on top of the browser's current one. + // `priceRange` is checked separately because `hasActiveFilters` + // only inspects `activeFilters`; without this gate a URL like + // `?min_price=10` would leave PHP's `isLoading: true` spinner + // stuck because no initial fetch ever fires. actions.search( { syncUrl: false } ); } }, diff --git a/projects/packages/search/src/search-blocks/store/url-state.js b/projects/packages/search/src/search-blocks/store/url-state.js index 900ca2c2eb11..34f04f7219a4 100644 --- a/projects/packages/search/src/search-blocks/store/url-state.js +++ b/projects/packages/search/src/search-blocks/store/url-state.js @@ -4,7 +4,26 @@ const DEFAULT_SORT_ORDER = 'relevance'; // Reserved query params — not treated as filter keys on parse. Mirrors the // allow-list on the PHP side in Search_Blocks::parse_url_filters(). -const RESERVED_PARAMS = new Set( [ 's', 'orderby' ] ); +const RESERVED_PARAMS = new Set( [ 's', 'orderby', 'min_price', 'max_price' ] ); + +/** + * Parse a `min_price` / `max_price` URL value into a finite number. + * Returns null on missing, non-numeric, or negative input so a garbage + * URL can't drive the API into producing zero results. + * + * @param {string|null} raw - Raw URL param value. + * @return {number|null} Parsed number or null. + */ +function parsePriceBound( raw ) { + if ( raw === null || raw === undefined || raw === '' ) { + return null; + } + const num = Number( raw ); + if ( ! Number.isFinite( num ) || num < 0 ) { + return null; + } + return num; +} /** * Serialize store state to URLSearchParams. @@ -13,13 +32,19 @@ const RESERVED_PARAMS = new Set( [ 's', 'orderby' ] ); * matching the shape instant-search already writes so deep links are * interchangeable between the two surfaces. * - * @param {object} state - Store state slice. - * @param {string} state.searchQuery - Current search query. - * @param {string} state.sortOrder - Current sort order. - * @param {object} [state.activeFilters] - { [filterKey]: string[] } selected filters. + * @param {object} state - Store state slice. + * @param {string} state.searchQuery - Current search query. + * @param {string} state.sortOrder - Current sort order. + * @param {object} [state.activeFilters] - { [filterKey]: string[] } selected filters. + * @param {object|null} [state.priceRange] - { min, max } price range; either bound may be null. * @return {URLSearchParams} URL-ready params. */ -export function stateToUrlParams( { searchQuery, sortOrder, activeFilters = {} } ) { +export function stateToUrlParams( { + searchQuery, + sortOrder, + activeFilters = {}, + priceRange = null, +} ) { const params = new URLSearchParams(); // Always emit `s` (even empty) so a refresh keeps WP routed to the @@ -38,6 +63,13 @@ export function stateToUrlParams( { searchQuery, sortOrder, activeFilters = {} } values.forEach( value => params.append( `${ key }[]`, value ) ); } + if ( priceRange?.min != null ) { + params.set( 'min_price', String( priceRange.min ) ); + } + if ( priceRange?.max != null ) { + params.set( 'max_price', String( priceRange.max ) ); + } + return params; } @@ -54,7 +86,7 @@ export function stateToUrlParams( { searchQuery, sortOrder, activeFilters = {} } * * @param {URLSearchParams} params - URL search params. * @param {object} [filterConfigs] - { [filterKey]: FilterConfig } map used to validate filter keys. - * @return {{ searchQuery: string, sortOrder: string, activeFilters: object }} Partial state. + * @return {{ searchQuery: string, sortOrder: string, activeFilters: object, priceRange: object|null }} Partial state. */ export function urlParamsToState( params, filterConfigs = {} ) { const rawOrderby = params.get( 'orderby' ); @@ -93,10 +125,44 @@ export function urlParamsToState( params, filterConfigs = {} ) { activeFilters[ filterKey ].push( normalized ); } + // Scalar comma-joined fallback for filterConfigs whose `urlFormat` is + // `scalar` (e.g. `?filter_stock_status=instock,outofstock`). Used by + // product filters whose URL contract is a single key with comma-joined + // values rather than the array-form `?key[]=v` default. + for ( const [ filterKey, config ] of Object.entries( filterConfigs ?? {} ) ) { + if ( config?.urlFormat !== 'scalar' || activeFilters[ filterKey ] ) { + continue; + } + const raw = params.get( filterKey ); + if ( ! raw ) { + continue; + } + const values = String( raw ) + .split( ',' ) + .map( v => v.trim() ) + .filter( Boolean ); + if ( values.length > 0 ) { + activeFilters[ filterKey ] = Array.from( new Set( values ) ); + } + } + + const minPrice = parsePriceBound( params.get( 'min_price' ) ); + const maxPrice = parsePriceBound( params.get( 'max_price' ) ); + // Inverted bounds (min > max) build an ES range clause that always + // matches zero documents, so a URL like `?min_price=100&max_price=10` + // would render an empty page. Treat that as garbage and drop the range + // entirely; mirrors parse_url_price_range() on the PHP side. + const hasInvertedBounds = minPrice !== null && maxPrice !== null && minPrice > maxPrice; + const priceRange = + ! hasInvertedBounds && ( minPrice !== null || maxPrice !== null ) + ? { min: minPrice, max: maxPrice } + : null; + return { searchQuery: params.get( 's' ) ?? '', sortOrder: VALID_SORT_ORDERS.includes( rawOrderby ) ? rawOrderby : DEFAULT_SORT_ORDER, activeFilters, + priceRange, }; } @@ -119,7 +185,7 @@ export function pushStateToUrl( state ) { * Read initial state from the current URL. * * @param {object} [filterConfigs] - { [filterKey]: FilterConfig } map used to validate filter keys. - * @return {{ searchQuery: string, sortOrder: string, activeFilters: object }} Partial state. + * @return {{ searchQuery: string, sortOrder: string, activeFilters: object, priceRange: object|null }} Partial state. */ export function readStateFromUrl( filterConfigs = {} ) { return urlParamsToState( new URLSearchParams( window.location.search ), filterConfigs ); diff --git a/projects/packages/search/tests/js/search-blocks/api.test.js b/projects/packages/search/tests/js/search-blocks/api.test.js index 7d49ef29775e..7ebf9a7a1b2f 100644 --- a/projects/packages/search/tests/js/search-blocks/api.test.js +++ b/projects/packages/search/tests/js/search-blocks/api.test.js @@ -1,5 +1,6 @@ import { SEARCH_FIELDS, + WC_RATING_RANGES, buildAggregations, buildFilterClause, buildSearchUrl, @@ -345,3 +346,165 @@ describe( 'buildFilterClause', () => { expect( buildFilterClause( {}, {} ) ).toBeUndefined(); } ); } ); + +describe( 'product-shaped filter helpers', () => { + describe( 'resolveFilterFields', () => { + it( 'maps wc_stock_status to the indexed meta keyword field', () => { + expect( resolveFilterFields( { filterType: 'wc_stock_status' } ) ).toEqual( { + aggField: 'meta._stock_status.value.raw', + filterField: 'meta._stock_status.value.raw', + bucketFormat: 'plain', + } ); + } ); + + it( 'maps wc_rating to the average-rating numeric field', () => { + expect( resolveFilterFields( { filterType: 'wc_rating' } ) ).toEqual( { + aggField: 'meta._wc_average_rating.double', + filterField: 'meta._wc_average_rating.double', + bucketFormat: 'plain', + } ); + } ); + } ); + + describe( 'buildAggregations', () => { + it( 'emits a terms agg for wc_stock_status', () => { + const aggs = buildAggregations( { + filter_stock_status: { filterType: 'wc_stock_status', maxItems: 10 }, + } ); + expect( aggs.filter_stock_status ).toEqual( { + terms: { + field: 'meta._stock_status.value.raw', + size: 10, + order: { _count: 'desc' }, + }, + } ); + } ); + + it( 'emits a histogram (not terms) for wc_rating because range aggs are not whitelisted', () => { + const aggs = buildAggregations( { rating_filter: { filterType: 'wc_rating' } } ); + expect( aggs.rating_filter ).toEqual( { + histogram: { + field: 'meta._wc_average_rating.double', + interval: 1, + offset: 0.5, + min_doc_count: 0, + }, + } ); + } ); + } ); + + describe( 'buildFilterClause: wc_rating range branch', () => { + it( 'emits a single range clause for one star selection', () => { + const clause = buildFilterClause( + { rating_filter: [ '5' ] }, + { rating_filter: { filterType: 'wc_rating' } } + ); + expect( clause ).toEqual( { + bool: { + must: [ { range: { 'meta._wc_average_rating.double': { gte: 4.5 } } } ], + }, + } ); + } ); + + it( 'wraps multi-star selections in bool.should (OR within rating filter)', () => { + const clause = buildFilterClause( + { rating_filter: [ '4', '5' ] }, + { rating_filter: { filterType: 'wc_rating' } } + ); + expect( clause ).toEqual( { + bool: { + must: [ + { + bool: { + should: [ + { range: { 'meta._wc_average_rating.double': { gte: 3.5, lt: 4.5 } } }, + { range: { 'meta._wc_average_rating.double': { gte: 4.5 } } }, + ], + }, + }, + ], + }, + } ); + } ); + + it( 'gives star=5 an open upper bound (no `lt`) so 5.0 ratings count', () => { + const five = WC_RATING_RANGES.find( r => r.key === '5' ); + expect( five.to ).toBeUndefined(); + expect( five.from ).toBe( 4.5 ); + } ); + + it( 'drops unknown star values', () => { + const clause = buildFilterClause( + { rating_filter: [ '99' ] }, + { rating_filter: { filterType: 'wc_rating' } } + ); + expect( clause ).toBeUndefined(); + } ); + } ); + + describe( 'buildFilterClause: wc_stock_status uses the standard term branch', () => { + it( 'OR-joins multiple stock-status selections within the filter', () => { + const clause = buildFilterClause( + { filter_stock_status: [ 'instock', 'outofstock' ] }, + { filter_stock_status: { filterType: 'wc_stock_status', urlFormat: 'scalar' } } + ); + expect( clause ).toEqual( { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'meta._stock_status.value.raw': 'instock' } }, + { term: { 'meta._stock_status.value.raw': 'outofstock' } }, + ], + }, + }, + ], + }, + } ); + } ); + } ); + + describe( 'buildSearchUrl: priceRange', () => { + const baseOpts = { + siteId: 1, + searchQuery: '', + sortOrder: 'relevance', + pageHandle: null, + isPrivateSite: false, + isWpcom: false, + apiRoot: '', + }; + + it( 'omits price range when both bounds are null', () => { + const url = buildSearchUrl( { ...baseOpts, priceRange: { min: null, max: null } } ); + expect( url ).not.toContain( 'wc.price' ); + } ); + + it( 'emits a half-open `gte` range when only min is set', () => { + const url = buildSearchUrl( { ...baseOpts, priceRange: { min: 10, max: null } } ); + const decoded = decodeURIComponent( url ); + expect( decoded ).toContain( 'filter[bool][must][0][range][wc.price][gte]=10' ); + expect( decoded ).not.toContain( '[lte]' ); + } ); + + it( 'emits a closed range when both bounds are set', () => { + const url = buildSearchUrl( { ...baseOpts, priceRange: { min: 10, max: 50 } } ); + const decoded = decodeURIComponent( url ); + expect( decoded ).toContain( 'filter[bool][must][0][range][wc.price][gte]=10' ); + expect( decoded ).toContain( 'filter[bool][must][0][range][wc.price][lte]=50' ); + } ); + + it( 'appends price range alongside an existing filter clause without overwriting it', () => { + const url = buildSearchUrl( { + ...baseOpts, + activeFilters: { category: [ 'news' ] }, + filterConfigs: { category: { filterType: 'taxonomy', taxonomy: 'category' } }, + priceRange: { min: 10, max: null }, + } ); + const decoded = decodeURIComponent( url ); + expect( decoded ).toContain( 'filter[bool][must][0][term][category.slug]=news' ); + expect( decoded ).toContain( 'filter[bool][must][1][range][wc.price][gte]=10' ); + } ); + } ); +} ); diff --git a/projects/packages/search/tests/js/search-blocks/store.test.js b/projects/packages/search/tests/js/search-blocks/store.test.js index 374c1342d38c..bea89acb318e 100644 --- a/projects/packages/search/tests/js/search-blocks/store.test.js +++ b/projects/packages/search/tests/js/search-blocks/store.test.js @@ -127,6 +127,7 @@ describe( 'store actions', () => { homeUrl: 'https://example.com', activeFilters: {}, filterConfigs: {}, + priceRange: null, results: [ { title: 'Existing result' } ], locale: 'en-US', isLoading: false, @@ -280,6 +281,22 @@ describe( 'store actions', () => { expect( state.isFilterPopoverOpen ).toBe( false ); } ); + it( 'syncToUrl writes priceRange so a price-filtered URL survives subsequent searches', () => { + // Regression: omitting priceRange from pushStateToUrl meant the + // first search after JS hydrated rewrote `?min_price=10` away, + // breaking shareable URLs and back-button behavior. + const replaceState = jest.spyOn( window.history, 'replaceState' ).mockImplementation(); + state.searchQuery = 'shoes'; + state.priceRange = { min: 10, max: 50 }; + + actions.syncToUrl(); + + expect( replaceState ).toHaveBeenCalledTimes( 1 ); + const writtenUrl = replaceState.mock.calls[ 0 ][ 2 ]; + expect( writtenUrl ).toContain( 'min_price=10' ); + expect( writtenUrl ).toContain( 'max_price=50' ); + } ); + it( 'closes open popovers on Escape only', () => { state.isFilterPopoverOpen = true; state.isSortPopoverOpen = true; @@ -379,11 +396,17 @@ describe( 'store callbacks', () => { } ); it( 'initializes popstate handling and runs one URL-seeded search', () => { + // Also covers the price-only URL case: `?min_price=10` with no text + // query and no checkbox filters seeds isLoading=true on the PHP side, + // so initialize() must fire a fetch for `priceRange` alone, not just + // for `searchQuery || hasActiveFilters`. const addEventListener = jest.spyOn( window, 'addEventListener' ); Object.assign( actions, originalActions ); jest.spyOn( actions, 'handlePopState' ).mockImplementation(); const search = jest.spyOn( actions, 'search' ).mockImplementation(); - state.searchQuery = 'seeded'; + state.searchQuery = ''; + state.activeFilters = {}; + state.priceRange = { min: 10, max: null }; captured.callbacks.initialize(); captured.callbacks.initialize(); diff --git a/projects/packages/search/tests/js/search-blocks/url-state.test.js b/projects/packages/search/tests/js/search-blocks/url-state.test.js index a0c91e5f4364..274ce1c28a81 100644 --- a/projects/packages/search/tests/js/search-blocks/url-state.test.js +++ b/projects/packages/search/tests/js/search-blocks/url-state.test.js @@ -65,6 +65,36 @@ describe( 'stateToUrlParams', () => { expect( params.has( 'category[]' ) ).toBe( false ); expect( params.getAll( 'authors[]' ) ).toEqual( [ 'jane' ] ); } ); + + it( 'serializes priceRange bounds to min_price/max_price', () => { + const params = stateToUrlParams( { + searchQuery: 'shoes', + sortOrder: 'relevance', + priceRange: { min: 10, max: 50 }, + } ); + expect( params.get( 'min_price' ) ).toBe( '10' ); + expect( params.get( 'max_price' ) ).toBe( '50' ); + } ); + + it( 'omits the absent bound when priceRange is half-open', () => { + const params = stateToUrlParams( { + searchQuery: '', + sortOrder: 'relevance', + priceRange: { min: 10, max: null }, + } ); + expect( params.get( 'min_price' ) ).toBe( '10' ); + expect( params.has( 'max_price' ) ).toBe( false ); + } ); + + it( 'omits both price params when priceRange is null', () => { + const params = stateToUrlParams( { + searchQuery: '', + sortOrder: 'relevance', + priceRange: null, + } ); + expect( params.has( 'min_price' ) ).toBe( false ); + expect( params.has( 'max_price' ) ).toBe( false ); + } ); } ); describe( 'urlParamsToState', () => { @@ -164,3 +194,97 @@ describe( 'urlParamsToState', () => { expect( state.activeFilters ).toEqual( { category: [ 'news', 'sports' ] } ); } ); } ); + +describe( 'urlParamsToState: priceRange', () => { + it( 'returns null priceRange when neither bound is set', () => { + const state = urlParamsToState( new URLSearchParams() ); + expect( state.priceRange ).toBeNull(); + } ); + + it( 'parses min and max into a numeric priceRange', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=10&max_price=50' ) ); + expect( state.priceRange ).toEqual( { min: 10, max: 50 } ); + } ); + + it( 'allows a half-open range when only one bound is set', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=10' ) ); + expect( state.priceRange ).toEqual( { min: 10, max: null } ); + } ); + + it( 'rejects garbage URL values so a bad URL cannot zero out results', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=abc&max_price=-5' ) ); + expect( state.priceRange ).toBeNull(); + } ); + + it( 'rejects partial-numeric values that PHP would parse but JS would not', () => { + // `(float)"1.5.3"` is 1.5 in PHP but `Number("1.5.3")` is NaN in JS; + // without the explicit numeric gate on both sides the PHP initial + // render and the JS hydration would disagree on the parsed value. + const state = urlParamsToState( new URLSearchParams( '?min_price=1.5.3' ) ); + expect( state.priceRange ).toBeNull(); + } ); + + it( 'rejects inverted bounds (min > max) so an empty ES range clause is never sent', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=100&max_price=10' ) ); + expect( state.priceRange ).toBeNull(); + } ); + + it( 'accepts equal bounds (min === max) as a single-value range', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=42&max_price=42' ) ); + expect( state.priceRange ).toEqual( { min: 42, max: 42 } ); + } ); + + it( 'accepts min_price=0 (free products are a valid lower bound)', () => { + const state = urlParamsToState( new URLSearchParams( '?min_price=0' ) ); + expect( state.priceRange ).toEqual( { min: 0, max: null } ); + } ); + + it( 'never treats min_price/max_price as filter keys', () => { + const params = new URLSearchParams(); + params.append( 'min_price[]', '10' ); + params.append( 'max_price[]', '50' ); + const state = urlParamsToState( params ); + expect( state.activeFilters ).toEqual( {} ); + } ); +} ); + +describe( 'urlParamsToState: scalar comma-joined URL fallback', () => { + it( 'parses comma-joined values for filterConfigs whose urlFormat is `scalar`', () => { + const state = urlParamsToState( + new URLSearchParams( '?filter_stock_status=instock,outofstock' ), + { + filter_stock_status: { filterType: 'wc_stock_status', urlFormat: 'scalar' }, + } + ); + expect( state.activeFilters ).toEqual( { + filter_stock_status: [ 'instock', 'outofstock' ], + } ); + } ); + + it( 'ignores scalar URL form when the filterConfig does not opt in', () => { + const state = urlParamsToState( new URLSearchParams( '?category=news,sports' ), { + category: { filterType: 'taxonomy', taxonomy: 'category' }, + } ); + expect( state.activeFilters ).toEqual( {} ); + } ); + + it( 'prefers array-form when both shapes are present for the same key', () => { + const params = new URLSearchParams(); + params.append( 'filter_stock_status[]', 'onbackorder' ); + params.append( 'filter_stock_status', 'instock,outofstock' ); + const state = urlParamsToState( params, { + filter_stock_status: { filterType: 'wc_stock_status', urlFormat: 'scalar' }, + } ); + expect( state.activeFilters ).toEqual( { filter_stock_status: [ 'onbackorder' ] } ); + } ); + + it( 'de-duplicates within the scalar value list', () => { + const state = urlParamsToState( + new URLSearchParams( '?filter_stock_status=instock,instock,outofstock' ), + { + filter_stock_status: { filterType: 'wc_stock_status', urlFormat: 'scalar' }, + } + ); + expect( state.activeFilters.filter_stock_status ).toEqual( [ 'instock', 'outofstock' ] ); + } ); +} ); diff --git a/projects/packages/stats/changelog/add-stats-abilities b/projects/packages/stats/changelog/add-stats-abilities new file mode 100644 index 000000000000..be5a8576632f --- /dev/null +++ b/projects/packages/stats/changelog/add-stats-abilities @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Abilities API: register jetpack-stats abilities for site overview, top content, post views, visits, followers, and stats configuration. diff --git a/projects/packages/stats/composer.json b/projects/packages/stats/composer.json index 7dfc9309e21f..e5fae4501b03 100644 --- a/projects/packages/stats/composer.json +++ b/projects/packages/stats/composer.json @@ -7,7 +7,8 @@ "php": ">=7.2", "automattic/jetpack-connection": "@dev", "automattic/jetpack-constants": "@dev", - "automattic/jetpack-status": "@dev" + "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-abilities": "@dev" }, "require-dev": { "yoast/phpunit-polyfills": "^4.0.0", diff --git a/projects/packages/stats/src/abilities/class-stats-abilities.php b/projects/packages/stats/src/abilities/class-stats-abilities.php new file mode 100644 index 000000000000..64d25f42b9f6 --- /dev/null +++ b/projects/packages/stats/src/abilities/class-stats-abilities.php @@ -0,0 +1,1410 @@ + -> ` + * array of rows into the uniform `{ rank, label, value, href? }` shape. + * `countries` (needs `country-info` join) and `tags` (flat `tags` array, + * no `days` envelope) are special-cased in the callback. + */ + const TOP_CONTENT_MAP = array( + 'posts' => array( + 'list' => 'postviews', + 'label' => 'title', + 'value' => 'views', + 'href' => 'href', + ), + 'referrers' => array( + // WPCOM `stats/referrers` keys per-day data under `groups`, not `referrers` — + // each group exposes `name`, `total`, and (sometimes) `url`. + 'list' => 'groups', + 'label' => 'name', + 'value' => 'total', + 'href' => 'url', + ), + 'search-terms' => array( + 'list' => 'search_terms', + 'label' => 'term', + 'value' => 'views', + ), + 'clicks' => array( + 'list' => 'clicks', + 'label' => 'name', + 'value' => 'views', + 'href' => 'url', + 'label_fallback' => 'url', + ), + 'authors' => array( + 'list' => 'authors', + 'label' => 'name', + 'value' => 'views', + ), + 'downloads' => array( + 'list' => 'files', + 'label' => 'filename', + 'value' => 'download_count', + 'href' => 'relative_url', + 'label_fallback' => 'relative_url', + ), + 'video-plays' => array( + 'list' => 'plays', + 'label' => 'title', + 'value' => 'plays', + ), + ); + + /** + * {@inheritDoc} + */ + public static function get_category_slug(): string { + return self::CATEGORY_SLUG; + } + + /** + * {@inheritDoc} + */ + public static function get_category_definition(): array { + return array( + // "Jetpack" is a product name and should not be translated. + 'label' => 'Jetpack Stats', + 'description' => __( 'Abilities for reading Jetpack Stats traffic insights and managing site-level Stats settings.', 'jetpack-stats' ), + ); + } + + /** + * {@inheritDoc} + */ + public static function get_abilities(): array { + return array( + 'jetpack-stats/get-site-overview' => self::spec_get_site_overview(), + 'jetpack-stats/get-top-content' => self::spec_get_top_content(), + 'jetpack-stats/get-post-views' => self::spec_get_post_views(), + 'jetpack-stats/get-visits' => self::spec_get_visits(), + 'jetpack-stats/get-followers' => self::spec_get_followers(), + 'jetpack-stats/get-settings' => self::spec_get_settings(), + 'jetpack-stats/update-settings' => self::spec_update_settings(), + ); + } + + /* + --------------------------------------------------------------------- + * Ability specs + * --------------------------------------------------------------------- + */ + + /** + * Spec: jetpack-stats/get-site-overview. + */ + private static function spec_get_site_overview(): array { + return array( + 'label' => __( 'Get site stats overview', 'jetpack-stats' ), + 'description' => __( + 'Return a single zero-argument snapshot answering "how is my site doing right now?" — today\'s views/visitors, this week/month totals, the current posting streak, today\'s top post, and top referrer. Shape: { date, views_today, visitors_today, views_week, views_month, streak: { current_length, longest_length, longest_start, longest_end }, top_post: { id, title, views }, top_referrer: { name, views }, partial: bool, errors?: [string] }. Composes the WPCOM stats/summary, stats/highlights, and stats/streak endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, count fields owned by the failed sub-call(s) are placeholder zeros rather than confirmed counts (cross-reference `errors` before treating a `0` as authoritative). If every sub-call fails, returns `jetpack_stats_data_unavailable`. Precondition: the site must be connected to WordPress.com. Results cached for ~5 minutes by WPCOM_Stats — safe to poll.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'date' => array( 'type' => 'string' ), + 'views_today' => array( 'type' => 'integer' ), + 'visitors_today' => array( 'type' => 'integer' ), + 'views_week' => array( 'type' => 'integer' ), + 'views_month' => array( 'type' => 'integer' ), + 'streak' => array( 'type' => 'object' ), + 'top_post' => array( 'type' => array( 'object', 'null' ) ), + 'top_referrer' => array( 'type' => array( 'object', 'null' ) ), + 'partial' => array( 'type' => 'boolean' ), + 'errors' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_site_overview' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/get-top-content. + */ + private static function spec_get_top_content(): array { + return array( + 'label' => __( 'Get top stats content', 'jetpack-stats' ), + 'description' => __( + 'Return the top items for a chosen content type — posts, referrers, search terms, outbound clicks, tags/categories, authors, countries, downloads, or video plays — in one filtered call. Replaces nine atomic WPCOM endpoints with a single ability. Uniform shape: { type, period, date, num, max, items: [ { rank, label, value, href? } ] } — agents MUST NOT see a different shape per type. `label` is human-readable (post title, referrer host, search term, country name, etc.). `value` is the view/hit count for that item. `href` is present only when the item has a canonical URL. Precondition: site must be connected to WordPress.com.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'type' ), + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'description' => __( 'Which top-N surface to fetch.', 'jetpack-stats' ), + 'enum' => self::TOP_CONTENT_TYPES, + ), + 'period' => array( + 'type' => 'string', + 'description' => __( 'Aggregation period.', 'jetpack-stats' ), + 'enum' => self::PERIODS, + 'default' => 'day', + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ), + 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', + ), + 'num' => array( + 'type' => 'integer', + 'description' => __( 'How many prior periods to roll up (1-90).', 'jetpack-stats' ), + 'minimum' => 1, + 'maximum' => 90, + 'default' => 1, + ), + 'max' => array( + 'type' => 'integer', + 'description' => __( 'Results cap (1-100).', 'jetpack-stats' ), + 'minimum' => 1, + 'maximum' => 100, + 'default' => 20, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'type' => array( 'type' => 'string' ), + 'period' => array( 'type' => 'string' ), + 'date' => array( 'type' => 'string' ), + 'num' => array( 'type' => 'integer' ), + 'max' => array( 'type' => 'integer' ), + 'items' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_top_content' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/get-post-views. + */ + private static function spec_get_post_views(): array { + return array( + 'label' => __( 'Get views for a post', 'jetpack-stats' ), + 'description' => __( + 'Return views history for a single post: total views, timeseries of per-period views, and the period metadata. Shape: { post_id, total_views, period, num, date, series: [ { date, views } ] }. Accepts post_id as integer or numeric string (the literal "0" is rejected only because WordPress has no post 0 — any positive numeric value is legal). Precondition: site must be connected to WordPress.com. Related: call jetpack-stats/get-top-content with type=posts first to discover which posts to drill into.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'post_id' ), + 'properties' => array( + 'post_id' => array( + 'type' => array( 'integer', 'string' ), + 'description' => __( 'The post ID to fetch views for. Must be positive.', 'jetpack-stats' ), + ), + 'period' => array( + 'type' => 'string', + 'description' => __( 'Aggregation period.', 'jetpack-stats' ), + 'enum' => self::PERIODS, + 'default' => 'day', + ), + 'num' => array( + 'type' => 'integer', + 'description' => __( 'How many prior periods to include (1-90).', 'jetpack-stats' ), + 'minimum' => 1, + 'maximum' => 90, + 'default' => 30, + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ), + 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'post_id' => array( 'type' => 'integer' ), + 'total_views' => array( 'type' => 'integer' ), + 'period' => array( 'type' => 'string' ), + 'num' => array( 'type' => 'integer' ), + 'date' => array( 'type' => 'string' ), + 'series' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_post_views' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/get-visits. + */ + private static function spec_get_visits(): array { + return array( + 'label' => __( 'Get site visits timeseries', 'jetpack-stats' ), + 'description' => __( + 'Return a site-level views/visitors/likes/comments timeseries — answers "is traffic trending up?". Shape: { unit, quantity, date, fields, series: [ { date, views, visitors, likes, comments } ] }. Every series row always includes every field listed in the request (no per-row omission). Precondition: site must be connected to WordPress.com.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'unit' => array( + 'type' => 'string', + 'description' => __( 'Granularity of each data point.', 'jetpack-stats' ), + 'enum' => self::PERIODS, + 'default' => 'day', + ), + 'quantity' => array( + 'type' => 'integer', + 'description' => __( 'How many data points to return (1-90).', 'jetpack-stats' ), + 'minimum' => 1, + 'maximum' => 90, + 'default' => 30, + ), + 'date' => array( + 'type' => 'string', + 'description' => __( 'End date (YYYY-MM-DD). Defaults to today.', 'jetpack-stats' ), + 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', + ), + 'fields' => array( + 'type' => 'array', + 'description' => __( 'Which metrics to include in each row. Defaults to views+visitors.', 'jetpack-stats' ), + 'items' => array( + 'type' => 'string', + 'enum' => self::VISIT_FIELDS, + ), + 'default' => self::DEFAULT_VISIT_FIELDS, + ), + ), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'unit' => array( 'type' => 'string' ), + 'quantity' => array( 'type' => 'integer' ), + 'date' => array( 'type' => 'string' ), + 'fields' => array( 'type' => 'array' ), + 'series' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_visits' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/get-followers. + */ + private static function spec_get_followers(): array { + return array( + 'label' => __( 'Get follower counts', 'jetpack-stats' ), + 'description' => __( + 'Return a breakdown of follower counts across email, WordPress.com, comment, and publicize (per-service) — answers "how is my audience growing?" in one call. Shape: { total, email, wpcom, comment, publicize: { : count }, partial: bool, errors?: [string] }. Composes three WPCOM endpoints — if any sub-call fails, `partial` is true and `errors` lists the failed sub-calls; when `partial` is true, source counts owned by the failed sub-call(s) are placeholder zeros rather than confirmed zero counts (cross-reference `errors` before treating a `0` as authoritative). Precondition: site must be connected to WordPress.com.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'total' => array( 'type' => 'integer' ), + 'email' => array( 'type' => 'integer' ), + 'wpcom' => array( 'type' => 'integer' ), + 'comment' => array( 'type' => 'integer' ), + 'publicize' => array( 'type' => 'object' ), + 'partial' => array( 'type' => 'boolean' ), + 'errors' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_followers' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/get-settings. + */ + private static function spec_get_settings(): array { + return array( + 'label' => __( 'Get Stats settings', 'jetpack-stats' ), + 'description' => __( + 'Read the current Jetpack Stats settings: who sees the Stats admin bar + menu, whose visits are counted, and DNT behavior. Shape: { admin_bar, roles, count_roles, do_not_track }. `roles` is an array of role slugs that can view Stats; `count_roles` is an array of role slugs whose visits are counted. Call jetpack-stats/update-settings to change any of these.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => self::settings_output_properties(), + ), + 'execute_callback' => array( __CLASS__, 'get_settings' ), + 'permission_callback' => array( __CLASS__, 'can_view_stats' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Spec: jetpack-stats/update-settings. + */ + private static function spec_update_settings(): array { + return array( + 'label' => __( 'Update Stats settings', 'jetpack-stats' ), + 'description' => __( + 'Update one or more Jetpack Stats settings. All fields are optional; only fields present in the call are written, and unrelated keys are preserved. Idempotent — setting a value to its current state returns changed=false. Shape: { changed, settings: { admin_bar, roles, count_roles, do_not_track } }. Role slugs in `roles` and `count_roles` are validated against the site\'s registered roles; unknown slugs return jetpack_stats_invalid_role. Narrowing `roles` can revoke Stats access for whole groups of users — confirm with the user before removing roles.', + 'jetpack-stats' + ), + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'admin_bar' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to show the Stats item in the admin bar for users who can view Stats.', 'jetpack-stats' ), + ), + 'roles' => array( + 'type' => 'array', + 'description' => __( 'Role slugs that can view Stats. Must be non-empty; each slug must be a registered role.', 'jetpack-stats' ), + 'items' => array( 'type' => 'string' ), + 'minItems' => 1, + ), + 'count_roles' => array( + 'type' => 'array', + 'description' => __( 'Role slugs whose visits are counted. May be empty (count visits from all users).', 'jetpack-stats' ), + 'items' => array( 'type' => 'string' ), + ), + 'do_not_track' => array( + 'type' => 'boolean', + 'description' => __( 'Whether to honor the browser Do Not Track header.', 'jetpack-stats' ), + ), + ), + 'additionalProperties' => false, + 'minProperties' => 1, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'changed' => array( 'type' => 'boolean' ), + 'settings' => array( + 'type' => 'object', + 'properties' => self::settings_output_properties(), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'update_settings' ), + 'permission_callback' => array( __CLASS__, 'can_manage_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', // default is already "tool", but can be explicit. + ), + ), + ); + } + + /** + * Output schema properties shared by get-settings and update-settings' `settings` field. + */ + private static function settings_output_properties(): array { + return array( + 'admin_bar' => array( 'type' => 'boolean' ), + 'roles' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'count_roles' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'do_not_track' => array( 'type' => 'boolean' ), + ); + } + + /* + --------------------------------------------------------------------- + * Permission callbacks + * --------------------------------------------------------------------- + */ + + /** + * Read-side permission: view_stats. + * + * The Stats package's `Main::map_meta_caps` maps `view_stats` to the + * user's `read` capability when their role is listed in + * `stats_options['roles']`. Honored on self-hosted sites. + * + * @return bool + */ + public static function can_view_stats(): bool { + return current_user_can( 'view_stats' ); + } + + /** + * Write-side permission: manage_options. + * + * Stats configuration writes modify the `stats_options` WP option, + * which includes the very roles that gate `view_stats`. Guard with the + * site-admin capability, not `view_stats`, so readers can't escalate. + * + * @return bool + */ + public static function can_manage_settings(): bool { + return current_user_can( 'manage_options' ); + } + + /* + --------------------------------------------------------------------- + * Execute callbacks + * --------------------------------------------------------------------- + */ + + /** + * Execute: get-site-overview. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array|WP_Error + */ + public static function get_site_overview( $input = null ) { + unset( $input ); + $stats = self::get_wpcom_stats(); + + $composed = self::compose_subcalls( + array( + 'summary' => $stats->get_stats_summary(), + 'highlights' => $stats->get_highlights(), + 'streak' => $stats->get_streak(), + ), + __( 'Stats data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats' ) + ); + if ( is_wp_error( $composed ) ) { + return $composed; + } + [ 'summary' => $summary, 'highlights' => $highlights, 'streak' => $streak ] = $composed['values']; + $errors = $composed['errors']; + + $highlights_today = isset( $highlights['today'] ) && is_array( $highlights['today'] ) ? $highlights['today'] : array(); + $highlights_top_post = isset( $highlights_today['top_post'] ) && is_array( $highlights_today['top_post'] ) + ? $highlights_today['top_post'] + : null; + + $out = array( + 'date' => self::first_string( array( $summary, $highlights_today ), 'date' ), + 'views_today' => self::as_int( $summary, 'views' ), + 'visitors_today' => self::as_int( $summary, 'visitors' ), + 'views_week' => self::as_int( $summary, 'period_total_views' ), + 'views_month' => isset( $highlights_today['views_month'] ) ? (int) $highlights_today['views_month'] : 0, + 'streak' => self::extract_streak_summary( $streak ), + 'top_post' => null === $highlights_top_post ? null : array( + 'id' => isset( $highlights_top_post['id'] ) ? (int) $highlights_top_post['id'] : 0, + 'title' => isset( $highlights_top_post['title'] ) ? (string) $highlights_top_post['title'] : '', + 'views' => isset( $highlights_top_post['views'] ) ? (int) $highlights_top_post['views'] : 0, + ), + 'top_referrer' => self::extract_top_referrer( $highlights_today ), + 'partial' => ! empty( $errors ), + ); + + if ( ! empty( $errors ) ) { + $out['errors'] = $errors; + } + + return $out; + } + + /** + * Execute: get-top-content. + * + * @param array|null $input Input matching the ability's input_schema. + * @return array|WP_Error + */ + public static function get_top_content( $input = null ) { + $input = is_array( $input ) ? $input : array(); + + if ( ! isset( $input['type'] ) || ! in_array( $input['type'], self::TOP_CONTENT_TYPES, true ) ) { + return new WP_Error( + self::ERROR_PREFIX . 'missing_type', + sprintf( + /* translators: %s: comma-separated list of valid type values. */ + __( 'A `type` is required. Valid values: %s.', 'jetpack-stats' ), + implode( ', ', self::TOP_CONTENT_TYPES ) + ) + ); + } + + $type = $input['type']; + $period = self::pick_period( $input['period'] ?? null ); + $date = self::sanitize_date( $input['date'] ?? null ); + $num = self::clamp_int( $input['num'] ?? 1, 1, 90, 1 ); + $max = self::clamp_int( $input['max'] ?? 20, 1, 100, 20 ); + + $args = array( + 'period' => $period, + 'date' => $date, + 'num' => $num, + 'max' => $max, + ); + + $stats = self::get_wpcom_stats(); + $raw = self::fetch_top_content_raw( $stats, $type, $args ); + if ( is_wp_error( $raw ) ) { + return $raw; + } + + $items = self::normalize_top_content_items( $type, $raw, $max ); + + return array( + 'type' => $type, + 'period' => $period, + 'date' => $date, + 'num' => $num, + 'max' => $max, + 'items' => $items, + ); + } + + /** + * Execute: get-post-views. + * + * @param array|null $input Input matching the ability's input_schema. + * @return array|WP_Error + */ + public static function get_post_views( $input = null ) { + $input = is_array( $input ) ? $input : array(); + + // Use isset()+is_numeric() — NOT empty() — so the literal "0" is rejected by the `> 0` check, not by a truthiness accident. + if ( ! isset( $input['post_id'] ) || ! is_numeric( $input['post_id'] ) || (int) $input['post_id'] <= 0 ) { + return new WP_Error( + self::ERROR_PREFIX . 'missing_post_id', + __( 'A positive post_id is required.', 'jetpack-stats' ) + ); + } + + $post_id = (int) $input['post_id']; + $period = self::pick_period( $input['period'] ?? null ); + $num = self::clamp_int( $input['num'] ?? 30, 1, 90, 30 ); + $date = self::sanitize_date( $input['date'] ?? null ); + + $args = array( + 'period' => $period, + 'num' => $num, + 'date' => $date, + ); + + $stats = self::get_wpcom_stats(); + $raw = $stats->get_post_views( $post_id, $args ); + if ( is_wp_error( $raw ) ) { + return $raw; + } + + return array( + 'post_id' => $post_id, + 'total_views' => isset( $raw['views'] ) ? (int) $raw['views'] : 0, + 'period' => $period, + 'num' => $num, + 'date' => $date, + 'series' => self::extract_post_views_series( $raw ), + ); + } + + /** + * Execute: get-visits. + * + * @param array|null $input Input matching the ability's input_schema. + * @return array|WP_Error + */ + public static function get_visits( $input = null ) { + $input = is_array( $input ) ? $input : array(); + + $unit = self::pick_period( $input['unit'] ?? null ); + $quantity = self::clamp_int( $input['quantity'] ?? 30, 1, 90, 30 ); + $date = self::sanitize_date( $input['date'] ?? null ); + + // Pass user input FIRST to array_intersect so caller-supplied field order is preserved. + $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) + ? array_values( array_intersect( $input['fields'], self::VISIT_FIELDS ) ) + : array(); + if ( empty( $fields ) ) { + $fields = self::DEFAULT_VISIT_FIELDS; + } + + $args = array( + 'unit' => $unit, + 'quantity' => $quantity, + 'date' => $date, + 'stat_fields' => implode( ',', $fields ), + ); + + $stats = self::get_wpcom_stats(); + $raw = $stats->get_visits( $args ); + if ( is_wp_error( $raw ) ) { + return $raw; + } + + return array( + 'unit' => $unit, + 'quantity' => $quantity, + 'date' => $date, + 'fields' => $fields, + 'series' => self::normalize_visits_series( $raw, $fields ), + ); + } + + /** + * Execute: get-followers. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array|WP_Error + */ + public static function get_followers( $input = null ) { + unset( $input ); + $stats = self::get_wpcom_stats(); + + $composed = self::compose_subcalls( + array( + 'followers' => $stats->get_followers(), + 'comment_followers' => $stats->get_comment_followers(), + 'publicize_followers' => $stats->get_publicize_followers(), + ), + __( 'Follower data could not be fetched from WordPress.com. Confirm the site is connected and try again.', 'jetpack-stats' ) + ); + if ( is_wp_error( $composed ) ) { + return $composed; + } + $followers = $composed['values']['followers']; + $comment_followers = $composed['values']['comment_followers']; + $publicize = $composed['values']['publicize_followers']; + $errors = $composed['errors']; + + $email = 0; + $wpcom = 0; + if ( isset( $followers['subscribers'] ) && is_array( $followers['subscribers'] ) ) { + foreach ( $followers['subscribers'] as $sub ) { + if ( isset( $sub['type'] ) && 'email' === $sub['type'] ) { + $email += isset( $sub['value'] ) ? (int) $sub['value'] : 0; + } elseif ( isset( $sub['type'] ) && 'wpcom' === $sub['type'] ) { + $wpcom += isset( $sub['value'] ) ? (int) $sub['value'] : 0; + } + } + } else { + $email = isset( $followers['email'] ) ? (int) $followers['email'] : 0; + $wpcom = isset( $followers['wpcom'] ) ? (int) $followers['wpcom'] : 0; + } + + $comment = isset( $comment_followers['total'] ) ? (int) $comment_followers['total'] : 0; + + $publicize_by_service = array(); + if ( isset( $publicize['services'] ) && is_array( $publicize['services'] ) ) { + foreach ( $publicize['services'] as $row ) { + if ( isset( $row['service'] ) && isset( $row['followers'] ) ) { + $publicize_by_service[ (string) $row['service'] ] = (int) $row['followers']; + } + } + } + + $total = $email + $wpcom + $comment + array_sum( $publicize_by_service ); + + $out = array( + 'total' => $total, + 'email' => $email, + 'wpcom' => $wpcom, + 'comment' => $comment, + 'publicize' => $publicize_by_service, + 'partial' => ! empty( $errors ), + ); + + if ( ! empty( $errors ) ) { + $out['errors'] = $errors; + } + + return $out; + } + + /** + * Execute: get-settings. + * + * @param array|null $input Ignored — zero-arg ability. + * @return array + */ + public static function get_settings( $input = null ) { + unset( $input ); + return self::settings_snapshot(); + } + + /** + * Execute: update-settings. + * + * @param array|null $input Input matching the ability's input_schema. + * @return array|WP_Error + */ + public static function update_settings( $input = null ) { + $input = is_array( $input ) ? $input : array(); + + // At least one of the whitelisted keys must be present. + $provided = array_intersect_key( $input, array_flip( self::SETTINGS_KEYS ) ); + if ( empty( $provided ) ) { + return new WP_Error( + self::ERROR_PREFIX . 'missing_setting_field', + sprintf( + /* translators: %s: comma-separated list of writable field names. */ + __( 'Provide at least one of: %s.', 'jetpack-stats' ), + implode( ', ', self::SETTINGS_KEYS ) + ) + ); + } + + // Validate role slugs against registered roles — but only load the role list + // if the caller is actually writing a role field. Boolean-only writes skip + // the wp_roles() resolution entirely. Role fields are detected from the + // option's default value type (array → role list). + $defaults = Options::get_defaults(); + $known_roles = null; + foreach ( self::SETTINGS_KEYS as $role_field ) { + if ( ! array_key_exists( $role_field, $provided ) ) { + continue; + } + if ( ! is_array( $defaults[ $role_field ] ?? null ) ) { + continue; + } + if ( null === $known_roles ) { + $known_roles = array_keys( wp_roles()->roles ); + } + if ( ! is_array( $provided[ $role_field ] ) ) { + return new WP_Error( + self::ERROR_PREFIX . 'invalid_' . $role_field, + sprintf( + /* translators: %s: the offending field name. */ + __( 'Field `%s` must be an array of role slugs.', 'jetpack-stats' ), + $role_field + ) + ); + } + // `roles` gates `view_stats` — an empty array would lock every user out, including + // the caller. Schema validation enforces minItems=1 on REST input, but direct PHP + // callers bypass that path; reject explicitly here. + if ( 'roles' === $role_field && empty( $provided[ $role_field ] ) ) { + return new WP_Error( + self::ERROR_PREFIX . 'invalid_roles', + __( 'Field `roles` must be a non-empty array of role slugs — an empty list would revoke Stats access for every user.', 'jetpack-stats' ) + ); + } + $sanitized = array(); + foreach ( $provided[ $role_field ] as $role ) { + if ( ! is_string( $role ) || '' === $role ) { + return new WP_Error( + self::ERROR_PREFIX . 'invalid_role', + sprintf( + /* translators: 1: field name, 2: comma-separated list of valid role slugs. */ + __( 'Role slugs in `%1$s` must be non-empty strings. Known roles: %2$s.', 'jetpack-stats' ), + $role_field, + implode( ', ', $known_roles ) + ) + ); + } + if ( ! in_array( $role, $known_roles, true ) ) { + return new WP_Error( + self::ERROR_PREFIX . 'invalid_role', + sprintf( + /* translators: 1: unknown role slug, 2: field name, 3: comma-separated list of valid role slugs. */ + __( 'Unknown role `%1$s` in `%2$s`. Known roles: %3$s.', 'jetpack-stats' ), + $role, + $role_field, + implode( ', ', $known_roles ) + ) + ); + } + $sanitized[] = $role; + } + $provided[ $role_field ] = array_values( array_unique( $sanitized ) ); + } + + $before = self::settings_snapshot(); + $changes = array(); + foreach ( $provided as $key => $value ) { + if ( is_bool( $defaults[ $key ] ?? null ) ) { + $value = (bool) $value; + } + $current = $before[ $key ] ?? null; + if ( $current === $value ) { + continue; + } + $changes[ $key ] = $value; + } + + // `changed` is derived from the POST-WRITE snapshot, not from `$changes` alone — + // if update_option fails or refuses to persist for any reason (DB error, + // serialization mismatch), we must not claim a change that didn't happen. + if ( ! empty( $changes ) ) { + // One merged write instead of N get+update cycles via set_option. + Options::set_options( $changes ); + } + + $after = self::settings_snapshot(); + + return array( + 'changed' => $after !== $before, + 'settings' => $after, + ); + } + + /* + --------------------------------------------------------------------- + * Helpers + * --------------------------------------------------------------------- + */ + + /** + * Build a whitelisted snapshot of the current Stats configuration. + * + * @return array + */ + private static function settings_snapshot(): array { + $options = Options::get_options(); + $defaults = Options::get_defaults(); + $out = array(); + foreach ( self::SETTINGS_KEYS as $key ) { + $raw = $options[ $key ] ?? null; + $default = $defaults[ $key ] ?? null; + if ( is_bool( $default ) ) { + $out[ $key ] = (bool) $raw; + } elseif ( is_array( $default ) ) { + $out[ $key ] = is_array( $raw ) ? array_values( $raw ) : array(); + } else { + $out[ $key ] = $raw; + } + } + return $out; + } + + /** + * Compose multiple WPCOM sub-call results into a partial-tolerant envelope. + * + * Each named result is either an array (kept as-is) or a WP_Error + * (replaced with `[]` and its key recorded under `errors`). Returns + * `jetpack_stats_data_unavailable` if every sub-call failed. + * + * @param array $named_results Map of error-tag => array|WP_Error. + * @param string $all_failed_message Message for the all-failed WP_Error. + * @return array{values: array, errors: array}|WP_Error + */ + private static function compose_subcalls( array $named_results, string $all_failed_message ) { + $values = array(); + $errors = array(); + foreach ( $named_results as $tag => $result ) { + if ( is_wp_error( $result ) ) { + $errors[] = (string) $tag; + $values[ $tag ] = array(); + } else { + $values[ $tag ] = $result; + } + } + + if ( count( $errors ) === count( $named_results ) ) { + return new WP_Error( self::ERROR_PREFIX . 'data_unavailable', $all_failed_message ); + } + + return array( + 'values' => $values, + 'errors' => $errors, + ); + } + + /** + * Return the first non-empty string at `$key` across the given source arrays. + * + * @param array[] $sources Ordered list of arrays to probe. + * @param string $key Key to read from each array. + * @return string + */ + private static function first_string( array $sources, string $key ): string { + foreach ( $sources as $source ) { + if ( isset( $source[ $key ] ) && '' !== $source[ $key ] ) { + return (string) $source[ $key ]; + } + } + return ''; + } + + /** + * Resolve an aggregation period from raw input, defaulting to `day`. + * + * @param mixed $raw Raw input value. + * @return string One of self::PERIODS. + */ + private static function pick_period( $raw ): string { + return is_string( $raw ) && in_array( $raw, self::PERIODS, true ) ? $raw : 'day'; + } + + /** + * Return a WPCOM_Stats instance. Filterable for tests. + * + * @return WPCOM_Stats + */ + protected static function get_wpcom_stats(): WPCOM_Stats { + /** + * Filters the WPCOM_Stats instance used by the Stats abilities. + * + * @since $$next-version$$ + * + * @param WPCOM_Stats $wpcom_stats The default instance. + */ + $instance = apply_filters( 'jetpack_stats_abilities_wpcom_stats', new WPCOM_Stats() ); + return $instance instanceof WPCOM_Stats ? $instance : new WPCOM_Stats(); + } + + /** + * Dispatch top-content raw fetch to the right WPCOM_Stats method. + * + * @param WPCOM_Stats $stats Client. + * @param string $type Content type enum. + * @param array $args Pre-built `{ period, date, num, max }` args — ignored for `tags` which takes only `max`. + * @return array|WP_Error + */ + private static function fetch_top_content_raw( WPCOM_Stats $stats, string $type, array $args ) { + switch ( $type ) { + case 'posts': + return $stats->get_top_posts( $args ); + case 'referrers': + return $stats->get_referrers( $args ); + case 'search-terms': + return $stats->get_search_terms( $args ); + case 'clicks': + return $stats->get_clicks( $args ); + case 'tags': + // get_tags has a narrower arg surface — pass only `max`. + return $stats->get_tags( array( 'max' => $args['max'] ) ); + case 'authors': + return $stats->get_top_authors( $args ); + case 'countries': + return $stats->get_views_by_country( $args ); + case 'downloads': + return $stats->get_file_downloads( $args ); + case 'video-plays': + return $stats->get_video_plays( $args ); + } + + return new WP_Error( self::ERROR_PREFIX . 'invalid_type', __( 'Unknown top-content type.', 'jetpack-stats' ) ); + } + + /** + * Normalize a WPCOM top-content response into the uniform item shape. + * + * Most `type` values follow the `days -> -> ` shape + * and project through `TOP_CONTENT_MAP`. `tags` (flat `tags` array, no + * `days` envelope) and `countries` (needs `country-info` code-to-name + * join) are special-cased. + * + * @param string $type Content type enum. + * @param array $raw Raw WPCOM response. + * @param int $max Result cap. + * @return array List of { rank, label, value, href? } items. + */ + private static function normalize_top_content_items( string $type, array $raw, int $max ): array { + if ( 'tags' === $type ) { + $rows = array(); + $tags = isset( $raw['tags'] ) && is_array( $raw['tags'] ) ? $raw['tags'] : array(); + foreach ( $tags as $tag ) { + $rows[] = array( + 'label' => isset( $tag['tag'] ) ? (string) $tag['tag'] : '', + 'value' => isset( $tag['views'] ) ? (int) $tag['views'] : 0, + ); + } + return self::rank_and_cap( $rows, $max ); + } + + $day_data = self::first_day( $raw ); + + if ( 'countries' === $type ) { + $rows = array(); + $list = isset( $day_data['views'] ) && is_array( $day_data['views'] ) ? $day_data['views'] : array(); + $country_info = isset( $raw['country-info'] ) && is_array( $raw['country-info'] ) ? $raw['country-info'] : array(); + foreach ( $list as $v ) { + $code = isset( $v['country_code'] ) ? (string) $v['country_code'] : ''; + $rows[] = array( + 'label' => (string) ( $country_info[ $code ]['country_full'] ?? $code ), + 'value' => isset( $v['views'] ) ? (int) $v['views'] : 0, + ); + } + return self::rank_and_cap( $rows, $max ); + } + + $map = self::TOP_CONTENT_MAP[ $type ] ?? null; + if ( null === $map ) { + return array(); + } + + $rows = array(); + $list = isset( $day_data[ $map['list'] ] ) && is_array( $day_data[ $map['list'] ] ) ? $day_data[ $map['list'] ] : array(); + foreach ( $list as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $label = isset( $row[ $map['label'] ] ) && '' !== $row[ $map['label'] ] ? (string) $row[ $map['label'] ] : ''; + if ( '' === $label && isset( $map['label_fallback'] ) && isset( $row[ $map['label_fallback'] ] ) ) { + $label = (string) $row[ $map['label_fallback'] ]; + } + $entry = array( + 'label' => $label, + 'value' => isset( $row[ $map['value'] ] ) ? (int) $row[ $map['value'] ] : 0, + ); + if ( isset( $map['href'] ) && isset( $row[ $map['href'] ] ) ) { + $entry['href'] = (string) $row[ $map['href'] ]; + } + $rows[] = $entry; + } + return self::rank_and_cap( $rows, $max ); + } + + /** + * Pick the first `days` entry from a WPCOM days-keyed response. + * + * Different top-content endpoints key their per-day data under `days` + * (posts, referrers, authors, countries, ...) or `days -> `; a few + * flatten it entirely (tags). This helper handles the common case. + * + * @param array $raw Raw WPCOM response. + * @return array The first day's sub-array, or []. + */ + private static function first_day( array $raw ): array { + if ( ! isset( $raw['days'] ) || ! is_array( $raw['days'] ) || empty( $raw['days'] ) ) { + return array(); + } + $first = reset( $raw['days'] ); + return is_array( $first ) ? $first : array(); + } + + /** + * Rank, cap, and strip null href fields. + * + * @param array $rows Unranked rows. + * @param int $max Result cap. + * @return array Ranked + capped rows with `rank` injected. + */ + private static function rank_and_cap( array $rows, int $max ): array { + $rows = array_slice( $rows, 0, $max ); + $out = array(); + foreach ( $rows as $i => $row ) { + $entry = array( + 'rank' => $i + 1, + 'label' => isset( $row['label'] ) ? (string) $row['label'] : '', + 'value' => isset( $row['value'] ) ? (int) $row['value'] : 0, + ); + if ( isset( $row['href'] ) && '' !== $row['href'] ) { + $entry['href'] = $row['href']; + } + $out[] = $entry; + } + return $out; + } + + /** + * Extract a compact streak summary from the WPCOM streak response. + * + * @param array $streak Raw WPCOM streak response. + * @return array Compact `{ current_length, longest_length, longest_start, longest_end }`. + */ + private static function extract_streak_summary( array $streak ): array { + $data = isset( $streak['streak'] ) && is_array( $streak['streak'] ) ? $streak['streak'] : array(); + return array( + 'current_length' => isset( $data['currentStreakLength'] ) ? (int) $data['currentStreakLength'] : 0, + 'longest_length' => isset( $data['longestStreakLength'] ) ? (int) $data['longestStreakLength'] : 0, + 'longest_start' => isset( $data['longestStreakStart'] ) ? (string) $data['longestStreakStart'] : '', + 'longest_end' => isset( $data['longestStreakEnd'] ) ? (string) $data['longestStreakEnd'] : '', + ); + } + + /** + * Extract the top referrer from a highlights `today` block. + * + * @param array $today Highlights today block. + * @return array|null { name, views } or null. + */ + private static function extract_top_referrer( array $today ): ?array { + $list = isset( $today['top_referrers'] ) && is_array( $today['top_referrers'] ) ? $today['top_referrers'] : array(); + if ( empty( $list ) ) { + return null; + } + $first = $list[0]; + if ( ! is_array( $first ) ) { + return null; + } + return array( + 'name' => isset( $first['name'] ) ? (string) $first['name'] : '', + 'views' => isset( $first['views'] ) ? (int) $first['views'] : 0, + ); + } + + /** + * Extract a post-views series from the WPCOM get_post_views response. + * + * WPCOM returns `data` as a list of `[date, views]` tuples under the + * `fields` header. We normalize to `[{ date, views }]`. + * + * @param array $raw Raw WPCOM response. + * @return array + */ + private static function extract_post_views_series( array $raw ): array { + if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) { + return array(); + } + + $fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array( 'period', 'views' ); + $date_idx = array_search( 'period', $fields, true ); + $views_idx = array_search( 'views', $fields, true ); + if ( false === $date_idx || false === $views_idx ) { + // If either column is missing from the WPCOM response, the positional + // fallback is unsafe (we might collide date/views on column 0). Drop + // to empty rather than emit lies. + return array(); + } + + $series = array(); + foreach ( $raw['data'] as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $series[] = array( + 'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '', + 'views' => isset( $row[ $views_idx ] ) ? (int) $row[ $views_idx ] : 0, + ); + } + return $series; + } + + /** + * Normalize the WPCOM get_visits response into `[{ date, : int, ... }]`. + * + * @param array $raw Raw WPCOM response. + * @param array $fields Requested metric fields. + * @return array + */ + private static function normalize_visits_series( array $raw, array $fields ): array { + if ( ! isset( $raw['data'] ) || ! is_array( $raw['data'] ) ) { + return array(); + } + + $raw_fields = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array(); + $field_idx = array(); + foreach ( $raw_fields as $idx => $name ) { + $field_idx[ (string) $name ] = $idx; + } + $date_idx = $field_idx['period'] ?? 0; + + $series = array(); + foreach ( $raw['data'] as $row ) { + if ( ! is_array( $row ) ) { + continue; + } + $entry = array( + 'date' => isset( $row[ $date_idx ] ) ? (string) $row[ $date_idx ] : '', + ); + foreach ( $fields as $field ) { + $idx = $field_idx[ $field ] ?? null; + $entry[ $field ] = ( null !== $idx && isset( $row[ $idx ] ) ) ? (int) $row[ $idx ] : 0; + } + $series[] = $entry; + } + return $series; + } + + /** + * Normalize a candidate date string. Returns today's date (UTC) on bad input. + * + * @param mixed $raw Raw input value. + * @return string YYYY-MM-DD. + */ + private static function sanitize_date( $raw ): string { + if ( is_string( $raw ) && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $raw ) ) { + return $raw; + } + return gmdate( 'Y-m-d' ); + } + + /** + * Clamp an integer into [$min, $max] with a default on bad input. + * + * @param mixed $raw Raw input. + * @param int $min Minimum. + * @param int $max Maximum. + * @param int $default Default on bad input. + * @return int + */ + private static function clamp_int( $raw, int $min, int $max, int $default ): int { + if ( ! is_numeric( $raw ) ) { + return $default; + } + $v = (int) $raw; + if ( $v < $min ) { + return $min; + } + if ( $v > $max ) { + return $max; + } + return $v; + } + + /** + * Safely read an int field from an array. + * + * @param array $arr Array. + * @param string $key Key. + * @return int + */ + private static function as_int( array $arr, string $key ): int { + return isset( $arr[ $key ] ) ? (int) $arr[ $key ] : 0; + } +} diff --git a/projects/packages/stats/src/class-main.php b/projects/packages/stats/src/class-main.php index 9f4eebe6b581..0d8e84c7d36f 100644 --- a/projects/packages/stats/src/class-main.php +++ b/projects/packages/stats/src/class-main.php @@ -10,6 +10,7 @@ use Automattic\Jetpack\Connection\Manager as Connection_Manager; use Automattic\Jetpack\Constants; use Automattic\Jetpack\Modules; +use Automattic\Jetpack\Stats\Abilities\Stats_Abilities; use Automattic\Jetpack\Status; use Automattic\Jetpack\Status\Visitor; use WP_User; @@ -83,6 +84,12 @@ private function __construct() { // Set up package version hook. add_filter( 'jetpack_package_versions', __NAMESPACE__ . '\Package_Version::send_package_version_to_tracker' ); + + // Register WP Abilities API surface. Gated behind the + // `jetpack_wp_abilities_enabled` filter inside Registrar::init(), + // which defaults to false — so this call is safe to make unconditionally + // and still opt-in per-site until the flag is flipped. + Stats_Abilities::init(); } /** diff --git a/projects/packages/stats/src/class-options.php b/projects/packages/stats/src/class-options.php index f0290fa5c86c..e1e3a44e39eb 100644 --- a/projects/packages/stats/src/class-options.php +++ b/projects/packages/stats/src/class-options.php @@ -151,9 +151,13 @@ public static function upgrade_options( $options ) { /** * Default Stats related options. * + * Exposed so consumers (e.g. the Abilities API surface) can introspect + * each option's default value and infer its type without redeclaring + * a parallel allow-list of bool/array fields. + * * @return array */ - protected static function get_defaults() { + public static function get_defaults() { return array( 'admin_bar' => true, 'roles' => array( 'administrator' ), diff --git a/projects/packages/stats/tests/php/abilities/Stats_Abilities_Test.php b/projects/packages/stats/tests/php/abilities/Stats_Abilities_Test.php new file mode 100644 index 000000000000..fd720a808737 --- /dev/null +++ b/projects/packages/stats/tests/php/abilities/Stats_Abilities_Test.php @@ -0,0 +1,1141 @@ + 'stats_abilities_admin_' . wp_generate_password( 8, false ), + 'user_pass' => 'pw', + 'role' => 'administrator', + ) + ); + self::$subscriber_id = wp_insert_user( + array( + 'user_login' => 'stats_abilities_sub_' . wp_generate_password( 8, false ), + 'user_pass' => 'pw', + 'role' => 'subscriber', + ) + ); + + // Most tests open the gate; the specific "disabled by default" test closes it explicitly. + add_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + + // Reset any hooks a prior test may have added for the Registrar lifecycle actions. + // We do NOT touch the Abilities registry here — accessing it via wp_has_ability() + // would fire wp_abilities_api_init as a side effect (WP_Abilities_Registry::get_instance() + // does do_action() on first call), breaking the init()-hooked-correctly assertions below. + remove_action( 'wp_abilities_api_categories_init', array( Stats_Abilities::class, 'register_category' ) ); + remove_action( 'wp_abilities_api_init', array( Stats_Abilities::class, 'register_abilities' ) ); + } + + /** + * {@inheritDoc} + */ + public function tear_down() { + remove_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + remove_all_filters( 'jetpack_wp_abilities_should_register' ); + remove_all_filters( 'jetpack_stats_abilities_wpcom_stats' ); + remove_action( 'wp_abilities_api_categories_init', array( Stats_Abilities::class, 'register_category' ) ); + remove_action( 'wp_abilities_api_init', array( Stats_Abilities::class, 'register_abilities' ) ); + wp_set_current_user( 0 ); + + // Only touch the registry if it has already been instantiated by something + // in this test (e.g., register_abilities / wp_has_ability). Otherwise this + // would force-instantiate it and fire wp_abilities_api_init as a side effect. + if ( did_action( 'wp_abilities_api_init' ) ) { + $this->deregister_category_and_abilities(); + } + + // Reset Options static cache so config-writes don't leak across tests. + self::reset_static_property( Options::class, 'options', array() ); + + parent::tear_down(); + } + + /** + * Remove our category + abilities from the registry so tests don't bleed. + */ + private function deregister_category_and_abilities(): void { + if ( function_exists( 'wp_has_ability' ) && function_exists( 'wp_unregister_ability' ) ) { + foreach ( array_keys( Stats_Abilities::get_abilities() ) as $slug ) { + if ( wp_has_ability( $slug ) ) { + wp_unregister_ability( $slug ); + } + } + } + if ( function_exists( 'wp_has_ability_category' ) && function_exists( 'wp_unregister_ability_category' ) ) { + if ( wp_has_ability_category( Stats_Abilities::CATEGORY_SLUG ) ) { + wp_unregister_ability_category( Stats_Abilities::CATEGORY_SLUG ); + } + } + } + + /** + * Run a callable while the given Abilities API lifecycle action appears to be firing. + * + * The Registrar guards its register_* methods with `doing_action()`. Pushing onto + * `$wp_current_filter` simulates that the action is currently running; we always + * pop the entry afterward so the simulated action doesn't leak into later tests + * (which would corrupt `current_filter()` / `doing_action()` state). + * + * @param string $action Action name to simulate. + * @param callable $fn Callable to run while the action is "firing". + */ + private function with_simulated_action( string $action, callable $fn ): void { + global $wp_current_filter; + $wp_current_filter[] = $action; + try { + $fn(); + } finally { + // Defensive: pop only our own entry, in case the callback pushed/popped its own actions. + for ( $i = count( $wp_current_filter ) - 1; $i >= 0; $i-- ) { + if ( $wp_current_filter[ $i ] === $action ) { + array_splice( $wp_current_filter, $i, 1 ); + break; + } + } + } + } + + public function test_category_slug_is_jetpack_stats(): void { + $this->assertSame( 'jetpack-stats', Stats_Abilities::get_category_slug() ); + } + + public function test_category_definition_has_label_and_description(): void { + $def = Stats_Abilities::get_category_definition(); + $this->assertArrayHasKey( 'label', $def ); + $this->assertArrayHasKey( 'description', $def ); + $this->assertNotSame( '', $def['label'] ); + $this->assertNotSame( '', $def['description'] ); + } + + public function test_abilities_map_is_non_empty_and_namespaced(): void { + $abilities = Stats_Abilities::get_abilities(); + $this->assertNotEmpty( $abilities ); + foreach ( array_keys( $abilities ) as $slug ) { + $this->assertStringStartsWith( 'jetpack-stats/', $slug ); + } + } + + public function test_no_spec_sets_category_explicitly(): void { + // Registrar auto-injects category; specs that set it are redundant and drift. + foreach ( Stats_Abilities::get_abilities() as $slug => $spec ) { + $this->assertArrayNotHasKey( + 'category', + $spec, + "Ability {$slug} should not set its own category — Registrar injects it." + ); + } + } + + public function test_every_spec_declares_annotations_permission_and_execute(): void { + foreach ( Stats_Abilities::get_abilities() as $slug => $spec ) { + $this->assertArrayHasKey( 'execute_callback', $spec, "Ability {$slug} missing execute_callback" ); + $this->assertIsCallable( $spec['execute_callback'], "Ability {$slug} execute_callback is not callable" ); + $this->assertArrayHasKey( 'permission_callback', $spec, "Ability {$slug} missing permission_callback" ); + $this->assertIsCallable( $spec['permission_callback'], "Ability {$slug} permission_callback is not callable" ); + $this->assertArrayHasKey( 'meta', $spec, "Ability {$slug} missing meta" ); + $this->assertArrayHasKey( 'annotations', $spec['meta'], "Ability {$slug} missing meta.annotations" ); + foreach ( array( 'readonly', 'destructive', 'idempotent' ) as $flag ) { + $this->assertArrayHasKey( $flag, $spec['meta']['annotations'], "Ability {$slug} missing annotation {$flag}" ); + $this->assertIsBool( $spec['meta']['annotations'][ $flag ], "Ability {$slug} annotation {$flag} must be bool" ); + } + } + } + + public function test_read_abilities_declared_readonly_and_non_destructive(): void { + $read_slugs = array( + 'jetpack-stats/get-site-overview', + 'jetpack-stats/get-top-content', + 'jetpack-stats/get-post-views', + 'jetpack-stats/get-visits', + 'jetpack-stats/get-followers', + 'jetpack-stats/get-settings', + ); + $abilities = Stats_Abilities::get_abilities(); + foreach ( $read_slugs as $slug ) { + $this->assertArrayHasKey( $slug, $abilities ); + $ann = $abilities[ $slug ]['meta']['annotations']; + $this->assertTrue( $ann['readonly'], "{$slug} should be readonly" ); + $this->assertFalse( $ann['destructive'], "{$slug} should not be destructive" ); + $this->assertTrue( $ann['idempotent'], "{$slug} should be idempotent" ); + } + } + + public function test_every_ability_opts_into_mcp_as_public_tool(): void { + foreach ( Stats_Abilities::get_abilities() as $slug => $spec ) { + $this->assertSame( true, $spec['meta']['mcp']['public'], "{$slug} must opt into MCP." ); + $this->assertSame( 'tool', $spec['meta']['mcp']['type'], "{$slug} must be exposed as an MCP tool." ); + } + } + + public function test_update_settings_is_not_readonly_but_idempotent(): void { + $abilities = Stats_Abilities::get_abilities(); + $ann = $abilities['jetpack-stats/update-settings']['meta']['annotations']; + $this->assertFalse( $ann['readonly'] ); + $this->assertFalse( $ann['destructive'] ); + $this->assertTrue( $ann['idempotent'] ); + } + + public function test_init_registers_nothing_when_gate_filter_is_false(): void { + remove_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + add_filter( 'jetpack_wp_abilities_enabled', '__return_false' ); + + Stats_Abilities::init(); + + $this->assertFalse( + has_action( + 'wp_abilities_api_categories_init', + array( Stats_Abilities::class, 'register_category' ) + ) + ); + $this->assertFalse( + has_action( + 'wp_abilities_api_init', + array( Stats_Abilities::class, 'register_abilities' ) + ) + ); + + remove_filter( 'jetpack_wp_abilities_enabled', '__return_false' ); + } + + public function test_init_hooks_lifecycle_actions_when_gate_is_true(): void { + // Only valid when the Abilities API lifecycle actions have NOT yet fired. + // WP_Abilities_Registry::get_instance() fires `wp_abilities_api_init` on first call + // (in response to any wp_has_ability / wp_register_ability call elsewhere in the run), + // which would push the Registrar down its synchronous late-load path and this test + // would assert against a world it doesn't apply to. + if ( did_action( 'wp_abilities_api_init' ) || did_action( 'wp_abilities_api_categories_init' ) ) { + $this->markTestSkipped( 'Abilities API lifecycle already fired in this test run; late-load path covered elsewhere.' ); + } + + Stats_Abilities::init(); + + $this->assertNotFalse( + has_action( + 'wp_abilities_api_categories_init', + array( Stats_Abilities::class, 'register_category' ) + ) + ); + $this->assertNotFalse( + has_action( + 'wp_abilities_api_init', + array( Stats_Abilities::class, 'register_abilities' ) + ) + ); + } + + public function test_register_abilities_registers_every_slug_with_auto_injected_category(): void { + if ( ! function_exists( 'wp_get_abilities' ) ) { + $this->markTestSkipped( 'Abilities API not available.' ); + } + + $this->with_simulated_action( + 'wp_abilities_api_categories_init', + static function () { + Stats_Abilities::register_category(); + } + ); + $this->with_simulated_action( + 'wp_abilities_api_init', + static function () { + Stats_Abilities::register_abilities(); + } + ); + + foreach ( array_keys( Stats_Abilities::get_abilities() ) as $slug ) { + $this->assertTrue( wp_has_ability( $slug ), "Ability {$slug} should be registered." ); + } + } + + public function test_per_ability_allow_list_filter_is_respected(): void { + if ( ! function_exists( 'wp_get_ability' ) ) { + $this->markTestSkipped( 'Abilities API not available.' ); + } + + add_filter( + 'jetpack_wp_abilities_should_register', + static function ( $enabled, $type, $slug ) { + // Allow only the zero-arg overview; deny the rest. + if ( 'ability' === $type ) { + return 'jetpack-stats/get-site-overview' === $slug; + } + return $enabled; + }, + 10, + 3 + ); + + $this->with_simulated_action( + 'wp_abilities_api_categories_init', + static function () { + Stats_Abilities::register_category(); + } + ); + $this->with_simulated_action( + 'wp_abilities_api_init', + static function () { + Stats_Abilities::register_abilities(); + } + ); + + $this->assertTrue( wp_has_ability( 'jetpack-stats/get-site-overview' ) ); + $this->assertFalse( wp_has_ability( 'jetpack-stats/get-top-content' ) ); + $this->assertFalse( wp_has_ability( 'jetpack-stats/update-settings' ) ); + } + + public function test_can_view_stats_allows_admin(): void { + wp_set_current_user( self::$admin_id ); + $this->assertTrue( Stats_Abilities::can_view_stats() ); + } + + public function test_can_view_stats_denies_subscriber(): void { + wp_set_current_user( self::$subscriber_id ); + $this->assertFalse( Stats_Abilities::can_view_stats() ); + } + + public function test_can_view_stats_denies_anonymous(): void { + wp_set_current_user( 0 ); + $this->assertFalse( Stats_Abilities::can_view_stats() ); + } + + public function test_can_manage_settings_allows_admin(): void { + wp_set_current_user( self::$admin_id ); + $this->assertTrue( Stats_Abilities::can_manage_settings() ); + } + + public function test_can_manage_settings_denies_subscriber(): void { + wp_set_current_user( self::$subscriber_id ); + $this->assertFalse( Stats_Abilities::can_manage_settings() ); + } + + public function test_get_site_overview_returns_composed_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_stats_summary' => array( + 'date' => '2026-04-23', + 'period' => 'day', + 'views' => 120, + 'visitors' => 90, + 'likes' => 4, + 'comments' => 2, + 'period_total_views' => 840, + ), + 'get_highlights' => array( + 'today' => array( + 'date' => '2026-04-23', + 'views_month' => 3500, + 'top_post' => array( + 'id' => 7, + 'title' => 'Hello world', + 'views' => 42, + ), + 'top_referrers' => array( + array( + 'name' => 'wordpress.com', + 'views' => 60, + ), + array( + 'name' => 'google.com', + 'views' => 30, + ), + ), + ), + ), + 'get_streak' => array( + 'streak' => array( + 'currentStreakLength' => 3, + 'longestStreakLength' => 12, + 'longestStreakStart' => '2026-01-01', + 'longestStreakEnd' => '2026-01-12', + ), + ), + ) + ); + + $result = Stats_Abilities::get_site_overview(); + + $this->assertIsArray( $result ); + $this->assertSame( '2026-04-23', $result['date'] ); + $this->assertSame( 120, $result['views_today'] ); + $this->assertSame( 90, $result['visitors_today'] ); + $this->assertSame( 840, $result['views_week'] ); + $this->assertSame( 3500, $result['views_month'] ); + $this->assertSame( 3, $result['streak']['current_length'] ); + $this->assertSame( 12, $result['streak']['longest_length'] ); + $this->assertSame( + array( + 'id' => 7, + 'title' => 'Hello world', + 'views' => 42, + ), + $result['top_post'] + ); + $this->assertSame( + array( + 'name' => 'wordpress.com', + 'views' => 60, + ), + $result['top_referrer'] + ); + $this->assertFalse( $result['partial'] ); + $this->assertArrayNotHasKey( 'errors', $result ); + } + + public function test_get_site_overview_flags_partial_when_one_subcall_fails(): void { + $this->filter_wpcom_stats( + array( + 'get_stats_summary' => array( + 'date' => '2026-04-23', + 'views' => 10, + ), + 'get_highlights' => new \WP_Error( 'boom', 'nope' ), + 'get_streak' => array( 'streak' => array( 'currentStreakLength' => 1 ) ), + ) + ); + + $result = Stats_Abilities::get_site_overview(); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['partial'] ); + $this->assertContains( 'highlights', $result['errors'] ); + $this->assertSame( 10, $result['views_today'] ); + $this->assertNull( $result['top_post'] ); + } + + public function test_get_site_overview_returns_wp_error_when_all_subcalls_fail(): void { + $this->filter_wpcom_stats( + array( + 'get_stats_summary' => new \WP_Error( 'boom', 'nope' ), + 'get_highlights' => new \WP_Error( 'boom', 'nope' ), + 'get_streak' => new \WP_Error( 'boom', 'nope' ), + ) + ); + + $result = Stats_Abilities::get_site_overview(); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_data_unavailable', $result->get_error_code() ); + } + + public function test_get_top_content_rejects_missing_type(): void { + $result = Stats_Abilities::get_top_content( array() ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_missing_type', $result->get_error_code() ); + } + + public function test_get_top_content_posts_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_top_posts' => self::days_fixture( + 'postviews', + array( + array( + 'id' => 1, + 'title' => 'Alpha', + 'views' => 100, + 'href' => 'https://x/alpha', + ), + array( + 'id' => 2, + 'title' => 'Beta', + 'views' => 50, + 'href' => 'https://x/beta', + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'posts', + 'date' => '2026-04-23', + 'max' => 5, + ) + ); + + $this->assertSame( 'posts', $result['type'] ); + $this->assertSame( 'day', $result['period'] ); + $this->assertSame( '2026-04-23', $result['date'] ); + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 1, $result['items'][0]['rank'] ); + $this->assertSame( 'Alpha', $result['items'][0]['label'] ); + $this->assertSame( 100, $result['items'][0]['value'] ); + $this->assertSame( 'https://x/alpha', $result['items'][0]['href'] ); + $this->assertSame( 2, $result['items'][1]['rank'] ); + } + + public function test_get_top_content_search_terms_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_search_terms' => self::days_fixture( + 'search_terms', + array( + array( + 'term' => 'jetpack', + 'views' => 20, + ), + array( + 'term' => 'stats', + 'views' => 7, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'search-terms', + 'date' => '2026-04-23', + ) + ); + + $this->assertSame( 'search-terms', $result['type'] ); + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'jetpack', $result['items'][0]['label'] ); + $this->assertSame( 20, $result['items'][0]['value'] ); + // No href for search terms. + $this->assertArrayNotHasKey( 'href', $result['items'][0] ); + } + + public function test_get_top_content_countries_uses_country_name(): void { + $this->filter_wpcom_stats( + array( + 'get_views_by_country' => array( + 'days' => array( + '2026-04-23' => array( + 'views' => array( + array( + 'country_code' => 'FR', + 'views' => 12, + ), + array( + 'country_code' => 'DE', + 'views' => 9, + ), + ), + ), + ), + 'country-info' => array( + 'FR' => array( 'country_full' => 'France' ), + 'DE' => array( 'country_full' => 'Germany' ), + ), + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'countries', + 'date' => '2026-04-23', + ) + ); + + $this->assertSame( 'France', $result['items'][0]['label'] ); + $this->assertSame( 'Germany', $result['items'][1]['label'] ); + } + + public function test_get_top_content_tags_handles_flat_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_tags' => array( + 'tags' => array( + array( + 'tag' => 'news', + 'views' => 40, + ), + array( + 'tag' => 'updates', + 'views' => 15, + ), + ), + ), + ) + ); + + $result = Stats_Abilities::get_top_content( array( 'type' => 'tags' ) ); + + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'news', $result['items'][0]['label'] ); + $this->assertSame( 40, $result['items'][0]['value'] ); + } + + public function test_get_top_content_enforces_max_cap(): void { + $this->filter_wpcom_stats( + array( + 'get_top_posts' => self::days_fixture( + 'postviews', + array( + array( + 'title' => 'A', + 'views' => 1, + ), + array( + 'title' => 'B', + 'views' => 1, + ), + array( + 'title' => 'C', + 'views' => 1, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'posts', + 'max' => 2, + ) + ); + + $this->assertCount( 2, $result['items'] ); + } + + public function test_get_top_content_propagates_wp_error(): void { + $this->filter_wpcom_stats( + array( + 'get_top_posts' => new \WP_Error( 'wpcom_boom', 'bad' ), + ) + ); + + $result = Stats_Abilities::get_top_content( array( 'type' => 'posts' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + public function test_get_top_content_referrers_normalizes_groups_shape(): void { + // Regression guard: WPCOM `stats/referrers` keys per-day data under `groups`, + // not `referrers`. The mapping must read from `groups -> name/total/url?`. + $this->filter_wpcom_stats( + array( + 'get_referrers' => self::days_fixture( + 'groups', + array( + array( + 'name' => 'wordpress.com', + 'url' => 'https://wordpress.com', + 'total' => 60, + ), + array( + 'name' => 'Search Engines', + 'total' => 25, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'referrers', + 'date' => '2026-04-23', + ) + ); + + $this->assertSame( 'referrers', $result['type'] ); + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'wordpress.com', $result['items'][0]['label'] ); + $this->assertSame( 60, $result['items'][0]['value'] ); + $this->assertSame( 'https://wordpress.com', $result['items'][0]['href'] ); + $this->assertSame( 'Search Engines', $result['items'][1]['label'] ); + $this->assertSame( 25, $result['items'][1]['value'] ); + $this->assertArrayNotHasKey( 'href', $result['items'][1] ); + } + + public function test_get_top_content_clicks_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_clicks' => self::days_fixture( + 'clicks', + array( + array( + 'name' => 'Docs', + 'url' => 'https://docs.example/x', + 'views' => 9, + ), + array( + // No `name` — normalization should fall back to `url`. + 'url' => 'https://example.com/y', + 'views' => 3, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'clicks', + 'date' => '2026-04-23', + ) + ); + + $this->assertSame( 'clicks', $result['type'] ); + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'Docs', $result['items'][0]['label'] ); + $this->assertSame( 9, $result['items'][0]['value'] ); + $this->assertSame( 'https://docs.example/x', $result['items'][0]['href'] ); + $this->assertSame( 'https://example.com/y', $result['items'][1]['label'] ); + $this->assertSame( 'https://example.com/y', $result['items'][1]['href'] ); + } + + public function test_get_top_content_authors_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_top_authors' => self::days_fixture( + 'authors', + array( + array( + 'name' => 'Ada', + 'views' => 80, + ), + array( + 'name' => 'Grace', + 'views' => 30, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'authors', + 'date' => '2026-04-23', + ) + ); + + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'Ada', $result['items'][0]['label'] ); + $this->assertSame( 80, $result['items'][0]['value'] ); + $this->assertArrayNotHasKey( 'href', $result['items'][0] ); + } + + public function test_get_top_content_downloads_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_file_downloads' => self::days_fixture( + 'files', + array( + array( + 'filename' => 'whitepaper.pdf', + 'relative_url' => '/downloads/whitepaper.pdf', + 'download_count' => 42, + ), + array( + // No `filename` — normalization should fall back to relative_url. + 'relative_url' => '/downloads/spec.zip', + 'download_count' => 7, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'downloads', + 'date' => '2026-04-23', + ) + ); + + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'whitepaper.pdf', $result['items'][0]['label'] ); + $this->assertSame( 42, $result['items'][0]['value'] ); + $this->assertSame( '/downloads/whitepaper.pdf', $result['items'][0]['href'] ); + $this->assertSame( '/downloads/spec.zip', $result['items'][1]['label'] ); + $this->assertSame( '/downloads/spec.zip', $result['items'][1]['href'] ); + } + + public function test_get_top_content_video_plays_uniform_shape(): void { + $this->filter_wpcom_stats( + array( + 'get_video_plays' => self::days_fixture( + 'plays', + array( + array( + 'title' => 'Launch demo', + 'plays' => 200, + ), + array( + 'title' => 'Tutorial', + 'plays' => 75, + ), + ) + ), + ) + ); + + $result = Stats_Abilities::get_top_content( + array( + 'type' => 'video-plays', + 'date' => '2026-04-23', + ) + ); + + $this->assertCount( 2, $result['items'] ); + $this->assertSame( 'Launch demo', $result['items'][0]['label'] ); + $this->assertSame( 200, $result['items'][0]['value'] ); + $this->assertArrayNotHasKey( 'href', $result['items'][0] ); + } + + public function test_get_post_views_rejects_missing_post_id(): void { + $result = Stats_Abilities::get_post_views( array() ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_missing_post_id', $result->get_error_code() ); + } + + public function test_get_post_views_rejects_post_id_zero(): void { + // Regression guard: post ID 0 is not a legal post; but our validation uses `> 0`, not `empty()`, + // which means a future ID-like value of string "0" behaves deterministically (also rejected + // by the positive check, not accidentally by falsy coercion). + $result = Stats_Abilities::get_post_views( array( 'post_id' => '0' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_missing_post_id', $result->get_error_code() ); + } + + public function test_get_post_views_rejects_non_numeric_post_id(): void { + $result = Stats_Abilities::get_post_views( array( 'post_id' => 'not-a-number' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + public function test_get_post_views_accepts_numeric_string(): void { + $this->filter_wpcom_stats( + array( + 'get_post_views' => array( + 'views' => 42, + 'fields' => array( 'period', 'views' ), + 'data' => array( + array( '2026-04-22', 20 ), + array( '2026-04-23', 22 ), + ), + ), + ) + ); + + $result = Stats_Abilities::get_post_views( array( 'post_id' => '7' ) ); + + $this->assertIsArray( $result ); + $this->assertSame( 7, $result['post_id'] ); + $this->assertSame( 42, $result['total_views'] ); + $this->assertCount( 2, $result['series'] ); + $this->assertSame( + array( + 'date' => '2026-04-22', + 'views' => 20, + ), + $result['series'][0] + ); + } + + public function test_get_visits_normalizes_series_rows(): void { + $this->filter_wpcom_stats( + array( + 'get_visits' => array( + 'fields' => array( 'period', 'views', 'visitors' ), + 'data' => array( + array( '2026-04-22', 100, 70 ), + array( '2026-04-23', 120, 90 ), + ), + ), + ) + ); + + $result = Stats_Abilities::get_visits( + array( + 'unit' => 'day', + 'quantity' => 2, + ) + ); + + $this->assertSame( array( 'views', 'visitors' ), $result['fields'] ); + $this->assertCount( 2, $result['series'] ); + $this->assertSame( + array( + 'date' => '2026-04-22', + 'views' => 100, + 'visitors' => 70, + ), + $result['series'][0] + ); + } + + public function test_get_visits_always_includes_requested_fields_even_when_missing_from_backing(): void { + // If WPCOM omits a field, we still emit it as 0 so the agent can iterate uniformly. + $this->filter_wpcom_stats( + array( + 'get_visits' => array( + 'fields' => array( 'period', 'views' ), + 'data' => array( array( '2026-04-23', 100 ) ), + ), + ) + ); + + $result = Stats_Abilities::get_visits( array( 'fields' => array( 'views', 'likes' ) ) ); + + $this->assertSame( array( 'views', 'likes' ), $result['fields'] ); + $this->assertSame( 100, $result['series'][0]['views'] ); + $this->assertSame( 0, $result['series'][0]['likes'] ); + } + + public function test_get_followers_composes_three_subcalls(): void { + $this->filter_wpcom_stats( + array( + 'get_followers' => array( + 'email' => 10, + 'wpcom' => 25, + ), + 'get_comment_followers' => array( 'total' => 4 ), + 'get_publicize_followers' => array( + 'services' => array( + array( + 'service' => 'twitter', + 'followers' => 300, + ), + array( + 'service' => 'facebook', + 'followers' => 80, + ), + ), + ), + ) + ); + + $result = Stats_Abilities::get_followers(); + + $this->assertSame( 10, $result['email'] ); + $this->assertSame( 25, $result['wpcom'] ); + $this->assertSame( 4, $result['comment'] ); + $this->assertSame( + array( + 'twitter' => 300, + 'facebook' => 80, + ), + $result['publicize'] + ); + $this->assertSame( 10 + 25 + 4 + 300 + 80, $result['total'] ); + $this->assertFalse( $result['partial'] ); + } + + public function test_get_followers_flags_partial_on_subcall_error(): void { + $this->filter_wpcom_stats( + array( + 'get_followers' => array( + 'email' => 5, + 'wpcom' => 10, + ), + 'get_comment_followers' => new \WP_Error( 'boom', 'bad' ), + 'get_publicize_followers' => array( 'services' => array() ), + ) + ); + + $result = Stats_Abilities::get_followers(); + + $this->assertTrue( $result['partial'] ); + $this->assertContains( 'comment_followers', $result['errors'] ); + } + + public function test_get_settings_returns_whitelisted_fields_only(): void { + $result = Stats_Abilities::get_settings(); + + $this->assertIsArray( $result ); + $this->assertSame( + array( 'admin_bar', 'roles', 'count_roles', 'do_not_track' ), + array_keys( $result ) + ); + // Internal keys must NOT leak. + $this->assertArrayNotHasKey( 'blog_id', $result ); + $this->assertArrayNotHasKey( 'version', $result ); + $this->assertArrayNotHasKey( 'notices', $result ); + } + + public function test_get_settings_classifies_types_from_options_defaults(): void { + // Regression guard: the snapshot derives bool/array typing from + // Options::get_defaults() rather than a duplicated allow-list, so a new + // option type added in Options must propagate here without code changes. + $result = Stats_Abilities::get_settings(); + + $this->assertIsBool( $result['admin_bar'] ); + $this->assertIsBool( $result['do_not_track'] ); + $this->assertIsArray( $result['roles'] ); + $this->assertIsArray( $result['count_roles'] ); + } + + public function test_update_settings_rejects_empty_input(): void { + $result = Stats_Abilities::update_settings( array() ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_missing_setting_field', $result->get_error_code() ); + } + + public function test_update_settings_rejects_unknown_role(): void { + $result = Stats_Abilities::update_settings( array( 'roles' => array( 'robot' ) ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_invalid_role', $result->get_error_code() ); + } + + public function test_update_settings_rejects_empty_roles_to_prevent_lockout(): void { + // Schema validation enforces minItems=1 on REST input, but direct PHP callers bypass + // that path; an empty `roles` array would revoke `view_stats` for every user, including + // the caller. Reject explicitly. + $result = Stats_Abilities::update_settings( array( 'roles' => array() ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_stats_invalid_roles', $result->get_error_code() ); + } + + public function test_update_settings_changes_admin_bar(): void { + $before = Stats_Abilities::get_settings(); + $this->assertTrue( $before['admin_bar'] ); + + $result = Stats_Abilities::update_settings( array( 'admin_bar' => false ) ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['changed'] ); + $this->assertFalse( $result['settings']['admin_bar'] ); + } + + public function test_update_settings_is_idempotent_for_current_state(): void { + $before = Stats_Abilities::get_settings(); + $result = Stats_Abilities::update_settings( array( 'admin_bar' => $before['admin_bar'] ) ); + $this->assertFalse( $result['changed'], 'Desired == current must be a no-op.' ); + } + + public function test_update_settings_accepts_empty_count_roles(): void { + $result = Stats_Abilities::update_settings( array( 'count_roles' => array() ) ); + $this->assertIsArray( $result ); + $this->assertSame( array(), $result['settings']['count_roles'] ); + } + + public function test_update_settings_preserves_unrelated_option_keys(): void { + // Seed an unrelated internal key. + update_option( + 'stats_options', + array( + 'admin_bar' => true, + 'roles' => array( 'administrator' ), + 'notices' => array( 'do_not_clobber' => 1 ), + 'version' => Main::STATS_VERSION, + ) + ); + // Force-reset the static cache so the next get_options() re-reads. + self::reset_static_property( Options::class, 'options', array() ); + + Stats_Abilities::update_settings( array( 'admin_bar' => false ) ); + + $this->assertSame( + array( 'do_not_clobber' => 1 ), + Options::get_option( 'notices' ), + 'update-settings must not blow away unrelated internal option keys.' + ); + } + + /** + * Build a single-day WPCOM `stats/` fixture. + * + * Most top-content endpoints share the shape `days -> -> -> [rows]`; + * this helper keeps the fixture wiring uniform and out of each test body. + * + * @param string $list_key Per-day list key (e.g. `postviews`, `groups`, `clicks`). + * @param array $rows Row dictionaries. + * @param string $date Day key (defaults to the canonical fixture date). + * @return array + */ + private static function days_fixture( string $list_key, array $rows, string $date = '2026-04-23' ): array { + return array( + 'days' => array( + $date => array( $list_key => $rows ), + ), + ); + } + + /** + * Reset a class's static property (defeats inter-test caches). + * + * @param string $class Fully qualified class name. + * @param string $prop Property name. + * @param mixed $value New value. + */ + private static function reset_static_property( string $class, string $prop, $value ): void { + $ref = new \ReflectionClass( $class ); + $property = $ref->getProperty( $prop ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $property->setValue( null, $value ); + } + + /** + * Install a filter that makes the abilities class use a stubbed WPCOM_Stats. + * + * @param array $method_returns Map of WPCOM_Stats method => return value (array or WP_Error). + */ + private function filter_wpcom_stats( array $method_returns ): void { + $stub = $this->createStub( WPCOM_Stats::class ); + foreach ( $method_returns as $method => $value ) { + $stub->method( $method )->willReturn( $value ); + } + + add_filter( + 'jetpack_stats_abilities_wpcom_stats', + static function () use ( $stub ) { + return $stub; + } + ); + } +} diff --git a/projects/packages/videopress/changelog/fix-videopress-admin-render-loop b/projects/packages/videopress/changelog/fix-videopress-admin-render-loop new file mode 100644 index 000000000000..024aeec452be --- /dev/null +++ b/projects/packages/videopress/changelog/fix-videopress-admin-render-loop @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +VideoPress: Fix runaway render loop on the admin library page when paginating on WordPress 7.0. diff --git a/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx b/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx index 0feccb2ae080..fe9703bb8762 100644 --- a/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx +++ b/projects/packages/videopress/src/client/admin/components/admin-page/index.tsx @@ -18,22 +18,18 @@ import { ConnectionError, } from '@automattic/jetpack-connection'; import { FormFileUpload } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import clsx from 'clsx'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; /** * Internal dependencies */ -import { STORE_ID } from '../../../state'; -import uid from '../../../utils/uid'; import { fileInputExtensions } from '../../../utils/video-extensions'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; +import { useDashboardVideos } from '../../hooks/use-dashboard-videos'; import { usePermission } from '../../hooks/use-permission'; import { usePlan } from '../../hooks/use-plan'; -import { useSearchParams } from '../../hooks/use-search-params'; import useSelectVideoFiles from '../../hooks/use-select-video-files'; -import useVideos, { useLocalVideos } from '../../hooks/use-videos'; import { NeedUserConnectionGlobalNotice } from '../global-notice'; import PricingSection from '../pricing-section'; import { ConnectSiteSettingsSection as SettingsSection } from '../site-settings-section'; @@ -42,120 +38,6 @@ import VideoUploadArea from '../video-upload-area'; import { LocalLibrary, VideoPressLibrary } from './libraries'; import styles from './styles.module.scss'; -const useDashboardVideos = () => { - const { uploadVideo, uploadVideoFromLibrary, setVideosQuery } = useDispatch( STORE_ID ); - const { - items, - uploadErrors, - uploading, - uploadedVideoCount, - isFetching, - search, - page, - itemsPerPage, - total, - } = useVideos(); - const { items: localVideos, uploadedLocalVideoCount } = useLocalVideos(); - const { hasVideoPressPurchase } = usePlan(); - - // Use a tempPage to catch changes in page from store and not URL - const tempPage = useRef( page ); - - /** Get the page number from the search parameters and set it to the state when the state is outdated */ - const searchParams = useSearchParams(); - const pageFromSearchParam = parseInt( searchParams.getParam( 'page', '1' ) ); - const searchFromSearchParam = searchParams.getParam( 'q', '' ); - const totalOfPages = Math.ceil( total / itemsPerPage ); - - useEffect( () => { - // when there are no search results, ensure that the current page number is 1 - if ( total === 0 && pageFromSearchParam !== 1 ) { - // go back to page 1 - searchParams.deleteParam( 'page' ); - searchParams.update(); - return; - } - - // when there are search results, ensure that the current page is between 1 and totalOfPages, inclusive - if ( total > 0 && ( pageFromSearchParam < 1 || pageFromSearchParam > totalOfPages ) ) { - // go back to page 1 - searchParams.deleteParam( 'page' ); - searchParams.update(); - return; - } - - // react to a page param change - if ( page !== pageFromSearchParam ) { - // store changed and not url - // update url to match store update - if ( page !== tempPage.current ) { - tempPage.current = page; - searchParams.setParam( 'page', page ); - searchParams.update(); - } else { - tempPage.current = pageFromSearchParam; - setVideosQuery( { - page: pageFromSearchParam, - } ); - } - - return; - } - - // react to a search param change - if ( search !== searchFromSearchParam ) { - setVideosQuery( { - search: searchFromSearchParam, - } ); - } - }, [ totalOfPages, page, pageFromSearchParam, search, searchFromSearchParam, tempPage.current ] ); - - // Do not show uploading videos if not in the first page or searching - let videos = page > 1 || Boolean( search ) ? items : [ ...uploadErrors, ...uploading, ...items ]; - - const hasVideos = - uploadedVideoCount > 0 || isFetching || uploading?.length > 0 || uploadErrors?.length > 0; - const hasLocalVideos = uploadedLocalVideoCount > 0; - - const handleFilesUpload = ( files: File[] ) => { - if ( hasVideoPressPurchase ) { - files.forEach( file => { - uploadVideo( file ); - } ); - } else if ( files.length > 0 ) { - uploadVideo( files[ 0 ] ); - } - }; - - const handleLocalVideoUpload = file => { - uploadVideoFromLibrary( file ); - }; - - // Fill with empty videos if loading - if ( isFetching ) { - const numPlaceholders = Math.max( - 1, // at least one placeholder - Math.min( itemsPerPage, uploadedVideoCount - itemsPerPage * ( page - 1 ) ) // at most the number of videos in the page without query - ); - // Use generated ID to work with React Key - videos = new Array( numPlaceholders ).fill( {} ).map( () => ( { id: uid() } ) ); - } - - return { - videos, - localVideos, - uploadedVideoCount, - uploadedLocalVideoCount, - hasVideos, - hasLocalVideos, - handleFilesUpload, - handleLocalVideoUpload, - loading: isFetching, - uploading: uploading?.length > 0 || uploadErrors?.length > 0, - hasVideoPressPurchase, - }; -}; - const Admin = () => { const { videos, diff --git a/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/index.ts b/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/index.ts new file mode 100644 index 000000000000..2b43e4c32a11 --- /dev/null +++ b/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/index.ts @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import { useDispatch } from '@wordpress/data'; +import { useEffect, useMemo, useRef } from 'react'; +/** + * Internal dependencies + */ +import { STORE_ID } from '../../../state'; +import { usePlan } from '../use-plan'; +import { useSearchParams } from '../use-search-params'; +import useVideos, { useLocalVideos } from '../use-videos'; + +export const useDashboardVideos = () => { + const { uploadVideo, uploadVideoFromLibrary, setVideosQuery } = useDispatch( STORE_ID ); + const { + items, + uploadErrors, + uploading, + uploadedVideoCount, + isFetching, + search, + page, + itemsPerPage, + total, + } = useVideos(); + const { items: localVideos, uploadedLocalVideoCount } = useLocalVideos(); + const { hasVideoPressPurchase } = usePlan(); + + // Use a tempPage to catch changes in page from store and not URL + const tempPage = useRef( page ); + + /** Get the page number from the search parameters and set it to the state when the state is outdated */ + const searchParams = useSearchParams(); + // Fall back to 1: NaN would silently slip past the `< 1`/`> totalOfPages` bounds checks below. + const parsedPageParam = parseInt( searchParams.getParam( 'page', '1' ), 10 ); + const pageFromSearchParam = Number.isNaN( parsedPageParam ) ? 1 : parsedPageParam; + const searchFromSearchParam = searchParams.getParam( 'q', '' ); + const totalOfPages = Math.ceil( total / itemsPerPage ); + + useEffect( () => { + // when there are no search results, ensure that the current page number is 1 + if ( total === 0 && pageFromSearchParam !== 1 ) { + // go back to page 1 + searchParams.deleteParam( 'page' ); + searchParams.update(); + return; + } + + // when there are search results, ensure that the current page is between 1 and totalOfPages, inclusive + if ( total > 0 && ( pageFromSearchParam < 1 || pageFromSearchParam > totalOfPages ) ) { + // go back to page 1 + searchParams.deleteParam( 'page' ); + searchParams.update(); + return; + } + + // react to a page param change + if ( page !== pageFromSearchParam ) { + // store changed and not url + // update url to match store update + if ( page !== tempPage.current ) { + tempPage.current = page; + searchParams.setParam( 'page', page ); + searchParams.update(); + } else { + tempPage.current = pageFromSearchParam; + setVideosQuery( { + page: pageFromSearchParam, + } ); + } + + return; + } + + // react to a search param change + if ( search !== searchFromSearchParam ) { + setVideosQuery( { + search: searchFromSearchParam, + } ); + } + // `tempPage.current` is intentionally excluded — including a ref's `.current` while mutating it inside the effect re-fires it in a loop. + }, [ totalOfPages, page, pageFromSearchParam, search, searchFromSearchParam ] ); + + // Stable placeholder list while fetching: deterministic IDs keyed on the + // inputs that determine count, so child keys don't churn every render. + const placeholders = useMemo( () => { + if ( ! isFetching ) { + return null; + } + const numPlaceholders = Math.max( + 1, // at least one placeholder + Math.min( itemsPerPage, uploadedVideoCount - itemsPerPage * ( page - 1 ) ) // at most the number of videos in the page without query + ); + return Array.from( { length: numPlaceholders }, ( _, i ) => ( { + id: `placeholder-${ i }`, + } ) ); + }, [ isFetching, itemsPerPage, uploadedVideoCount, page ] ); + + // Do not show uploading videos if not in the first page or searching + const videos = + placeholders ?? + ( page > 1 || Boolean( search ) ? items : [ ...uploadErrors, ...uploading, ...items ] ); + + const hasVideos = + uploadedVideoCount > 0 || isFetching || uploading?.length > 0 || uploadErrors?.length > 0; + const hasLocalVideos = uploadedLocalVideoCount > 0; + + const handleFilesUpload = ( files: File[] ) => { + if ( hasVideoPressPurchase ) { + files.forEach( file => { + uploadVideo( file ); + } ); + } else if ( files.length > 0 ) { + uploadVideo( files[ 0 ] ); + } + }; + + const handleLocalVideoUpload = file => { + uploadVideoFromLibrary( file ); + }; + + return { + videos, + localVideos, + uploadedVideoCount, + uploadedLocalVideoCount, + hasVideos, + hasLocalVideos, + handleFilesUpload, + handleLocalVideoUpload, + loading: isFetching, + uploading: uploading?.length > 0 || uploadErrors?.length > 0, + hasVideoPressPurchase, + }; +}; diff --git a/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/test/index.test.ts b/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/test/index.test.ts new file mode 100644 index 000000000000..24704cde7cc8 --- /dev/null +++ b/projects/packages/videopress/src/client/admin/hooks/use-dashboard-videos/test/index.test.ts @@ -0,0 +1,178 @@ +import { renderHook } from '@testing-library/react'; +import { useDashboardVideos } from '../index'; + +const mockUseVideos = jest.fn(); +const mockUseLocalVideos = jest.fn(); +const mockUseSearchParams = jest.fn(); +const mockUsePlan = jest.fn(); +const mockUseDispatch = jest.fn(); + +jest.mock( '../../use-videos', () => ( { + __esModule: true, + default: ( ...args: unknown[] ) => mockUseVideos( ...args ), + useLocalVideos: ( ...args: unknown[] ) => mockUseLocalVideos( ...args ), +} ) ); + +jest.mock( '../../use-search-params', () => ( { + useSearchParams: ( ...args: unknown[] ) => mockUseSearchParams( ...args ), +} ) ); + +jest.mock( '../../use-plan', () => ( { + usePlan: ( ...args: unknown[] ) => mockUsePlan( ...args ), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + useDispatch: ( ...args: unknown[] ) => mockUseDispatch( ...args ), + useSelect: jest.fn(), + dispatch: jest.fn(), + combineReducers: ( reducers: unknown ) => reducers, + register: jest.fn(), + createReduxStore: jest.fn(), +} ) ); + +jest.mock( '../../../../state', () => ( { + STORE_ID: 'videopress/media', +} ) ); + +const baseVideosState = { + items: [], + uploading: [], + uploadErrors: [], + uploadedVideoCount: 30, + isFetching: false, + search: '', + page: 1, + itemsPerPage: 10, + total: 30, +}; + +const setupSearchParams = ( pageParam: string | null = '1', qParam: string | null = '' ) => { + const setParam = jest.fn(); + const update = jest.fn(); + const deleteParam = jest.fn(); + const getParam = jest.fn( ( name: string, defaultValue: string | null = null ): string | null => { + if ( name === 'page' ) { + return pageParam ?? defaultValue; + } + if ( name === 'q' ) { + return qParam ?? defaultValue; + } + return defaultValue; + } ); + mockUseSearchParams.mockReturnValue( { getParam, setParam, update, deleteParam } ); + return { getParam, setParam, update, deleteParam }; +}; + +beforeEach( () => { + jest.clearAllMocks(); + + mockUseLocalVideos.mockReturnValue( { items: [], uploadedLocalVideoCount: 0 } ); + mockUsePlan.mockReturnValue( { hasVideoPressPurchase: true } ); + mockUseDispatch.mockReturnValue( { + uploadVideo: jest.fn(), + uploadVideoFromLibrary: jest.fn(), + setVideosQuery: jest.fn(), + } ); + mockUseVideos.mockReturnValue( baseVideosState ); + setupSearchParams( '1', '' ); +} ); + +describe( 'useDashboardVideos', () => { + it( 'effect does not oscillate when store and URL page disagree', () => { + const setVideosQuery = jest.fn(); + mockUseDispatch.mockReturnValue( { + uploadVideo: jest.fn(), + uploadVideoFromLibrary: jest.fn(), + setVideosQuery, + } ); + const { setParam, update, deleteParam } = setupSearchParams( '1', '' ); + // store says page 2, URL says page 1 — the two-branch sync logic + mockUseVideos.mockReturnValue( { + ...baseVideosState, + page: 2, + } ); + + const { rerender } = renderHook( () => useDashboardVideos() ); + rerender(); + rerender(); + rerender(); + + // With Bug 1, the effect alternates between setParam('page', 2)+update() + // and setVideosQuery({ page: 1 }) on every rerender as tempPage.current + // flips. After the fix, the effect stabilises to a single dispatch. + const totalEffectCalls = + setVideosQuery.mock.calls.length + + setParam.mock.calls.length + + update.mock.calls.length + + deleteParam.mock.calls.length; + expect( totalEffectCalls ).toBeLessThanOrEqual( 2 ); + } ); + + it( 'resets URL page when total is 0 and URL is past page 1', () => { + mockUseVideos.mockReturnValue( { + ...baseVideosState, + total: 0, + uploadedVideoCount: 0, + page: 1, + } ); + const { deleteParam, update, setParam } = setupSearchParams( '3', '' ); + + renderHook( () => useDashboardVideos() ); + + expect( deleteParam ).toHaveBeenCalledWith( 'page' ); + expect( update ).toHaveBeenCalledTimes( 1 ); + expect( setParam ).not.toHaveBeenCalled(); + } ); + + it( 'pushes store page to URL after store-side page change', () => { + mockUseVideos.mockReturnValue( { ...baseVideosState, page: 1 } ); + const { setParam, update } = setupSearchParams( '1', '' ); + + const { rerender } = renderHook( () => useDashboardVideos() ); + + // Simulate the store dispatching a new page (e.g. via setPage handler). + mockUseVideos.mockReturnValue( { ...baseVideosState, page: 3 } ); + rerender(); + + expect( setParam ).toHaveBeenCalledWith( 'page', 3 ); + expect( update ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'treats a non-numeric URL ?page= as page 1 instead of dispatching NaN', () => { + const setVideosQuery = jest.fn(); + mockUseDispatch.mockReturnValue( { + uploadVideo: jest.fn(), + uploadVideoFromLibrary: jest.fn(), + setVideosQuery, + } ); + mockUseVideos.mockReturnValue( { ...baseVideosState, page: 1 } ); + const { deleteParam, update } = setupSearchParams( 'foo', '' ); + + renderHook( () => useDashboardVideos() ); + + expect( setVideosQuery ).not.toHaveBeenCalledWith( expect.objectContaining( { page: NaN } ) ); + // total > 0 and the parsed page falls back to 1, which is in range, so + // no URL reset and no store dispatch happen for this benign input. + expect( deleteParam ).not.toHaveBeenCalled(); + expect( update ).not.toHaveBeenCalled(); + expect( setVideosQuery ).not.toHaveBeenCalled(); + } ); + + it( 'placeholder IDs are stable across re-renders while fetching', () => { + mockUseVideos.mockReturnValue( { + ...baseVideosState, + isFetching: true, + page: 2, + } ); + setupSearchParams( '2', '' ); + + const { result, rerender } = renderHook( () => useDashboardVideos() ); + const firstIds = result.current.videos.map( v => v.id ); + + rerender(); + const secondIds = result.current.videos.map( v => v.id ); + + expect( firstIds.length ).toBeGreaterThan( 0 ); + expect( secondIds ).toEqual( firstIds ); + } ); +} ); diff --git a/projects/packages/wp-build-polyfills/changelog/update-admin-ui b/projects/packages/wp-build-polyfills/changelog/update-admin-ui new file mode 100644 index 000000000000..8f06ed6ec849 --- /dev/null +++ b/projects/packages/wp-build-polyfills/changelog/update-admin-ui @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update @wordpress/admin-ui to 2.0.0. diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index 031cae46a646..bba3ed667a90 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -23,7 +23,7 @@ "devDependencies": { "@automattic/jetpack-webpack-config": "workspace:*", "@wordpress/a11y": "^4.40.0", - "@wordpress/admin-ui": "^1.9.0", + "@wordpress/admin-ui": "2.0.0", "@wordpress/boot": "^0.11.0", "@wordpress/icons": "^12.0.0", "@wordpress/lazy-editor": "^1.6.1", diff --git a/projects/plugins/crm/.phan/baseline.php b/projects/plugins/crm/.phan/baseline.php index 83da7d40edec..0f978f0ae1fb 100644 --- a/projects/plugins/crm/.phan/baseline.php +++ b/projects/plugins/crm/.phan/baseline.php @@ -14,8 +14,8 @@ // PhanRedundantCondition : 410+ occurrences // PhanTypeMismatchReturn : 330+ occurrences // PhanUnextractableAnnotationElementName : 200+ occurrences - // PhanTypeMismatchArgument : 160+ occurrences // PhanPossiblyUndeclaredVariable : 150+ occurrences + // PhanTypeMismatchArgument : 150+ occurrences // PhanPluginUnreachableCode : 140+ occurrences // PhanTypePossiblyInvalidDimOffset : 120+ occurrences // PhanTypeMismatchReturnProbablyReal : 110+ occurrences @@ -33,11 +33,11 @@ // PhanTypeMismatchArgumentProbablyReal : 50+ occurrences // PhanTypeExpectedObjectPropAccess : 45+ occurrences // PhanTypeMismatchArgumentNullableInternal : 45+ occurrences - // PhanDeprecatedFunction : 40+ occurrences // PhanSuspiciousWeakTypeComparison : 40+ occurrences // PhanTypeArraySuspicious : 40+ occurrences // PhanTypeMismatchDimFetch : 40+ occurrences // PhanUndeclaredMethod : 40+ occurrences + // PhanDeprecatedFunction : 35+ occurrences // PhanPluginDuplicateAdjacentStatement : 35+ occurrences // PhanPluginSimplifyExpressionBool : 35+ occurrences // PhanTypeConversionFromArray : 30+ occurrences @@ -171,7 +171,7 @@ 'includes/ZeroBSCRM.DAL2.Mail.php' => ['PhanPluginRedundantAssignment', 'PhanTypeArraySuspiciousNullable', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchDefault'], 'includes/ZeroBSCRM.DAL3.Export.php' => ['PhanRedundantCondition'], 'includes/ZeroBSCRM.DAL3.Fields.php' => ['PhanPluginMixedKeyNoKey', 'PhanPluginUseReturnValueInternalKnown', 'PhanPossiblyUndeclaredVariable', 'PhanRedefineFunction', 'PhanRedundantCondition', 'PhanTypeMismatchArgumentInternal'], - 'includes/ZeroBSCRM.DAL3.Helpers.php' => ['PhanDeprecatedFunction', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginRedundantAssignment', 'PhanPluginUnreachableCode', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanSuspiciousValueComparison', 'PhanTypeArraySuspiciousNullable', 'PhanTypeConversionFromArray', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchDefault', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnProbablyReal', 'PhanTypeSuspiciousStringExpression', 'PhanUndeclaredVariable', 'PhanUnextractableAnnotationElementName'], + 'includes/ZeroBSCRM.DAL3.Helpers.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginRedundantAssignment', 'PhanPluginUnreachableCode', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanSuspiciousValueComparison', 'PhanTypeArraySuspiciousNullable', 'PhanTypeConversionFromArray', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchDefault', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnProbablyReal', 'PhanTypeSuspiciousStringExpression', 'PhanUndeclaredVariable', 'PhanUnextractableAnnotationElementName'], 'includes/ZeroBSCRM.DAL3.Obj.Addresses.php' => ['PhanEmptyForeach'], 'includes/ZeroBSCRM.DAL3.Obj.Companies.php' => ['PhanCommentParamWithoutRealParam', 'PhanEmptyForeach', 'PhanNoopBinaryOperator', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginDuplicateExpressionAssignmentOperation', 'PhanPluginRedundantAssignment', 'PhanPluginUnreachableCode', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanSuspiciousValueComparison', 'PhanSuspiciousWeakTypeComparison', 'PhanTypeArraySuspicious', 'PhanTypeArraySuspiciousNull', 'PhanTypeArraySuspiciousNullable', 'PhanTypeComparisonFromArray', 'PhanTypeConversionFromArray', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternalReal', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentNullableInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchDefault', 'PhanTypeMismatchDimAssignment', 'PhanTypeMismatchDimFetchNullable', 'PhanTypeMismatchForeach', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnNullable', 'PhanTypeMismatchReturnProbablyReal', 'PhanTypePossiblyInvalidDimOffset', 'PhanUndeclaredVariable', 'PhanUndeclaredVariableDim', 'PhanUnextractableAnnotationElementName'], 'includes/ZeroBSCRM.DAL3.Obj.Contacts.php' => ['PhanAccessMethodPrivate', 'PhanCommentParamWithoutRealParam', 'PhanEmptyForeach', 'PhanNoopBinaryOperator', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginDuplicateExpressionAssignmentOperation', 'PhanPluginRedundantAssignment', 'PhanPluginUnreachableCode', 'PhanPossiblyUndeclaredVariable', 'PhanRedundantCondition', 'PhanSuspiciousValueComparison', 'PhanSuspiciousWeakTypeComparison', 'PhanTypeArraySuspicious', 'PhanTypeArraySuspiciousNull', 'PhanTypeArraySuspiciousNullable', 'PhanTypeComparisonFromArray', 'PhanTypeConversionFromArray', 'PhanTypeExpectedObjectPropAccess', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentInternalProbablyReal', 'PhanTypeMismatchArgumentInternalReal', 'PhanTypeMismatchArgumentNullable', 'PhanTypeMismatchArgumentNullableInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchArgumentReal', 'PhanTypeMismatchDefault', 'PhanTypeMismatchDimFetchNullable', 'PhanTypeMismatchForeach', 'PhanTypeMismatchReturn', 'PhanTypeMismatchReturnNullable', 'PhanTypeMismatchReturnProbablyReal', 'PhanTypePossiblyInvalidDimOffset', 'PhanUndeclaredVariable', 'PhanUndeclaredVariableDim', 'PhanUnextractableAnnotationElementName'], diff --git a/projects/plugins/crm/admin/contact/contact.ajax.php b/projects/plugins/crm/admin/contact/contact.ajax.php index 1bbc5f3a75f7..5f0caecc2829 100644 --- a/projects/plugins/crm/admin/contact/contact.ajax.php +++ b/projects/plugins/crm/admin/contact/contact.ajax.php @@ -58,8 +58,7 @@ function zeroBSCRM_generateClientPortalUser() { // phpcs:ignore WordPress.Naming $m = array(); - // Perms check - if ( zeroBSCRM_permsCustomers() ) { + if ( jpcrm_perms_manage_options() ) { $email = ''; $contact_id = -1; @@ -133,8 +132,7 @@ function zeroBSCRM_AJAX_zbsPortalAction() { // phpcs:ignore WordPress.NamingConv check_ajax_referer( 'zbsportalaction-ajax-nonce', 'security' ); - // can manage users? - if ( zeroBSCRM_permsCustomers() ) { + if ( jpcrm_perms_manage_options() ) { // sanitize? $action = sanitize_text_field( $_POST['portalAction'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated,WordPress.Security.ValidatedSanitizedInput.MissingUnslash @@ -163,15 +161,9 @@ function zeroBSCRM_AJAX_zbsPortalAction() { // phpcs:ignore WordPress.NamingConv break; // Reset client portal password case 'resetpw': - // fire dal disable - $newpw = zeroBSCRM_customerPortalPWReset( $contact_id ); - - // send success + $success = zeroBSCRM_customerPortalPWReset( $contact_id ) ? 1 : 0; wp_send_json( - array( - 'success' => 1, - 'pw' => $newpw, - ), + array( 'success' => $success ), 200, JSON_UNESCAPED_SLASHES ); diff --git a/projects/plugins/crm/admin/settings/mail-delivery.ajax.php b/projects/plugins/crm/admin/settings/mail-delivery.ajax.php index bdb551f152dc..ca2423cd3b71 100644 --- a/projects/plugins/crm/admin/settings/mail-delivery.ajax.php +++ b/projects/plugins/crm/admin/settings/mail-delivery.ajax.php @@ -225,7 +225,7 @@ function zeroBSCRM_AJAX_mailDelivery_validateWPMail() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { wp_send_json( array( 'permserror' => 1 ), 403, JSON_UNESCAPED_SLASHES ); } @@ -345,7 +345,7 @@ function zeroBSCRM_AJAX_mailDelivery_validateSMTP() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { exit( 0 ); } @@ -499,7 +499,7 @@ function zeroBSCRM_AJAX_mailDelivery_validateSMTPPorts() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { exit( 0 ); } @@ -571,7 +571,7 @@ function jpcrm_ajax_mail_delivery_validate_api_oauth() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // Permission check - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { wp_send_json( array( 'permserror' => 1 ), 403, JSON_UNESCAPED_SLASHES ); } @@ -719,7 +719,7 @@ function zeroBSCRM_AJAX_mailDelivery_testEmail() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { exit( 0 ); } @@ -794,7 +794,7 @@ function zeroBSCRM_AJAX_mailDelivery_removeMailDelivery() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { exit( 0 ); } @@ -875,7 +875,7 @@ function zeroBSCRM_AJAX_mailDelivery_setMailDeliveryAsDefault() { check_ajax_referer( 'wpzbs-ajax-nonce', 'sec' ); // nonce to bounce out if not from right page // } Perms? - if ( ! zeroBSCRM_permsMailCampaigns() ) { + if ( ! jpcrm_perms_manage_options() ) { exit( 0 ); } diff --git a/projects/plugins/crm/changelog/add-crm-export-object-tags b/projects/plugins/crm/changelog/add-crm-export-object-tags new file mode 100644 index 000000000000..65331a1a6162 --- /dev/null +++ b/projects/plugins/crm/changelog/add-crm-export-object-tags @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add tags as an exportable field for contacts, companies, quotes, invoices, and transactions. diff --git a/projects/plugins/crm/changelog/fix-crm-listview-counts-sprintf b/projects/plugins/crm/changelog/fix-crm-listview-counts-sprintf new file mode 100644 index 000000000000..a89ab55eaa19 --- /dev/null +++ b/projects/plugins/crm/changelog/fix-crm-listview-counts-sprintf @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Listview: Fix "Showing X of Y items" text. diff --git a/projects/plugins/crm/changelog/fix-crm-mail_delivery_ajax_caps b/projects/plugins/crm/changelog/fix-crm-mail_delivery_ajax_caps new file mode 100644 index 000000000000..180972c27751 --- /dev/null +++ b/projects/plugins/crm/changelog/fix-crm-mail_delivery_ajax_caps @@ -0,0 +1,4 @@ +Significance: patch +Type: security + +Mail Delivery: Harden setting permissions. diff --git a/projects/plugins/crm/changelog/fix-crm-portal_pw_reset_guard b/projects/plugins/crm/changelog/fix-crm-portal_pw_reset_guard new file mode 100644 index 000000000000..4737e3ea4810 --- /dev/null +++ b/projects/plugins/crm/changelog/fix-crm-portal_pw_reset_guard @@ -0,0 +1,4 @@ +Significance: patch +Type: security + +Client Portal: Restrict various contact functions to admins. diff --git a/projects/plugins/crm/changelog/try-crm-button-wp-ui b/projects/plugins/crm/changelog/try-crm-button-wp-ui new file mode 100644 index 000000000000..ed2992d236be --- /dev/null +++ b/projects/plugins/crm/changelog/try-crm-button-wp-ui @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Internal refactor to use core WordPress UI primitives. No user-facing change. + + diff --git a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php index 839e56b315cb..2898572cd4da 100644 --- a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php +++ b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php @@ -314,6 +314,13 @@ function jpcrm_export_process_file_export() { } if ( is_array( $availObjs ) ) { + + // Bulk-fetch all tags in one go when requested. + $tag_map = array(); + if ( in_array( 'tags', $fields, true ) ) { + $tag_map = jpcrm_export_bulk_fetch_tags( $obj_type_id, array_column( $availObjs, 'id' ) ); + } + foreach ( $availObjs as $obj ) { // per obj @@ -353,6 +360,11 @@ function jpcrm_export_process_file_export() { } } + // Create pipe-separated tags field. + if ( $fK === 'tags' ) { + $v = implode( '|', $tag_map[ (int) $obj['id'] ] ?? array() ); + } + // here we account for linked objects // as of 4.1.1 this is contact/company for quote/invoice/transaction // passed in format: linked_obj_{OBJTYPEINT}_{FIELD} @@ -449,6 +461,44 @@ function jpcrm_export_process_file_export() { } add_action( 'jpcrm_post_wp_loaded', 'jpcrm_export_process_file_export' ); +/** + * Fetches tag names for a list of object IDs in a single query. + * Returns a map of [obj_id => array of tag names], for fast row-loop lookup during export. + * + * @param int $obj_type_id Object type ID. + * @param int[] $obj_ids Array of object IDs. + * + * @return array + */ +function jpcrm_export_bulk_fetch_tags( $obj_type_id, $obj_ids ) { + global $wpdb, $ZBSCRM_t; + + $tag_map = array(); + + if ( count( $obj_ids ) === 0 ) { + return $tag_map; + } + + $placeholders = implode( ',', array_fill( 0, count( $obj_ids ), '%d' ) ); + $params = array_merge( array( $obj_type_id ), $obj_ids ); + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- $ZBSCRM_t is defined in includes/ZeroBSCRM.Database.php + "SELECT tl.zbstl_objid AS obj_id, t.zbstag_name AS tag_name FROM {$ZBSCRM_t['taglinks']} tl INNER JOIN {$ZBSCRM_t['tags']} t ON t.ID = tl.zbstl_tagid WHERE tl.zbstl_objtype = %d AND tl.zbstl_objid IN ($placeholders) ORDER BY t.zbstag_name ASC", // phpcs:ignore WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $params + ) + ); + + if ( is_array( $rows ) ) { + foreach ( $rows as $row ) { + $tag_map[ (int) $row->obj_id ][] = $row->tag_name; + } + } + + return $tag_map; +} + /** * Takes an object being exported, and a linked object type, and returns the subobject * e.g. $obj could be a quote, linkedType could be contact, this would return the contact object against the quote @@ -661,6 +711,16 @@ function zeroBSCRM_export_produceAvailableFields( $objTypeToExport = false, $inc } } + // Tags pseudo-field (works for all object types via taglinks) + if ( $includeAreas ) { + $fieldsAvailable['tags'] = array( + 'label' => __( 'Tags', 'zero-bs-crm' ), + 'area' => __( 'Tags', 'zero-bs-crm' ), + ); + } else { + $fieldsAvailable['tags'] = __( 'Tags', 'zero-bs-crm' ); + } + return $fieldsAvailable; } diff --git a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Helpers.php b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Helpers.php index c030ad52bb66..f78665ba9cf8 100644 --- a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Helpers.php +++ b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Helpers.php @@ -352,7 +352,7 @@ function zeroBS_searchCustomers( $args = array(), $withMoneyData = false ) { function zeroBSCRM_customerPortalDisableEnable( $contact_id = -1, $enable_or_disable = 'disable' ) { global $zbs; - if ( zeroBSCRM_permsCustomers() && ! empty( $contact_id ) ) { + if ( jpcrm_perms_manage_options() && ! empty( $contact_id ) ) { // Verify this user can be changed. // Has to have singular role of `zerobs_customer`. This helps to avoid users changing each others accounts via crm. $wp_user_id = zeroBSCRM_getClientPortalUserID( $contact_id ); @@ -376,18 +376,19 @@ function zeroBSCRM_customerPortalPWReset( $contact_id = -1 ) { global $zbs; - if ( zeroBSCRM_permsCustomers() && ! empty( $contact_id ) ) { + if ( jpcrm_perms_manage_options() && ! empty( $contact_id ) ) { $wp_user_id = zeroBS_getCustomerWPID( $contact_id ); $contact = $zbs->DAL->contacts->getContact( $contact_id ); $contact_email = $contact['email']; - $user_object = get_userdata( $contact_email ); + $user_object = get_userdata( $wp_user_id ); if ( $wp_user_id > 0 && ! empty( $contact_email ) ) { - // Verify this user can be changed - // (Has to have singular role of `zerobs_customer`. This helps to avoid users resetting each others passwords via crm) - if ( jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { + // Verify this user can be changed. + // Has to have singular role of `zerobs_customer`. This helps to avoid + // users resetting each others passwords via the CRM. + if ( ! jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { return false; @@ -451,7 +452,7 @@ function zeroBSCRM_customerPortalPWReset( $contact_id = -1 ) { } - return $new_password; + return true; } // if wpid diff --git a/projects/plugins/crm/includes/ZeroBSCRM.MetaBoxes3.Contacts.php b/projects/plugins/crm/includes/ZeroBSCRM.MetaBoxes3.Contacts.php index 3a196c9f2df8..304eb9a87c6d 100644 --- a/projects/plugins/crm/includes/ZeroBSCRM.MetaBoxes3.Contacts.php +++ b/projects/plugins/crm/includes/ZeroBSCRM.MetaBoxes3.Contacts.php @@ -1566,8 +1566,8 @@ public function html( $contact, $metabox ) { // revoke/disable access echo ' ' . esc_html( __( 'Enabled', 'zero-bs-crm' ) ) . ''; - // wp admins get reset link, unless the crm contact is assigned to any other role than CRM Customer - if ( zeroBSCRM_isWPAdmin() && jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { + // CRM/site admins can send a reset email as long as the CRM contact only has the CRM Customer role. + if ( jpcrm_perms_manage_options() && jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { echo '
'; @@ -1580,7 +1580,7 @@ public function html( $contact, $metabox ) { } else { // explainer - rarely shown - echo '

' . esc_html__( 'The WordPress user has a role other than CRM Contact. They will need to reset their password via the WP login page.', 'zero-bs-crm' ) . '

'; + echo '

' . esc_html__( 'You cannot edit this user. Password resets must be done via the WP login page.', 'zero-bs-crm' ) . '

'; } @@ -1592,8 +1592,8 @@ public function html( $contact, $metabox ) { // enable access echo ' ' . esc_html( __( 'Disabled', 'zero-bs-crm' ) ) . ''; - // wp admins get enable link, unless the crm contact is assigned to any other role than CRM Customer - if ( zeroBSCRM_isWPAdmin() && jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { + // CRM/site admins can enable/disable Client Portal access as long as the CRM contact only has the CRM Customer role. + if ( jpcrm_perms_manage_options() && jpcrm_role_check( $user_object, array(), array(), array( 'zerobs_customer' ) ) ) { echo '
'; echo ''; @@ -1610,10 +1610,12 @@ public function html( $contact, $metabox ) { echo '
'; echo esc_html( __( 'No WordPress User exists with this email', 'zero-bs-crm' ) ); echo '

'; + if ( jpcrm_perms_manage_options() ) { echo '
'; - echo esc_html( __( 'Generate WordPress User', 'zero-bs-crm' ) ); - echo '
'; - echo ''; + echo esc_html( __( 'Generate WordPress User', 'zero-bs-crm' ) ); + echo '
'; + echo ''; + } echo '
'; } else { echo esc_html( __( 'Save your contact, or add an email to enable Client Portal functionality', 'zero-bs-crm' ) ); @@ -1710,32 +1712,21 @@ public function html( $contact, $metabox ) { dataType: "json" }); i.done(function(e) { - //console.log(e); - if(typeof e.success != "undefined"){ - - var newPassword = ''; - if (typeof e.pw != "undefined") newPassword = e.pw; + if ( e && e.success ) { - if ( newPassword !== false ){ + swal( + '', + '', + 'info' + ); - // swal confirm - swal( - '', - '
' + newPassword + '', - 'info' - ); - - } else { - - // swal confirm - swal( - '', - '', - 'info' - ); - - } + } else { + swal( + '', + '', + 'info' + ); } }), i.fail(function(e) { diff --git a/projects/plugins/crm/js/ZeroBSCRM.admin.listview.js b/projects/plugins/crm/js/ZeroBSCRM.admin.listview.js index d7fa69b9ef9c..8e98938fdfe8 100644 --- a/projects/plugins/crm/js/ZeroBSCRM.admin.listview.js +++ b/projects/plugins/crm/js/ZeroBSCRM.admin.listview.js @@ -543,8 +543,8 @@ function jpcrm_update_listview_counts() { } let html = zeroBSCRMJS_listViewLang( 'listview_counts' ); - html = html.replace( '%s', current_range ); - html = html.replace( '%s', zbsListViewCount ); + html = html.replace( '%1$s', current_range ); + html = html.replace( '%2$s', zbsListViewCount ); listview_count_els.forEach( element => ( element.textContent = html ) ); } diff --git a/projects/plugins/crm/package.json b/projects/plugins/crm/package.json index 62878223f17f..252aa461e15f 100644 --- a/projects/plugins/crm/package.json +++ b/projects/plugins/crm/package.json @@ -32,6 +32,7 @@ "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", "@wordpress/theme": "0.11.0", + "@wordpress/ui": "0.11.0", "clsx": "2.1.1", "prop-types": "15.8.1", "react": "18.3.1", diff --git a/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx b/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx index d0a8a8cf8793..d3787a3a3500 100644 --- a/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx +++ b/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx @@ -1,6 +1,7 @@ -import { Button, IconTooltip, ToggleControl } from '@automattic/jetpack-components'; +import { IconTooltip, ToggleControl } from '@automattic/jetpack-components'; import { dispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/ui'; import { useCallback } from 'react'; import { useNavigate } from 'react-router'; import { useMutateAutomationWorkflows } from 'crm/data/hooks/mutations'; @@ -89,7 +90,7 @@ export const WorkflowRow: FC< WorkflowRowProps > = props => {
- diff --git a/projects/plugins/jetpack/_inc/client/ai/style.scss b/projects/plugins/jetpack/_inc/client/ai/style.scss index 8f766b53f550..c24afae0b683 100644 --- a/projects/plugins/jetpack/_inc/client/ai/style.scss +++ b/projects/plugins/jetpack/_inc/client/ai/style.scss @@ -1,11 +1,11 @@ // phpcs:ignore -- SCSS selector, not a PHP file -.jetpack_page_ai .admin-ui-page { +.jetpack_page_ai .jp-admin-page__page { // Fill the viewport height minus the fixed 32px WP admin bar, // so the footer sits at the bottom of the visible screen area. min-height: calc(100vh - 32px); - // The fluid container (2nd direct child, between header and footer) should - // grow to fill available space, pushing the footer to the bottom. + // The fluid container (2nd direct child, between the
and footer) + // should grow to fill available space, pushing the footer to the bottom. > div:nth-child(2) { flex: 1; } diff --git a/projects/plugins/jetpack/_inc/client/components/connection-banner/index.jsx b/projects/plugins/jetpack/_inc/client/components/connection-banner/index.jsx index df2fc05795a3..2d3834959de0 100644 --- a/projects/plugins/jetpack/_inc/client/components/connection-banner/index.jsx +++ b/projects/plugins/jetpack/_inc/client/components/connection-banner/index.jsx @@ -1,5 +1,5 @@ -import { ActionButton, Notice } from '@automattic/jetpack-components'; import { __ } from '@wordpress/i18n'; +import { Notice } from '@wordpress/ui'; import PropTypes from 'prop-types'; import { Component } from 'react'; import { connect } from 'react-redux'; @@ -12,26 +12,23 @@ export class ConnectionBanner extends Component { from: PropTypes.string, }; - handleClick() { + handleClick = () => { this.props.doConnectUser( null, this.props.from ); - } + }; render() { const { description, title } = this.props; - const connectButtonProps = { - label: __( 'Connect your WordPress.com account', 'jetpack' ), - onClick: () => this.handleClick(), - }; - return ( - ] } - > - { description } - + + { title } + { description } + + + { __( 'Connect your WordPress.com account', 'jetpack' ) } + + + ); } } diff --git a/projects/plugins/jetpack/_inc/client/components/connection-banner/test/component.js b/projects/plugins/jetpack/_inc/client/components/connection-banner/test/component.js index 4495b450d3f6..73696c2641b0 100644 --- a/projects/plugins/jetpack/_inc/client/components/connection-banner/test/component.js +++ b/projects/plugins/jetpack/_inc/client/components/connection-banner/test/component.js @@ -21,9 +21,11 @@ describe( 'ConnectionBanner', () => { }; describe( 'Initially', () => { - it( 'does not pass any properties to ConnectButton', () => { + it( 'renders the connect action button', () => { render( ); - const button = screen.getByLabelText( 'Connect your WordPress.com account' ); + const button = screen.getByRole( 'button', { + name: 'Connect your WordPress.com account', + } ); expect( button ).toBeInTheDocument(); } ); } ); diff --git a/projects/plugins/jetpack/_inc/client/settings/style.scss b/projects/plugins/jetpack/_inc/client/settings/style.scss index 45264e3cb887..5aad1da719d6 100644 --- a/projects/plugins/jetpack/_inc/client/settings/style.scss +++ b/projects/plugins/jetpack/_inc/client/settings/style.scss @@ -1,7 +1,7 @@ @use "../scss/mixins/breakpoints"; @use "../scss/functions/rem"; -.jp-settings-admin-page .admin-ui-page { +.jp-settings-admin-page .jp-admin-page__page { background-color: #fcfcfc; } diff --git a/projects/plugins/jetpack/changelog/add-activity-log-package b/projects/plugins/jetpack/changelog/add-activity-log-package new file mode 100644 index 000000000000..14427e7d5ec4 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-activity-log-package @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Activity Log: replace the external sidebar redirect with a native wp-admin page — search, activity-type filter, sort, pagination, and a date-range picker. diff --git a/projects/plugins/jetpack/changelog/add-newsletter-abilities b/projects/plugins/jetpack/changelog/add-newsletter-abilities new file mode 100644 index 000000000000..7d567b64466e --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-newsletter-abilities @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Newsletter: register Abilities API surface for module settings and subscriber stats on WP 6.9+. diff --git a/projects/plugins/jetpack/changelog/add-ship-stats-abilities b/projects/plugins/jetpack/changelog/add-ship-stats-abilities new file mode 100644 index 000000000000..2e4d94177acc --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-ship-stats-abilities @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Add stats abilities diff --git a/projects/plugins/jetpack/changelog/jetpack-page-layout-admin-ui-2x b/projects/plugins/jetpack/changelog/jetpack-page-layout-admin-ui-2x new file mode 100644 index 000000000000..52235ccaa114 --- /dev/null +++ b/projects/plugins/jetpack/changelog/jetpack-page-layout-admin-ui-2x @@ -0,0 +1,4 @@ +Significance: patch +Type: bugfix + +Settings and AI pages: replace the `.admin-ui-page` selector hook (gone in admin-ui 2.0.0) with the stable `.jp-admin-page__page` className passed through by AdminPage, restoring page-specific layout overrides. diff --git a/projects/plugins/jetpack/changelog/update-components-notice-use-wp-ui b/projects/plugins/jetpack/changelog/update-components-notice-use-wp-ui new file mode 100644 index 000000000000..7631d429cb84 --- /dev/null +++ b/projects/plugins/jetpack/changelog/update-components-notice-use-wp-ui @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Internal: migrate the connection banner Notice to @wordpress/ui. diff --git a/projects/plugins/jetpack/class.jetpack.php b/projects/plugins/jetpack/class.jetpack.php index dfbcacad2a74..d06b83c30ab3 100644 --- a/projects/plugins/jetpack/class.jetpack.php +++ b/projects/plugins/jetpack/class.jetpack.php @@ -7,6 +7,7 @@ * @package automattic/jetpack */ +use Automattic\Jetpack\Activity_Log\Jetpack_Activity_Log as Activity_Log_Init; use Automattic\Jetpack\Assets; use Automattic\Jetpack\Boost_Speed_Score\Speed_Score; use Automattic\Jetpack\Config; @@ -869,6 +870,7 @@ function ( $methods ) { public function late_initialization() { add_action( 'after_setup_theme', array( 'Jetpack', 'load_modules' ), -2 ); My_Jetpack_Initializer::init(); + Activity_Log_Init::initialize(); // Initialize Boost Speed Score new Speed_Score( array(), 'jetpack-dashboard' ); diff --git a/projects/plugins/jetpack/composer.json b/projects/plugins/jetpack/composer.json index 3e2d5fd1c6a2..c4e4afbc3301 100644 --- a/projects/plugins/jetpack/composer.json +++ b/projects/plugins/jetpack/composer.json @@ -14,6 +14,7 @@ "automattic/block-delimiter": "@dev", "automattic/jetpack-a8c-mc-stats": "@dev", "automattic/jetpack-account-protection": "@dev", + "automattic/jetpack-activity-log": "@dev", "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-assets": "@dev", "automattic/jetpack-autoloader": "@dev", diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 57f29e01629b..3e32bb432fd2 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff5646ec92656db4a5e84144887bd6ab", + "content-hash": "7f4ec822d399a46053e8c16b122223c0", "packages": [ { "name": "automattic/block-delimiter", @@ -190,6 +190,72 @@ "relative": true } }, + { + "name": "automattic/jetpack-activity-log", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/activity-log", + "reference": "dc868729e923f3ba834fa42cc9a2e156b89368b8" + }, + "require": { + "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-assets": "@dev", + "automattic/jetpack-autoloader": "@dev", + "automattic/jetpack-composer-plugin": "@dev", + "automattic/jetpack-connection": "@dev", + "automattic/jetpack-status": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-activity-log", + "textdomain": "jetpack-activity-log", + "version-constants": { + "::PACKAGE_VERSION": "src/class-package-version.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-activity-log/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production-concurrently" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Activity Log UI for the Jetpack plugin in wp-admin.", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-admin-ui", "version": "dev-trunk", @@ -3050,12 +3116,13 @@ "dist": { "type": "path", "url": "../../packages/stats", - "reference": "21404943feba5a6e1b57197aa3df02cee0b413fe" + "reference": "927d43834de1e46b37cfba8bba95f4ddd7edf251" }, "require": { "automattic/jetpack-connection": "@dev", "automattic/jetpack-constants": "@dev", "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-abilities": "@dev", "php": ">=7.2" }, "require-dev": { @@ -5683,6 +5750,7 @@ "automattic/block-delimiter": 20, "automattic/jetpack-a8c-mc-stats": 20, "automattic/jetpack-account-protection": 20, + "automattic/jetpack-activity-log": 20, "automattic/jetpack-admin-ui": 20, "automattic/jetpack-assets": 20, "automattic/jetpack-autoloader": 20, diff --git a/projects/plugins/jetpack/modules/subscriptions.php b/projects/plugins/jetpack/modules/subscriptions.php index b4ddc348e0da..79425d2616eb 100644 --- a/projects/plugins/jetpack/modules/subscriptions.php +++ b/projects/plugins/jetpack/modules/subscriptions.php @@ -1116,3 +1116,6 @@ public function track_newsletter_category_creation() { require __DIR__ . '/subscriptions/subscribe-overlay/class-jetpack-subscribe-overlay.php'; require __DIR__ . '/subscriptions/subscribe-floating-button/class-jetpack-subscribe-floating-button.php'; require __DIR__ . '/subscriptions/newsletter-widget/class-jetpack-newsletter-dashboard-widget.php'; + +require_once __DIR__ . '/subscriptions/abilities/class-newsletter-abilities.php'; +\Automattic\Jetpack\Plugin\Abilities\Newsletter_Abilities::init(); diff --git a/projects/plugins/jetpack/modules/subscriptions/abilities/class-newsletter-abilities.php b/projects/plugins/jetpack/modules/subscriptions/abilities/class-newsletter-abilities.php new file mode 100644 index 000000000000..7b1853dab9d1 --- /dev/null +++ b/projects/plugins/jetpack/modules/subscriptions/abilities/class-newsletter-abilities.php @@ -0,0 +1,532 @@ + 'Jetpack Newsletter', + 'description' => __( 'Abilities for reading and updating Jetpack Newsletter settings.', 'jetpack' ), + ); + } + + /** + * Returns the abilities category, definition, or registered abilities. + * + * @inheritDoc + */ + public static function get_abilities(): array { + $settings_object_schema = array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'subscribe_post_end_enabled' => array( + 'type' => 'boolean', + 'description' => __( 'Show a "subscribe to blog" checkbox at the end of every post. Default true.', 'jetpack' ), + ), + 'subscribe_comments_enabled' => array( + 'type' => 'boolean', + 'description' => __( 'Show a "notify me of new comments" checkbox in the comment form. Default true.', 'jetpack' ), + ), + 'notify_admin_on_subscribe' => array( + 'type' => 'boolean', + 'description' => __( 'Email the site admin whenever a new subscriber signs up. Default true.', 'jetpack' ), + ), + 'reply_to' => array( + 'type' => 'string', + 'enum' => self::REPLY_TO_VALUES, + 'description' => __( 'Reply-to address for newsletter emails. "comment" routes to the post comment author, "author" to the post author, "no-reply" disables replies. Default "comment".', 'jetpack' ), + ), + 'from_name' => array( + 'type' => 'string', + 'description' => __( 'Sender name shown on newsletter emails. Empty string falls back to the site name.', 'jetpack' ), + 'maxLength' => 200, + ), + ), + ); + + return array( + 'jetpack-newsletter/get-settings' => array( + 'label' => __( 'Get Newsletter settings', 'jetpack' ), + 'description' => __( + 'Return the current Jetpack Newsletter settings as a flat object. Always returns the same five fields: subscribe_post_end_enabled (bool), subscribe_comments_enabled (bool), notify_admin_on_subscribe (bool), reply_to ("comment"|"author"|"no-reply"), and from_name (string). Read-only and idempotent. To change any value, call jetpack-newsletter/update-settings.', + 'jetpack' + ), + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array(), + 'additionalProperties' => false, + ), + 'output_schema' => $settings_object_schema, + 'execute_callback' => array( __CLASS__, 'get_settings' ), + 'permission_callback' => array( __CLASS__, 'can_view_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ), + + 'jetpack-newsletter/update-settings' => array( + 'label' => __( 'Update Newsletter settings', 'jetpack' ), + 'description' => __( + 'Update one or more Jetpack Newsletter settings. Any subset of the five fields may be supplied; omitted fields are left untouched. Idempotent — fields whose desired value already matches the current value are not rewritten. Returns { settings: , changed: }. An empty input or input matching the current state returns changed = [].', + 'jetpack' + ), + 'input_schema' => $settings_object_schema, + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'settings' => $settings_object_schema, + 'changed' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => __( 'Names of the fields that actually changed during this call. Empty when the call was a no-op.', 'jetpack' ), + ), + ), + ), + 'execute_callback' => array( __CLASS__, 'update_settings' ), + 'permission_callback' => array( __CLASS__, 'can_manage_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ), + + 'jetpack-newsletter/get-subscriber-stats' => array( + 'label' => __( 'Get Newsletter subscriber stats', 'jetpack' ), + 'description' => __( + 'Return aggregate subscriber counts for the site. Always returns { all: int, email: int, paid: int }: all is the total subscriber count (email + WordPress.com followers); email is the subset that receives email; paid is the subset on a paid newsletter plan. Numbers are fetched from WordPress.com and cached locally for one hour, so transient network errors yield a stale-but-non-zero response when one is available. Requires an active Jetpack connection — sites without one return jetpack_newsletter_not_connected.', + 'jetpack' + ), + 'input_schema' => array( + 'type' => 'object', + 'default' => array(), + 'properties' => array(), + 'additionalProperties' => false, + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'all' => array( 'type' => 'integer' ), + 'email' => array( 'type' => 'integer' ), + 'paid' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( __CLASS__, 'get_subscriber_stats' ), + 'permission_callback' => array( __CLASS__, 'can_view_settings' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ), + ); + } + + /** + * Permission check for read abilities. Newsletter settings live on the WP + * Newsletter settings screen, which is itself gated on `manage_options`. + */ + public static function can_view_settings(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Permission check for write abilities. Mirrors the gating on the + * Newsletter settings screen. + */ + public static function can_manage_settings(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Execute: return the current newsletter settings. + * + * @param array|null $input Unused — input schema accepts no parameters. + * @return array + */ + public static function get_settings( $input = null ): array { + unset( $input ); + return self::current_settings(); + } + + /** + * Transient key for the wpcom subscriber-stats response. + */ + private const SUBSCRIBER_STATS_CACHE_KEY = 'jetpack_newsletter_subscriber_stats'; + + /** + * Transient TTL for subscriber-stats responses, in seconds. + * + * Matches the existing legacy widget pattern of an hour-long cache so + * agents calling this ability repeatedly don't fan out to wpcom. + */ + private const SUBSCRIBER_STATS_CACHE_TTL = HOUR_IN_SECONDS; + + /** + * Execute: fetch (and cache) aggregate subscriber counts from WordPress.com. + * + * @param array|null $input Unused — input schema accepts no parameters. + * @return array|\WP_Error + */ + public static function get_subscriber_stats( $input = null ) { + unset( $input ); + + $cached = get_transient( self::SUBSCRIBER_STATS_CACHE_KEY ); + if ( is_array( $cached ) ) { + return $cached; + } + + if ( ! class_exists( 'Jetpack' ) || ! Jetpack::is_connection_ready() ) { + return new \WP_Error( + 'jetpack_newsletter_not_connected', + __( 'Subscriber stats are only available on Jetpack-connected sites. Connect Jetpack and retry.', 'jetpack' ) + ); + } + + $site_id = (int) Jetpack_Options::get_option( 'id' ); + if ( $site_id <= 0 ) { + return new \WP_Error( + 'jetpack_newsletter_not_connected', + __( 'No Jetpack site ID is registered. Connect Jetpack and retry.', 'jetpack' ) + ); + } + + $response = Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%d/subscribers/stats', $site_id ), + '2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return new \WP_Error( + 'jetpack_newsletter_subscriber_stats_unavailable', + $response->get_error_message() + ); + } + + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return new \WP_Error( + 'jetpack_newsletter_subscriber_stats_unavailable', + __( 'WordPress.com did not return subscriber stats. Retry shortly.', 'jetpack' ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $counts = is_array( $body ) && isset( $body['counts'] ) && is_array( $body['counts'] ) + ? $body['counts'] + : array(); + + $stats = array( + 'all' => isset( $counts['all_subscribers'] ) ? (int) $counts['all_subscribers'] : 0, + 'email' => isset( $counts['email_subscribers'] ) ? (int) $counts['email_subscribers'] : 0, + 'paid' => isset( $counts['paid_subscribers'] ) ? (int) $counts['paid_subscribers'] : 0, + ); + + set_transient( self::SUBSCRIBER_STATS_CACHE_KEY, $stats, self::SUBSCRIBER_STATS_CACHE_TTL ); + + return $stats; + } + + /** + * Execute: idempotent partial update of newsletter settings. + * + * Validates every supplied field before writing anything, so a malformed + * field cannot leave the option set in a partially-updated state. + * + * @param array|null $input Input matching the ability's input_schema. + * @return array|\WP_Error + */ + public static function update_settings( $input = null ) { + $input = is_array( $input ) ? $input : array(); + $map = self::settings_map(); + + // Validate + normalize every supplied field up-front. Any failure + // short-circuits the call with no writes, so a bad field can't leave + // earlier fields in a partially-updated state. + $normalized = array(); + foreach ( $map as $field => $config ) { + if ( ! array_key_exists( $field, $input ) ) { + continue; + } + + $result = self::normalize_input_value( $field, $config, $input[ $field ] ); + if ( $result instanceof \WP_Error ) { + return $result; + } + $normalized[ $field ] = $result; + } + + // Read every field's current value once. This pass also feeds the + // post-update response, avoiding a second `get_option` sweep. + $current_storage = array(); + foreach ( $map as $field => $config ) { + $current_storage[ $field ] = self::read_option( $config ); + } + + $changed = array(); + foreach ( $normalized as $field => $desired ) { + // String-cast on both sides because every field's storage form is + // scalar (`0`/`1` for BOOL, `'on'`/`'off'` for ON_OFF, plain strings + // for ENUM/STRING). New field types added later must keep that + // invariant or this comparison will misfire. + if ( (string) $desired === (string) $current_storage[ $field ] ) { + continue; + } + update_option( $map[ $field ]['option'], $desired ); + $current_storage[ $field ] = $desired; + $changed[] = $field; + } + + $settings = array(); + foreach ( $map as $field => $config ) { + $settings[ $field ] = self::cast_to_response( $config, $current_storage[ $field ] ); + } + + return array( + 'settings' => $settings, + 'changed' => $changed, + ); + } + + /** + * Map of public ability field name → backing option config. + * + * Storage shape (option key, type tag, default, enum). The agent-facing + * descriptions and JSON Schema live in `get_abilities()`; this map drives + * the storage-side validation, normalization, and casting. + * + * Kept as a method (not a class constant) so the description strings + * referenced from `cast_to_response()` and `normalize_input_value()` can + * resolve through `__()` at call time rather than file load time. + */ + private static function settings_map(): array { + return array( + 'subscribe_post_end_enabled' => array( + 'option' => 'stb_enabled', + 'type' => self::TYPE_BOOL, + 'default' => 1, + ), + 'subscribe_comments_enabled' => array( + 'option' => 'stc_enabled', + 'type' => self::TYPE_BOOL, + 'default' => 1, + ), + 'notify_admin_on_subscribe' => array( + 'option' => 'social_notifications_subscribe', + 'type' => self::TYPE_ON_OFF, + 'default' => 'on', + ), + 'reply_to' => array( + 'option' => 'jetpack_subscriptions_reply_to', + 'type' => self::TYPE_ENUM, + 'default' => Subscriptions_Settings::$default_reply_to, + 'enum' => self::REPLY_TO_VALUES, + ), + 'from_name' => array( + 'option' => 'jetpack_subscriptions_from_name', + 'type' => self::TYPE_STRING, + 'default' => '', + 'max_length' => 200, + ), + ); + } + + /** + * Read all settings as the public response shape. + */ + private static function current_settings(): array { + $out = array(); + foreach ( self::settings_map() as $field => $config ) { + $out[ $field ] = self::cast_to_response( $config, self::read_option( $config ) ); + } + return $out; + } + + /** + * Read the raw option for a field config, falling back to its default. + * + * @param array $config Field config from `settings_map()`. + * @return mixed + */ + private static function read_option( array $config ) { + return get_option( $config['option'], $config['default'] ); + } + + /** + * Validate + normalize a single input value to the storage form. + * + * @param string $field Public field name (used in error messages). + * @param array $config Field config from `settings_map()`. + * @param mixed $value Raw input value. + * @return mixed|\WP_Error Storage-form value, or WP_Error when invalid. + */ + private static function normalize_input_value( string $field, array $config, $value ) { + switch ( $config['type'] ) { + case self::TYPE_BOOL: + if ( ! is_bool( $value ) ) { + return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) ); + } + return $value ? 1 : 0; + + case self::TYPE_ON_OFF: + if ( ! is_bool( $value ) ) { + return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) ); + } + return $value ? 'on' : 'off'; + + case self::TYPE_ENUM: + // reply_to is the only enum today and shares its allowed-values + // list with `Subscriptions_Settings::is_valid_reply_to()`. Defer + // to that validator so the two surfaces can't drift. + $valid = 'reply_to' === $field + ? Subscriptions_Settings::is_valid_reply_to( $value ) + : ( is_string( $value ) && in_array( $value, $config['enum'], true ) ); + if ( ! $valid ) { + return self::invalid_field( + $field, + sprintf( + /* translators: %s: comma-separated list of allowed values. */ + __( 'allowed values are %s.', 'jetpack' ), + implode( ', ', $config['enum'] ) + ) + ); + } + return $value; + + case self::TYPE_STRING: + if ( ! is_string( $value ) ) { + return self::invalid_field( $field, __( 'expected a string.', 'jetpack' ) ); + } + $sanitized = sanitize_text_field( $value ); + if ( isset( $config['max_length'] ) && mb_strlen( $sanitized ) > (int) $config['max_length'] ) { + return self::invalid_field( + $field, + sprintf( + /* translators: %d: maximum number of characters. */ + __( 'must be %d characters or fewer.', 'jetpack' ), + (int) $config['max_length'] + ) + ); + } + return $sanitized; + } + + return self::invalid_field( $field, __( 'unsupported field type.', 'jetpack' ) ); + } + + /** + * Cast a stored option value to the public response shape. + * + * @param array $config Field config from `settings_map()`. + * @param mixed $value Raw stored value. + * @return mixed + */ + private static function cast_to_response( array $config, $value ) { + switch ( $config['type'] ) { + case self::TYPE_BOOL: + return 1 === (int) $value; + case self::TYPE_ON_OFF: + return 'on' === (string) $value; + case self::TYPE_ENUM: + $value = (string) $value; + return in_array( $value, $config['enum'], true ) ? $value : (string) $config['default']; + case self::TYPE_STRING: + return (string) $value; + } + return $value; + } + + /** + * Build a `jetpack_newsletter_invalid_` WP_Error with a message + * that names the field and tells the agent how to fix the input. + * + * @param string $field Public field name; appears in the error code and message. + * @param string $reason Translated explanation of the expected value. + * @return \WP_Error + */ + private static function invalid_field( string $field, string $reason ): \WP_Error { + return new \WP_Error( + 'jetpack_newsletter_invalid_' . $field, + sprintf( + /* translators: 1: field name, 2: explanation of the expected value. */ + __( 'Invalid value for "%1$s": %2$s', 'jetpack' ), + $field, + $reason + ) + ); + } +} diff --git a/projects/plugins/jetpack/tests/php/modules/subscriptions/Newsletter_Abilities_Test.php b/projects/plugins/jetpack/tests/php/modules/subscriptions/Newsletter_Abilities_Test.php new file mode 100644 index 000000000000..ea5b0dbe15d0 --- /dev/null +++ b/projects/plugins/jetpack/tests/php/modules/subscriptions/Newsletter_Abilities_Test.php @@ -0,0 +1,397 @@ +admin_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $this->subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + + // Open the gate by default; individual tests override. + // Hooks added here are reverted when WP_UnitTestCase rolls the + // per-test transaction back, so no manual remove_filter in tear_down. + add_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + } + + public function test_category_slug_is_jetpack_newsletter() { + $this->assertSame( 'jetpack-newsletter', Newsletter_Abilities::get_category_slug() ); + } + + public function test_category_definition_has_label_and_description() { + $def = Newsletter_Abilities::get_category_definition(); + $this->assertArrayHasKey( 'label', $def ); + $this->assertArrayHasKey( 'description', $def ); + $this->assertNotEmpty( $def['label'] ); + $this->assertNotEmpty( $def['description'] ); + } + + public function test_abilities_map_is_non_empty_and_namespaced() { + $abilities = Newsletter_Abilities::get_abilities(); + $this->assertNotEmpty( $abilities ); + $this->assertArrayHasKey( 'jetpack-newsletter/get-settings', $abilities ); + $this->assertArrayHasKey( 'jetpack-newsletter/update-settings', $abilities ); + $this->assertArrayHasKey( 'jetpack-newsletter/get-subscriber-stats', $abilities ); + foreach ( array_keys( $abilities ) as $slug ) { + $this->assertStringStartsWith( 'jetpack-newsletter/', $slug ); + } + } + + public function test_no_spec_sets_category_explicitly() { + // Registrar auto-injects category; specs that set it duplicate info that drifts. + foreach ( Newsletter_Abilities::get_abilities() as $slug => $spec ) { + $this->assertArrayNotHasKey( + 'category', + $spec, + "Ability {$slug} should not set its own category — Registrar injects it." + ); + } + } + + public function test_read_and_write_have_distinct_annotations() { + $abilities = Newsletter_Abilities::get_abilities(); + + $read = $abilities['jetpack-newsletter/get-settings']['meta']['annotations']; + $this->assertTrue( $read['readonly'] ); + $this->assertFalse( $read['destructive'] ); + $this->assertTrue( $read['idempotent'] ); + + $write = $abilities['jetpack-newsletter/update-settings']['meta']['annotations']; + $this->assertFalse( $write['readonly'] ); + $this->assertFalse( $write['destructive'] ); + $this->assertTrue( $write['idempotent'] ); + + $stats = $abilities['jetpack-newsletter/get-subscriber-stats']['meta']['annotations']; + $this->assertTrue( $stats['readonly'] ); + $this->assertFalse( $stats['destructive'] ); + $this->assertTrue( $stats['idempotent'] ); + } + + public function test_init_registers_nothing_when_gate_filter_is_false() { + remove_filter( 'jetpack_wp_abilities_enabled', '__return_true' ); + add_filter( 'jetpack_wp_abilities_enabled', '__return_false' ); + + Newsletter_Abilities::init(); + + $this->assertFalse( + has_action( 'wp_abilities_api_categories_init', array( Newsletter_Abilities::class, 'register_category' ) ) + ); + $this->assertFalse( + has_action( 'wp_abilities_api_init', array( Newsletter_Abilities::class, 'register_abilities' ) ) + ); + } + + public function test_init_hooks_lifecycle_actions_when_gate_is_true() { + Newsletter_Abilities::init(); + + $this->assertNotFalse( + has_action( 'wp_abilities_api_categories_init', array( Newsletter_Abilities::class, 'register_category' ) ) + ); + $this->assertNotFalse( + has_action( 'wp_abilities_api_init', array( Newsletter_Abilities::class, 'register_abilities' ) ) + ); + } + + public function test_ability_class_is_colocated_with_module() { + $reflector = new \ReflectionClass( Newsletter_Abilities::class ); + $path = $reflector->getFileName(); + $this->assertStringContainsString( + '/modules/subscriptions/', + $path, + 'Module-backed abilities must live inside their module directory, not in src/abilities/.' + ); + $this->assertStringNotContainsString( + '/src/abilities/', + $path, + 'Found a module-backed ability in the plugin-global src/abilities/ tree. Move it into modules/subscriptions/abilities/.' + ); + } + + public function test_not_wired_from_class_jetpack_php() { + $bootstrap = file_get_contents( JETPACK__PLUGIN_DIR . 'class.jetpack.php' ); + $this->assertStringNotContainsString( + 'Newsletter_Abilities::init()', + $bootstrap, + 'class.jetpack.php must not init a module-backed ability. Wire from modules/subscriptions.php instead.' + ); + } + + public function test_wired_from_subscriptions_module_file() { + $module = file_get_contents( JETPACK__PLUGIN_DIR . 'modules/subscriptions.php' ); + $this->assertStringContainsString( + 'Newsletter_Abilities::init()', + $module, + 'Newsletter_Abilities must be initialized from modules/subscriptions.php so registration is gated by module activation.' + ); + } + + public function test_can_view_allows_admin() { + wp_set_current_user( $this->admin_id ); + $this->assertTrue( Newsletter_Abilities::can_view_settings() ); + } + + public function test_can_view_denies_subscriber() { + wp_set_current_user( $this->subscriber_id ); + $this->assertFalse( Newsletter_Abilities::can_view_settings() ); + } + + public function test_can_view_denies_anonymous() { + wp_set_current_user( 0 ); + $this->assertFalse( Newsletter_Abilities::can_view_settings() ); + } + + public function test_can_manage_allows_admin() { + wp_set_current_user( $this->admin_id ); + $this->assertTrue( Newsletter_Abilities::can_manage_settings() ); + } + + public function test_can_manage_denies_subscriber() { + wp_set_current_user( $this->subscriber_id ); + $this->assertFalse( Newsletter_Abilities::can_manage_settings() ); + } + + public function test_get_settings_returns_all_fields() { + $result = Newsletter_Abilities::get_settings(); + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'subscribe_post_end_enabled', $result ); + $this->assertArrayHasKey( 'subscribe_comments_enabled', $result ); + $this->assertArrayHasKey( 'notify_admin_on_subscribe', $result ); + $this->assertArrayHasKey( 'reply_to', $result ); + $this->assertArrayHasKey( 'from_name', $result ); + } + + public function test_get_settings_reflects_stored_options() { + update_option( 'stb_enabled', 0 ); + update_option( 'social_notifications_subscribe', 'off' ); + update_option( 'jetpack_subscriptions_reply_to', 'author' ); + update_option( 'jetpack_subscriptions_from_name', 'My Sender' ); + + $result = Newsletter_Abilities::get_settings(); + $this->assertFalse( $result['subscribe_post_end_enabled'] ); + $this->assertFalse( $result['notify_admin_on_subscribe'] ); + $this->assertSame( 'author', $result['reply_to'] ); + $this->assertSame( 'My Sender', $result['from_name'] ); + } + + public function test_update_settings_with_empty_input_is_noop() { + $result = Newsletter_Abilities::update_settings( array() ); + $this->assertIsArray( $result ); + $this->assertSame( array(), $result['changed'] ); + $this->assertArrayHasKey( 'settings', $result ); + } + + public function test_update_settings_writes_only_changed_fields() { + update_option( 'jetpack_subscriptions_reply_to', 'comment' ); + update_option( 'jetpack_subscriptions_from_name', 'Old Name' ); + + $result = Newsletter_Abilities::update_settings( + array( + 'reply_to' => 'author', + 'from_name' => 'Old Name', // unchanged + ) + ); + + $this->assertSame( array( 'reply_to' ), $result['changed'] ); + $this->assertSame( 'author', $result['settings']['reply_to'] ); + $this->assertSame( 'Old Name', $result['settings']['from_name'] ); + $this->assertSame( 'author', get_option( 'jetpack_subscriptions_reply_to' ) ); + } + + public function test_update_settings_is_idempotent() { + Newsletter_Abilities::update_settings( array( 'reply_to' => 'no-reply' ) ); + $second = Newsletter_Abilities::update_settings( array( 'reply_to' => 'no-reply' ) ); + + $this->assertSame( array(), $second['changed'], 'Second call with the same desired state must be a no-op.' ); + $this->assertSame( 'no-reply', $second['settings']['reply_to'] ); + } + + public function test_update_settings_translates_booleans_to_storage_form() { + Newsletter_Abilities::update_settings( + array( + 'subscribe_post_end_enabled' => false, + 'notify_admin_on_subscribe' => false, + ) + ); + + // Booleans persist as 1/0 for stb_enabled, 'on'/'off' for social_notifications_subscribe. + $this->assertSame( '0', (string) get_option( 'stb_enabled' ) ); + $this->assertSame( 'off', get_option( 'social_notifications_subscribe' ) ); + + // And the public response casts them back to bool. + $result = Newsletter_Abilities::get_settings(); + $this->assertFalse( $result['subscribe_post_end_enabled'] ); + $this->assertFalse( $result['notify_admin_on_subscribe'] ); + } + + public function test_update_settings_rejects_invalid_reply_to() { + $result = Newsletter_Abilities::update_settings( array( 'reply_to' => 'bogus' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_newsletter_invalid_reply_to', $result->get_error_code() ); + } + + public function test_update_settings_rejects_non_boolean_for_boolean_field() { + $result = Newsletter_Abilities::update_settings( array( 'subscribe_post_end_enabled' => 'yes' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'jetpack_newsletter_invalid_subscribe_post_end_enabled', $result->get_error_code() ); + } + + public function test_update_settings_does_not_partial_write_on_validation_error() { + // from_name is valid; reply_to is invalid → expect the whole call to fail with no writes. + $before_from_name = get_option( 'jetpack_subscriptions_from_name', '' ); + + $result = Newsletter_Abilities::update_settings( + array( + 'from_name' => 'Should Not Persist', + 'reply_to' => 'bogus', + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( + $before_from_name, + get_option( 'jetpack_subscriptions_from_name', '' ), + 'Validation errors must short-circuit before any update_option call.' + ); + } + + public function test_update_settings_sanitizes_from_name() { + Newsletter_Abilities::update_settings( + array( + 'from_name' => " My Sender \n", + ) + ); + + // sanitize_text_field strips tags, collapses whitespace, trims. + $stored = get_option( 'jetpack_subscriptions_from_name' ); + $this->assertStringNotContainsString( '