From e00e00e174ee5fa65844af6ae1d791d28e7e9918 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 16:22:41 +0100 Subject: [PATCH 01/44] Activity Log: Phase 0 package scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new projects/packages/activity-log/ package mirroring the Backup package's structure, and wire it into the main Jetpack plugin's late_initialization() so it registers its admin page and REST namespace on every request. Phase 0 only lays the foundation: a placeholder Admin component renders at admin.php?page=jetpack-activity-log, the jetpack/v4/activity-log REST namespace is reserved (no handlers yet), and the menu item is gated the same way the existing my-jetpack "Activity Log ↗" item is (connected user + non-multisite + manage_options). The old my-jetpack Cloud-redirect menu item is intentionally left in place for this phase — Phase 1 removes it. Refs #48242. --- pnpm-lock.yaml | 76 ++++++++ projects/packages/activity-log/.babelrc | 12 ++ projects/packages/activity-log/.gitattributes | 15 ++ projects/packages/activity-log/.gitignore | 6 + .../packages/activity-log/.phan/config.php | 13 ++ projects/packages/activity-log/.phpcs.dir.xml | 24 +++ projects/packages/activity-log/README.md | 15 ++ projects/packages/activity-log/actions.php | 24 +++ .../packages/activity-log/babel.config.js | 10 ++ .../changelog/add-package-scaffold | 4 + projects/packages/activity-log/composer.json | 76 ++++++++ projects/packages/activity-log/package.json | 55 ++++++ .../activity-log/src/class-initial-state.php | 63 +++++++ .../src/class-jetpack-activity-log.php | 162 ++++++++++++++++++ .../src/class-package-version.php | 35 ++++ .../src/class-rest-controller.php | 51 ++++++ .../activity-log/src/js/components/Admin.jsx | 21 +++ .../packages/activity-log/src/js/index.js | 34 ++++ projects/packages/activity-log/tsconfig.json | 4 + projects/packages/activity-log/types.d.ts | 4 + .../packages/activity-log/webpack.config.js | 56 ++++++ .../changelog/add-activity-log-package | 4 + projects/plugins/jetpack/class.jetpack.php | 2 + projects/plugins/jetpack/composer.json | 1 + projects/plugins/jetpack/composer.lock | 72 +++++++- 25 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 projects/packages/activity-log/.babelrc create mode 100644 projects/packages/activity-log/.gitattributes create mode 100644 projects/packages/activity-log/.gitignore create mode 100644 projects/packages/activity-log/.phan/config.php create mode 100644 projects/packages/activity-log/.phpcs.dir.xml create mode 100644 projects/packages/activity-log/README.md create mode 100644 projects/packages/activity-log/actions.php create mode 100644 projects/packages/activity-log/babel.config.js create mode 100644 projects/packages/activity-log/changelog/add-package-scaffold create mode 100644 projects/packages/activity-log/composer.json create mode 100644 projects/packages/activity-log/package.json create mode 100644 projects/packages/activity-log/src/class-initial-state.php create mode 100644 projects/packages/activity-log/src/class-jetpack-activity-log.php create mode 100644 projects/packages/activity-log/src/class-package-version.php create mode 100644 projects/packages/activity-log/src/class-rest-controller.php create mode 100644 projects/packages/activity-log/src/js/components/Admin.jsx create mode 100644 projects/packages/activity-log/src/js/index.js create mode 100644 projects/packages/activity-log/tsconfig.json create mode 100644 projects/packages/activity-log/types.d.ts create mode 100644 projects/packages/activity-log/webpack.config.js create mode 100644 projects/plugins/jetpack/changelog/add-activity-log-package diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce82504586c..33e2c2623e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1831,6 +1831,82 @@ importers: projects/packages/account-protection: {} + projects/packages/activity-log: + dependencies: + '@automattic/jetpack-components': + specifier: workspace:* + version: link:../../js-packages/components + '@automattic/jetpack-connection': + specifier: workspace:* + version: link:../../js-packages/connection + '@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/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) + 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/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': diff --git a/projects/packages/activity-log/.babelrc b/projects/packages/activity-log/.babelrc new file mode 100644 index 000000000000..9e9ed77c53ff --- /dev/null +++ b/projects/packages/activity-log/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "current" + } + } + ] + ] +} diff --git a/projects/packages/activity-log/.gitattributes b/projects/packages/activity-log/.gitattributes new file mode 100644 index 000000000000..43e1b87b5811 --- /dev/null +++ b/projects/packages/activity-log/.gitattributes @@ -0,0 +1,15 @@ +# Files not needed to be distributed. +.babelrc export-ignore +.gitattributes export-ignore +.github/ export-ignore +.gitignore export-ignore + +# Files not needed in the production build. +.phpcs.dir.xml production-exclude +/changelog/** production-exclude +/tests/** production-exclude +types.d.ts production-exclude +/src/js/** production-exclude + +# Files needed in the production build. +build/** production-include diff --git a/projects/packages/activity-log/.gitignore b/projects/packages/activity-log/.gitignore new file mode 100644 index 000000000000..346f5b0e15f4 --- /dev/null +++ b/projects/packages/activity-log/.gitignore @@ -0,0 +1,6 @@ +wordpress +node_modules +vendor +jetpack_vendor +.cache +build diff --git a/projects/packages/activity-log/.phan/config.php b/projects/packages/activity-log/.phan/config.php new file mode 100644 index 000000000000..7e5e0a0d7a8d --- /dev/null +++ b/projects/packages/activity-log/.phan/config.php @@ -0,0 +1,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/activity-log/README.md b/projects/packages/activity-log/README.md new file mode 100644 index 000000000000..9d8d1250d291 --- /dev/null +++ b/projects/packages/activity-log/README.md @@ -0,0 +1,15 @@ +# Activity Log + +Activity Log UI for the Jetpack plugin in wp-admin. + +## Using this package in your WordPress plugin + +If you plan on using this package in your WordPress plugin, we would recommend that you use [Jetpack Autoloader](https://packagist.org/packages/automattic/jetpack-autoloader) as your autoloader. This will allow for maximum interoperability with other plugins that use this package as well. + +## Security + +Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic). + +## License + +jetpack-activity-log is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) diff --git a/projects/packages/activity-log/actions.php b/projects/packages/activity-log/actions.php new file mode 100644 index 000000000000..af2754cca2e2 --- /dev/null +++ b/projects/packages/activity-log/actions.php @@ -0,0 +1,24 @@ + $accepted_args, + 'function' => $cb, + ); + }; +} + +// Set up package version hook. +$add_filter( 'jetpack_package_versions', 'Automattic\\Jetpack\\Activity_Log\\Package_Version::send_package_version_to_tracker' ); diff --git a/projects/packages/activity-log/babel.config.js b/projects/packages/activity-log/babel.config.js new file mode 100644 index 000000000000..c7d8a7f3fe38 --- /dev/null +++ b/projects/packages/activity-log/babel.config.js @@ -0,0 +1,10 @@ +const config = { + presets: [ + [ + '@automattic/jetpack-webpack-config/babel/preset', + { pluginReplaceTextdomain: { textdomain: 'jetpack-activity-log' } }, + ], + ], +}; + +module.exports = config; diff --git a/projects/packages/activity-log/changelog/add-package-scaffold b/projects/packages/activity-log/changelog/add-package-scaffold new file mode 100644 index 000000000000..6f4857d1521b --- /dev/null +++ b/projects/packages/activity-log/changelog/add-package-scaffold @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial scaffold for the Activity Log package that will host the in-wp-admin Activity Log UI. diff --git a/projects/packages/activity-log/composer.json b/projects/packages/activity-log/composer.json new file mode 100644 index 000000000000..2450a7076c18 --- /dev/null +++ b/projects/packages/activity-log/composer.json @@ -0,0 +1,76 @@ +{ + "name": "automattic/jetpack-activity-log", + "description": "Activity Log UI for the Jetpack plugin in wp-admin.", + "type": "jetpack-library", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.2", + "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" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "yoast/phpunit-polyfills": "^4.0.0", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "autoload": { + "files": [ + "actions.php" + ], + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production-concurrently" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "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" + } + }, + "config": { + "allow-plugins": { + "roots/wordpress-core-installer": true, + "automattic/jetpack-autoloader": true, + "automattic/jetpack-composer-plugin": true + } + } +} diff --git a/projects/packages/activity-log/package.json b/projects/packages/activity-log/package.json new file mode 100644 index 000000000000..9247636f2178 --- /dev/null +++ b/projects/packages/activity-log/package.json @@ -0,0 +1,55 @@ +{ + "private": true, + "description": "Activity Log UI for the Jetpack plugin in wp-admin.", + "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/activity-log/#readme", + "bugs": { + "url": "https://github.com/Automattic/jetpack/labels/[Package] Activity Log" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/packages/activity-log" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic", + "scripts": { + "build": "pnpm run clean && pnpm run build-client", + "build-client": "webpack", + "build-production-concurrently": "pnpm run clean && concurrently 'NODE_ENV=production BABEL_ENV=production pnpm run build-client' && pnpm run validate", + "clean": "rm -rf build/", + "typecheck": "tsgo --noEmit", + "validate": "pnpm exec validate-es build/", + "watch": "pnpm run build && webpack watch" + }, + "browserslist": [ + "extends @wordpress/browserslist-config" + ], + "dependencies": { + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-connection": "workspace:*", + "@tanstack/react-query": "5.90.8", + "@wordpress/api-fetch": "7.44.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.44.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@automattic/jetpack-base-styles": "workspace:*", + "@automattic/jetpack-webpack-config": "workspace:*", + "@babel/core": "7.29.0", + "@babel/preset-env": "7.29.2", + "@babel/runtime": "7.29.2", + "@types/react": "18.3.28", + "@typescript/native-preview": "7.0.0-dev.20260225.1", + "@wordpress/browserslist-config": "6.44.0", + "concurrently": "9.2.1", + "sass-embedded": "1.97.3", + "sass-loader": "16.0.5", + "webpack": "5.105.2", + "webpack-cli": "6.0.1" + } +} diff --git a/projects/packages/activity-log/src/class-initial-state.php b/projects/packages/activity-log/src/class-initial-state.php new file mode 100644 index 000000000000..ccf88d515f39 --- /dev/null +++ b/projects/packages/activity-log/src/class-initial-state.php @@ -0,0 +1,63 @@ + array( + 'WP_API_root' => esc_url_raw( rest_url() ), + 'WP_API_nonce' => wp_create_nonce( 'wp_rest' ), + ), + 'jetpackStatus' => array( + 'calypsoSlug' => ( new Status() )->get_site_suffix(), + ), + 'siteData' => array( + 'id' => Jetpack_Options::get_option( 'id' ), + 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), + 'adminUrl' => esc_url( admin_url() ), + ), + 'assets' => array( + 'buildUrl' => plugins_url( '../build/', __FILE__ ), + ), + ); + } + + /** + * Render the initial state into a JavaScript variable. + * + * @return string + */ + public function render() { + return 'var JPACTIVITYLOG_INITIAL_STATE=' . wp_json_encode( $this->get_data(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';'; + } +} diff --git a/projects/packages/activity-log/src/class-jetpack-activity-log.php b/projects/packages/activity-log/src/class-jetpack-activity-log.php new file mode 100644 index 000000000000..03f26106ec1f --- /dev/null +++ b/projects/packages/activity-log/src/class-jetpack-activity-log.php @@ -0,0 +1,162 @@ +is_user_connected(); + } + + /** + * Fires when the admin page is loaded. + */ + public static function admin_init() { + add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) ); + } + + /** + * Enqueue the admin bundle and seed initial state. + */ + public static function enqueue_admin_scripts() { + Assets::register_script( + self::SCRIPT_HANDLE, + '../build/index.js', + __FILE__, + array( + 'in_footer' => true, + 'textdomain' => 'jetpack-activity-log', + ) + ); + Assets::enqueue_script( self::SCRIPT_HANDLE ); + + wp_add_inline_script( self::SCRIPT_HANDLE, ( new Activity_Log_Initial_State() )->render(), 'before' ); + Connection_Initial_State::render_script( self::SCRIPT_HANDLE ); + } + + /** + * Render the admin page root node. React mounts into this element. + */ + public static function render_page() { + ?> +
+ +

{ __( 'Activity Log', 'jetpack-activity-log' ) }

+

+ { __( + 'Activity Log is being ported into Jetpack. The full UI will appear here soon.', + 'jetpack-activity-log' + ) } +

+ + ); +} diff --git a/projects/packages/activity-log/src/js/index.js b/projects/packages/activity-log/src/js/index.js new file mode 100644 index 000000000000..8841e1b60850 --- /dev/null +++ b/projects/packages/activity-log/src/js/index.js @@ -0,0 +1,34 @@ +import { ThemeProvider } from '@automattic/jetpack-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as WPElement from '@wordpress/element'; +import Admin from './components/Admin'; + +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: Infinity, + }, + }, +} ); + +/** + * Initial render function. + */ +function render() { + const container = document.getElementById( 'jetpack-activity-log-root' ); + + if ( null === container ) { + return; + } + + const component = ( + + + + + + ); + WPElement.createRoot( container ).render( component ); +} + +render(); diff --git a/projects/packages/activity-log/tsconfig.json b/projects/packages/activity-log/tsconfig.json new file mode 100644 index 000000000000..2a129589160d --- /dev/null +++ b/projects/packages/activity-log/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "jetpack-js-tools/tsconfig.base.json", + "include": [ "./src/js", "types.d.ts" ] +} diff --git a/projects/packages/activity-log/types.d.ts b/projects/packages/activity-log/types.d.ts new file mode 100644 index 000000000000..4333ece78302 --- /dev/null +++ b/projects/packages/activity-log/types.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: string; + export default content; +} diff --git a/projects/packages/activity-log/webpack.config.js b/projects/packages/activity-log/webpack.config.js new file mode 100644 index 000000000000..c7f5956e0dcc --- /dev/null +++ b/projects/packages/activity-log/webpack.config.js @@ -0,0 +1,56 @@ +const path = require( 'path' ); +const jetpackWebpackConfig = require( '@automattic/jetpack-webpack-config/webpack' ); + +module.exports = [ + { + entry: { + index: './src/js/index.js', + }, + mode: jetpackWebpackConfig.mode, + devtool: jetpackWebpackConfig.devtool, + output: { + ...jetpackWebpackConfig.output, + path: path.resolve( './build' ), + }, + optimization: { + ...jetpackWebpackConfig.optimization, + }, + resolve: { + ...jetpackWebpackConfig.resolve, + }, + node: false, + plugins: [ ...jetpackWebpackConfig.StandardPlugins() ], + module: { + strictExportPresence: true, + rules: [ + // Transpile JavaScript + jetpackWebpackConfig.TranspileRule( { + exclude: /node_modules\//, + } ), + + // Transpile @automattic/jetpack-* in node_modules too. + jetpackWebpackConfig.TranspileRule( { + includeNodeModules: [ '@automattic/jetpack-' ], + } ), + + // Workarounds for non-extracted `@wordpress/*` packages. + ...jetpackWebpackConfig.BundledWpPkgsTranspileRules(), + + // Handle CSS. + jetpackWebpackConfig.CssRule( { + extensions: [ 'css', 'sass', 'scss' ], + extraLoaders: [ { loader: 'sass-loader', options: { api: 'modern-compiler' } } ], + } ), + + // Handle images. + jetpackWebpackConfig.FileRule(), + ], + }, + externals: { + ...jetpackWebpackConfig.externals, + jetpackConfig: JSON.stringify( { + consumer_slug: 'jetpack-activity-log', + } ), + }, + }, +]; 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..a34d6ac7cb94 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-activity-log-package @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Wire the new Activity Log package so it registers its admin page and REST namespace during plugin bootstrap. diff --git a/projects/plugins/jetpack/class.jetpack.php b/projects/plugins/jetpack/class.jetpack.php index 98ebef2295df..ac956ac2cf2b 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\V0001\Jetpack_Activity_Log as Activity_Log_Init; use Automattic\Jetpack\Assets; use Automattic\Jetpack\Boost_Speed_Score\Speed_Score; use Automattic\Jetpack\Config; @@ -866,6 +867,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 53bf87f18888..dc47335a6e69 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 abd9e5421f62..da814a891d3b 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": "4fd91d0e9458bade498f43254d740fd2", + "content-hash": "fd07c60eb4a335c419a71e5685f9828b", "packages": [ { "name": "automattic/block-delimiter", @@ -193,6 +193,75 @@ "relative": true } }, + { + "name": "automattic/jetpack-activity-log", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/activity-log", + "reference": "60da5f505adc949e2e2e4ad152bf93fc31d5cad7" + }, + "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": { + "files": [ + "actions.php" + ], + "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", @@ -6561,6 +6630,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, From 9ec5f2198f7dbd370ef24513da0368669b46d835 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 16:42:06 +0100 Subject: [PATCH 02/44] Activity Log: Phase 1 menu swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the legacy "Activity Log ↗" Cloud-redirect item that my-jetpack was registering. The Phase 0 package now solely owns the menu entry at position 14; no more duplicate entries, no more ↗ arrow. Changes: - Delete projects/packages/my-jetpack/src/class-activitylog.php and its test. Remove the Activitylog::init() call from the package's Initializer. The class had one job (register an external-redirect menu item) and that job is gone. - On WPCOM hosts, jetpack-mu-wpcom's wpcom-admin-menu now hides the new jetpack-activity-log slug instead of the now-defunct cloud-activity-log-wp-menu redirect URL, preserving the existing WPCOM behavior where wordpress.com/activity-log/ is the active link on those sites. Self-hosted users: single "Activity Log" item, routes to the local (placeholder) admin page. WPCOM-hosted users: single "Activity Log" item, routes to wordpress.com/activity-log (unchanged from before this PR). Refs #48242. --- .../changelog/hide-activity-log-native | 4 + .../wpcom-admin-menu/wpcom-admin-menu.php | 5 +- .../changelog/remove-activitylog-menu | 4 + .../my-jetpack/src/class-activitylog.php | 58 ------------- .../my-jetpack/src/class-initializer.php | 3 - .../my-jetpack/tests/php/Activitylog_Test.php | 84 ------------------- 6 files changed, 11 insertions(+), 147 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/hide-activity-log-native create mode 100644 projects/packages/my-jetpack/changelog/remove-activitylog-menu delete mode 100644 projects/packages/my-jetpack/src/class-activitylog.php delete mode 100644 projects/packages/my-jetpack/tests/php/Activitylog_Test.php diff --git a/projects/packages/jetpack-mu-wpcom/changelog/hide-activity-log-native b/projects/packages/jetpack-mu-wpcom/changelog/hide-activity-log-native new file mode 100644 index 000000000000..b1b786ad74e2 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/hide-activity-log-native @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +wpcom-admin-menu: hide the new jetpack-activity-log submenu on WPCOM hosts so the direct wordpress.com/activity-log link wins. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index aed36460d337..f299e5fdf496 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -430,8 +430,9 @@ function () { ); } - // Jetpack > Activity Log. - wpcom_hide_submenu_page( 'jetpack', esc_url( Redirect::get_url( 'cloud-activity-log-wp-menu', array( 'site' => $blog_id ) ) ) ); + // Jetpack > Activity Log. On WPCOM hosts we prefer the direct wordpress.com/activity-log link + // below; hide the native Jetpack Activity Log page added by the `jetpack-activity-log` package. + wpcom_hide_submenu_page( 'jetpack', 'jetpack-activity-log' ); add_submenu_page( 'jetpack', /** "Activity Log" is a product name, do not translate. */ diff --git a/projects/packages/my-jetpack/changelog/remove-activitylog-menu b/projects/packages/my-jetpack/changelog/remove-activitylog-menu new file mode 100644 index 000000000000..5d53d2ef7c12 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/remove-activitylog-menu @@ -0,0 +1,4 @@ +Significance: patch +Type: removed + +Drop the legacy Activity Log menu registration; the new activity-log package now owns that menu item. diff --git a/projects/packages/my-jetpack/src/class-activitylog.php b/projects/packages/my-jetpack/src/class-activitylog.php deleted file mode 100644 index fecdce9ca310..000000000000 --- a/projects/packages/my-jetpack/src/class-activitylog.php +++ /dev/null @@ -1,58 +0,0 @@ -is_user_connected() ) { - return; - } - - // Do not display the menu on Multisite. - if ( is_multisite() ) { - return; - } - - $args = array(); - - $blog_id = Connection_Manager::get_site_id( true ); - if ( $blog_id ) { - $args = array( 'site' => $blog_id ); - } - - return Admin_Menu::add_menu( - /** "Activity Log" is a product name, do not translate. */ - 'Activity Log', - 'Activity Log ', - 'manage_options', - esc_url( Redirect::get_url( 'cloud-activity-log-wp-menu', $args ) ), - null, - 14 - ); - } -} diff --git a/projects/packages/my-jetpack/src/class-initializer.php b/projects/packages/my-jetpack/src/class-initializer.php index 0de1137ff005..61f6d97932b1 100644 --- a/projects/packages/my-jetpack/src/class-initializer.php +++ b/projects/packages/my-jetpack/src/class-initializer.php @@ -104,9 +104,6 @@ public static function init() { // Sets up JITMS. JITM::configure(); - // Add "Activity Log" menu item. - Activitylog::init(); - // Add "Jetpack Manage" menu item. Jetpack_Manage::init(); diff --git a/projects/packages/my-jetpack/tests/php/Activitylog_Test.php b/projects/packages/my-jetpack/tests/php/Activitylog_Test.php deleted file mode 100644 index b07248c307de..000000000000 --- a/projects/packages/my-jetpack/tests/php/Activitylog_Test.php +++ /dev/null @@ -1,84 +0,0 @@ -admin_id = wp_insert_user( - array( - 'user_login' => 'dummy_user', - 'user_pass' => 'dummy_pass', - 'role' => 'administrator', - ) - ); - - $this->editor_id = wp_insert_user( - array( - 'user_login' => 'dummy_user_2', - 'user_pass' => 'dummy_pass_2', - 'role' => 'editor', - ) - ); - wp_set_current_user( 0 ); - } - - /** - * Tear down after each test. - */ - public function tear_down() { - wp_set_current_user( 0 ); - } - - /** - * Test that the menu is not added when on multisite. - */ - public function test_add_submenu_jetpack_multisite() { - if ( is_multisite() ) { - $this->assertFalse( Activitylog::add_submenu_jetpack() ); - } - - $this->assertNotFalse( Activitylog::add_submenu_jetpack() ); - } - - /** - * Test that the menu doesn't appear for non-admins. - */ - public function test_add_submenu_jetpack_editor() { - wp_set_current_user( $this->editor_id ); - - $this->assertNull( Activitylog::add_submenu_jetpack() ); - } - - /** - * Test that the menu appears for admins. - */ - public function test_add_submenu_jetpack_admin() { - wp_set_current_user( $this->admin_id ); - - $this->assertNotFalse( Activitylog::add_submenu_jetpack() ); - } -} From a543e4faae755555b5665d558e22e8e3fc883661 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 16:52:24 +0100 Subject: [PATCH 03/44] Activity Log: Phase 2 REST surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Phase 0 REST_Controller stub with real handlers backing the two endpoints the Calypso Dashboard Activity Log UI actually calls: GET /jetpack/v4/activity-log GET /jetpack/v4/activity-log/count/group Both are thin proxies to wpcom/v2 /sites/{blog_id}/activity[/count/group] via Client::wpcom_json_api_request_as_blog(), and they return the raw WPCOM response shape so the ported @automattic/api-core fetcher in Phase 3 keeps its existing `response.current.orderedItems → activityLogs` transform unchanged. Permission callback is `manage_options`, matching the menu gate. The Calypso UI has no single-event lookup, so this phase does not add /activity-log/{id} — a trimmed scope from the original plan in #48242. Accepted query params match Calypso's ActivityLogParams 1:1: number, page, sort_order, after, before, group[], not_group[], text_search (plus the group-counts subset). Unknown params are dropped server-side, so the UI can forward its filter state verbatim. Refs #48242. --- .../activity-log/changelog/add-rest-endpoints | 4 + .../src/class-rest-controller.php | 217 +++++++++++++++++- 2 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-rest-endpoints diff --git a/projects/packages/activity-log/changelog/add-rest-endpoints b/projects/packages/activity-log/changelog/add-rest-endpoints new file mode 100644 index 000000000000..adceb8d3d09b --- /dev/null +++ b/projects/packages/activity-log/changelog/add-rest-endpoints @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add GET /jetpack/v4/activity-log and /jetpack/v4/activity-log/count/group as thin WPCOM proxies backing the Activity Log UI. diff --git a/projects/packages/activity-log/src/class-rest-controller.php b/projects/packages/activity-log/src/class-rest-controller.php index 46958a0c6717..d5710cec711c 100644 --- a/projects/packages/activity-log/src/class-rest-controller.php +++ b/projects/packages/activity-log/src/class-rest-controller.php @@ -2,8 +2,9 @@ /** * The Activity Log REST Controller. * - * Registers the `/jetpack/v4/activity-log/*` routes that back the admin - * UI. Phase 0 reserves the namespace; concrete endpoints land in Phase 2. + * Registers the `/jetpack/v4/activity-log/*` routes backing the admin + * UI. Each route is a thin proxy to the corresponding WPCOM v2 + * endpoint, authenticated with the site's blog token. * * @package automattic/jetpack-activity-log */ @@ -14,7 +15,20 @@ // are installed, or in some other cases). namespace Automattic\Jetpack\Activity_Log\V0001; +use Automattic\Jetpack\Connection\Client; +use Jetpack_Options; +use WP_Error; +use WP_REST_Request; +use WP_REST_Server; use function current_user_can; +use function esc_html__; +use function http_build_query; +use function is_wp_error; +use function json_decode; +use function register_rest_route; +use function rest_ensure_response; +use function wp_remote_retrieve_body; +use function wp_remote_retrieve_response_code; /** * REST routes for the Activity Log UI. @@ -28,24 +42,213 @@ class REST_Controller { */ const REST_NAMESPACE = 'jetpack/v4'; + /** + * Query params accepted by the list endpoint. Shape matches Calypso's + * ActivityLogParams so the ported UI can forward its filter state + * verbatim. + * + * @return array + */ + private static function list_args() { + return array( + 'number' => array( + 'description' => __( 'Number of items to return per page.', 'jetpack-activity-log' ), + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 1000, + ), + 'page' => array( + 'description' => __( '1-indexed page number.', 'jetpack-activity-log' ), + 'type' => 'integer', + 'minimum' => 1, + ), + 'sort_order' => array( + 'description' => __( 'Sort direction.', 'jetpack-activity-log' ), + 'type' => 'string', + 'enum' => array( 'asc', 'desc' ), + ), + 'after' => array( + 'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'before' => array( + 'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'group' => array( + 'description' => __( 'Only return events in these groups.', 'jetpack-activity-log' ), + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'not_group' => array( + 'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ), + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'text_search' => array( + 'description' => __( 'Full-text search string.', 'jetpack-activity-log' ), + 'type' => 'string', + ), + ); + } + + /** + * Query params accepted by the group-counts endpoint. A subset of the + * list params — no pagination or sort, no text search. + * + * @return array + */ + private static function group_counts_args() { + return array( + 'number' => array( + 'description' => __( 'Cap on the number of events considered when counting groups.', 'jetpack-activity-log' ), + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 1000, + ), + 'after' => array( + 'description' => __( 'ISO 8601 lower bound on event timestamp.', 'jetpack-activity-log' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'before' => array( + 'description' => __( 'ISO 8601 upper bound on event timestamp.', 'jetpack-activity-log' ), + 'type' => 'string', + 'format' => 'date-time', + ), + 'group' => array( + 'description' => __( 'Only count events in these groups.', 'jetpack-activity-log' ), + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'not_group' => array( + 'description' => __( 'Exclude events in these groups.', 'jetpack-activity-log' ), + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ); + } + /** * Register the Activity Log REST routes. * * Hooked on `rest_api_init` by {@see Jetpack_Activity_Log::initialize()}. */ public static function register_rest_routes() { - // Routes land in Phase 2: - // - GET /jetpack/v4/activity-log list with filters + pagination - // - GET /jetpack/v4/activity-log/counts counts per group for filter UI - // - GET /jetpack/v4/activity-log/{id} single event lookup + register_rest_route( + self::REST_NAMESPACE, + '/activity-log', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_activity_log' ), + 'permission_callback' => array( __CLASS__, 'permissions_callback' ), + 'args' => self::list_args(), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/activity-log/count/group', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_activity_log_group_counts' ), + 'permission_callback' => array( __CLASS__, 'permissions_callback' ), + 'args' => self::group_counts_args(), + ) + ); } /** - * Permission callback for Activity Log endpoints. + * Permission callback. Mirrors the menu gating — any admin on a + * non-multisite install with a user connection can read the log. * * @return bool */ public static function permissions_callback() { return current_user_can( 'manage_options' ); } + + /** + * Proxy the paginated activity list. + * + * @param WP_REST_Request $request Request. + * @return mixed + */ + public static function get_activity_log( WP_REST_Request $request ) { + return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) ); + } + + /** + * Proxy the group-counts endpoint. + * + * @param WP_REST_Request $request Request. + * @return mixed + */ + public static function get_activity_log_group_counts( WP_REST_Request $request ) { + return self::proxy_get( '/activity/count/group', $request, array_keys( self::group_counts_args() ) ); + } + + /** + * Shared helper: forward whitelisted query params from $request to the + * equivalent WPCOM v2 path under `/sites/{blog_id}`. + * + * @param string $wpcom_path Path relative to the site, starting with "/". + * @param WP_REST_Request $request Incoming request. + * @param array $allowed_keys Params to forward. Any unset keys are dropped. + * @return mixed Decoded JSON response from WPCOM, or WP_Error on failure. + */ + private static function proxy_get( $wpcom_path, WP_REST_Request $request, array $allowed_keys ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( + 'activity_log_not_connected', + esc_html__( 'This site is not connected to WordPress.com.', 'jetpack-activity-log' ), + array( 'status' => 400 ) + ); + } + + $params = array(); + foreach ( $allowed_keys as $key ) { + $value = $request->get_param( $key ); + if ( $value !== null ) { + $params[ $key ] = $value; + } + } + + $path = sprintf( '/sites/%d%s', (int) $blog_id, $wpcom_path ); + if ( ! empty( $params ) ) { + $path .= '?' . http_build_query( $params ); + } + + $response = Client::wpcom_json_api_request_as_blog( + $path, + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'activity_log_request_failed', + $response->get_error_message(), + array( 'status' => 500 ) + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( 200 !== $status ) { + return new WP_Error( + 'activity_log_request_failed', + isset( $body['message'] ) ? (string) $body['message'] : esc_html__( 'Unable to fetch activity log.', 'jetpack-activity-log' ), + array( 'status' => $status ? $status : 500 ) + ); + } + + return rest_ensure_response( $body ); + } } From 8dcfc8fcd16ea16debcb1a75318110a69b974f66 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:02:44 +0100 Subject: [PATCH 04/44] Activity Log: Sign REST proxies as the user, not the blog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 2 endpoints were signing with the blog token via `wpcom_json_api_request_as_blog()`, and WPCOM's `/sites/{id}/activity` rejects that with "Only Administrators can query information about the current site." — the upstream endpoint needs to know *which* admin is asking. Switch to `wpcom_json_api_request_as_user()` (matching the existing `/jetpack/v4/site/activity` proxy in `class.core-rest-api-endpoints.php:2041`), pass API version `'2'`, and forward the visitor IP via `X-Forwarded-For` for parity. Also tighten the permission callback: admins without a user-level WPCOM connection now get a clear 403 with `activity_log_user_not_connected` instead of a confusing forwarded "Only Administrators…" error. Refs #48242. --- .../activity-log/changelog/fix-rest-user-auth | 4 ++ .../src/class-rest-controller.php | 38 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 projects/packages/activity-log/changelog/fix-rest-user-auth diff --git a/projects/packages/activity-log/changelog/fix-rest-user-auth b/projects/packages/activity-log/changelog/fix-rest-user-auth new file mode 100644 index 000000000000..0b62c6b595a6 --- /dev/null +++ b/projects/packages/activity-log/changelog/fix-rest-user-auth @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +REST proxies now sign requests as the current user (not the blog) so WPCOM can verify the admin's identity. Permission callback also requires a user-level WPCOM connection. diff --git a/projects/packages/activity-log/src/class-rest-controller.php b/projects/packages/activity-log/src/class-rest-controller.php index d5710cec711c..c20113eb5097 100644 --- a/projects/packages/activity-log/src/class-rest-controller.php +++ b/projects/packages/activity-log/src/class-rest-controller.php @@ -16,6 +16,8 @@ namespace Automattic\Jetpack\Activity_Log\V0001; use Automattic\Jetpack\Connection\Client; +use Automattic\Jetpack\Connection\Manager as Connection_Manager; +use Automattic\Jetpack\Status\Visitor; use Jetpack_Options; use WP_Error; use WP_REST_Request; @@ -162,12 +164,28 @@ public static function register_rest_routes() { /** * Permission callback. Mirrors the menu gating — any admin on a - * non-multisite install with a user connection can read the log. + * non-multisite install with a user-level WPCOM connection can read + * the log. A user-level connection is required because the upstream + * WPCOM endpoint is user-gated (it needs to identify *which* admin + * is asking); signing as the blog gets rejected with "Only + * Administrators can query information about the current site." * - * @return bool + * @return bool|WP_Error */ public static function permissions_callback() { - return current_user_can( 'manage_options' ); + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + + if ( ! ( new Connection_Manager() )->is_user_connected() ) { + return new WP_Error( + 'activity_log_user_not_connected', + esc_html__( 'Your WordPress.com account is not connected to this site. Connect it to use the Activity Log.', 'jetpack-activity-log' ), + array( 'status' => 403 ) + ); + } + + return true; } /** @@ -222,10 +240,18 @@ private static function proxy_get( $wpcom_path, WP_REST_Request $request, array $path .= '?' . http_build_query( $params ); } - $response = Client::wpcom_json_api_request_as_blog( + // Sign as the current user, not the blog: the upstream /sites/{id}/activity + // endpoint checks that a specific admin is asking. Forward the visitor IP + // so WPCOM logs match the existing /jetpack/v4/site/activity proxy. + $response = Client::wpcom_json_api_request_as_user( $path, - 'v2', - array(), + '2', + array( + 'method' => 'GET', + 'headers' => array( + 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ), + ), + ), null, 'wpcom' ); From a54e0512d8e7014261426361da781eeb07ec6f90 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:27:52 +0100 Subject: [PATCH 05/44] =?UTF-8?q?Activity=20Log:=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20port=20the=20DataViews=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported Calypso Dashboard's `client/dashboard/sites/logs-activity/dataviews/` into the activity-log package. The admin page now renders a real table wired to the Phase 2 REST endpoints via `@wordpress/api-fetch`: - DataViews table (search, activity-type filter, sort, pagination) - ActivityActor + ActivityEvent cell renderers (avatar, gridicon→WP icon map, activity title + formatted description) - Simplified FormattedBlock renderer: text decorators (strong/em/pre/ filepath) + Link when `range.url` is present. Entity renderers (post/comment/person/plugin/theme/backup) render children-only because those Calypso routes don't resolve inside wp-admin. - Local TanStack Query factories (activityLogQuery, activityLogGroupCountsQuery) that mirror the Calypso api-core fetcher shapes (including the `current.orderedItems → activityLogs` unwrap), so future ports stay aligned. - DataViews stylesheet imported via Sass (it's a bundled WP package in jetpack-webpack-config, not externalized — same pattern as `projects/packages/forms/routes/shared.scss`). - `jetpack-admin-page-layout` mixin scoped to the Activity Log body class. - Initial_State seeds `gmtOffset`, `timezoneString`, `slug`, `locale` for date-cell formatting. Deliberate scope simplifications vs. Calypso (each tracked in the PR body): - No date range picker (was a sibling control in the parent logs shell). - No URL-persistent view state. - No analytics. - No tier gating / upsell → Phase 4. - Backup row action stubbed (disabled) → Phase 5. Refs #48242. --- pnpm-lock.yaml | 12 + .../activity-log/changelog/add-phase-3-ui | 4 + projects/packages/activity-log/package.json | 4 + .../activity-log/src/class-initial-state.php | 17 +- .../components/ActivityLog/ActivityActor.tsx | 150 ++++++++++ .../components/ActivityLog/ActivityEvent.tsx | 42 +++ .../src/js/components/ActivityLog/actions.tsx | 46 +++ .../ActivityLog/activity-actor.scss | 21 ++ .../ActivityLog/activity-event.scss | 23 ++ .../ActivityLog/activity-transformer.ts | 88 ++++++ .../src/js/components/ActivityLog/fields.tsx | 273 ++++++++++++++++++ .../src/js/components/ActivityLog/filters.ts | 16 + .../ActivityLog/formatted-block/index.tsx | 127 ++++++++ .../ActivityLog/formatted-block/parser.ts | 259 +++++++++++++++++ .../ActivityLog/formatted-block/types.ts | 29 ++ .../js/components/ActivityLog/gridicons.ts | 79 +++++ .../src/js/components/ActivityLog/index.tsx | 166 +++++++++++ .../src/js/components/ActivityLog/types.ts | 123 ++++++++ .../src/js/components/ActivityLog/views.ts | 21 ++ .../activity-log/src/js/components/Admin.jsx | 21 -- .../src/js/hooks/use-activity-log.ts | 95 ++++++ .../packages/activity-log/src/js/index.js | 5 +- .../packages/activity-log/src/js/style.scss | 25 ++ 23 files changed, 1620 insertions(+), 26 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-phase-3-ui create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/actions.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/filters.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/gridicons.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/index.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/types.ts create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/views.ts delete mode 100644 projects/packages/activity-log/src/js/components/Admin.jsx create mode 100644 projects/packages/activity-log/src/js/hooks/use-activity-log.ts create mode 100644 projects/packages/activity-log/src/js/style.scss diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33e2c2623e98..3e2da299b99a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1848,6 +1848,15 @@ importers: '@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 @@ -1860,6 +1869,9 @@ importers: '@wordpress/icons': specifier: 12.2.0 version: 12.2.0(react@18.3.1) + fast-deep-equal: + specifier: 3.1.3 + version: 3.1.3 react: specifier: 18.3.1 version: 18.3.1 diff --git a/projects/packages/activity-log/changelog/add-phase-3-ui b/projects/packages/activity-log/changelog/add-phase-3-ui new file mode 100644 index 000000000000..a4b7fa5636b7 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-phase-3-ui @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Port the Calypso Dashboard Activity Log UI as a DataViews-based admin page. Search, activity-type filter, sort, pagination wired to /jetpack/v4/activity-log. Phase 4 tier gating and Phase 5 backup deep-links follow. diff --git a/projects/packages/activity-log/package.json b/projects/packages/activity-log/package.json index 9247636f2178..0d73c8869207 100644 --- a/projects/packages/activity-log/package.json +++ b/projects/packages/activity-log/package.json @@ -30,10 +30,14 @@ "@tanstack/react-query": "5.90.8", "@wordpress/api-fetch": "7.44.0", "@wordpress/components": "32.6.0", + "@wordpress/compose": "7.44.0", + "@wordpress/data": "10.44.0", + "@wordpress/dataviews": "14.1.0", "@wordpress/date": "5.44.0", "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", + "fast-deep-equal": "3.1.3", "react": "18.3.1", "react-dom": "18.3.1" }, diff --git a/projects/packages/activity-log/src/class-initial-state.php b/projects/packages/activity-log/src/class-initial-state.php index ccf88d515f39..e000f3d8b71c 100644 --- a/projects/packages/activity-log/src/class-initial-state.php +++ b/projects/packages/activity-log/src/class-initial-state.php @@ -17,11 +17,14 @@ use function esc_url; use function esc_url_raw; use function get_bloginfo; +use function get_locale; +use function get_option; use function get_site_url; use function plugins_url; use function rest_url; use function wp_create_nonce; use function wp_json_encode; +use function wp_parse_url; /** * The Activity Log React initial state. @@ -33,6 +36,10 @@ class Initial_State { * @return array */ private function get_data() { + $gmt_offset = get_option( 'gmt_offset' ); + $timezone_string = get_option( 'timezone_string' ); + $home_host = wp_parse_url( get_site_url(), PHP_URL_HOST ); + return array( 'API' => array( 'WP_API_root' => esc_url_raw( rest_url() ), @@ -42,9 +49,13 @@ private function get_data() { 'calypsoSlug' => ( new Status() )->get_site_suffix(), ), 'siteData' => array( - 'id' => Jetpack_Options::get_option( 'id' ), - 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), - 'adminUrl' => esc_url( admin_url() ), + 'id' => Jetpack_Options::get_option( 'id' ), + 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), + 'adminUrl' => esc_url( admin_url() ), + 'slug' => is_string( $home_host ) ? $home_host : '', + 'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0, + 'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '', + 'locale' => str_replace( '_', '-', (string) get_locale() ), ), 'assets' => array( 'buildUrl' => plugins_url( '../build/', __FILE__ ), diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx new file mode 100644 index 000000000000..4b2eae85688e --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx @@ -0,0 +1,150 @@ +import { __experimentalHStack as HStack, Icon } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { __, sprintf } from '@wordpress/i18n'; +import { commentAuthorAvatar, globe, wordpress } from '@wordpress/icons'; +import type { ActivityActorDetails } from './types'; +import type { ReactNode } from 'react'; +import './activity-actor.scss'; + +const ICON_SIZE = 24; + +/** + * Build the short "via " string shown under the actor name + * when the originating agent was an MCP client. + * + * @param actor - The actor details for the current log row. + * @return The formatted string, or null if the actor isn't an MCP agent. + */ +function getMcpIndicator( actor?: ActivityActorDetails ): string | null { + if ( ! actor?.isMcpAgent ) { + return null; + } + return actor.mcpClient + ? sprintf( + /* translators: %s: MCP client name and version */ + __( 'via %s (MCP)', 'jetpack-activity-log' ), + actor.mcpClient + ) + : __( 'via MCP', 'jetpack-activity-log' ); +} + +/** + * Decide the icon + label for a given actor. Matches Calypso's mapping so + * WordPress / Jetpack / Server / Happiness Engineer / avatar branches render + * the same thing users already recognize. + * + * @param actor - The actor details for the current log row. + * @return Icon element (or null) + display label. + */ +function getActorPresentation( actor?: ActivityActorDetails ): { icon: ReactNode; label: string } { + let actorName = __( 'Unknown', 'jetpack-activity-log' ); + + if ( ! actor ) { + return { icon: null, label: actorName }; + } + + const { actorName: name, actorType, actorAvatarUrl } = actor; + actorName = name || actorName; + + switch ( actorType ) { + case 'Application': + if ( name === 'WordPress' ) { + return { + icon: ( + + ), + label: name, + }; + } + if ( name === 'Jetpack' || name === 'Jetpack Boost' ) { + return { + icon: ( + + ), + label: name, + }; + } + break; + case 'Person': + if ( name === 'Server' ) { + return { + icon: ( + + ), + label: __( 'Server', 'jetpack-activity-log' ), + }; + } + break; + case 'Happiness Engineer': + return { + icon: ( + + ), + label: __( 'Happiness Engineer', 'jetpack-activity-log' ), + }; + } + + if ( actorAvatarUrl ) { + return { + icon: ( + { + ), + label: actorName, + }; + } + + return { + icon: ( + + ), + label: actorName, + }; +} + +/** + * DataViews cell renderer for the "User" column. Shows the actor's avatar + * (or a branded icon for system actors) next to their name. + * + * @param props - Component props. + * @param props.actor - Actor details for the current log row. + * @return The actor cell. + */ +export function ActivityActor( { actor }: { actor?: ActivityActorDetails } ) { + const { icon, label } = getActorPresentation( actor ); + const mcpIndicator = getMcpIndicator( actor ); + + return ( + + { icon } + + { label } + { mcpIndicator && { mcpIndicator } } + + + ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx new file mode 100644 index 000000000000..bb98c40ab165 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx @@ -0,0 +1,42 @@ +import { __experimentalHStack as HStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { Icon } from '@wordpress/icons'; +import { renderFormattedContent } from './formatted-block'; +import { gridiconToWordPressIcon } from './gridicons'; +import type { Activity } from './types'; +import './activity-event.scss'; + +/** + * DataViews cell renderer for the "Event" column. Composes the gridicon, + * activity title, and formatted description into the row's main content. + * + * @param props - Component props. + * @param props.activity - Normalized Activity for the current log row. + * @return The event cell. + */ +export function ActivityEvent( { activity }: { activity: Activity } ) { + const { activityDescription, activityIcon, activityTitle } = activity; + const formattedContent = activityDescription.items.length + ? renderFormattedContent( { items: activityDescription.items } ) + : null; + + return ( + + { activityIcon && ( + + ) } + + { activityTitle } + { formattedContent && { formattedContent } } + + + ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/actions.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/actions.tsx new file mode 100644 index 000000000000..90b604bf6a32 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/actions.tsx @@ -0,0 +1,46 @@ +import { Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { backup } from '@wordpress/icons'; +import { useMemo } from 'react'; +import type { Activity } from './types'; +import type { Action } from '@wordpress/dataviews'; + +type UseActivityActionsOptions = { + isLoading: boolean; +}; + +/** + * Row actions for the DataViews table. Phase 5 wires the "Manage backup" + * action into the Backup package's admin page; for now the action is + * present but disabled so the column space is preserved and the planned + * feature is visible. + * + * @param options - Hook options. + * @param options.isLoading - Whether the list is currently fetching. Kept + * in the API so Phase 5 doesn't need to refactor + * the call site. + * @return The actions array for ``. + */ +export function useActivityActions( { + isLoading, +}: UseActivityActionsOptions ): Action< Activity >[] { + return useMemo( () => { + const backupAction: Action< Activity > = { + id: 'backup', + isPrimary: true, + label: __( 'Manage backup', 'jetpack-activity-log' ), + icon: , + // Phase 5: enable and deep-link into the Backup package's admin page. + disabled: true, + isEligible: item => item.activityIsRewindable, + callback: async () => { + /* no-op until Phase 5 */ + }, + }; + + // Keep the flag referenced so the param isn't flagged as unused. + void isLoading; + + return [ backupAction ]; + }, [ isLoading ] ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss new file mode 100644 index 000000000000..c5bc5e2f726a --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss @@ -0,0 +1,21 @@ +.site-activity-logs__actor { + + > [class^="site-activity-logs__actor-icon-"] { + border-radius: 50%; + height: 24px; + width: 24px; + } + + .site-activity-logs__actor-icon-server, + .site-activity-logs__actor-icon-default { + background-color: #dcdcde; + fill: #50575e; + } + + .site-activity-logs__actor-mcp { + display: block; + font-size: 0.75rem; + color: #757575; + margin-block-start: 2px; + } +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss new file mode 100644 index 000000000000..8886a753765f --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss @@ -0,0 +1,23 @@ +.site-activity-logs__event-icon { + fill: #50575e; + background-color: #f0f0f1; + border-radius: 2px; + padding: 4px; +} + +.site-activity-logs__event { + color: #1e1e1e; +} + +.site-activity-logs__event-content { + flex: 1 0 0; + flex-wrap: wrap; + + > span { + color: #50575e; + } +} + +.site-activity-logs__event-title { + font-weight: 500; +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts new file mode 100644 index 000000000000..0cf1c185f2ff --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts @@ -0,0 +1,88 @@ +import parseActivityLogEntryContent from './formatted-block/parser'; +import type { + Activity, + ActivityLogEntry, + ActivityLogEntryImage, + ActivityMediaDetails, +} from './types'; + +const parseTimestamp = ( published?: string ): number => { + if ( ! published ) { + return 0; + } + const timestamp = Date.parse( published ); + return Number.isNaN( timestamp ) ? 0 : timestamp; +}; + +const normalizeActivityMedia = ( image?: ActivityLogEntryImage | null ): ActivityMediaDetails => { + if ( ! image ) { + return { + available: false, + medium_url: '', + name: '', + thumbnail_url: '', + type: '', + url: '', + }; + } + + return { + available: Boolean( image.available ), + medium_url: image.medium_url ?? '', + name: image.name ?? '', + thumbnail_url: image.thumbnail_url ?? '', + type: image.type ?? '', + url: image.url ?? '', + }; +}; + +/** + * Transform an ActivityLogEntry (raw WPCOM shape) into an Activity (UI shape). + * + * @param entry - Raw entry from the /jetpack/v4/activity-log endpoint. + * @return Normalized Activity for the DataViews table. + */ +export const transformActivityLogEntry = ( entry: ActivityLogEntry ): Activity => { + const { + content, + actor, + image, + gridicon, + activity_id: rawActivityId, + name, + status, + summary, + published, + is_rewindable, + rewind_id, + } = entry; + const descriptionItems = parseActivityLogEntryContent( content ); + const textDescription = content?.text ?? ''; + + return { + activityDescription: { + textDescription, + items: descriptionItems, + }, + activityIcon: gridicon, + activityId: rawActivityId, + activityMedia: normalizeActivityMedia( image ), + activityName: name, + activityStatus: status ?? '', + activityTitle: summary, + activityUnparsedTs: published ?? '', + activityTs: parseTimestamp( published ), + activityActor: { + actorAvatarUrl: actor?.icon?.url, + actorName: actor?.name, + actorRole: actor?.role, + actorType: actor?.type, + isCli: actor?.is_cli, + isSupport: actor?.is_happiness, + isMcpAgent: actor?.is_mcp_agent, + mcpClient: actor?.mcp_client, + }, + activityIsRewindable: Boolean( is_rewindable ), + rewindId: rewind_id || undefined, + }; +}; diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx new file mode 100644 index 000000000000..eb7da6803511 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx @@ -0,0 +1,273 @@ +import { useViewportMatch } from '@wordpress/compose'; +import { dateI18n } from '@wordpress/date'; +import { __, sprintf } from '@wordpress/i18n'; +import { useMemo } from 'react'; +import { ActivityActor } from './ActivityActor'; +import { ActivityEvent } from './ActivityEvent'; +import type { Activity, ActivityLogGroupCountResponse } from './types'; +import type { Field, Operator } from '@wordpress/dataviews'; + +export type ActivityLogTypeOption = { + value: string; + label: string; +}; + +type UseActivityFieldsArgs = { + timezoneString?: string; + gmtOffset?: number; + activityLogTypes?: ActivityLogGroupCountResponse[ 'groups' ] | undefined; +}; + +/** + * Extract the leading "group" segment from an event name like + * "plugin__updated" → "plugin". Used both as the DataViews filter value + * and to look up a human-readable description from the group-counts + * payload. + * + * @param name - Raw event name from the API (e.g. "plugin__updated"). + * @return The group slug, or an empty string if no name was given. + */ +const getActivityLogTypeSlugFromName = ( name?: string ): string => { + if ( ! name ) { + return ''; + } + const [ group ] = name.split( '__' ); + return group ?? ''; +}; + +/** + * Resolve a user-facing description for an event name by looking the + * leading group segment up in the group-counts response. Falls back to + * the slug itself when the lookup misses. + * + * @param name - Raw event name. + * @param activityLogTypes - Group map from /activity-log/count/group. + * @return Display label (e.g. "Plugins and Themes") or the slug. + */ +const getActivityLogTypeDescriptionFromName = ( + name?: string, + activityLogTypes?: ActivityLogGroupCountResponse[ 'groups' ] | undefined +): string => { + if ( ! name ) { + return ''; + } + const slug = getActivityLogTypeSlugFromName( name ); + return activityLogTypes?.[ slug ]?.name ?? slug; +}; + +/** + * Format a numeric hour offset (e.g. `-5`, `5.5`) as "UTC±HH:MM". + * + * @param gmtOffset - Hour offset from UTC, decimal hours. + * @return The formatted offset string. + */ +const formatUtcOffset = ( gmtOffset: number ): string => { + const sign = gmtOffset < 0 ? '-' : '+'; + const abs = Math.abs( gmtOffset ); + const hours = Math.floor( abs ); + const minutes = Math.round( ( abs - hours ) * 60 ); + return `UTC${ sign }${ String( hours ).padStart( 2, '0' ) }:${ String( minutes ).padStart( + 2, + '0' + ) }`; +}; + +/** + * Compute the date column header. Includes the site's timezone (on wide + * screens when we know it) or its UTC offset. + * + * @param args - Inputs. + * @param args.timezoneString - IANA timezone (e.g. "Europe/London"). + * @param args.gmtOffset - Decimal hour offset from UTC. + * @param args.isLargeScreen - True when the viewport is wide enough to + * show the full timezone name. + * @return The header label for the date column. + */ +const getDateTimeLabel = ( { + timezoneString, + gmtOffset, + isLargeScreen, +}: { + timezoneString?: string; + gmtOffset?: number; + isLargeScreen: boolean; +} ): string => { + /* translators: %s is the site's timezone (e.g., "Europe/London") or UTC offset (e.g., "UTC+02:00") */ + const template = __( 'Date & time (%s)', 'jetpack-activity-log' ); + if ( timezoneString && isLargeScreen ) { + return sprintf( template, timezoneString ); + } + if ( typeof gmtOffset === 'number' ) { + return sprintf( template, formatUtcOffset( gmtOffset ) ); + } + return __( 'Date & time', 'jetpack-activity-log' ); +}; + +/** + * Format a single date cell value, honoring the site's timezone preference + * and (optionally) forcing a UTC rendering for the parallel "UTC" column. + * + * @param args - Inputs. + * @param args.value - ISO string or unix-seconds timestamp. + * @param args.timezoneString - IANA timezone (e.g. "Europe/London"). + * @param args.gmtOffset - Decimal hour offset from UTC. + * @param args.formatAsUTC - True to render in UTC regardless of the + * site preference. + * @return The formatted date string. + */ +const formatDateCell = ( { + timezoneString, + gmtOffset, + value, + formatAsUTC, +}: { + timezoneString?: string; + gmtOffset?: number; + value?: string | number; + formatAsUTC?: boolean; +} ): string => { + if ( ! value ) { + return ''; + } + const dateFormat = 'M j, Y \\a\\t g:i A'; + const date = typeof value === 'number' ? new Date( value * 1000 ) : new Date( value ); + if ( formatAsUTC ) { + return dateI18n( dateFormat, date, 'UTC' ); + } + if ( timezoneString ) { + return dateI18n( dateFormat, date, timezoneString ); + } + if ( typeof gmtOffset === 'number' ) { + // `@wordpress/date` accepts the offset in minutes when passed a number; + // translate the site's hour-offset accordingly. + return dateI18n( dateFormat, date, gmtOffset * 60 ); + } + return dateI18n( dateFormat, date ); +}; + +/** + * Build the DataViews `fields` array for the Activity Log table: the + * Date & time column (optionally paired with a UTC column when the site + * isn't already on UTC), the Event cell, the User cell, and the hidden + * `activity_type` field that powers the filter dropdown. + * + * @param args - Hook options. + * @param args.timezoneString - IANA timezone (e.g. "Europe/London"). + * @param args.gmtOffset - Decimal hour offset from UTC. + * @param args.activityLogTypes - Group map from /activity-log/count/group. + * @return The fields array passed to ``. + */ +export function useActivityFields( { + timezoneString, + gmtOffset, + activityLogTypes, +}: UseActivityFieldsArgs ): Field< Activity >[] { + const isLargeScreen = useViewportMatch( 'huge', '>=' ); + const dateTimeLabel = getDateTimeLabel( { timezoneString, gmtOffset, isLargeScreen } ); + const localIsUTC = gmtOffset === 0; + + const activityLogTypeElements = useMemo< ActivityLogTypeOption[] >( () => { + if ( ! activityLogTypes ) { + return []; + } + return Object.entries( activityLogTypes ) + .map( ( [ value, { name, count } ] ) => ( { + value, + label: `${ name } (${ count })`, + } ) ) + .sort( ( a, b ) => a.label.localeCompare( b.label ) ); + }, [ activityLogTypes ] ); + + return useMemo( () => { + const fields: Field< Activity >[] = [ + { + id: 'published', + type: 'datetime', + label: dateTimeLabel, + enableHiding: true, + enableSorting: true, + getValue: ( { item } ) => item.activityUnparsedTs, + render: ( { item } ) => ( + + { formatDateCell( { + value: item.activityUnparsedTs, + timezoneString, + gmtOffset, + } ) } + + ), + filterBy: { operators: [] }, + }, + ]; + + if ( ! localIsUTC ) { + fields.push( { + id: 'published_utc', + type: 'datetime', + label: __( 'Date & time (UTC)', 'jetpack-activity-log' ), + enableHiding: true, + enableSorting: true, + getValue: ( { item } ) => item.activityUnparsedTs, + render: ( { item } ) => ( + + { formatDateCell( { + value: item.activityUnparsedTs, + timezoneString, + gmtOffset, + formatAsUTC: true, + } ) } + + ), + filterBy: { operators: [] }, + } ); + } + + fields.push( + { + id: 'event', + type: 'text', + label: __( 'Event', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => + `${ item.activityTitle }: ${ item.activityDescription.textDescription }`, + render: ( { item } ) => , + filterBy: { operators: [] }, + }, + { + id: 'actor', + type: 'text', + label: __( 'User', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => + item.activityActor?.actorName || __( 'Unknown', 'jetpack-activity-log' ), + render: ( { item } ) => , + filterBy: { operators: [] }, + }, + { + id: 'activity_type', + type: 'text', + label: __( 'Activity type', 'jetpack-activity-log' ), + getValue: ( { item } ) => getActivityLogTypeSlugFromName( item.activityName ), + render: ( { item } ) => ( + + { getActivityLogTypeDescriptionFromName( item.activityName, activityLogTypes ) } + + ), + elements: activityLogTypeElements, + isVisible: () => false, + filterBy: { operators: [ 'isAny' as Operator ] }, + } + ); + + return fields; + }, [ + timezoneString, + gmtOffset, + dateTimeLabel, + activityLogTypeElements, + activityLogTypes, + localIsUTC, + ] ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/filters.ts b/projects/packages/activity-log/src/js/components/ActivityLog/filters.ts new file mode 100644 index 000000000000..0b0cffd29159 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/filters.ts @@ -0,0 +1,16 @@ +import type { Filter } from '@wordpress/dataviews'; + +export const extractActivityLogTypeValues = ( filters: Filter[] ): string[] => { + const filter = filters.find( item => item.field === 'activity_type' ); + if ( ! filter ) { + return []; + } + const { value } = filter; + if ( Array.isArray( value ) ) { + return value.filter( ( item ): item is string => typeof item === 'string' && item.length > 0 ); + } + if ( typeof value === 'string' && value.length > 0 ) { + return [ value ]; + } + return []; +}; diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx new file mode 100644 index 000000000000..01a1844cc110 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx @@ -0,0 +1,127 @@ +/** + * Renders the structured tokens produced by the parser. + * + * Ported (simplified) from Calypso's logs-activity-formatted-block. In the + * wp-admin context we can't resolve Calypso routes like /reader/blogs/…, + * /people/edit/…, or /plugins/… — those renderers fall through to their + * children (plain strong text). Direct URL ranges (release notes, docs) still + * render as external links, which covers the common case visible in the + * screenshot (e.g. "Gutenberg 23.0.0 ↗"). + */ +import { ExternalLink } from '@wordpress/components'; +import { Fragment, type MouseEvent, type ReactNode } from 'react'; +import type { ActivityBlockContent, ActivityBlockMeta, ActivityBlockNode } from './types'; + +type BlockClickHandler = ( event: MouseEvent< HTMLAnchorElement > ) => void; + +type BlockRenderer = ( args: { + content: ActivityBlockNode; + children: ReactNode[]; + onClick: BlockClickHandler | undefined; + meta: ActivityBlockMeta; +} ) => ReactNode; + +interface FormattedBlockProps { + content: ActivityBlockContent; + onClick: BlockClickHandler | undefined; + meta: ActivityBlockMeta; +} + +const Strong = ( { children }: { children: ReactNode } ) => { children }; +const Emphasis = ( { children }: { children: ReactNode } ) => { children }; +const Preformatted = ( { children }: { children: ReactNode } ) =>
{ children }
; +const FilePath = ( { children }: { children: ReactNode } ) => ( +
+ { children } +
+); + +const Link: BlockRenderer = ( { content, children, onClick, meta } ) => { + const { url, activity, section, intent } = content; + + if ( ! url ) { + return { children }; + } + + return ( + + { children } + + ); +}; + +// Entity renderers (post/comment/person/plugin/theme/backup) render children +// only — the in-admin equivalents would need the plugin itself (e.g. Backup) +// to expose a known route, which we don't have a generic hook for yet. +const EntityAsStrong: BlockRenderer = ( { children } ) => { children }; +const EntityAsFragment: BlockRenderer = ( { children } ) => { children }; + +const blockTypeMapping: Record< string, BlockRenderer > = { + b: ( { children } ) => { children }, + strong: ( { children } ) => { children }, + i: ( { children } ) => { children }, + em: ( { children } ) => { children }, + pre: ( { children } ) => { children }, + a: Link, + link: Link, + filepath: ( { children } ) => { children }, + post: EntityAsFragment, + comment: EntityAsFragment, + person: EntityAsStrong, + plugin: EntityAsFragment, + theme: EntityAsFragment, + backup: EntityAsFragment, +}; + +export const createFormattedBlock = ( mapping: Record< string, BlockRenderer > ) => { + const FormattedBlock = ( { content, onClick, meta }: FormattedBlockProps ): ReactNode => { + if ( typeof content === 'string' ) { + return <>{ content }; + } + + const nestedContent = content.children ?? []; + const { type, text } = content; + + if ( type === undefined && nestedContent.length === 0 ) { + return text ? <>{ text } : null; + } + + const children = nestedContent.map( ( child, index ) => ( + + ) ); + + if ( type ) { + const renderer = mapping[ type ]; + if ( renderer ) { + return renderer( { content, children, onClick, meta } ); + } + } + + return <>{ children }; + }; + + return FormattedBlock; +}; + +const FormattedBlock = createFormattedBlock( blockTypeMapping ); + +export const renderFormattedContent = ( { + items, + onClick = null, + meta = {}, +}: { + items: ActivityBlockContent[]; + onClick?: BlockClickHandler | null; + meta?: ActivityBlockMeta; +} ): ReactNode[] => + items.map( ( item, index ) => ( + + ) ); + +export default FormattedBlock; diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts new file mode 100644 index 000000000000..65800e3ecc95 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts @@ -0,0 +1,259 @@ +/** + * Rewrites activity-log payloads from the WPCOM API into a tree of nodes that + * the FormattedBlock renderer understands. Ported verbatim from Calypso's + * `client/dashboard/components/logs-activity-formatted-block/api-core-parser.ts`. + */ +import type { ActivityBlockContent, ActivityBlockNode } from './types'; +import type { ActivityLogEntry, ActivityNotificationRange } from '../types'; + +interface RangeWithChildren extends ActivityNotificationRange { + children: RangeWithChildren[]; + [ key: string ]: unknown; +} + +type ParseState = [ ActivityBlockContent[], string, number ]; + +type RangePredicate = ( range: RangeWithChildren ) => boolean; + +const isNonEmpty = < T >( value: T | null | undefined | false | '' ): value is T => + Boolean( value ); + +const rangeSort = ( + { indices: [ aStart, aEnd ] }: RangeWithChildren, + { indices: [ bStart, bEnd ] }: RangeWithChildren +) => { + if ( aStart === 0 && aEnd === 0 && bEnd !== 0 ) { + return -1; + } + if ( aStart < bStart ) { + return -1; + } + if ( bStart < aStart ) { + return 1; + } + return bEnd - aEnd; +}; + +const encloses = + ( { indices: [ innerStart, innerEnd ] }: RangeWithChildren ): RangePredicate => + ( { indices: [ outerStart = 0, outerEnd = 0 ] = [ 0, 0 ] } ) => + innerStart !== 0 && innerEnd !== 0 && outerStart <= innerStart && outerEnd >= innerEnd; + +const addRange = ( ranges: RangeWithChildren[], range: RangeWithChildren ): RangeWithChildren[] => { + const parentIndex = [ ...ranges ] + .reverse() + .findIndex( candidate => encloses( range )( candidate ) ); + + if ( parentIndex === -1 ) { + return [ ...ranges, range ]; + } + + const actualIndex = ranges.length - 1 - parentIndex; + const parent = ranges[ actualIndex ]; + const updatedChildren = addRange( parent.children, range ); + const updatedParent: RangeWithChildren = { + ...parent, + children: updatedChildren, + }; + + return [ ...ranges.slice( 0, actualIndex ), updatedParent, ...ranges.slice( actualIndex + 1 ) ]; +}; + +const commentNode = ( { + id: commentId, + post_id: postId, + site_id: siteId, +}: RangeWithChildren ) => ( { + type: 'comment', + commentId, + postId, + siteId, +} ); + +const linkNode = ( { url, intent, section }: RangeWithChildren ) => ( { + type: 'link', + url, + intent, + section, +} ); + +const postNode = ( { id: postId, site_id: siteId, published }: RangeWithChildren ) => ( { + type: 'post', + postId, + siteId, + published, +} ); + +const siteNode = ( { id: siteId, intent, section }: RangeWithChildren ) => ( { + type: 'site', + siteId, + intent, + section, +} ); + +const userNode = ( { + id: userId, + name, + site_id: siteId, + intent, + section, +}: RangeWithChildren ) => ( { + type: 'person', + name, + siteId, + userId, + intent, + section, +} ); + +const pluginNode = ( { + site_slug: siteSlug, + slug, + version, + intent, + section, +}: RangeWithChildren ) => ( { + type: 'plugin', + siteSlug, + pluginSlug: slug, + version, + intent, + section, +} ); + +const themeNode = ( { + site_slug: siteSlug, + slug, + version, + uri, + intent, + section, +}: RangeWithChildren ) => ( { + type: 'theme', + siteSlug, + themeSlug: slug, + themeUri: uri, + version, + intent, + section, +} ); + +const backupNode = ( { + site_slug: siteSlug, + rewind_id: rewindId, + intent, + section, +}: RangeWithChildren ) => ( { + type: 'backup', + siteSlug, + rewindId, + intent, + section, +} ); + +const inferNode = ( range: RangeWithChildren ) => { + if ( range.url ) { + return linkNode( range ); + } + if ( range.type ) { + return { type: range.type }; + } + return range; +}; + +const nodeMappings = ( type?: string ) => { + switch ( type ) { + case 'comment': + return commentNode; + case 'post': + return postNode; + case 'site': + return siteNode; + case 'user': + return userNode; + case 'plugin': + return pluginNode; + case 'theme': + return themeNode; + case 'backup': + return backupNode; + default: + return inferNode; + } +}; + +const newNode = ( text: string, range: RangeWithChildren ): ActivityBlockNode => ( { + ...nodeMappings( range.type )( range ), + text, + children: text ? [ text ] : [], +} ); + +const joinResults = ( [ reduced, remainder ]: [ + ActivityBlockContent[], + string, +] ): ActivityBlockContent[] => { + if ( reduced.length ) { + return [ ...reduced, remainder ].filter( isNonEmpty ); + } + return remainder ? [ remainder ] : []; +}; + +const parseRange = ( + [ prev, text, offset ]: ParseState, + nextRange: RangeWithChildren +): ParseState => { + const { + indices: [ start, end ], + } = nextRange; + const offsetStart = start - offset; + const offsetEnd = end - offset; + const preText = offsetStart > 0 ? [ text.slice( 0, offsetStart ) ] : []; + const innerText = text.slice( offsetStart, offsetEnd ); + const [ childReduced, childRemainder ] = nextRange.children.reduce< ParseState >( + ( state, range ) => parseRange( state, range ), + [ [], innerText, start ] + ); + const parsedChildren = joinResults( [ childReduced, childRemainder ] ); + const baseNode = newNode( innerText, nextRange ); + const parsedNode: ActivityBlockNode = parsedChildren.length + ? { ...baseNode, children: parsedChildren } + : baseNode; + + return [ [ ...prev, ...preText, parsedNode ], text.slice( offsetEnd ), end ]; +}; + +export const parseActivityLogEntryContent = ( + content?: string | ActivityLogEntry[ 'content' ] +): ActivityBlockContent[] => { + if ( typeof content === 'string' ) { + return content ? [ content ] : []; + } + if ( Array.isArray( content ) ) { + return content; + } + if ( ! content ) { + return []; + } + const { text = '' } = content; + + if ( ! content.ranges || ! content.ranges.length ) { + return text ? [ text ] : []; + } + + const rangesWithChildren = content.ranges + .map< RangeWithChildren >( range => ( { + ...range, + children: [] as RangeWithChildren[], + } ) ) + .sort( rangeSort ) + .reduce( addRange, [] as RangeWithChildren[] ); + + const [ reduced, remainder ] = rangesWithChildren.reduce< ParseState >( + ( state, range ) => parseRange( state, range ), + [ [], text, 0 ] + ); + + return joinResults( [ reduced, remainder ] ); +}; + +export default parseActivityLogEntryContent; diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts new file mode 100644 index 000000000000..d631a621c8b8 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts @@ -0,0 +1,29 @@ +export interface ActivityBlockNode { + type?: string; + text?: string | null; + children?: ActivityBlockContent[]; + url?: string | null; + activity?: string; + section?: string; + intent?: string; + siteId?: number | string; + postId?: number | string; + isTrashed?: boolean; + commentId?: number | string; + name?: string; + siteSlug?: string; + pluginSlug?: string; + themeUri?: string; + themeSlug?: string; + version?: string; + rewindId?: string; +} + +export type ActivityBlockContent = string | ActivityBlockNode; + +export interface ActivityBlockMeta { + activity?: string; + intent?: string; + section?: string; + published?: number | string; +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/gridicons.ts b/projects/packages/activity-log/src/js/components/ActivityLog/gridicons.ts new file mode 100644 index 000000000000..68b7d5550daa --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/gridicons.ts @@ -0,0 +1,79 @@ +import { + audio, + background, + backup, + brush, + caution, + check, + cloud, + cog, + comment, + commentAuthorAvatar, + connection, + customPostType, + error, + globe, + homeButton, + image, + layout, + lock, + menu, + pages, + people, + postContent, + plugins, + published, + receipt, + rotateRight, + swatch, + trash, + update, + video, + wordpress, +} from '@wordpress/icons'; +import type { ReactElement } from 'react'; + +const icons: Record< string, ReactElement > = { + audio, + checkmark: check, + cart: receipt, + cloud, + cog, + comment, + 'custom-post-type': customPostType, + globe, + history: backup, + image, + layout, + lock, + menu, + 'multiple-users': people, + 'my-sites': wordpress, + notice: caution, + posts: postContent, + pages, + plans: connection, + plugins, + published, + rotateRight, + science: swatch, + spam: error, + status: homeButton, + sync: update, + themes: brush, + trash, + user: commentAuthorAvatar, + video, +}; + +/** + * Translate a Calypso gridicon slug (as returned by the WPCOM activity log + * in `entry.gridicon`) into a `@wordpress/icons` element. Falls back to the + * generic `background` icon for unknown slugs. + * + * @param slug - The gridicon slug (e.g. "plugins", "posts", "cloud"). + * @return The corresponding WP icon element. + */ +export function gridiconToWordPressIcon( slug: string ): ReactElement { + return icons[ slug ] ?? background; +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx new file mode 100644 index 000000000000..fa2f036a03fe --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -0,0 +1,166 @@ +/** + * Top-level Activity Log admin page. Ported from Calypso's + * `client/dashboard/sites/logs-activity/dataviews/index.tsx`. Scope + * simplifications vs. the source are tracked in the Phase 3 PR: no date + * range picker, no URL-persistent view state, no analytics, no tier + * gating, no upsell callout. + */ +import { useQuery } from '@tanstack/react-query'; +import { DataViews } from '@wordpress/dataviews'; +import { __ } from '@wordpress/i18n'; +import fastDeepEqual from 'fast-deep-equal/es6'; +import { useCallback, useMemo, useState } from 'react'; +import { activityLogQuery, activityLogGroupCountsQuery } from '../../hooks/use-activity-log'; +import { useActivityActions } from './actions'; +import { transformActivityLogEntry } from './activity-transformer'; +import { useActivityFields } from './fields'; +import { extractActivityLogTypeValues } from './filters'; +import { DEFAULT_VIEW } from './views'; +import type { Activity, ActivityLogParams } from './types'; +import type { Field, Filter, View } from '@wordpress/dataviews'; + +const ACTIVITY_LOGS_DEFAULT_PAGE_SIZE = 20; + +interface InitialState { + siteData?: { + gmtOffset?: number; + timezoneString?: string; + }; +} + +declare global { + const JPACTIVITYLOG_INITIAL_STATE: InitialState | undefined; +} + +/** + * Read the site's timezone/offset from the Initial_State payload seeded + * by class-initial-state.php. Falls back to UTC when the global isn't + * present (e.g. in storybook or tests). + * + * @return The resolved site time context. + */ +const readSiteTimeContext = (): { gmtOffset: number; timezoneString?: string } => { + const state = + typeof JPACTIVITYLOG_INITIAL_STATE !== 'undefined' ? JPACTIVITYLOG_INITIAL_STATE : undefined; + return { + gmtOffset: state?.siteData?.gmtOffset ?? 0, + timezoneString: state?.siteData?.timezoneString || undefined, + }; +}; + +/** + * The Activity Log admin page. Renders the DataViews table and drives + * its dataset/filter/counts queries against /jetpack/v4/activity-log. + * + * @return The admin page. + */ +export default function ActivityLog() { + const { gmtOffset, timezoneString } = readSiteTimeContext(); + const [ view, setView ] = useState< View >( DEFAULT_VIEW ); + + const activityLogTypeValues = useMemo( () => { + const filters = ( view.filters as Filter[] | undefined ) ?? []; + return extractActivityLogTypeValues( filters ); + }, [ view.filters ] ); + + const searchTerm = view.search?.trim() ?? ''; + + const listParams: ActivityLogParams = useMemo( () => { + const params: ActivityLogParams = { + sort_order: view.sort?.direction, + number: view.perPage || ACTIVITY_LOGS_DEFAULT_PAGE_SIZE, + page: view.page, + }; + if ( searchTerm ) { + params.text_search = searchTerm; + } + if ( activityLogTypeValues.length ) { + params.group = activityLogTypeValues; + } + return params; + }, [ view.sort?.direction, view.perPage, view.page, searchTerm, activityLogTypeValues ] ); + + const { + data: activityLogData, + isFetching: isFetchingData, + isLoading: isLoadingList, + } = useQuery( { + ...activityLogQuery( listParams ), + select: data => ( { + ...data, + activityLogs: ( data.activityLogs ?? [] ).map( transformActivityLogEntry ), + } ), + } ); + + // Counts query excludes `text_search` intentionally: keeping the filter + // dropdown stable as users type (matches Calypso's behavior at + // logs-activity/dataviews/index.tsx:100-102). + const { data: groupCountsData, isFetching: isFetchingFilters } = useQuery( + activityLogGroupCountsQuery( { number: 1000 } ) + ); + + const isFetching = isFetchingData || isFetchingFilters; + + const paginationInfo = { + totalItems: activityLogData?.totalItems ?? 0, + totalPages: activityLogData?.totalPages ?? 0, + }; + + const fields = useActivityFields( { + gmtOffset, + timezoneString, + activityLogTypes: groupCountsData?.groups, + } ); + + const actions = useActivityActions( { isLoading: isFetching } ); + + const onChangeView = useCallback( + ( next: View ) => { + const nextSearch = next.search?.trim() ?? ''; + const currentPage = view.page ?? 1; + const requestedPage = next.page ?? currentPage; + + const perPageChanged = next.perPage !== view.perPage; + const sortChanged = next.sort?.direction !== view.sort?.direction; + const filtersChanged = ! fastDeepEqual( next.filters, view.filters ); + const searchChanged = nextSearch !== searchTerm; + + const datasetChanged = perPageChanged || sortChanged || filtersChanged || searchChanged; + + setView( { + ...next, + page: datasetChanged ? 1 : requestedPage, + } ); + }, + [ view, searchTerm ] + ); + + const resetView = useCallback( () => setView( DEFAULT_VIEW ), [] ); + + const getItemId = useCallback( ( item: Activity ) => item.activityId.toString(), [] ); + + const logData = ( activityLogData?.activityLogs ?? [] ) as Activity[]; + + return ( + + data={ logData } + isLoading={ isFetching || isLoadingList } + paginationInfo={ paginationInfo } + fields={ fields as Field< Activity >[] } + view={ view } + actions={ actions } + getItemId={ getItemId } + search + defaultLayouts={ { table: {} } } + onChangeView={ onChangeView } + onResetView={ resetView } + empty={ +

+ { view.search + ? __( 'No activity found', 'jetpack-activity-log' ) + : __( 'No activities', 'jetpack-activity-log' ) } +

+ } + /> + ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/types.ts b/projects/packages/activity-log/src/js/components/ActivityLog/types.ts new file mode 100644 index 000000000000..48398c00c8ae --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/types.ts @@ -0,0 +1,123 @@ +import type { ActivityBlockContent } from './formatted-block/types'; + +export interface ActivityDescription { + textDescription: string; + items: ActivityBlockContent[]; +} + +export interface ActivityActorDetails { + actorAvatarUrl?: string; + actorName?: string; + actorRole?: string; + actorType?: string; + isCli?: boolean; + isSupport?: boolean; + isMcpAgent?: boolean; + mcpClient?: string; +} + +export interface ActivityMediaDetails { + available: boolean; + medium_url: string; + name: string; + thumbnail_url: string; + type: string; + url: string; +} + +export interface Activity { + activityDescription: ActivityDescription; + activityIcon?: string; + activityId: string; + activityMedia: ActivityMediaDetails; + activityName: string; + activityStatus: string; + activityTitle: string; + activityTs: number; + activityUnparsedTs: string; + activityActor: ActivityActorDetails; + activityIsRewindable: boolean; + rewindId?: string; +} + +/** + * Minimal shape from the WPCOM activity log endpoint. We only type the + * fields the UI actually consumes; other fields flow through untyped. + */ +export interface ActivityNotificationRange { + indices: [ number, number ]; + type?: string; + url?: string; + section?: string; + intent?: string; + activity?: string; + id?: number | string; + name?: string; + site_id?: number | string; + post_id?: number | string; + site_slug?: string; + slug?: string; + version?: string; + uri?: string; + rewind_id?: string; + published?: string; + [ key: string ]: unknown; +} + +export interface ActivityLogActor { + type?: 'Person' | 'Application' | 'Happiness Engineer'; + name?: string; + role?: string; + icon?: { type?: string; url?: string; width?: number; height?: number }; + is_cli?: boolean; + is_happiness?: boolean; + is_mcp_agent?: boolean; + mcp_client?: string; +} + +export interface ActivityLogEntryImage { + available?: boolean; + medium_url?: string; + thumbnail_url?: string; + type?: string; + name?: string; + url?: string; +} + +export interface ActivityLogEntry { + activity_id: string; + actor?: ActivityLogActor; + content?: { text?: string; ranges?: ActivityNotificationRange[] }; + gridicon?: string; + image?: ActivityLogEntryImage | null; + name: string; + is_rewindable?: boolean; + published?: string; + rewind_id?: string; + status?: 'error' | 'info' | 'success' | 'warning' | null; + summary: string; +} + +export interface ActivityLogsData { + activityLogs: ActivityLogEntry[]; + totalItems?: number; + pages?: number; + itemsPerPage?: number; + totalPages?: number; +} + +export interface ActivityLogGroupCountResponse { + groups: Record< string, { name: string; count: number } >; + totalItems?: number; +} + +export interface ActivityLogParams { + number?: number; + page?: number; + sort_order?: 'asc' | 'desc'; + after?: string; + before?: string; + group?: string[]; + not_group?: string[]; + text_search?: string; +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts new file mode 100644 index 000000000000..b343be3961e0 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts @@ -0,0 +1,21 @@ +import type { View } from '@wordpress/dataviews'; + +export const DEFAULT_VIEW: View = { + type: 'table', + perPage: 20, + sort: { + field: 'published', + direction: 'desc', + }, + fields: [ 'published', 'event', 'actor' ], + layout: { + density: 'balanced', + styles: { + published: { maxWidth: '300px', minWidth: '175px' }, + published_utc: { maxWidth: '200px', minWidth: '175px' }, + actor: { maxWidth: '150px', minWidth: '75px' }, + event: { minWidth: '500px' }, + }, + }, + showLevels: false, +}; diff --git a/projects/packages/activity-log/src/js/components/Admin.jsx b/projects/packages/activity-log/src/js/components/Admin.jsx deleted file mode 100644 index 335117776e31..000000000000 --- a/projects/packages/activity-log/src/js/components/Admin.jsx +++ /dev/null @@ -1,21 +0,0 @@ -import { __ } from '@wordpress/i18n'; - -/** - * Placeholder Admin component. The real UI lands in Phase 3, ported from - * Calypso Dashboard's `client/dashboard/sites/logs-activity/`. - * - * @return {import('react').ReactElement} The Activity Log admin page placeholder. - */ -export default function Admin() { - return ( -
-

{ __( 'Activity Log', 'jetpack-activity-log' ) }

-

- { __( - 'Activity Log is being ported into Jetpack. The full UI will appear here soon.', - 'jetpack-activity-log' - ) } -

-
- ); -} diff --git a/projects/packages/activity-log/src/js/hooks/use-activity-log.ts b/projects/packages/activity-log/src/js/hooks/use-activity-log.ts new file mode 100644 index 000000000000..cf2ac2f93956 --- /dev/null +++ b/projects/packages/activity-log/src/js/hooks/use-activity-log.ts @@ -0,0 +1,95 @@ +/** + * TanStack Query factories for the Activity Log REST endpoints. + * + * These mirror Calypso's `siteActivityLogQuery` / `siteActivityLogGroupCountsQuery` + * shapes — same query keys, same response transform (`current.orderedItems → + * activityLogs`) — so the UI code ports with minimal changes. The transport + * is `@wordpress/api-fetch` against `/jetpack/v4/activity-log/*` instead of + * WPCOM directly. + */ +import { queryOptions } from '@tanstack/react-query'; +import apiFetch from '@wordpress/api-fetch'; +import type { + ActivityLogEntry, + ActivityLogGroupCountResponse, + ActivityLogParams, + ActivityLogsData, +} from '../components/ActivityLog/types'; + +interface RawActivityLogResponse { + current?: { orderedItems?: ActivityLogEntry[] }; + totalItems?: number; + pages?: number; + itemsPerPage?: number; + totalPages?: number; +} + +/** + * Assemble a REST path with a query string, handling array params by + * serializing them with `[]` brackets (what our server-side controller + * expects). + * + * @param base - Path prefix, e.g. `/jetpack/v4/activity-log`. + * @param params - Key/value map of query params. Arrays produce repeated + * `key[]=value` pairs; undefined/null are dropped. + * @return The combined path. + */ +const buildPath = ( base: string, params: ActivityLogParams ): string => { + const search = new URLSearchParams(); + Object.entries( params ).forEach( ( [ key, value ] ) => { + if ( value === undefined || value === null ) { + return; + } + if ( Array.isArray( value ) ) { + value.forEach( entry => search.append( `${ key }[]`, String( entry ) ) ); + return; + } + search.append( key, String( value ) ); + } ); + const query = search.toString(); + return query ? `${ base }?${ query }` : base; +}; + +/** + * TanStack Query options for the paginated activity list. Unwraps the WPCOM + * `current.orderedItems` shape into the flatter `activityLogs` shape the UI + * components consume. + * + * @param params - Forwarded to the server as query params. + * @return `queryOptions` ready to pass to `useQuery`. + */ +export function activityLogQuery( params: ActivityLogParams ) { + return queryOptions( { + queryKey: [ 'jetpack-activity-log', 'list', params ], + queryFn: async (): Promise< ActivityLogsData > => { + const response = await apiFetch< RawActivityLogResponse >( { + path: buildPath( '/jetpack/v4/activity-log', params ), + } ); + return { + activityLogs: response.current?.orderedItems ?? [], + totalItems: response.totalItems, + pages: response.pages, + itemsPerPage: response.itemsPerPage, + totalPages: response.totalPages, + }; + }, + } ); +} + +/** + * TanStack Query options for the group-counts endpoint powering the + * Activity Type filter dropdown. + * + * @param params - Forwarded to the server as query params. + * @return `queryOptions` ready to pass to `useQuery`. + */ +export function activityLogGroupCountsQuery( params: ActivityLogParams ) { + return queryOptions( { + queryKey: [ 'jetpack-activity-log', 'group-counts', params ], + queryFn: async (): Promise< ActivityLogGroupCountResponse > => { + return apiFetch< ActivityLogGroupCountResponse >( { + path: buildPath( '/jetpack/v4/activity-log/count/group', params ), + } ); + }, + } ); +} diff --git a/projects/packages/activity-log/src/js/index.js b/projects/packages/activity-log/src/js/index.js index 8841e1b60850..acdcd56f39e4 100644 --- a/projects/packages/activity-log/src/js/index.js +++ b/projects/packages/activity-log/src/js/index.js @@ -1,7 +1,8 @@ import { ThemeProvider } from '@automattic/jetpack-components'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as WPElement from '@wordpress/element'; -import Admin from './components/Admin'; +import ActivityLog from './components/ActivityLog'; +import './style.scss'; const queryClient = new QueryClient( { defaultOptions: { @@ -24,7 +25,7 @@ function render() { const component = ( - + ); diff --git a/projects/packages/activity-log/src/js/style.scss b/projects/packages/activity-log/src/js/style.scss new file mode 100644 index 000000000000..bd697cd745e9 --- /dev/null +++ b/projects/packages/activity-log/src/js/style.scss @@ -0,0 +1,25 @@ +@use "@automattic/jetpack-base-styles/admin-page-layout" as *; +@use "sass:meta"; + +// DataViews is a bundled (not externalized) @wordpress package in +// jetpack-webpack-config, so its stylesheet must be brought in alongside +// the JS. See projects/packages/forms/routes/shared.scss for the same pattern. +@include meta.load-css("@wordpress/dataviews/build-style/style.css"); + +body.toplevel_page_jetpack-activity-log, +body.jetpack_page_jetpack-activity-log { + + @include jetpack-admin-page-layout; +} + +#jetpack-activity-log-root { + padding: 24px; + font-size: var(--font-body, 13px); + line-height: 1.5; + + // Keep the DataViews table's internal surface from fighting wp-admin's + // own max-widths. The mixin above already pins the viewport column. + .dataviews-wrapper { + background: #fff; + } +} From a68aa38591a88dd2318b50e194c17a00c7184718 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:40:51 +0100 Subject: [PATCH 06/44] =?UTF-8?q?Activity=20Log:=20Polish=20=E2=80=94=20Ad?= =?UTF-8?q?minPage=20header,=20full-bleed=20table,=2032px=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feedback-driven polish on the Phase 3 UI: - Wrap the page in `` from `@automattic/jetpack-components` so the Jetpack masthead + footer + viewport-pinned scroll column come for free. Same pattern SEO (#48154) and the new Backup overview (#48236) adopted. - Drop the 24px inset around the DataViews table — it now runs full-bleed inside AdminPage's content column. The wrapper keeps a flex-column layout so DataViews' internal scroll continues to work under `jetpack-admin-page-layout`. - Lock the event-icon tile to a fixed 32x32 (box-sizing: border-box, explicit width/height/min-width + 4px padding over the 24px SVG). Matches Calypso's visual spec and stays stable against surrounding cascade. Column widths were already in views.ts via DataViews' documented `layout.styles` per-field object — same mechanism Calypso uses. Refs #48242. --- .../changelog/polish-adminpage-header | 4 ++ .../ActivityLog/activity-event.scss | 11 +++- .../src/js/components/ActivityLog/index.tsx | 51 +++++++++++-------- .../packages/activity-log/src/js/style.scss | 20 +++++--- 4 files changed, 56 insertions(+), 30 deletions(-) create mode 100644 projects/packages/activity-log/changelog/polish-adminpage-header diff --git a/projects/packages/activity-log/changelog/polish-adminpage-header b/projects/packages/activity-log/changelog/polish-adminpage-header new file mode 100644 index 000000000000..66fae09f217f --- /dev/null +++ b/projects/packages/activity-log/changelog/polish-adminpage-header @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Wrap the Activity Log page in the shared Jetpack AdminPage header, make the DataViews table full-bleed, and lock the event-icon tile to 32x32 for visual parity with Calypso. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss index 8886a753765f..74faa4c43af1 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss @@ -1,8 +1,15 @@ +// Event row icon — fixed 32×32 tile so the layout is stable regardless of +// surrounding cascade. The inner SVG renders at 24×24 (via the Icon size +// prop) and the 4px padding completes the 32×32 tile. .site-activity-logs__event-icon { + box-sizing: border-box; + width: 32px; + height: 32px; + min-width: 32px; + padding: 4px; + border-radius: 2px; fill: #50575e; background-color: #f0f0f1; - border-radius: 2px; - padding: 4px; } .site-activity-logs__event { diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx index fa2f036a03fe..9469f00f48ad 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -5,6 +5,7 @@ * range picker, no URL-persistent view state, no analytics, no tier * gating, no upsell callout. */ +import { AdminPage } from '@automattic/jetpack-components'; import { useQuery } from '@tanstack/react-query'; import { DataViews } from '@wordpress/dataviews'; import { __ } from '@wordpress/i18n'; @@ -142,25 +143,35 @@ export default function ActivityLog() { const logData = ( activityLogData?.activityLogs ?? [] ) as Activity[]; return ( - - data={ logData } - isLoading={ isFetching || isLoadingList } - paginationInfo={ paginationInfo } - fields={ fields as Field< Activity >[] } - view={ view } - actions={ actions } - getItemId={ getItemId } - search - defaultLayouts={ { table: {} } } - onChangeView={ onChangeView } - onResetView={ resetView } - empty={ -

- { view.search - ? __( 'No activity found', 'jetpack-activity-log' ) - : __( 'No activities', 'jetpack-activity-log' ) } -

- } - /> + +
+ + data={ logData } + isLoading={ isFetching || isLoadingList } + paginationInfo={ paginationInfo } + fields={ fields as Field< Activity >[] } + view={ view } + actions={ actions } + getItemId={ getItemId } + search + defaultLayouts={ { table: {} } } + onChangeView={ onChangeView } + onResetView={ resetView } + empty={ +

+ { view.search + ? __( 'No activity found', 'jetpack-activity-log' ) + : __( 'No activities', 'jetpack-activity-log' ) } +

+ } + /> +
+
); } diff --git a/projects/packages/activity-log/src/js/style.scss b/projects/packages/activity-log/src/js/style.scss index bd697cd745e9..a852de7cb059 100644 --- a/projects/packages/activity-log/src/js/style.scss +++ b/projects/packages/activity-log/src/js/style.scss @@ -12,14 +12,18 @@ body.jetpack_page_jetpack-activity-log { @include jetpack-admin-page-layout; } -#jetpack-activity-log-root { - padding: 24px; - font-size: var(--font-body, 13px); - line-height: 1.5; +// AdminPage provides the Jetpack header + footer + viewport-pinned +// scroll column; let the DataViews table live full-bleed inside it with +// no extra inset. +.jp-activity-log__dataviews-wrapper { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; + padding: 0; - // Keep the DataViews table's internal surface from fighting wp-admin's - // own max-widths. The mixin above already pins the viewport column. - .dataviews-wrapper { - background: #fff; + // DataViews renders its own surface; don't double-pad it. + > .dataviews-wrapper { + margin: 0; } } From 4c79bad322746df8e52cf4827810d5f5fa98b45e Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:47:13 +0100 Subject: [PATCH 07/44] Activity Log: Tweak event-icon fill, hide footer, tighten columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up polish from design feedback on Phase 3: - Event-icon fill → #757575. - AdminPage `showFooter={ false }`: the full-bleed DataViews page doesn't need Jetpack's footer eating vertical space. - Column minimum widths in views.ts: published, published_utc: 200 actor: 100 event: 520 (Dropped the maxWidth pairs — columns flex above these minima.) Refs #48242. --- .../activity-log/changelog/polish-icon-footer-columns | 4 ++++ .../src/js/components/ActivityLog/activity-event.scss | 2 +- .../activity-log/src/js/components/ActivityLog/index.tsx | 1 + .../activity-log/src/js/components/ActivityLog/views.ts | 8 ++++---- 4 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 projects/packages/activity-log/changelog/polish-icon-footer-columns diff --git a/projects/packages/activity-log/changelog/polish-icon-footer-columns b/projects/packages/activity-log/changelog/polish-icon-footer-columns new file mode 100644 index 000000000000..0a1499704b84 --- /dev/null +++ b/projects/packages/activity-log/changelog/polish-icon-footer-columns @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Tune the event-icon fill to #757575, hide AdminPage footer on the full-bleed DataViews page, and tighten column minimum widths. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss index 74faa4c43af1..d78471dc127f 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss @@ -8,7 +8,7 @@ min-width: 32px; padding: 4px; border-radius: 2px; - fill: #50575e; + fill: #757575; background-color: #f0f0f1; } diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx index 9469f00f48ad..a39c4c559dc0 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -149,6 +149,7 @@ export default function ActivityLog() { 'Every change made to your site, in one searchable timeline.', 'jetpack-activity-log' ) } + showFooter={ false } >
diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts index b343be3961e0..1128b179899b 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts @@ -11,10 +11,10 @@ export const DEFAULT_VIEW: View = { layout: { density: 'balanced', styles: { - published: { maxWidth: '300px', minWidth: '175px' }, - published_utc: { maxWidth: '200px', minWidth: '175px' }, - actor: { maxWidth: '150px', minWidth: '75px' }, - event: { minWidth: '500px' }, + published: { minWidth: '200px' }, + published_utc: { minWidth: '200px' }, + actor: { minWidth: '100px' }, + event: { minWidth: '520px' }, }, }, showLevels: false, From 93bc44a75c7557bf536168f94d5e1e1960da75f4 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:50:59 +0100 Subject: [PATCH 08/44] Activity Log: Widen date and event minimum column widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit published / published_utc → 240px (was 200) event → 580px (was 520) Refs #48242. --- .../activity-log/src/js/components/ActivityLog/views.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts index 1128b179899b..d7ef9e3d2497 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts @@ -11,10 +11,10 @@ export const DEFAULT_VIEW: View = { layout: { density: 'balanced', styles: { - published: { minWidth: '200px' }, - published_utc: { minWidth: '200px' }, + published: { minWidth: '240px' }, + published_utc: { minWidth: '240px' }, actor: { minWidth: '100px' }, - event: { minWidth: '520px' }, + event: { minWidth: '580px' }, }, }, showLevels: false, From fc8fd5d9ba191926bf5dfd9f73f5db1cdf01b49b Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 17:52:45 +0100 Subject: [PATCH 09/44] Activity Log: Let the Event column absorb remaining width Give event `width: 100%` alongside its minWidth. Classic HTML-table trick: 100%-width column consumes leftover space when siblings have fixed widths, so Event now stretches to fill the viewport while Date and User stay at their minima. Refs #48242. --- .../activity-log/src/js/components/ActivityLog/views.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts index d7ef9e3d2497..b894cee209e6 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/views.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/views.ts @@ -14,7 +14,10 @@ export const DEFAULT_VIEW: View = { published: { minWidth: '240px' }, published_utc: { minWidth: '240px' }, actor: { minWidth: '100px' }, - event: { minWidth: '580px' }, + // `width: 100%` makes Event the flex column: it consumes whatever the + // date and user columns don't use, while still refusing to drop below + // its minWidth on narrow viewports. + event: { width: '100%', minWidth: '580px' }, }, }, showLevels: false, From 4e26ae3a0bf76ec512fb3d3aec41723d78812d3f Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Wed, 22 Apr 2026 18:16:34 +0100 Subject: [PATCH 10/44] Activity Log: Use the real Jetpack logo for the "Jetpack" actor When the actor is "Jetpack" / "Jetpack Boost" / Happiness Engineer, the avatar was falling back to the WordPress icon. Swap in JetpackLogo from @automattic/jetpack-components (already a dep) so those rows show the proper brand. Refs #48242. --- .../src/js/components/ActivityLog/ActivityActor.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx index 4b2eae85688e..305aa255ec9d 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx @@ -1,3 +1,4 @@ +import { JetpackLogo } from '@automattic/jetpack-components'; import { __experimentalHStack as HStack, Icon } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { __, sprintf } from '@wordpress/i18n'; import { commentAuthorAvatar, globe, wordpress } from '@wordpress/icons'; @@ -62,10 +63,10 @@ function getActorPresentation( actor?: ActivityActorDetails ): { icon: ReactNode if ( name === 'Jetpack' || name === 'Jetpack Boost' ) { return { icon: ( - ), label: name, @@ -89,10 +90,10 @@ function getActorPresentation( actor?: ActivityActorDetails ): { icon: ReactNode case 'Happiness Engineer': return { icon: ( - ), label: __( 'Happiness Engineer', 'jetpack-activity-log' ), From d12f570be3c815672e8254aa830961a158e373f3 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:07:57 +0100 Subject: [PATCH 11/44] Activity Log: wire Reset view + persist view options Rename the broken `onResetView` prop to DataViews' actual `onReset` with the `false | function` semantics so the built-in Reset view button renders in the cog popover (disabled until the view is modified, indicator dot on the cog when modified). Extract the view state into a `usePersistentView` hook that mirrors Calypso's behavior: persist the non-transient bits (fields, density, sort, perPage, layout) to `localStorage` under `jetpack-activity-log:view`, strip `page`/`search`/empty `filters` before writing, and clear the entry when the view returns to default. The hook's interface leaves room to swap the backing store to user-meta later without touching the component. Refs #48242. --- .../changelog/add-view-options-persistence | 4 + .../src/js/components/ActivityLog/index.tsx | 11 +- .../src/js/hooks/use-persistent-view.ts | 111 ++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-view-options-persistence create mode 100644 projects/packages/activity-log/src/js/hooks/use-persistent-view.ts diff --git a/projects/packages/activity-log/changelog/add-view-options-persistence b/projects/packages/activity-log/changelog/add-view-options-persistence new file mode 100644 index 000000000000..a4ee86dc12c8 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-view-options-persistence @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Activity Log: surface the DataViews Reset view button and persist view options (column order, density, sort, per-page) across page refresh via localStorage. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx index a39c4c559dc0..5c41e3b2b249 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -10,8 +10,9 @@ import { useQuery } from '@tanstack/react-query'; import { DataViews } from '@wordpress/dataviews'; import { __ } from '@wordpress/i18n'; import fastDeepEqual from 'fast-deep-equal/es6'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { activityLogQuery, activityLogGroupCountsQuery } from '../../hooks/use-activity-log'; +import { usePersistentView } from '../../hooks/use-persistent-view'; import { useActivityActions } from './actions'; import { transformActivityLogEntry } from './activity-transformer'; import { useActivityFields } from './fields'; @@ -57,7 +58,7 @@ const readSiteTimeContext = (): { gmtOffset: number; timezoneString?: string } = */ export default function ActivityLog() { const { gmtOffset, timezoneString } = readSiteTimeContext(); - const [ view, setView ] = useState< View >( DEFAULT_VIEW ); + const { view, setView, resetView, isViewModified } = usePersistentView( DEFAULT_VIEW ); const activityLogTypeValues = useMemo( () => { const filters = ( view.filters as Filter[] | undefined ) ?? []; @@ -133,11 +134,9 @@ export default function ActivityLog() { page: datasetChanged ? 1 : requestedPage, } ); }, - [ view, searchTerm ] + [ setView, view, searchTerm ] ); - const resetView = useCallback( () => setView( DEFAULT_VIEW ), [] ); - const getItemId = useCallback( ( item: Activity ) => item.activityId.toString(), [] ); const logData = ( activityLogData?.activityLogs ?? [] ) as Activity[]; @@ -163,7 +162,7 @@ export default function ActivityLog() { search defaultLayouts={ { table: {} } } onChangeView={ onChangeView } - onResetView={ resetView } + onReset={ isViewModified ? resetView : false } empty={

{ view.search diff --git a/projects/packages/activity-log/src/js/hooks/use-persistent-view.ts b/projects/packages/activity-log/src/js/hooks/use-persistent-view.ts new file mode 100644 index 000000000000..edcc052c4bd4 --- /dev/null +++ b/projects/packages/activity-log/src/js/hooks/use-persistent-view.ts @@ -0,0 +1,111 @@ +/** + * Persistent DataViews view state for the Activity Log. + * + * Mirrors the behavior of Calypso's `usePersistentView` + * (client/dashboard/app/hooks/use-persistent-view.ts): persist the + * non-transient view config (fields, density, perPage, sort, layout), + * not the transient bits (`page`, `search`, empty `filters`). Calypso + * persists to WordPress.com user preferences; in a self-hosted Jetpack + * plugin we don't have that API, so we back the store with + * `localStorage` instead. The hook signature stays swappable: a future + * move to a user-meta-backed store only touches this file. + */ +import fastDeepEqual from 'fast-deep-equal/es6'; +import { useCallback, useMemo, useState } from 'react'; +import type { View } from '@wordpress/dataviews'; + +const STORAGE_KEY = 'jetpack-activity-log:view'; + +const readPersistedView = (): View | null => { + if ( typeof window === 'undefined' ) { + return null; + } + try { + const raw = window.localStorage.getItem( STORAGE_KEY ); + if ( ! raw ) { + return null; + } + const parsed = JSON.parse( raw ); + return parsed && typeof parsed === 'object' ? ( parsed as View ) : null; + } catch { + return null; + } +}; + +const writePersistedView = ( view: View | null ): void => { + if ( typeof window === 'undefined' ) { + return; + } + try { + if ( view === null ) { + window.localStorage.removeItem( STORAGE_KEY ); + } else { + window.localStorage.setItem( STORAGE_KEY, JSON.stringify( view ) ); + } + } catch { + // Quota exceeded or localStorage disabled — drop silently. + } +}; + +const stripTransient = ( v: View ): View => { + const next = { ...v }; + delete next.page; + delete next.search; + if ( ! next.filters?.length ) { + delete next.filters; + } + return next; +}; + +/** + * Hook that tracks a DataViews view and persists the non-transient + * parts to localStorage. + * + * @param defaultView - The fallback view used when no persisted entry + * exists. Also the reference point for `isViewModified` and the target + * of `resetView`. + * @return An object with the current `view`, a `setView` persistence- + * aware setter, a `resetView` function, and the `isViewModified` flag + * the `onReset` prop needs to decide whether to show the Reset view + * button. + */ +export function usePersistentView( defaultView: View ): { + view: View; + setView: ( next: View ) => void; + resetView: () => void; + isViewModified: boolean; +} { + const [ view, setViewState ] = useState< View >( () => { + const persisted = readPersistedView(); + return persisted ?? defaultView; + } ); + + const setView = useCallback( + ( next: View ) => { + setViewState( next ); + + // Persist only if the stripped view differs from the stripped + // default — otherwise clear the entry so a "back to default" + // session doesn't leave a redundant row in localStorage. + const stripped = stripTransient( next ); + if ( fastDeepEqual( stripped, stripTransient( defaultView ) ) ) { + writePersistedView( null ); + } else { + writePersistedView( stripped ); + } + }, + [ defaultView ] + ); + + const resetView = useCallback( () => { + setViewState( defaultView ); + writePersistedView( null ); + }, [ defaultView ] ); + + const isViewModified = useMemo( + () => ! fastDeepEqual( stripTransient( view ), stripTransient( defaultView ) ), + [ view, defaultView ] + ); + + return { view, setView, resetView, isViewModified }; +} From 4a885126b6ae36fba0351171b0905377ac672024 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:21:42 +0100 Subject: [PATCH 12/44] Activity Log: document the UI primitives preference order Add a package-level AGENTS.md that steers future UI work toward the WordPress Design System in the right order: `@wordpress/ui` first, `@wordpress/components` only where `@wordpress/ui` has no stable equivalent, `@wordpress/dataviews` sub-components for data-presentation extensions, and `@wordpress/admin-ui` (via `AdminPage`) for page layout. Also flags the `@wordpress/design-system-mcp` server as the canonical lookup path for component/token metadata. Guidance follows the April 2026 WordPress Design System P2 post. Refs #48242. --- projects/packages/activity-log/AGENTS.md | 33 +++++++++++++++++++ .../activity-log/changelog/docs-agents-md | 5 +++ 2 files changed, 38 insertions(+) create mode 100644 projects/packages/activity-log/AGENTS.md create mode 100644 projects/packages/activity-log/changelog/docs-agents-md diff --git a/projects/packages/activity-log/AGENTS.md b/projects/packages/activity-log/AGENTS.md new file mode 100644 index 000000000000..4842575da84c --- /dev/null +++ b/projects/packages/activity-log/AGENTS.md @@ -0,0 +1,33 @@ +# Activity Log + +## UI primitives + +When adding React UI in this package, prefer the WordPress Design System +packages in this order: + +1. **`@wordpress/ui`** — foundational primitives. Check each component's + Storybook "Status" badge (anything other than "stable" is still in + flux); avoid experimental APIs here. +2. **`@wordpress/components`** — general-purpose legacy library. + Predates the design system. Use only when `@wordpress/ui` doesn't + have a stable equivalent, and still check Status in Storybook. +3. **`@wordpress/dataviews`** — higher-level data presentation (tables, + lists, grids). Already the backbone here. Extend via its + sub-components (`DataViews.Search`, `DataViews.FiltersToggle`, + `DataViews.Layout`, `DataViews.Footer`) before reaching for lower- + level primitives. +4. **`@wordpress/admin-ui`** — page layout primitives, accessed via + `AdminPage` from `@automattic/jetpack-components` (which wraps + admin-ui's `Page`). + +Rationale: WordPress is moving new work to `@wordpress/ui`; +`@wordpress/components` is being kept as a legacy fallback. Guidance +from the WordPress Design System P2 (April 2026). + +## Design-system lookup + +A dedicated MCP server is wired into this project's local Claude Code +config: `@wordpress/design-system-mcp`. It exposes the authoritative +list of stable `@wordpress/ui` + `@wordpress/components` components and +`--wpds-*` design tokens. Prefer querying it over spelunking through +`node_modules/@wordpress/components/src/**` for component metadata. diff --git a/projects/packages/activity-log/changelog/docs-agents-md b/projects/packages/activity-log/changelog/docs-agents-md new file mode 100644 index 000000000000..926f5c8bfb6b --- /dev/null +++ b/projects/packages/activity-log/changelog/docs-agents-md @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Add package-level AGENTS.md documenting the UI primitives preference order. + + From 6ac9c44a6edcf6a96aa8e89d6d3cc80280f7fe5c Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:26:47 +0100 Subject: [PATCH 13/44] Activity Log: adopt --wpds-* design tokens in SCSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the remaining hard-coded hex values in activity-actor.scss and activity-event.scss for WordPress Design System semantic tokens, keeping the original hex as each `var()` fallback so rendering stays identical when the tokens aren't in scope: - `#dcdcde`, `#f0f0f1` → `--wpds-color-bg-surface-neutral-weak` - `#1e1e1e` → `--wpds-color-fg-content-neutral` - `#50575e`, `#757575` → `--wpds-color-fg-content-neutral-weak` The `@wordpress/dataviews` bundled stylesheet already declares the wpds tokens at :root, so the values take effect immediately rather than waiting on a separate `@wordpress/theme` provider. Expect a small shift: the actor icon bubble becomes slightly lighter (#dcdcde → #f4f4f4) and the two variants of medium grey unify on #707070. Primary text (#1e1e1e) and event-icon background (#f0f0f1 → #f4f4f4) are visually unchanged. Refs #48242. --- projects/packages/activity-log/changelog/wpds-tokens | 4 ++++ .../src/js/components/ActivityLog/activity-actor.scss | 6 +++--- .../src/js/components/ActivityLog/activity-event.scss | 8 ++++---- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 projects/packages/activity-log/changelog/wpds-tokens diff --git a/projects/packages/activity-log/changelog/wpds-tokens b/projects/packages/activity-log/changelog/wpds-tokens new file mode 100644 index 000000000000..93793fa57120 --- /dev/null +++ b/projects/packages/activity-log/changelog/wpds-tokens @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Activity Log: swap hard-coded SCSS colors for WordPress Design System --wpds-* tokens (with hex fallbacks). diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss index c5bc5e2f726a..27fda3f575bf 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-actor.scss @@ -8,14 +8,14 @@ .site-activity-logs__actor-icon-server, .site-activity-logs__actor-icon-default { - background-color: #dcdcde; - fill: #50575e; + background-color: var(--wpds-color-bg-surface-neutral-weak, #dcdcde); + fill: var(--wpds-color-fg-content-neutral-weak, #50575e); } .site-activity-logs__actor-mcp { display: block; font-size: 0.75rem; - color: #757575; + color: var(--wpds-color-fg-content-neutral-weak, #757575); margin-block-start: 2px; } } diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss index d78471dc127f..bdbd75e09111 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss @@ -8,12 +8,12 @@ min-width: 32px; padding: 4px; border-radius: 2px; - fill: #757575; - background-color: #f0f0f1; + fill: var(--wpds-color-fg-content-neutral-weak, #757575); + background-color: var(--wpds-color-bg-surface-neutral-weak, #f0f0f1); } .site-activity-logs__event { - color: #1e1e1e; + color: var(--wpds-color-fg-content-neutral, #1e1e1e); } .site-activity-logs__event-content { @@ -21,7 +21,7 @@ flex-wrap: wrap; > span { - color: #50575e; + color: var(--wpds-color-fg-content-neutral-weak, #50575e); } } From 778d4c52c248e32236cc0292a16d9cc5ecf14ff5 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:33:48 +0100 Subject: [PATCH 14/44] Activity Log: link event-description entities to wp-admin screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parser already produces typed entity tokens (post, person, comment, plugin, theme) with the IDs and slugs needed to deep-link into wp-admin. Wire each one to its matching core screen so users can jump straight from a log row to the edited object: - post → post.php?post={id}&action=edit - person → user-edit.php?user_id={id} - comment → comment.php?action=editcomment&c={id} - plugin → plugins.php?s={slug} - theme → themes.php?theme={slug} The admin URL prefix comes from the Initial_State `siteData.adminUrl` value so non-standard admin paths (subdirectory installs, custom `admin_url` filters) are respected instead of hard-coding `/wp-admin/`. Entities with no wp-admin analog (site, backup) stay plain-strong. Actor-column linking would need a `wp_user_id` in the activity-log API actor payload and is out of scope here. Refs #48242. --- .../changelog/add-entity-admin-links | 4 ++ .../js/components/ActivityLog/admin-links.ts | 58 +++++++++++++++++++ .../ActivityLog/formatted-block/index.tsx | 49 +++++++++++----- .../ActivityLog/formatted-block/types.ts | 1 + 4 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-entity-admin-links create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts diff --git a/projects/packages/activity-log/changelog/add-entity-admin-links b/projects/packages/activity-log/changelog/add-entity-admin-links new file mode 100644 index 000000000000..583b3317f901 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-entity-admin-links @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Activity Log: link post, user, comment, plugin, and theme entity tokens in event descriptions to their respective wp-admin screens. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts new file mode 100644 index 000000000000..61b4305091dc --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts @@ -0,0 +1,58 @@ +/** + * Resolve wp-admin URLs for entity tokens embedded in an activity-log + * description. The `adminUrl` comes from the Initial_State payload + * (`class-initial-state.php::get_data()`) so we honor non-standard + * installs (subdirectory, custom `admin_url` filter) instead of + * hard-coding `/wp-admin/`. + */ +import type { ActivityBlockNode } from './formatted-block/types'; + +interface InitialStateWithAdminUrl { + siteData?: { adminUrl?: string }; +} + +declare const JPACTIVITYLOG_INITIAL_STATE: InitialStateWithAdminUrl | undefined; + +// Read once at module load; the value doesn't change within a session. +const adminUrlPrefix: string = ( () => { + const raw = + typeof JPACTIVITYLOG_INITIAL_STATE !== 'undefined' + ? JPACTIVITYLOG_INITIAL_STATE?.siteData?.adminUrl + : undefined; + const base = raw && raw.length > 0 ? raw : '/wp-admin/'; + return base.endsWith( '/' ) ? base : `${ base }/`; +} )(); + +const q = ( value: string | number ) => encodeURIComponent( String( value ) ); + +/** + * Build a wp-admin URL for a given activity-log entity token, or null + * when no reasonable target exists (entity has no ID/slug, or no core + * screen matches the entity type). + * + * @param node - The parsed activity-log block node. + * @return A fully-qualified wp-admin URL string, or null. + */ +export function buildAdminLink( node: ActivityBlockNode ): string | null { + switch ( node.type ) { + case 'post': + return node.postId + ? `${ adminUrlPrefix }post.php?post=${ q( node.postId ) }&action=edit` + : null; + case 'person': + return node.userId ? `${ adminUrlPrefix }user-edit.php?user_id=${ q( node.userId ) }` : null; + case 'comment': + return node.commentId + ? `${ adminUrlPrefix }comment.php?action=editcomment&c=${ q( node.commentId ) }` + : null; + case 'plugin': + return node.pluginSlug ? `${ adminUrlPrefix }plugins.php?s=${ q( node.pluginSlug ) }` : null; + case 'theme': + return node.themeSlug ? `${ adminUrlPrefix }themes.php?theme=${ q( node.themeSlug ) }` : null; + // `site` (we're already on it) and `backup` (needs the Backup plugin's + // own route) have no generic wp-admin destination — fall through to + // plain-text rendering. + default: + return null; + } +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx index 01a1844cc110..4bf198ed2fa0 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx @@ -1,15 +1,17 @@ /** * Renders the structured tokens produced by the parser. * - * Ported (simplified) from Calypso's logs-activity-formatted-block. In the - * wp-admin context we can't resolve Calypso routes like /reader/blogs/…, - * /people/edit/…, or /plugins/… — those renderers fall through to their - * children (plain strong text). Direct URL ranges (release notes, docs) still - * render as external links, which covers the common case visible in the - * screenshot (e.g. "Gutenberg 23.0.0 ↗"). + * Ported (simplified) from Calypso's logs-activity-formatted-block. Calypso + * links entities into its own routes (/reader/blogs/…, /people/edit/…, + * /plugins/…); in wp-admin we link into the equivalent core screens + * (post.php, user-edit.php, plugins.php, themes.php, comment.php) via + * `buildAdminLink`. Entities without a wp-admin equivalent (site, backup) + * fall through to plain strong text. Direct URL ranges (release notes, + * docs) still render as external links. */ import { ExternalLink } from '@wordpress/components'; import { Fragment, type MouseEvent, type ReactNode } from 'react'; +import { buildAdminLink } from '../admin-links'; import type { ActivityBlockContent, ActivityBlockMeta, ActivityBlockNode } from './types'; type BlockClickHandler = ( event: MouseEvent< HTMLAnchorElement > ) => void; @@ -56,11 +58,23 @@ const Link: BlockRenderer = ( { content, children, onClick, meta } ) => { ); }; -// Entity renderers (post/comment/person/plugin/theme/backup) render children -// only — the in-admin equivalents would need the plugin itself (e.g. Backup) -// to expose a known route, which we don't have a generic hook for yet. +// Resolve a token's wp-admin destination (if any). Entities without a +// target (site, backup, or a malformed payload missing an id/slug) fall +// through to plain strong text. Wrapping the label in keeps the +// entity visually emphasized whether or not it resolved to a link. +const EntityLink: BlockRenderer = ( { content, children } ) => { + const href = buildAdminLink( content ); + if ( ! href ) { + return { children }; + } + return ( + + { children } + + ); +}; + const EntityAsStrong: BlockRenderer = ( { children } ) => { children }; -const EntityAsFragment: BlockRenderer = ( { children } ) => { children }; const blockTypeMapping: Record< string, BlockRenderer > = { b: ( { children } ) => { children }, @@ -71,12 +85,15 @@ const blockTypeMapping: Record< string, BlockRenderer > = { a: Link, link: Link, filepath: ( { children } ) => { children }, - post: EntityAsFragment, - comment: EntityAsFragment, - person: EntityAsStrong, - plugin: EntityAsFragment, - theme: EntityAsFragment, - backup: EntityAsFragment, + post: EntityLink, + comment: EntityLink, + person: EntityLink, + plugin: EntityLink, + theme: EntityLink, + // site (we're already on it) and backup (needs the Backup plugin's own + // route) have no generic wp-admin target — render as plain strong text. + site: EntityAsStrong, + backup: EntityAsStrong, }; export const createFormattedBlock = ( mapping: Record< string, BlockRenderer > ) => { diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts index d631a621c8b8..d2ca26104cf1 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts @@ -10,6 +10,7 @@ export interface ActivityBlockNode { postId?: number | string; isTrashed?: boolean; commentId?: number | string; + userId?: number | string; name?: string; siteSlug?: string; pluginSlug?: string; From ff954f1acf9a1d4f0f053a051c1bfc32434e120f Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:43:25 +0100 Subject: [PATCH 15/44] Activity Log: route entity tokens to wp-admin, not wordpress.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes on top of the entity-link pass: 1. The WPCOM activity-log API wraps typed entity ranges (post, person, plugin, theme, comment) in an outer anchor whose href points at the `https://wordpress.com/…` equivalent. That outer `Link` was winning and sending users to wordpress.com before the inner `EntityLink` could emit the local wp-admin URL. Match Calypso's Jetpack Cloud guard: if the anchor URL is a wordpress.com URL, drop it and render children only so the inner renderer takes over. This is what makes post entities link to `post.php?post=…` and user entities stop escaping to wordpress.com. 2. Drop the `` wrapper from entity rendering (both linked and unlinked paths) — entity names now render at the same weight as surrounding prose; the anchor alone signals interactivity. Refs #48242. --- .../changelog/fix-entity-admin-links | 4 +++ .../ActivityLog/formatted-block/index.tsx | 34 ++++++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) create mode 100644 projects/packages/activity-log/changelog/fix-entity-admin-links diff --git a/projects/packages/activity-log/changelog/fix-entity-admin-links b/projects/packages/activity-log/changelog/fix-entity-admin-links new file mode 100644 index 000000000000..fb8160514063 --- /dev/null +++ b/projects/packages/activity-log/changelog/fix-entity-admin-links @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activity Log: strip WordPress.com anchor wrappers so entity tokens link to their wp-admin screens, and remove the emphasis on entity names. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx index 4bf198ed2fa0..d9302fcc6984 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx @@ -38,6 +38,12 @@ const FilePath = ( { children }: { children: ReactNode } ) => (

); +// The extra trailing slash prevents hostnames like +// `wordpress.com.malicious.example` from matching. Same guard Calypso's +// formatted-block uses. +const isWordPressDotComUrl = ( url?: string | null ): boolean => + !! url && url.startsWith( 'https://wordpress.com/' ); + const Link: BlockRenderer = ( { content, children, onClick, meta } ) => { const { url, activity, section, intent } = content; @@ -45,6 +51,15 @@ const Link: BlockRenderer = ( { content, children, onClick, meta } ) => { return { children }; } + // The WPCOM activity-log API frequently wraps a typed entity range + // (post/person/…) in an outer anchor pointing at the WordPress.com + // equivalent. In wp-admin those destinations aren't useful — drop the + // URL and render only the children, so the nested entity renderer + // (EntityLink) can emit the local wp-admin link instead. + if ( isWordPressDotComUrl( url ) ) { + return { children }; + } + return ( { // Resolve a token's wp-admin destination (if any). Entities without a // target (site, backup, or a malformed payload missing an id/slug) fall -// through to plain strong text. Wrapping the label in keeps the -// entity visually emphasized whether or not it resolved to a link. +// through to plain text. const EntityLink: BlockRenderer = ( { content, children } ) => { const href = buildAdminLink( content ); if ( ! href ) { - return { children }; + return { children }; } - return ( - - { children } - - ); + return { children }; }; -const EntityAsStrong: BlockRenderer = ( { children } ) => { children }; - const blockTypeMapping: Record< string, BlockRenderer > = { b: ( { children } ) => { children }, strong: ( { children } ) => { children }, @@ -91,9 +99,9 @@ const blockTypeMapping: Record< string, BlockRenderer > = { plugin: EntityLink, theme: EntityLink, // site (we're already on it) and backup (needs the Backup plugin's own - // route) have no generic wp-admin target — render as plain strong text. - site: EntityAsStrong, - backup: EntityAsStrong, + // route) have no generic wp-admin target — render as plain text. + site: ( { children } ) => { children }, + backup: ( { children } ) => { children }, }; export const createFormattedBlock = ( mapping: Record< string, BlockRenderer > ) => { From 0ca1cd44467b62fc6923fc8da34ff6ce0542a1d0 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 12:58:19 +0100 Subject: [PATCH 16/44] Activity Log: link entities that don't surface as typed ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real payloads from /jetpack/v4/activity-log expose entity identity three ways. The previous pass only handled typed ranges (type: 'post'/'person'/…); this adds the other two: Section-tagged anchor ranges. The login event's content.ranges is a single range with type: 'a', url pointing at wordpress.com, and `section: 'user'` + `id: 1`. The parser now preserves `id` and `site_id` on link nodes, `buildAdminLink` accepts link/a nodes and routes by `section`, and the Link renderer tries the local link before its wordpress.com-drop guard. Net effect: "keoshi successfully logged in…" now links the username to user-edit.php. Entry-level object fallback. post__published events return no content.ranges at all — the post title lives only in the top-level `object: { type: 'Article', object_id, name }`. Activity now carries `activityObject` end-to-end, a new `buildObjectAdminLink` maps Article→post.php and Person→user-edit.php, and ActivityEvent wraps the whole description in a link when there's a linkable object and the description has no ranges of its own. Refs #48242. --- .../changelog/fix-entity-link-fallbacks | 4 + .../components/ActivityLog/ActivityEvent.tsx | 21 ++++- .../ActivityLog/activity-transformer.ts | 2 + .../js/components/ActivityLog/admin-links.ts | 91 +++++++++++++++---- .../ActivityLog/formatted-block/index.tsx | 22 ++++- .../ActivityLog/formatted-block/parser.ts | 8 +- .../ActivityLog/formatted-block/types.ts | 4 + .../src/js/components/ActivityLog/types.ts | 11 +++ 8 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 projects/packages/activity-log/changelog/fix-entity-link-fallbacks diff --git a/projects/packages/activity-log/changelog/fix-entity-link-fallbacks b/projects/packages/activity-log/changelog/fix-entity-link-fallbacks new file mode 100644 index 000000000000..44224f97d386 --- /dev/null +++ b/projects/packages/activity-log/changelog/fix-entity-link-fallbacks @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activity Log: link user names in login descriptions (via section-tagged anchors) and link entire descriptions for entries whose subject only surfaces through the entry-level object (e.g. post__published). diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx index bb98c40ab165..c0da43e2848a 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx @@ -1,5 +1,6 @@ import { __experimentalHStack as HStack } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { Icon } from '@wordpress/icons'; +import { buildObjectAdminLink } from './admin-links'; import { renderFormattedContent } from './formatted-block'; import { gridiconToWordPressIcon } from './gridicons'; import type { Activity } from './types'; @@ -14,11 +15,27 @@ import './activity-event.scss'; * @return The event cell. */ export function ActivityEvent( { activity }: { activity: Activity } ) { - const { activityDescription, activityIcon, activityTitle } = activity; + const { activityDescription, activityIcon, activityObject, activityTitle } = activity; + + const hasRanges = activityDescription.items.some( + item => typeof item === 'object' && item !== null && 'type' in item + ); + + // Token-level links inside the description are produced by + // FormattedBlock. When the description has no ranges at all — e.g. a + // post__published event where `content.text` is literally the post + // title — fall back to the entry-level `object` and wrap the whole + // description in a link. + const objectHref = hasRanges ? null : buildObjectAdminLink( activityObject ); + const formattedContent = activityDescription.items.length ? renderFormattedContent( { items: activityDescription.items } ) : null; + const descriptionNode = + formattedContent && + ( objectHref ? { formattedContent } : formattedContent ); + return ( { activityIcon && ( @@ -35,7 +52,7 @@ export function ActivityEvent( { activity }: { activity: Activity } ) { className="site-activity-logs__event-content" > { activityTitle } - { formattedContent && { formattedContent } } + { descriptionNode && { descriptionNode } } ); diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts index 0cf1c185f2ff..4f287246b1bc 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts @@ -50,6 +50,7 @@ export const transformActivityLogEntry = ( entry: ActivityLogEntry ): Activity = gridicon, activity_id: rawActivityId, name, + object, status, summary, published, @@ -68,6 +69,7 @@ export const transformActivityLogEntry = ( entry: ActivityLogEntry ): Activity = activityId: rawActivityId, activityMedia: normalizeActivityMedia( image ), activityName: name, + activityObject: object, activityStatus: status ?? '', activityTitle: summary, activityUnparsedTs: published ?? '', diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts index 61b4305091dc..e8cb46452e24 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts @@ -1,11 +1,23 @@ /** - * Resolve wp-admin URLs for entity tokens embedded in an activity-log - * description. The `adminUrl` comes from the Initial_State payload - * (`class-initial-state.php::get_data()`) so we honor non-standard - * installs (subdirectory, custom `admin_url` filter) instead of + * Resolve wp-admin URLs for activity-log entities. The WPCOM activity-log + * API exposes entity identity three different ways, and we handle all + * three: + * + * 1. Typed range nodes (type: 'post'/'person'/'plugin'/…) with dedicated + * id/slug fields — handled by `buildAdminLink`. + * 2. Anchor ranges (type: 'a'/'link') with a `section` hint and `id` + * (e.g. section: 'user', id: 42) — also handled by `buildAdminLink`. + * 3. A top-level `object` on the entry (e.g. object: { type: 'Article', + * object_id: 25 }) used when the description has no ranges at all — + * handled by `buildObjectAdminLink`. + * + * The `adminUrl` prefix comes from the Initial_State payload + * (`class-initial-state.php::get_data()`) so non-standard installs + * (subdirectory, custom `admin_url` filter) are respected instead of * hard-coding `/wp-admin/`. */ import type { ActivityBlockNode } from './formatted-block/types'; +import type { ActivityLogObject } from './types'; interface InitialStateWithAdminUrl { siteData?: { adminUrl?: string }; @@ -25,33 +37,74 @@ const adminUrlPrefix: string = ( () => { const q = ( value: string | number ) => encodeURIComponent( String( value ) ); +const postEditUrl = ( id: string | number ) => + `${ adminUrlPrefix }post.php?post=${ q( id ) }&action=edit`; +const userEditUrl = ( id: string | number ) => + `${ adminUrlPrefix }user-edit.php?user_id=${ q( id ) }`; +const commentEditUrl = ( id: string | number ) => + `${ adminUrlPrefix }comment.php?action=editcomment&c=${ q( id ) }`; +const pluginSearchUrl = ( slug: string ) => `${ adminUrlPrefix }plugins.php?s=${ q( slug ) }`; +const themeDetailsUrl = ( slug: string ) => `${ adminUrlPrefix }themes.php?theme=${ q( slug ) }`; + /** - * Build a wp-admin URL for a given activity-log entity token, or null - * when no reasonable target exists (entity has no ID/slug, or no core - * screen matches the entity type). + * Build a wp-admin URL from a parsed block node (typed entity range or + * section-tagged anchor), or null when no target can be derived. * * @param node - The parsed activity-log block node. * @return A fully-qualified wp-admin URL string, or null. */ export function buildAdminLink( node: ActivityBlockNode ): string | null { + // Typed entity ranges — dedicated id/slug fields. switch ( node.type ) { case 'post': - return node.postId - ? `${ adminUrlPrefix }post.php?post=${ q( node.postId ) }&action=edit` - : null; + return node.postId ? postEditUrl( node.postId ) : null; case 'person': - return node.userId ? `${ adminUrlPrefix }user-edit.php?user_id=${ q( node.userId ) }` : null; + return node.userId ? userEditUrl( node.userId ) : null; case 'comment': - return node.commentId - ? `${ adminUrlPrefix }comment.php?action=editcomment&c=${ q( node.commentId ) }` - : null; + return node.commentId ? commentEditUrl( node.commentId ) : null; case 'plugin': - return node.pluginSlug ? `${ adminUrlPrefix }plugins.php?s=${ q( node.pluginSlug ) }` : null; + return node.pluginSlug ? pluginSearchUrl( String( node.pluginSlug ) ) : null; case 'theme': - return node.themeSlug ? `${ adminUrlPrefix }themes.php?theme=${ q( node.themeSlug ) }` : null; - // `site` (we're already on it) and `backup` (needs the Backup plugin's - // own route) have no generic wp-admin destination — fall through to - // plain-text rendering. + return node.themeSlug ? themeDetailsUrl( String( node.themeSlug ) ) : null; + } + + // Anchor ranges carrying entity identity via `section` + `id`. Common + // when the WPCOM payload wraps a name in an `` pointing at a + // wordpress.com path (e.g. /people/edit/{blog}/{name}) and tags the + // range with section: 'user'. + if ( ( node.type === 'link' || node.type === 'a' ) && node.id !== undefined ) { + switch ( node.section ) { + case 'user': + return userEditUrl( node.id ); + case 'post': + return postEditUrl( node.id ); + case 'comment': + return commentEditUrl( node.id ); + } + } + + return null; +} + +/** + * Build a wp-admin URL from the entry's top-level `object` field, used + * when the activity description carries no ranges and the only way to + * identify the subject is the entry-level object (e.g. a `post__published` + * event whose content.text is literally the post title). + * + * @param object - The entry's `object` field, if present. + * @return A fully-qualified wp-admin URL string, or null. + */ +export function buildObjectAdminLink( object?: ActivityLogObject ): string | null { + if ( ! object ) { + return null; + } + const { type, object_id: objectId, external_user_id: externalUserId } = object; + switch ( type ) { + case 'Article': + return objectId ? postEditUrl( objectId ) : null; + case 'Person': + return externalUserId ? userEditUrl( externalUserId ) : null; default: return null; } diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx index d9302fcc6984..5881d5c0f265 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx @@ -51,11 +51,23 @@ const Link: BlockRenderer = ( { content, children, onClick, meta } ) => { return { children }; } - // The WPCOM activity-log API frequently wraps a typed entity range - // (post/person/…) in an outer anchor pointing at the WordPress.com - // equivalent. In wp-admin those destinations aren't useful — drop the - // URL and render only the children, so the nested entity renderer - // (EntityLink) can emit the local wp-admin link instead. + // Anchor ranges frequently carry section + id hints (e.g. + // section: 'user', id: 42) pointing at a WordPress.com URL. Prefer + // the local wp-admin equivalent when we can derive one, regardless + // of the outer URL. + const adminHref = buildAdminLink( content ); + if ( adminHref ) { + return ( + + { children } + + ); + } + + // No local equivalent. If the URL itself is a wordpress.com URL, + // drop it — those destinations aren't useful from wp-admin and any + // nested entity renderer (EntityLink) can still emit its own link + // from the children tree. if ( isWordPressDotComUrl( url ) ) { return { children }; } diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts index 65800e3ecc95..e9d0eae327fb 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts @@ -70,11 +70,17 @@ const commentNode = ( { siteId, } ); -const linkNode = ( { url, intent, section }: RangeWithChildren ) => ( { +const linkNode = ( { url, intent, section, id, site_id: siteId }: RangeWithChildren ) => ( { type: 'link', url, intent, section, + // `id` + `site_id` let the renderer build a local wp-admin link for + // anchors that carry section hints (e.g. section: 'user', id: 42 → + // `user-edit.php?user_id=42`), even when the `url` itself points at + // wordpress.com. + id, + siteId, } ); const postNode = ( { id: postId, site_id: siteId, published }: RangeWithChildren ) => ( { diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts index d2ca26104cf1..c72e1a254f2f 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts @@ -6,6 +6,10 @@ export interface ActivityBlockNode { activity?: string; section?: string; intent?: string; + // `id` arrives on anchor ranges that carry a `section` hint (e.g. the + // local WP user id on a user anchor). Typed entity ranges put their + // identifier into a dedicated field (postId/userId/…) instead. + id?: number | string; siteId?: number | string; postId?: number | string; isTrashed?: boolean; diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/types.ts b/projects/packages/activity-log/src/js/components/ActivityLog/types.ts index 48398c00c8ae..b9ec50b69388 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/types.ts +++ b/projects/packages/activity-log/src/js/components/ActivityLog/types.ts @@ -31,6 +31,7 @@ export interface Activity { activityId: string; activityMedia: ActivityMediaDetails; activityName: string; + activityObject?: ActivityLogObject; activityStatus: string; activityTitle: string; activityTs: number; @@ -84,6 +85,15 @@ export interface ActivityLogEntryImage { url?: string; } +export interface ActivityLogObject { + type?: string; + name?: string; + object_id?: number | string; + external_user_id?: number | string; + wpcom_user_id?: number | string; + [ key: string ]: unknown; +} + export interface ActivityLogEntry { activity_id: string; actor?: ActivityLogActor; @@ -92,6 +102,7 @@ export interface ActivityLogEntry { image?: ActivityLogEntryImage | null; name: string; is_rewindable?: boolean; + object?: ActivityLogObject; published?: string; rewind_id?: string; status?: 'error' | 'info' | 'success' | 'warning' | null; From a60f9953846246b80e42db190cee9903395d7bf1 Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 13:23:12 +0100 Subject: [PATCH 17/44] =?UTF-8?q?Activity=20Log:=20Phase=204=20=E2=80=94?= =?UTF-8?q?=20tier=20gating=20and=20upsell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the full Activity Log behind a paid Backup-enabled plan. Mirrors Calypso's gating (free tier: last 20 events only, no search/filters/ sort/pagination) while adding a real server-side cap so the limit can't be bypassed from DevTools. Server-side: - `REST_Controller::has_activity_logs_access()` calls WPCOM's `/sites/{id}/rewind` endpoint (same signal Jetpack_Backup uses) and caches the boolean in a site transient for 5 minutes, keyed on blog_id. Fail-closed (no access) on error, with a short 1-min cache so transient WPCOM hiccups don't hammer the endpoint. - `get_activity_log` clamps `number` to 20 and forces `page=1` when access is false, regardless of what the caller sent. `wp.apiFetch` from the console can't page past the free-tier boundary. - `Initial_State.siteData.hasActivityLogsAccess` exposes the same boolean to the React bundle so the UI starts in the right state on page load. Client-side: - Read `hasActivityLogsAccess` from the initial state; when false, force `config.perPageSizes = [ 20 ]` on DataViews, zero out `paginationInfo.totalPages`, and replace the default UI via `children={}` (hides search, filters, sort, view-config). Same switches as Calypso at wp-calypso:client/dashboard/sites/logs-activity/dataviews/ index.tsx:201-208. - `UpsellCallout` renders beneath the table when gated and `logData` is non-empty. Title, body copy, and the illustration SVG are a 1:1 port of Calypso's ActivityLogsCallout. CTA flows through Jetpack's standard `useProductCheckoutWorkflow` with `productSlug: 'jetpack_security_t1_yearly'` (Security bundle unlocks 30 days of history per cloud.jetpack.com/features/comparison) and `from: 'activity-log-page-purchase'` as the checkout source. Closes Phase 4 of #48242. Refs #48242. --- .../changelog/add-phase-4-tier-gating | 4 + .../activity-log/src/class-initial-state.php | 18 ++-- .../src/class-rest-controller.php | 98 +++++++++++++++++++ .../components/ActivityLog/UpsellCallout.tsx | 75 ++++++++++++++ .../activity-logs-callout-illustration.svg | 52 ++++++++++ .../src/js/components/ActivityLog/index.tsx | 44 ++++++++- .../ActivityLog/upsell-callout.scss | 49 ++++++++++ 7 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-phase-4-tier-gating create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/activity-logs-callout-illustration.svg create mode 100644 projects/packages/activity-log/src/js/components/ActivityLog/upsell-callout.scss diff --git a/projects/packages/activity-log/changelog/add-phase-4-tier-gating b/projects/packages/activity-log/changelog/add-phase-4-tier-gating new file mode 100644 index 000000000000..7931967b7137 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-phase-4-tier-gating @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Activity Log: gate full log access behind a paid Backup-enabled plan. Free tier sees the 20 most recent events with search, filters, sort and pagination disabled, plus an upsell callout. Server-side clamp in the REST proxy prevents bypass. diff --git a/projects/packages/activity-log/src/class-initial-state.php b/projects/packages/activity-log/src/class-initial-state.php index e000f3d8b71c..0441c13bb79c 100644 --- a/projects/packages/activity-log/src/class-initial-state.php +++ b/projects/packages/activity-log/src/class-initial-state.php @@ -49,13 +49,17 @@ private function get_data() { 'calypsoSlug' => ( new Status() )->get_site_suffix(), ), 'siteData' => array( - 'id' => Jetpack_Options::get_option( 'id' ), - 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), - 'adminUrl' => esc_url( admin_url() ), - 'slug' => is_string( $home_host ) ? $home_host : '', - 'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0, - 'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '', - 'locale' => str_replace( '_', '-', (string) get_locale() ), + 'id' => Jetpack_Options::get_option( 'id' ), + 'title' => get_bloginfo( 'name' ) ? get_bloginfo( 'name' ) : get_site_url(), + 'adminUrl' => esc_url( admin_url() ), + 'slug' => is_string( $home_host ) ? $home_host : '', + 'gmtOffset' => is_numeric( $gmt_offset ) ? (float) $gmt_offset : 0.0, + 'timezoneString' => is_string( $timezone_string ) ? $timezone_string : '', + 'locale' => str_replace( '_', '-', (string) get_locale() ), + // The paid-plan capability check. Drives the free-tier + // upsell callout and matches the server-side clamp in + // REST_Controller::get_activity_log(). + 'hasActivityLogsAccess' => REST_Controller::has_activity_logs_access(), ), 'assets' => array( 'buildUrl' => plugins_url( '../build/', __FILE__ ), diff --git a/projects/packages/activity-log/src/class-rest-controller.php b/projects/packages/activity-log/src/class-rest-controller.php index c20113eb5097..4b306068d4cc 100644 --- a/projects/packages/activity-log/src/class-rest-controller.php +++ b/projects/packages/activity-log/src/class-rest-controller.php @@ -23,12 +23,15 @@ use WP_REST_Request; use WP_REST_Server; use function current_user_can; +use function delete_site_transient; use function esc_html__; +use function get_site_transient; use function http_build_query; use function is_wp_error; use function json_decode; use function register_rest_route; use function rest_ensure_response; +use function set_site_transient; use function wp_remote_retrieve_body; use function wp_remote_retrieve_response_code; @@ -44,6 +47,28 @@ class REST_Controller { */ const REST_NAMESPACE = 'jetpack/v4'; + /** + * Max items returned per request on the free tier. Matches the "20 most + * recent events" copy used by Calypso's upsell callout. + * + * @var int + */ + const FREE_TIER_ITEM_CAP = 20; + + /** + * Site-transient TTL for the has-access capability check. + * + * @var int + */ + const CAPABILITY_CACHE_TTL = 5 * MINUTE_IN_SECONDS; + + /** + * Transient key prefix for the per-blog access cache. + * + * @var string + */ + const CAPABILITY_CACHE_KEY = 'jetpack_activity_log_has_access_'; + /** * Query params accepted by the list endpoint. Shape matches Calypso's * ActivityLogParams so the ported UI can forward its filter state @@ -188,13 +213,86 @@ public static function permissions_callback() { return true; } + /** + * Whether the site's current plan unlocks the full activity log. + * + * Reads the WPCOM `/sites/{id}/rewind` state endpoint (same signal + * `Jetpack_Backup::has_backup_plan()` uses) and caches the boolean for + * {@see self::CAPABILITY_CACHE_TTL} seconds in a site transient so the + * list endpoint doesn't pay the round-trip on every pagination page. + * The cache is per-blog (fine for multisite) and keyed on `blog_id`. + * + * @return bool True when the site has a paid Backup-enabled plan. + */ + public static function has_activity_logs_access() { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return false; + } + + $cache_key = self::CAPABILITY_CACHE_KEY . $blog_id; + $cached = get_site_transient( $cache_key ); + if ( false !== $cached ) { + return 'yes' === $cached; + } + + $response = Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%d/rewind?force=wpcom', $blog_id ), + '2', + array( 'timeout' => 2 ), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + // Fail closed: assume no access if we can't reach WPCOM. Cache for + // a short window to avoid hammering the endpoint on every call. + set_site_transient( $cache_key, 'no', MINUTE_IN_SECONDS ); + return false; + } + + $body = json_decode( wp_remote_retrieve_body( $response ) ); + $state = is_object( $body ) && isset( $body->state ) ? (string) $body->state : ''; + $has_it = $state !== '' && $state !== 'unavailable'; + set_site_transient( $cache_key, $has_it ? 'yes' : 'no', self::CAPABILITY_CACHE_TTL ); + return $has_it; + } + + /** + * Clear the cached has-access flag. Exposed so front-end flows that + * know the plan just changed (e.g. a successful checkout redirect) can + * force a refresh on the next request. + * + * @return void + */ + public static function clear_access_cache() { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( $blog_id ) { + delete_site_transient( self::CAPABILITY_CACHE_KEY . $blog_id ); + } + } + /** * Proxy the paginated activity list. * + * Enforces the free-tier cap server-side — when the site doesn't have + * access, `number` is clamped to {@see self::FREE_TIER_ITEM_CAP} and + * `page` is forced to 1, regardless of what the caller sent. That way + * a client-side bypass (DevTools, direct `wp.apiFetch`) can't page + * past the free-tier boundary. + * * @param WP_REST_Request $request Request. * @return mixed */ public static function get_activity_log( WP_REST_Request $request ) { + if ( ! self::has_activity_logs_access() ) { + $requested = (int) $request->get_param( 'number' ); + $request->set_param( + 'number', + $requested > 0 ? min( $requested, self::FREE_TIER_ITEM_CAP ) : self::FREE_TIER_ITEM_CAP + ); + $request->set_param( 'page', 1 ); + } return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) ); } diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx new file mode 100644 index 000000000000..c0ef61cb4164 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx @@ -0,0 +1,75 @@ +/** + * Free-tier upsell shown beneath the Activity Log table. Title, copy, + * and illustration are a 1:1 port of Calypso's `ActivityLogsCallout` + * (client/dashboard/sites/logs-activity/activity-logs-callout.tsx). + * The CTA is wp-admin-native: it routes through Jetpack's standard + * `useProductCheckoutWorkflow` into wordpress.com/checkout/{siteSuffix}/ + * {productSlug}?source=activity-log-page-purchase&redirect_to=. + * + * Destination product: `jetpack_security_t1_yearly` — the Security + * bundle unlocks 30 days of activity history (the cap documented on + * cloud.jetpack.com/features/comparison). + */ +import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; +import { Button, __experimentalText as Text } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis +import { __ } from '@wordpress/i18n'; +import illustrationUrl from './activity-logs-callout-illustration.svg'; +import './upsell-callout.scss'; + +const PRODUCT_SLUG = 'jetpack_security_t1_yearly'; +const UPSELL_SOURCE = 'activity-log-page-purchase'; + +/** + * DataViews-adjacent upsell banner. Rendered as a sibling to the table + * (not nested inside DataViews) so it sits below the locked view and + * aligns with the page's AdminPage container. + * + * @return The callout element. + */ +export function UpsellCallout() { + const redirectUrl = typeof window !== 'undefined' ? window.location.href : ''; + const { run, hasCheckoutStarted } = useProductCheckoutWorkflow( { + productSlug: PRODUCT_SLUG, + redirectUrl, + from: UPSELL_SOURCE, + } ); + + return ( +
+ +
+

+ { __( 'Track every action with Jetpack Activity', 'jetpack-activity-log' ) } +

+ + { __( + 'Debug issues faster with insights from a comprehensive audit log of all your admin activities.', + 'jetpack-activity-log' + ) } + + + { __( + 'With your free plan, you can see your 20 most recent events. Upgrade for 30 days of history, plus filtering and date range controls.', + 'jetpack-activity-log' + ) } + + + { __( 'Available on WordPress.com paid plans.', 'jetpack-activity-log' ) } + + +
+
+ ); +} diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/activity-logs-callout-illustration.svg b/projects/packages/activity-log/src/js/components/ActivityLog/activity-logs-callout-illustration.svg new file mode 100644 index 000000000000..e6b0fb17ee8c --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-logs-callout-illustration.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx index 5c41e3b2b249..845ba7b9e146 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -1,9 +1,9 @@ /** * Top-level Activity Log admin page. Ported from Calypso's * `client/dashboard/sites/logs-activity/dataviews/index.tsx`. Scope - * simplifications vs. the source are tracked in the Phase 3 PR: no date - * range picker, no URL-persistent view state, no analytics, no tier - * gating, no upsell callout. + * simplifications vs. the source are tracked in the PR (#48244): no + * date range picker, no URL-persistent view state (localStorage + * only), no analytics events. */ import { AdminPage } from '@automattic/jetpack-components'; import { useQuery } from '@tanstack/react-query'; @@ -13,6 +13,7 @@ import fastDeepEqual from 'fast-deep-equal/es6'; import { useCallback, useMemo } from 'react'; import { activityLogQuery, activityLogGroupCountsQuery } from '../../hooks/use-activity-log'; import { usePersistentView } from '../../hooks/use-persistent-view'; +import { UpsellCallout } from './UpsellCallout'; import { useActivityActions } from './actions'; import { transformActivityLogEntry } from './activity-transformer'; import { useActivityFields } from './fields'; @@ -27,6 +28,7 @@ interface InitialState { siteData?: { gmtOffset?: number; timezoneString?: string; + hasActivityLogsAccess?: boolean; }; } @@ -50,6 +52,20 @@ const readSiteTimeContext = (): { gmtOffset: number; timezoneString?: string } = }; }; +/** + * Read the paid-plan capability flag seeded by Initial_State. Defaults + * to `true` when the global isn't present (storybook/tests) so the + * free-tier gating path only activates from a real backend signal. + * + * @return Whether the site has full Activity Log access. + */ +const readHasActivityLogsAccess = (): boolean => { + if ( typeof JPACTIVITYLOG_INITIAL_STATE === 'undefined' ) { + return true; + } + return JPACTIVITYLOG_INITIAL_STATE?.siteData?.hasActivityLogsAccess !== false; +}; + /** * The Activity Log admin page. Renders the DataViews table and drives * its dataset/filter/counts queries against /jetpack/v4/activity-log. @@ -58,6 +74,7 @@ const readSiteTimeContext = (): { gmtOffset: number; timezoneString?: string } = */ export default function ActivityLog() { const { gmtOffset, timezoneString } = readSiteTimeContext(); + const hasActivityLogsAccess = readHasActivityLogsAccess(); const { view, setView, resetView, isViewModified } = usePersistentView( DEFAULT_VIEW ); const activityLogTypeValues = useMemo( () => { @@ -105,7 +122,11 @@ export default function ActivityLog() { const paginationInfo = { totalItems: activityLogData?.totalItems ?? 0, - totalPages: activityLogData?.totalPages ?? 0, + // Zero `totalPages` on the free tier to hide DataViews' pagination + // controls. The server-side clamp in REST_Controller already caps + // the returned set at FREE_TIER_ITEM_CAP; this just keeps the UI + // honest. + totalPages: hasActivityLogsAccess ? activityLogData?.totalPages ?? 0 : 0, }; const fields = useActivityFields( { @@ -163,6 +184,16 @@ export default function ActivityLog() { defaultLayouts={ { table: {} } } onChangeView={ onChangeView } onReset={ isViewModified ? resetView : false } + // On the free tier, lock the perPage selector to the + // capped size and hide search/filters/sort/view-config + // by replacing the default UI with just the table (same + // switches Calypso uses at logs-activity/dataviews/ + // index.tsx:201-208). + config={ + hasActivityLogsAccess + ? undefined + : { perPageSizes: [ ACTIVITY_LOGS_DEFAULT_PAGE_SIZE ] } + } empty={

{ view.search @@ -170,7 +201,10 @@ export default function ActivityLog() { : __( 'No activities', 'jetpack-activity-log' ) }

} - /> + > + { hasActivityLogsAccess ? undefined : } +
+ { ! hasActivityLogsAccess && ! isFetching && logData.length > 0 && } ); diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/upsell-callout.scss b/projects/packages/activity-log/src/js/components/ActivityLog/upsell-callout.scss new file mode 100644 index 000000000000..3d025da7b103 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/upsell-callout.scss @@ -0,0 +1,49 @@ +// Upsell callout rendered below the Activity Log table on free plans. +// Visual intent mirrors Calypso's `ActivityLogsCallout` layout: a centered +// card with the illustration on one side, copy + CTA on the other. +.jp-activity-log__upsell-callout { + display: flex; + flex-direction: column-reverse; + align-items: center; + gap: 24px; + padding: 32px 24px; + margin: 32px auto 0; + max-width: 960px; + + @media (min-width: 782px) { + flex-direction: row; + align-items: center; + gap: 48px; + padding: 40px; + } +} + +.jp-activity-log__upsell-callout-image { + display: block; + width: 100%; + max-width: 320px; + height: auto; + flex-shrink: 0; +} + +.jp-activity-log__upsell-callout-content { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1 1 auto; + min-width: 0; +} + +.jp-activity-log__upsell-callout-title { + margin: 0; + font-size: 24px; + line-height: 1.3; + font-weight: 500; + color: var(--wpds-color-fg-content-neutral, #1e1e1e); +} + +// The + + + + ); +} diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/datetime.ts b/projects/packages/activity-log/src/js/components/DateRangePicker/datetime.ts new file mode 100644 index 000000000000..b1944d0ef1cf --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/datetime.ts @@ -0,0 +1,88 @@ +/** + * Minimum-surface date/time helpers used by the Activity Log date-range + * picker. Ported from Calypso's `client/dashboard/utils/datetime.ts` — + * only the four functions the picker actually needs are lifted here so + * the port stays self-contained (no cross-package utils dependency). + */ +import { dateI18n } from '@wordpress/date'; +import { parse, isValid, format as fnsFormat } from 'date-fns'; + +const HOUR_MS = 3_600_000; +const YMD_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Localized date formatting via `Intl.DateTimeFormat`. + * + * @param date - The date to format. + * @param locale - BCP 47 locale tag (e.g. `en-US`). + * @param formatOptions - `Intl.DateTimeFormatOptions`; defaults to medium. + * @return Formatted string, or `''` when `date` is invalid. + */ +export function formatDate( + date: Date, + locale: string, + formatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium' } +): string { + if ( isNaN( date.getTime() ) ) { + return ''; + } + return new Intl.DateTimeFormat( locale, formatOptions ).format( date ); +} + +/** + * Parse a `YYYY-MM-DD` string as a local-time Date. Returns null for + * anything malformed or for "overflow" dates like `2023-02-31` that + * date-fns would otherwise silently normalize. + * + * @param value - The input string. + * @return Parsed Date, or null. + */ +export function parseYmdLocal( value: string ): Date | null { + if ( ! YMD_REGEX.test( value ) ) { + return null; + } + const parsed = parse( value, 'yyyy-MM-dd', new Date() ); + if ( ! isValid( parsed ) ) { + return null; + } + return fnsFormat( parsed, 'yyyy-MM-dd' ) === value ? parsed : null; +} + +/** + * Format a Date as the site's calendar day (`YYYY-MM-DD`). Respects + * the site timezone string when provided; otherwise falls back to the + * numeric `gmtOffset`; finally to the user-locale default. + * + * @param date - The Date to format. + * @param timezoneString - IANA timezone identifier. + * @param gmtOffset - Offset in hours. + * @return `YYYY-MM-DD` string. + */ +export function formatYmd( date: Date, timezoneString?: string, gmtOffset?: number ): string { + if ( timezoneString ) { + return dateI18n( 'Y-m-d', date, timezoneString ); + } + if ( typeof gmtOffset === 'number' ) { + const shifted = new Date( date.getTime() + gmtOffset * HOUR_MS ); + const year = shifted.getUTCFullYear(); + const month = String( shifted.getUTCMonth() + 1 ).padStart( 2, '0' ); + const day = String( shifted.getUTCDate() ).padStart( 2, '0' ); + return `${ year }-${ month }-${ day }`; + } + return dateI18n( 'Y-m-d', date ); +} + +/** + * Format a Date that already represents a site calendar day as + * `YYYY-MM-DD`, without reapplying timezone math. Used for Dates that + * came out of the picker or from a URL. + * + * @param date - Date to format. + * @return `YYYY-MM-DD` string. + */ +export function formatSiteYmd( date: Date ): string { + const year = date.getFullYear(); + const month = String( date.getMonth() + 1 ).padStart( 2, '0' ); + const day = String( date.getDate() ).padStart( 2, '0' ); + return `${ year }-${ month }-${ day }`; +} diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx b/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx new file mode 100644 index 000000000000..6abd0a599e3d --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx @@ -0,0 +1,226 @@ +/** + * Date-range picker shown above the Activity Log table on paid tiers. + * Port of Calypso's + * `client/dashboard/components/date-range-picker/index.tsx`. + * + * The picker is two pieces: a Dropdown toggle showing the current + * label, and the `DateRangeContent` popover with the preset sidebar, + * date inputs, and calendar. State inside the popover is intentionally + * remounted whenever the committed range changes (via the `resetKey`) + * so draft edits don't linger across opens. + */ +import { Dropdown, Tooltip, Button } from '@wordpress/components'; +import { useMediaQuery, useInstanceId } from '@wordpress/compose'; +import { __, sprintf } from '@wordpress/i18n'; +import { calendar } from '@wordpress/icons'; +import { useMemo, useState } from 'react'; +import { DateRangeContent } from './date-range-content'; +import { parseYmdLocal, formatYmd, formatSiteYmd } from './datetime'; +import { formatLabel } from './utils'; +import type { PresetId } from './utils'; +import './style.scss'; + +type DateRangePickerProps = { + start: Date; + end: Date; + onChange: ( next: { start: Date; end: Date } ) => void; + timezoneString?: string; + gmtOffset?: number; + locale: string; + disableFuture?: boolean; + defaultFallbackPreset?: PresetId; + inputsProps?: { + onStartFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onEndFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onStartBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onEndBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + }; +}; + +/** + * + * @param root0 + * @param root0.start + * @param root0.end + * @param root0.onChange + * @param root0.gmtOffset + * @param root0.timezoneString + * @param root0.locale + * @param root0.disableFuture + * @param root0.defaultFallbackPreset + * @param root0.inputsProps + */ +export function DateRangePicker( { + start, + end, + onChange, + gmtOffset, + timezoneString, + locale, + disableFuture = true, + defaultFallbackPreset = 'last-7-days', + inputsProps, +}: DateRangePickerProps ) { + const isSmall = useMediaQuery( '(max-width: 600px)' ); + const showTwoMonths = useMediaQuery( '(min-width: 900px)' ); + const instanceId = useInstanceId( DateRangePicker, 'daterange' ); + const mobileLabelId = `presets-label-${ instanceId }-mobile`; + const desktopLabelId = `presets-label-${ instanceId }-desktop`; + + const label = formatLabel( start, end, locale ); + + const resetKey = [ + formatSiteYmd( start ), + formatSiteYmd( end ), + timezoneString ?? '', + gmtOffset ?? '', + ].join( '|' ); + + return ( + ( + +
+ +
+
+ ) } + renderContent={ ( { onClose } ) => ( + + ) } + /> + ); +} + +/** + * + * @param root0 + * @param root0.isSmall + * @param root0.showTwoMonths + * @param root0.start + * @param root0.end + * @param root0.timezoneString + * @param root0.gmtOffset + * @param root0.onChange + * @param root0.onClose + * @param root0.mobileLabelId + * @param root0.desktopLabelId + * @param root0.disableFuture + * @param root0.defaultFallbackPreset + * @param root0.inputsProps + * @param root0.inputsProps.onStartFocus + * @param root0.inputsProps.onEndFocus + * @param root0.inputsProps.onStartBlur + * @param root0.inputsProps.onEndBlur + */ +function DateRangePickerInner( { + isSmall, + showTwoMonths, + start, + end, + timezoneString, + gmtOffset, + onChange, + onClose, + mobileLabelId, + desktopLabelId, + disableFuture, + defaultFallbackPreset, + inputsProps, +}: { + isSmall: boolean; + showTwoMonths: boolean; + start: Date; + end: Date; + timezoneString?: string; + gmtOffset?: number; + onChange: ( next: { start: Date; end: Date } ) => void; + onClose: () => void; + mobileLabelId: string; + desktopLabelId: string; + disableFuture: boolean; + defaultFallbackPreset: PresetId; + inputsProps?: { + onStartFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onEndFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onStartBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + onEndBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void; + }; +} ) { + const [ fromDraft, setFromDraft ] = useState< Date | undefined >( () => start ); + const [ toDraft, setToDraft ] = useState< Date | undefined >( () => end ); + const [ fromStr, setFromStr ] = useState( () => formatSiteYmd( start ) ); + const [ toStr, setToStr ] = useState( () => formatSiteYmd( end ) ); + const [ compositeActiveId, setCompositeActiveId ] = useState< string | null >( null ); + + const today = useMemo( () => { + const parsed = parseYmdLocal( formatYmd( new Date(), timezoneString, gmtOffset ) ); + return ( + parsed ?? new Date( new Date().getFullYear(), new Date().getMonth(), new Date().getDate() ) + ); + }, [ timezoneString, gmtOffset ] ); + + const todayStr = useMemo( () => formatSiteYmd( today ), [ today ] ); + + return ( + + ); +} diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/presets-listbox.tsx b/projects/packages/activity-log/src/js/components/DateRangePicker/presets-listbox.tsx new file mode 100644 index 000000000000..3a5a1b2721f9 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/presets-listbox.tsx @@ -0,0 +1,79 @@ +/** + * Keyboard-navigable list of date-range presets. Verbatim port of + * Calypso's `client/dashboard/components/date-range-picker/presets-listbox.tsx` + * with a local `utils` import. + */ +import { + Button, + __experimentalVStack as VStack, // eslint-disable-line @wordpress/no-unsafe-wp-apis + Composite, + VisuallyHidden, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { presetDefs } from './utils'; +import type { PresetId } from './utils'; + +type PresetsListboxProps = { + labelId: string; + activePresetId?: PresetId; + onSelect: ( id: PresetId ) => void; + compositeActiveId: string | null; + setCompositeActiveId: ( id: string | null ) => void; +}; + +/** + * + * @param root0 + * @param root0.labelId + * @param root0.activePresetId + * @param root0.onSelect + * @param root0.compositeActiveId + * @param root0.setCompositeActiveId + */ +export function PresetsListbox( { + labelId, + activePresetId, + onSelect, + compositeActiveId, + setCompositeActiveId, +}: PresetsListboxProps ) { + const items: ReadonlyArray< { id: PresetId; label: string } > = [ + ...presetDefs, + { id: 'custom' as const, label: __( 'Custom', 'jetpack-activity-log' ) }, + ]; + + return ( + + + { __( 'Date range presets', 'jetpack-activity-log' ) } + + setCompositeActiveId( id ?? null ) } + focusLoop + virtualFocus + role="listbox" + > + + { items.map( preset => { + const isSelected = activePresetId === preset.id; + return ( + } + onClick={ () => onSelect( preset.id ) } + role="option" + aria-selected={ isSelected || undefined } + className="preset-listbox__item" + > + { preset.label } + + ); + } ) } + + + + ); +} diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/style.scss b/projects/packages/activity-log/src/js/components/DateRangePicker/style.scss new file mode 100644 index 000000000000..9a83bed07b70 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/style.scss @@ -0,0 +1,76 @@ +// Port of Calypso's `date-range-picker/style.scss`, with Calypso's +// base-style SCSS variables resolved to either literal values or the +// matching `--wpds-*` tokens we adopted in style.scss. + +.daterange-popover .components-popover__content { + overflow: visible; + width: auto; + max-width: calc(100vw - 48px); +} + +.daterange-inputs { + margin-bottom: 12px; +} + +.daterange-input { + + &__field { + + &.components-button { + background: #fff; + padding: 4px 4px 4px 8px; + box-shadow: none; + border-radius: 2px; + border: 1px solid var(--wpds-color-stroke-surface-neutral, #949494); + } + + svg { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); + padding: 4px; + } + } + + &__text { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); + padding: 0 4px; + } +} + +@media (max-width: 600px) { + + .daterange-calendar { + display: flex; + justify-content: center; + width: 100%; + } +} + +@media (min-width: 601px) and (max-width: 899px) { + + .daterange-body { + justify-content: space-between !important; + } +} + +@media (min-width: 601px) { + + .daterange-presets { + min-width: 240px; + } +} + +.preset-listbox__item .components-button { + width: 100%; + justify-content: flex-start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.daterange-inputs input[type="date"]::-webkit-calendar-picker-indicator { + display: none; +} + +.daterange-inputs input[type="date"]::-webkit-clear-button { + display: none; +} diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/utils.ts b/projects/packages/activity-log/src/js/components/DateRangePicker/utils.ts new file mode 100644 index 000000000000..de4ce1bdc18d --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/utils.ts @@ -0,0 +1,195 @@ +/** + * Preset computation + active-preset detection for the date-range + * picker. Verbatim port of Calypso's + * `client/dashboard/components/date-range-picker/utils.ts` with local + * datetime imports. + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + startOfDay, + isSameDay, + addDays, + addYears, + startOfMonth, + startOfYear, + differenceInCalendarDays, +} from 'date-fns'; +import { formatDate, parseYmdLocal, formatYmd } from './datetime'; + +const lastNDays = ( date: Date, number: number ) => ( { + from: new Date( date.getFullYear(), date.getMonth(), date.getDate() - ( number - 1 ) ), + to: date, +} ); +const monthToDate = ( date: Date ) => ( { + from: new Date( date.getFullYear(), date.getMonth(), 1 ), + to: date, +} ); +const yearToDate = ( date: Date ) => ( { + from: new Date( date.getFullYear(), 0, 1 ), + to: date, +} ); +const lastTwelveMonths = ( date: Date ) => ( { + from: new Date( date.getFullYear() - 1, date.getMonth(), date.getDate() + 1 ), + to: date, +} ); +const lastThreeYears = ( date: Date ) => ( { + from: new Date( date.getFullYear() - 3, date.getMonth(), date.getDate() + 1 ), + to: date, +} ); + +export type PresetId = + | 'today' + | 'yesterday' + | 'last-7-days' + | 'last-30-days' + | 'month-to-date' + | 'last-12-months' + | 'year-to-date' + | 'last-3-years' + | 'custom'; + +export const presetDefs = [ + { id: 'today', label: __( 'Today', 'jetpack-activity-log' ) }, + { id: 'yesterday', label: __( 'Yesterday', 'jetpack-activity-log' ) }, + { id: 'last-7-days', label: __( 'Last 7 days', 'jetpack-activity-log' ) }, + { id: 'last-30-days', label: __( 'Last 30 days', 'jetpack-activity-log' ) }, + { id: 'month-to-date', label: __( 'Month to date', 'jetpack-activity-log' ) }, + { id: 'last-12-months', label: __( 'Last 12 months', 'jetpack-activity-log' ) }, + { id: 'year-to-date', label: __( 'Year to date', 'jetpack-activity-log' ) }, + { id: 'last-3-years', label: __( 'Last 3 years', 'jetpack-activity-log' ) }, +] as const satisfies ReadonlyArray< { id: Exclude< PresetId, 'custom' >; label: string } >; + +/** + * + * @param preset + * @param baseDate + */ +export function computePresetRange( preset: PresetId, baseDate: Date ) { + switch ( preset ) { + case 'today': + return { from: baseDate, to: baseDate }; + case 'yesterday': + return { + from: new Date( baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate() - 1 ), + to: new Date( baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate() - 1 ), + }; + case 'last-7-days': + return lastNDays( baseDate, 7 ); + case 'last-30-days': + return lastNDays( baseDate, 30 ); + case 'month-to-date': + return monthToDate( baseDate ); + case 'last-12-months': + return lastTwelveMonths( baseDate ); + case 'year-to-date': + return yearToDate( baseDate ); + case 'last-3-years': + return lastThreeYears( baseDate ); + default: + return undefined; + } +} + +/** + * + * @param from + * @param to + * @param baseDate + */ +export function getActivePresetId( from?: Date, to?: Date, baseDate?: Date ): PresetId | undefined { + if ( ! from || ! to || ! baseDate ) { + return; + } + let newFrom = startOfDay( from ); + let newTo = startOfDay( to ); + if ( newFrom.getTime() > newTo.getTime() ) { + const tmp = newFrom; + newFrom = newTo; + newTo = tmp; + } + + const todayStart = startOfDay( baseDate ); + const yesterdayStart = addDays( todayStart, -1 ); + + if ( isSameDay( newFrom, todayStart ) && isSameDay( newTo, todayStart ) ) { + return 'today'; + } + if ( isSameDay( newFrom, yesterdayStart ) && isSameDay( newTo, yesterdayStart ) ) { + return 'yesterday'; + } + + if ( isSameDay( newTo, todayStart ) ) { + const diff = differenceInCalendarDays( todayStart, newFrom ); + if ( diff === 6 ) { + return 'last-7-days'; + } + if ( diff === 29 ) { + return 'last-30-days'; + } + if ( + isSameDay( newFrom, addYears( todayStart, -1 ) ) || + isSameDay( newFrom, addDays( addYears( todayStart, -1 ), 1 ) ) + ) { + return 'last-12-months'; + } + if ( + isSameDay( newFrom, addYears( todayStart, -3 ) ) || + isSameDay( newFrom, addDays( addYears( todayStart, -3 ), 1 ) ) + ) { + return 'last-3-years'; + } + } + + if ( isSameDay( newFrom, startOfMonth( todayStart ) ) && isSameDay( newTo, todayStart ) ) { + return 'month-to-date'; + } + if ( isSameDay( newFrom, startOfYear( todayStart ) ) && isSameDay( newTo, todayStart ) ) { + return 'year-to-date'; + } + + for ( const preset of presetDefs ) { + const range = computePresetRange( preset.id as PresetId, todayStart ); + if ( + range && + isSameDay( newFrom, startOfDay( range.from ) ) && + isSameDay( newTo, startOfDay( range.to ) ) + ) { + return preset.id as PresetId; + } + } + return undefined; +} + +/** + * + * @param start + * @param end + * @param locale + */ +export function formatLabel( start: Date, end: Date, locale: string ): string { + return sprintf( + /* translators: %1$s: start date, %2$s: end date */ + __( '%1$s to %2$s', 'jetpack-activity-log' ), + formatDate( start, locale, { dateStyle: 'medium' } ), + formatDate( end, locale, { dateStyle: 'medium' } ) + ); +} + +/** + * + * @param range + * @param range.start + * @param range.end + * @param timezoneString + * @param gmtOffset + */ +export function isLast7Days( + range: { start: Date; end: Date }, + timezoneString?: string, + gmtOffset?: number +): boolean { + const siteToday = + parseYmdLocal( formatYmd( new Date(), timezoneString, gmtOffset ) ) ?? + new Date( new Date().getFullYear(), new Date().getMonth(), new Date().getDate() ); + return getActivePresetId( range.start, range.end, siteToday ) === 'last-7-days'; +} diff --git a/projects/packages/activity-log/src/js/style.scss b/projects/packages/activity-log/src/js/style.scss index a852de7cb059..d83adbc11240 100644 --- a/projects/packages/activity-log/src/js/style.scss +++ b/projects/packages/activity-log/src/js/style.scss @@ -27,3 +27,14 @@ body.jetpack_page_jetpack-activity-log { margin: 0; } } + +// DateRangePicker sits above the DataViews table, right-aligned so it +// visually aligns with the settings cog on the toolbar below. Matches +// the layout in Calypso's logs page: the picker is a sibling above, +// not an overlay on the toolbar. +.jp-activity-log__date-range-row { + display: flex; + justify-content: flex-end; + padding: 12px 16px 0; + flex-shrink: 0; +} From 6b220bffe3cd5ff4621a94204de8b26fae20eebd Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 14:28:14 +0100 Subject: [PATCH 22/44] Activity Log: move date-range picker into the AdminPage header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two polish fixes for the picker landed in the previous commit: 1. Move it into the page header via the admin-ui `actions` slot (AdminPage threads `actions` straight into `@wordpress/admin-ui`'s `Page`), so the picker sits alongside the title/subtitle instead of floating on its own row above the table. Matches MSD's layout for the logs pages. 2. Import `@automattic/ui/style.css`. The JS bundle for `@automattic/ui`'s `DateRangeCalendar` doesn't carry its own styles — the published package ships them as a separate entry in its `exports` map. Without it the calendar cells render with wp-admin's default button styling (each day is a boxed button outline); with it we get the clean text-only numbers that Calypso and MSD both show. Refs #48242. --- .../changelog/fix-date-range-header-styling | 4 +++ .../src/js/components/ActivityLog/index.tsx | 27 ++++++++++--------- .../js/components/DateRangePicker/index.tsx | 6 +++++ .../packages/activity-log/src/js/style.scss | 11 +------- 4 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 projects/packages/activity-log/changelog/fix-date-range-header-styling diff --git a/projects/packages/activity-log/changelog/fix-date-range-header-styling b/projects/packages/activity-log/changelog/fix-date-range-header-styling new file mode 100644 index 000000000000..0ae8a5418213 --- /dev/null +++ b/projects/packages/activity-log/changelog/fix-date-range-header-styling @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Activity Log: move the date-range picker into the AdminPage header (via the admin-ui actions slot) and bundle @automattic/ui's stylesheet so the calendar cells render with Calypso-style clean numbers instead of wp-admin default button boxes. diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx index fdc97a94cbaa..5c574e6c68de 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -225,6 +225,20 @@ export default function ActivityLog() { const logData = ( activityLogData?.activityLogs ?? [] ) as Activity[]; + // Mounting the picker as an admin-ui `actions` slot places it in the + // AdminPage header alongside the title/subtitle — matches MSD's + // layout for the logs pages. + const headerActions = hasActivityLogsAccess ? ( + + ) : undefined; + return (
- { hasActivityLogsAccess && ( -
- -
- ) } data={ logData } isLoading={ isFetching || isLoadingList } diff --git a/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx b/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx index 6abd0a599e3d..2cca7fca9924 100644 --- a/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx @@ -20,6 +20,12 @@ import { formatLabel } from './utils'; import type { PresetId } from './utils'; import './style.scss'; +// `@automattic/ui`'s `DateRangeCalendar` styling lives in its own +// stylesheet — the JS bundle doesn't carry it. Import it here so the +// calendar renders with the Calypso-style clean day numbers instead +// of wp-admin's default button boxes. +import '@automattic/ui/style.css'; + type DateRangePickerProps = { start: Date; end: Date; diff --git a/projects/packages/activity-log/src/js/style.scss b/projects/packages/activity-log/src/js/style.scss index d83adbc11240..e66fe06fad3c 100644 --- a/projects/packages/activity-log/src/js/style.scss +++ b/projects/packages/activity-log/src/js/style.scss @@ -6,6 +6,7 @@ // the JS. See projects/packages/forms/routes/shared.scss for the same pattern. @include meta.load-css("@wordpress/dataviews/build-style/style.css"); + body.toplevel_page_jetpack-activity-log, body.jetpack_page_jetpack-activity-log { @@ -28,13 +29,3 @@ body.jetpack_page_jetpack-activity-log { } } -// DateRangePicker sits above the DataViews table, right-aligned so it -// visually aligns with the settings cog on the toolbar below. Matches -// the layout in Calypso's logs page: the picker is a sibling above, -// not an overlay on the toolbar. -.jp-activity-log__date-range-row { - display: flex; - justify-content: flex-end; - padding: 12px 16px 0; - flex-shrink: 0; -} From d8c37798ab4ae891686712a6109c0f9d87a324fa Mon Sep 17 00:00:00 2001 From: Filipe Varela Date: Thu, 23 Apr 2026 15:14:15 +0100 Subject: [PATCH 23/44] Activity Log: add tracks events for user interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `@automattic/jetpack-analytics` (already the canonical tracker for Jetpack wp-admin packages — Backup, Search, Publicize, etc.) into the Activity Log page so product / growth can see which affordances people actually use. Small local `use-analytics` hook initializes the tracker with the connected WPCOM user identity, same pattern as `projects/packages/backup/src/js/hooks/useAnalytics.js`. Seven events, all namespaced `jetpack_activity_log_*`: Calypso parity (same breakdown as wp-calypso:client/dashboard/sites/ logs-activity/dataviews/index.tsx:146-174): - `per_page_changed` — `{ per_page }` - `filter_changed` — `{ num_groups_selected, num_total_activities_selected, group_: bool }` - `search` — `{ has_query }` (boolean so the query text never leaks) - `page_changed` — `{ page }` New for this port: - `date_range_changed` — `{ days_in_range }` (sidecar date picker) - `reset_view_click` — `{}` (wraps DataViews' onReset) - `upsell_cta_click` — `{ source: 'free_tier_callout' }` (paid-plan CTA) Deferred: sort change (Calypso doesn't track), entity-link clicks in the description (too noisy), Phase 5 restore-point click (blocked on #48236). Also refreshes the stale header comment in ActivityLog/index.tsx so it reflects the current scope-simplifications set (drops "no date range picker", "no analytics events"; keeps localStorage, unlinked actor, disabled backup action). Refs #48242. --- pnpm-lock.yaml | 3 + .../activity-log/changelog/add-tracks-events | 4 ++ projects/packages/activity-log/package.json | 1 + .../components/ActivityLog/UpsellCallout.tsx | 12 +++- .../src/js/components/ActivityLog/index.tsx | 62 +++++++++++++++++-- .../src/js/hooks/use-analytics.ts | 37 +++++++++++ 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 projects/packages/activity-log/changelog/add-tracks-events create mode 100644 projects/packages/activity-log/src/js/hooks/use-analytics.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d40d5d0571..07a20b9df4a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1833,6 +1833,9 @@ importers: 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 diff --git a/projects/packages/activity-log/changelog/add-tracks-events b/projects/packages/activity-log/changelog/add-tracks-events new file mode 100644 index 000000000000..e4ccee1d5283 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-tracks-events @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Activity Log: track view-config, filter, search, pagination, date-range, reset-view, and upsell CTA interactions as jetpack_activity_log_* events via @automattic/jetpack-analytics. Mirrors Calypso's per_page / filter / search / page_changed breakdown, plus Jetpack-specific events for the date-range picker, Reset view, and the paid-tier upgrade click. diff --git a/projects/packages/activity-log/package.json b/projects/packages/activity-log/package.json index 23eb3c7d740e..c4d1d235ffe3 100644 --- a/projects/packages/activity-log/package.json +++ b/projects/packages/activity-log/package.json @@ -25,6 +25,7 @@ "extends @wordpress/browserslist-config" ], "dependencies": { + "@automattic/jetpack-analytics": "workspace:*", "@automattic/jetpack-components": "workspace:*", "@automattic/jetpack-connection": "workspace:*", "@automattic/ui": "1.0.2", diff --git a/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx index cc0edef491ad..44bce8790691 100644 --- a/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx +++ b/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx @@ -14,6 +14,8 @@ import { useProductCheckoutWorkflow } from '@automattic/jetpack-connection'; import { Button, __experimentalText as Text } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; +import { useCallback } from 'react'; +import { useAnalytics } from '../../hooks/use-analytics'; import illustrationUrl from './activity-logs-callout-illustration.svg'; import './upsell-callout.scss'; @@ -57,12 +59,20 @@ const buildPostCheckoutReturnUrl = (): string => { * @return The callout element. */ export function UpsellCallout() { + const { tracks } = useAnalytics(); const { run, hasCheckoutStarted } = useProductCheckoutWorkflow( { productSlug: PRODUCT_SLUG, redirectUrl: buildPostCheckoutReturnUrl(), from: UPSELL_SOURCE, } ); + const onClickUpgrade = useCallback( () => { + tracks.recordEvent( 'jetpack_activity_log_upsell_cta_click', { + source: 'free_tier_callout', + } ); + run(); + }, [ run, tracks ] ); + return (