From 4d46a79937f5176fb92d2ca019cbf4fce02a34e0 Mon Sep 17 00:00:00 2001 From: Automattic Bot Date: Thu, 30 Apr 2026 14:14:15 +0200 Subject: [PATCH 01/28] Update dependency @wordpress/build to v0.13.0 (#48403) Co-authored-by: Renovate Bot --- pnpm-lock.yaml | 24 ++++++++++--------- .../forms/changelog/renovate-@wordpressbuild | 4 ++++ projects/packages/forms/package.json | 2 +- .../changelog/renovate-@wordpressbuild#2 | 4 ++++ .../packages/premium-analytics/package.json | 2 +- 5 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 projects/packages/forms/changelog/renovate-@wordpressbuild create mode 100644 projects/packages/premium-analytics/changelog/renovate-@wordpressbuild#2 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18936cf37583..2717fe56e3cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2595,8 +2595,8 @@ importers: specifier: 6.44.0 version: 6.44.0 '@wordpress/build': - specifier: 0.12.0 - version: 0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2) + specifier: 0.13.0 + version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2) '@wordpress/date': specifier: 5.44.0 version: 5.44.0 @@ -3494,8 +3494,8 @@ importers: specifier: 7.29.0 version: 7.29.0 '@wordpress/build': - specifier: 0.12.0 - version: 0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + specifier: 0.13.0 + version: 0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: 4.28.2 version: 4.28.2 @@ -10403,15 +10403,15 @@ packages: resolution: {integrity: sha512-lYtkO7U7ok9RfRBIHWvVWXhcOys6cQuLfwFr1bGuPTE6+LmVHmRyniMnImZlG8Jb3XE4pvH8gXT1ecXogpDI2Q==} engines: {node: '>=18.12.0', npm: '>=8.19.2'} - '@wordpress/build@0.12.0': - resolution: {integrity: sha512-PCwxVXEKGVjwZRRVGhl6jaiOXX4y3ENsUj7UFKFPC9Nna6ov9YOQvhM+1+87Wvqqlze9jAOAMU0cuUQ+WmntjQ==} + '@wordpress/build@0.13.0': + resolution: {integrity: sha512-a442H7Kh1hW1b9UH8DZzqLaxYAspe84/dWDRyep1R3YZFx2TcMCBs1tAF96xzvli5pN83PAN/gscv2DfNYBHyw==} engines: {node: '>=20.10.0', npm: '>=10.2.3'} hasBin: true peerDependencies: - '@wordpress/boot': '>=0.3.0' + '@wordpress/boot': '>=0.3.0 <1.0.0' '@wordpress/private-apis': ^1.0.0 - '@wordpress/route': '>=0.2.0' - '@wordpress/theme': '>=0.3.0' + '@wordpress/route': '>=0.2.0 <1.0.0' + '@wordpress/theme': '>=0.8.0 <1.0.0' peerDependenciesMeta: '@wordpress/boot': optional: true @@ -17413,10 +17413,12 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -23671,7 +23673,7 @@ snapshots: '@wordpress/browserslist-config@6.44.0': {} - '@wordpress/build@0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.4.27(postcss@8.5.10) @@ -23697,7 +23699,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.12.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/boot@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0))(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.4.27(postcss@8.5.10) diff --git a/projects/packages/forms/changelog/renovate-@wordpressbuild b/projects/packages/forms/changelog/renovate-@wordpressbuild new file mode 100644 index 000000000000..45028352a802 --- /dev/null +++ b/projects/packages/forms/changelog/renovate-@wordpressbuild @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update package dependencies. diff --git a/projects/packages/forms/package.json b/projects/packages/forms/package.json index 349ebd39970b..9501d8580259 100644 --- a/projects/packages/forms/package.json +++ b/projects/packages/forms/package.json @@ -114,7 +114,7 @@ "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/api-fetch": "7.44.0", "@wordpress/browserslist-config": "6.44.0", - "@wordpress/build": "0.12.0", + "@wordpress/build": "0.13.0", "@wordpress/date": "5.44.0", "autoprefixer": "10.4.20", "browserslist": "^4.24.0", diff --git a/projects/packages/premium-analytics/changelog/renovate-@wordpressbuild#2 b/projects/packages/premium-analytics/changelog/renovate-@wordpressbuild#2 new file mode 100644 index 000000000000..45028352a802 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/renovate-@wordpressbuild#2 @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Update package dependencies. diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 7a3df7ec4a31..5fef5eb6f30c 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -38,7 +38,7 @@ }, "devDependencies": { "@babel/core": "7.29.0", - "@wordpress/build": "0.12.0", + "@wordpress/build": "0.13.0", "browserslist": "4.28.2" } } From 8aec7369460f7d346c1e360aa54c758acae9fe4a Mon Sep 17 00:00:00 2001 From: Allison Levine <1689238+allilevine@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:23:51 -0400 Subject: [PATCH 02/28] fix: change category picker icon from gear to tag for clarity (#48391) The gear icon (dashicons-admin-generic) didn't communicate that the floating button opens the category picker. Replaced with dashicons-tag which better conveys "categorize this post." Co-authored-by: Claude Opus 4.6 (1M context) --- .../jetpack-mu-wpcom/changelog/update-write-category-icon | 4 ++++ .../packages/jetpack-mu-wpcom/src/features/write/write.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/update-write-category-icon diff --git a/projects/packages/jetpack-mu-wpcom/changelog/update-write-category-icon b/projects/packages/jetpack-mu-wpcom/changelog/update-write-category-icon new file mode 100644 index 000000000000..a20ba0440c60 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/update-write-category-icon @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Write: Change category picker icon from gear to tag for clarity diff --git a/projects/packages/jetpack-mu-wpcom/src/features/write/write.php b/projects/packages/jetpack-mu-wpcom/src/features/write/write.php index d78ddedc76df..6420446d8697 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/write/write.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/write/write.php @@ -460,7 +460,7 @@ class="bw-image-url-input"
- +
diff --git a/projects/js-packages/components/components/admin-page/types.ts b/projects/js-packages/components/components/admin-page/types.ts index b911caa740e9..79184b62d90e 100644 --- a/projects/js-packages/components/components/admin-page/types.ts +++ b/projects/js-packages/components/components/admin-page/types.ts @@ -94,4 +94,13 @@ export type AdminPageProps = { * Hidden when `tabs` is used. */ showBottomBorder?: boolean; + + /** + * Render `children` directly inside the admin-ui Page, skipping the + * default `{children}` + * wrap. Use for full-bleed pages (DataViews-based admin surfaces, full-app + * dashboards) that own their own scroll/layout model and don't want the + * outer Container's grid to break their flex chain. Defaults to `false`. + */ + unwrapped?: boolean; }; 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/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.md b/projects/packages/activity-log/CHANGELOG.md new file mode 100644 index 000000000000..304d2c8c1d8c --- /dev/null +++ b/projects/packages/activity-log/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 0.1.0-alpha - unreleased + +Initial release. 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/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/.gitkeep b/projects/packages/activity-log/changelog/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 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..42ae66790162 --- /dev/null +++ b/projects/packages/activity-log/changelog/add-package-scaffold @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial release of the Activity Log package: hosts the in-wp-admin Activity Log UI and its REST endpoints. diff --git a/projects/packages/activity-log/changelog/use-adminpage-unwrapped b/projects/packages/activity-log/changelog/use-adminpage-unwrapped new file mode 100644 index 000000000000..31818e76e374 --- /dev/null +++ b/projects/packages/activity-log/changelog/use-adminpage-unwrapped @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Activity Log: opt into `` so DataViews can fill the bounded content slot and scroll its table body internally. Header, date picker, and DataViews toolbar stay pinned on short viewports. diff --git a/projects/packages/activity-log/composer.json b/projects/packages/activity-log/composer.json new file mode 100644 index 000000000000..358f82167871 --- /dev/null +++ b/projects/packages/activity-log/composer.json @@ -0,0 +1,73 @@ +{ + "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": { + "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/eslint.config.mjs b/projects/packages/activity-log/eslint.config.mjs new file mode 100644 index 000000000000..65c80344e400 --- /dev/null +++ b/projects/packages/activity-log/eslint.config.mjs @@ -0,0 +1,26 @@ +import { makeBaseConfig, defineConfig } from 'jetpack-js-tools/eslintrc/base.mjs'; + +/** + * The `DateRangePicker/` subdirectory is a near-verbatim port of + * Calypso's `components/date-range-picker/` — forcing full JSDoc on + * every internal helper would add churn on each upstream re-sync + * without adding clarity (param names match signatures, behavior + * matches the Calypso docs). Soften the Jetpack eslint profile for + * those files only. + */ +export default defineConfig( makeBaseConfig( import.meta.url ), { + files: [ 'src/js/components/DateRangePicker/**' ], + rules: { + 'jsdoc/require-description': 'off', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/require-returns-description': 'off', + '@wordpress/no-unused-vars-before-return': 'off', + // Calypso's picker passes inline arrow callbacks to a bunch of + // child components (Dropdown renderToggle/renderContent, + // DateInputs onFrom/onTo handlers). Keeping the verbatim form + // makes upstream re-syncs mechanical; the callbacks aren't + // performance-critical at this scale. + 'react/jsx-no-bind': 'off', + }, +} ); diff --git a/projects/packages/activity-log/package.json b/projects/packages/activity-log/package.json new file mode 100644 index 000000000000..c15567423b3b --- /dev/null +++ b/projects/packages/activity-log/package.json @@ -0,0 +1,64 @@ +{ + "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-analytics": "workspace:*", + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-connection": "workspace:*", + "@automattic/ui": "1.0.2", + "@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", + "@wordpress/url": "4.44.0", + "date-fns": "4.1.0", + "fast-deep-equal": "3.1.3", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@automattic/babel-plugin-replace-textdomain": "workspace:*", + "@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..d767e43f4865 --- /dev/null +++ b/projects/packages/activity-log/src/class-initial-state.php @@ -0,0 +1,84 @@ + 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_raw( 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(). The result is + // cached in a site transient by the REST controller + // (5-minute TTL); on a cache miss this call issues a + // synchronous WPCOM request (~200–800ms typical, up to + // the 2s internal timeout) that blocks page rendering + // until it returns. + 'hasActivityLogsAccess' => REST_Controller::has_activity_logs_access(), + ), + 'nonces' => array( + // Consumed by `UpsellCallout` to build a `redirect_to` + // URL that invalidates the access cache on return from + // checkout. See `Jetpack_Activity_Log::admin_init()`. + 'refreshAccess' => wp_create_nonce( Jetpack_Activity_Log::REFRESH_ACCESS_NONCE_ACTION ), + ), + '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..c89f5ab8c316 --- /dev/null +++ b/projects/packages/activity-log/src/class-jetpack-activity-log.php @@ -0,0 +1,190 @@ +is_user_connected(); + } + + /** + * Fires when the admin page is loaded. + * + * When the user is returning from a successful checkout, the upsell + * CTA appends `?refresh_access=1&_wpnonce=…` to the `redirect_to` + * value it hands off to WordPress.com. Detect that here, verify the + * nonce, and drop the cached paid-plan signal so + * `Initial_State::get_data()` (which runs later in the same request, + * when the bundle is enqueued) rehydrates from WPCOM instead of + * re-serving the pre-checkout value. Mirrors the pattern in + * `Automattic\Jetpack\Publicize\Social_Admin_Page::admin_init()`. + */ + public static function admin_init() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified with wp_verify_nonce below. + if ( isset( $_GET['refresh_access'] ) && isset( $_GET['_wpnonce'] ) ) { + $nonce = sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ); + if ( wp_verify_nonce( $nonce, self::REFRESH_ACCESS_NONCE_ACTION ) ) { + REST_Controller::clear_access_cache(); + } + } + + 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() { + ?> +
+ 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() { + 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. Mirrors the menu gating — any admin on a + * 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|WP_Error + */ + public static function permissions_callback() { + 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; + } + + /** + * 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', 10 ); + 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 ); + } + } + + /** + * Free-tier params that survive the filter strip in + * {@see self::get_activity_log()}. Anything outside this list is + * nulled out before the request reaches WPCOM. + * + * @var string[] + */ + const FREE_TIER_ALLOWED_PARAMS = array( 'number', 'page', 'sort_order' ); + + /** + * Proxy the paginated activity list. + * + * Enforces the free-tier boundary server-side. When the site doesn't + * have access: + * + * 1. `number` is clamped to {@see self::FREE_TIER_ITEM_CAP}. + * 2. `page` is forced to 1. + * 3. All filter inputs (`after`, `before`, `group`, `not_group`, + * `text_search`) are dropped. + * + * Together these mean a client-side bypass (DevTools, direct + * `wp.apiFetch`) is bounded to "the 20 most recent events overall" — + * the same dataset the locked-down UI surfaces. Without (3), + * date-walking via `before` would let a free-tier caller page through + * the entire history 20 rows at a time. + * + * @param WP_REST_Request $request Request. + * @return mixed + */ + public static function get_activity_log( WP_REST_Request $request ) { + if ( ! self::has_activity_logs_access() ) { + // Mutating the request in-place is deliberate: any + // downstream `rest_request_*` filter sees the clamped + // values, not the caller's originals. For a security + // clamp that's the right side of the trade — no filter + // can undo the limit. + $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 ); + + foreach ( array_keys( self::list_args() ) as $key ) { + if ( ! in_array( $key, self::FREE_TIER_ALLOWED_PARAMS, true ) ) { + $request->set_param( $key, null ); + } + } + } + return self::proxy_get( '/activity', $request, array_keys( self::list_args() ) ); + } + + /** + * Proxy the group-counts endpoint. + * + * Deliberately not tier-clamped — the free-tier list clamp + * (`number` → 20 / `page` → 1) is the security boundary; the group + * counts are cosmetic metadata that powers the filter dropdown. A + * stable, full-history count keeps the dropdown from flickering as + * users type in the search field, matching Calypso's behavior at + * `wp-calypso:client/dashboard/sites/logs-activity/dataviews/ + * index.tsx:100-102`. + * + * @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 ); + } + + // 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, + '2', + array( + 'method' => 'GET', + 'headers' => array( + 'X-Forwarded-For' => ( new Visitor() )->get_ip( true ), + ), + ), + 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 ); + } +} 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..9260668f4b97 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityActor.tsx @@ -0,0 +1,154 @@ +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'; +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 } { + // tsgo types `__()`'s return as a branded `TransformedText<…>` rather + // than plain `string`; annotating the variable keeps later + // `actorName = name || actorName` assignments widened to `string`. + let actorName: string = __( '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..763be0fc2c6f --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/ActivityEvent.tsx @@ -0,0 +1,101 @@ +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'; +import './activity-event.scss'; + +/** + * Just the gridicon for an activity row. Exposed as its own component + * so the Activity layout can mount it in DataViews' `mediaField` slot + * (rendering left-aligned outside the title block). + * + * @param props - Component props. + * @param props.activity - Normalized Activity for the current log row. + * @return The icon element, or null when the activity has none. + */ +export function ActivityEventIcon( { activity }: { activity: Activity } ) { + const { activityIcon } = activity; + if ( ! activityIcon ) { + return null; + } + return ( + + ); +} + +/** + * Just the bold activity title (e.g. "Plugin activated"). Plugged into + * DataViews' `titleField` slot for the Activity layout. + * + * @param props - Component props. + * @param props.activity - Normalized Activity for the current log row. + * @return The title element. + */ +export function ActivityEventTitle( { activity }: { activity: Activity } ) { + // `.site-activity-logs__event-title` both lightens the default + // `` font-weight to 500 *and* — because the class is on a + // non-span element — keeps the `> span { color: grey }` rule on the + // enclosing `.site-activity-logs__event-content` from dimming the + // title in the Table layout. + return { activity.activityTitle }; +} + +/** + * The formatted description body — parsed ranges (entity links, + * release-notes, etc.) with an `object`-level link fallback for + * range-less payloads like `post__published`. Plugs into DataViews' + * `descriptionField` slot for the Activity layout. + * + * @param props - Component props. + * @param props.activity - Normalized Activity for the current log row. + * @return The description element, or null when the activity has none. + */ +export function ActivityEventDescription( { activity }: { activity: Activity } ) { + const { activityDescription, activityObject } = activity; + const hasRanges = activityDescription.items.some( + item => typeof item === 'object' && item !== null && 'type' in item + ); + const objectHref = hasRanges ? null : buildObjectAdminLink( activityObject ); + const formattedContent = activityDescription.items.length + ? renderFormattedContent( { items: activityDescription.items } ) + : null; + if ( ! formattedContent ) { + return null; + } + return ( + { objectHref ?
{ formattedContent } : formattedContent } + ); +} + +/** + * DataViews cell renderer for the Table layout's "Event" column. + * Composes the icon, title, and description into a single cell. The + * Activity layout uses the three sub-components directly in their + * respective field slots instead. + * + * @param props - Component props. + * @param props.activity - Normalized Activity for the current log row. + * @return The event cell. + */ +export function ActivityEvent( { activity }: { activity: Activity } ) { + return ( + + + + + + + + ); +} 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..44bce8790691 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/UpsellCallout.tsx @@ -0,0 +1,114 @@ +/** + * 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 { 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'; + +const PRODUCT_SLUG = 'jetpack_security_t1_yearly'; +const UPSELL_SOURCE = 'activity-log-page-purchase'; + +interface InitialStateWithNonce { + nonces?: { refreshAccess?: string }; +} + +declare const JPACTIVITYLOG_INITIAL_STATE: InitialStateWithNonce | undefined; + +/** + * Compute the URL we want WordPress.com to send the user back to after + * checkout. We stay on the Activity Log page but append + * `refresh_access=1&_wpnonce=…`, which `Jetpack_Activity_Log::admin_init()` + * detects to drop the paid-plan access cache — eliminating the 5-minute + * "still showing the upsell after upgrade" window. + * + * @return Absolute URL to pass as `redirectUrl`. + */ +const buildPostCheckoutReturnUrl = (): string => { + if ( typeof window === 'undefined' ) { + return ''; + } + const nonce = + typeof JPACTIVITYLOG_INITIAL_STATE !== 'undefined' + ? JPACTIVITYLOG_INITIAL_STATE?.nonces?.refreshAccess + : undefined; + if ( ! nonce ) { + return window.location.href; + } + return addQueryArgs( window.location.href, { refresh_access: '1', _wpnonce: nonce } ); +}; + +/** + * 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 { 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 ( +
+ +
+

+ { __( 'Track every action with Jetpack Activity Log', '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 the Jetpack Security and Complete plans.', 'jetpack-activity-log' ) } + + +
+
+ ); +} 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..27fda3f575bf --- /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: 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: 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 new file mode 100644 index 000000000000..f30f4e93a965 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-event.scss @@ -0,0 +1,35 @@ +.site-activity-logs__event { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); + + // Event row icon — fixed 32×32 tile inside the Table layout's composite + // cell so the row alignment is stable regardless of surrounding + // cascade. The inner SVG renders at 24×24 (via the Icon size prop) and + // the 4px padding completes the tile. Scoped to `.site-activity-logs__event` + // so the *same* `` used standalone in the Activity + // layout's mediaField slot picks up DataViews' own circular bullet + // styling instead of our square-tile overrides (otherwise min-width:32px + // wins against DataViews' width:100% and produces a non-square rect). + .site-activity-logs__event-icon { + box-sizing: border-box; + width: 32px; + height: 32px; + min-width: 32px; + padding: 4px; + border-radius: 2px; + fill: var(--wpds-color-fg-content-neutral-weak, #757575); + background-color: var(--wpds-color-bg-surface-neutral-weak, #f0f0f1); + } +} + +.site-activity-logs__event-content { + flex: 1 0 0; + flex-wrap: wrap; + + > span { + color: var(--wpds-color-fg-content-neutral-weak, #50575e); + } +} + +.site-activity-logs__event-title { + font-weight: 500; +} 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/activity-transformer.ts b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts new file mode 100644 index 000000000000..4f287246b1bc --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/activity-transformer.ts @@ -0,0 +1,90 @@ +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, + object, + 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, + activityObject: object, + 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/admin-links.ts b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts new file mode 100644 index 000000000000..e8cb46452e24 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/admin-links.ts @@ -0,0 +1,111 @@ +/** + * 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 }; +} + +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 ) ); + +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 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 ? postEditUrl( node.postId ) : null; + case 'person': + return node.userId ? userEditUrl( node.userId ) : null; + case 'comment': + return node.commentId ? commentEditUrl( node.commentId ) : null; + case 'plugin': + return node.pluginSlug ? pluginSearchUrl( String( node.pluginSlug ) ) : null; + case 'theme': + 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/fields.tsx b/projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx new file mode 100644 index 000000000000..74e4cf1ca825 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/fields.tsx @@ -0,0 +1,352 @@ +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, + ActivityEventDescription, + ActivityEventIcon, + ActivityEventTitle, +} 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. + * @param args.dateFormat - `dateI18n` format string, defaulting to + * "M j, Y at g:i A" (used for the Date & + * time column). Pass "F j, Y" for the + * day-only group header. + * @return The formatted date string. + */ +const formatDateCell = ( { + timezoneString, + gmtOffset, + value, + formatAsUTC, + dateFormat = 'M j, Y \\a\\t g:i A', +}: { + timezoneString?: string; + gmtOffset?: number; + value?: string | number; + formatAsUTC?: boolean; + dateFormat?: string; +} ): string => { + if ( ! value ) { + return ''; + } + 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: [] }, + }, + // The Activity layout renders icon / title / description in + // dedicated mediaField / titleField / descriptionField slots, + // so we expose three atomic fields alongside the Table + // layout's composite `event`. DataViews' `getHideableFields` + // excludes slot-bound fields from the Properties toggle + // list, so these don't double-expose in the cog popover. + { + id: 'event_icon', + type: 'text', + label: __( 'Icon', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => item.activityIcon ?? '', + render: ( { item } ) => , + filterBy: { operators: [] }, + }, + { + id: 'event_title', + type: 'text', + label: __( 'Title', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => item.activityTitle, + render: ( { item } ) => , + filterBy: { operators: [] }, + }, + { + id: 'event_description', + type: 'text', + label: __( 'Description', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => 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: [] }, + }, + { + // Day-level grouping key for the Activity layout. Returns + // e.g. "Apr 24, 2026" in the site's local timezone so + // events on the same calendar day collapse under a + // single group header. Never rendered as a column — + // `enableHiding: false` keeps it out of the Properties + // toggle (getHideableFields filters those out) and it's + // never included in any layout's `view.fields`, so the + // Table layout ignores it entirely. + id: 'published_date', + type: 'text', + label: __( 'Date', 'jetpack-activity-log' ), + enableSorting: false, + enableHiding: false, + getValue: ( { item } ) => + formatDateCell( { + value: item.activityUnparsedTs, + timezoneString, + gmtOffset, + dateFormat: 'F j, Y', + } ), + render: ( { item } ) => ( + + { formatDateCell( { + value: item.activityUnparsedTs, + timezoneString, + gmtOffset, + dateFormat: 'F j, Y', + } ) } + + ), + 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..5881d5c0f265 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/index.tsx @@ -0,0 +1,164 @@ +/** + * Renders the structured tokens produced by the parser. + * + * 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; + +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 } +
+); + +// 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; + + if ( ! url ) { + return { children }; + } + + // 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 }; + } + + return ( + + { children } + + ); +}; + +// 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 text. +const EntityLink: BlockRenderer = ( { content, children } ) => { + const href = buildAdminLink( content ); + if ( ! href ) { + return { children }; + } + return { 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: 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 text. + site: ( { children } ) => { children }, + backup: ( { children } ) => { children }, +}; + +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..e9d0eae327fb --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/parser.ts @@ -0,0 +1,265 @@ +/** + * 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, 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 ) => ( { + 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..c72e1a254f2f --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/formatted-block/types.ts @@ -0,0 +1,34 @@ +export interface ActivityBlockNode { + type?: string; + text?: string | null; + children?: ActivityBlockContent[]; + url?: string | null; + 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; + commentId?: number | string; + userId?: 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..bac3a9381b1a --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/index.tsx @@ -0,0 +1,405 @@ +/** + * 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 PR (#48244): view + * state persists to localStorage rather than URL params, the actor + * column isn't linked, and the "Manage backup" row action is stubbed + * until #48236 lands. + */ +import { AdminPage } from '@automattic/jetpack-components'; +import { useQuery } from '@tanstack/react-query'; +import { DataViews } from '@wordpress/dataviews'; +import { __ } from '@wordpress/i18n'; +import fastDeepEqual from 'fast-deep-equal/es6'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { activityLogQuery, activityLogGroupCountsQuery } from '../../hooks/use-activity-log'; +import { useAnalytics } from '../../hooks/use-analytics'; +import { usePersistentView } from '../../hooks/use-persistent-view'; +import { DateRangePicker } from '../DateRangePicker'; +import { formatYmd, parseYmdLocal } from '../DateRangePicker/datetime'; +import { UpsellCallout } from './UpsellCallout'; +import { useActivityActions } from './actions'; +import { transformActivityLogEntry } from './activity-transformer'; +import { useActivityFields } from './fields'; +import { extractActivityLogTypeValues } from './filters'; +import { DEFAULT_LAYOUTS, 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; + hasActivityLogsAccess?: boolean; + locale?: 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; + locale: string; +} => { + const state = + typeof JPACTIVITYLOG_INITIAL_STATE !== 'undefined' ? JPACTIVITYLOG_INITIAL_STATE : undefined; + return { + gmtOffset: state?.siteData?.gmtOffset ?? 0, + timezoneString: state?.siteData?.timezoneString || undefined, + // Fall back to the browser's navigator locale if Initial_State + // didn't seed one — the picker's date labels only care about + // number/weekday formatting. + locale: + state?.siteData?.locale || ( typeof navigator !== 'undefined' ? navigator.language : 'en' ), + }; +}; + +/** + * 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. + * + * @return The admin page. + */ +export default function ActivityLog() { + const { gmtOffset, timezoneString, locale } = readSiteTimeContext(); + const hasActivityLogsAccess = readHasActivityLogsAccess(); + const { view, setView, resetView, isViewModified } = usePersistentView( DEFAULT_VIEW ); + const { tracks } = useAnalytics(); + const wrapperRef = useRef< HTMLDivElement >( null ); + + // DataViews' `Action` API doesn't expose a tooltip prop, so the + // disabled "Manage backup" stub renders without any hint as to *why* + // it can't be clicked. Attach a `title` to those buttons after each + // render so users hovering get the "Coming soon" context — and + // screen readers pick it up too. The MutationObserver re-runs on + // pagination / filter changes, when DataViews swaps the row DOM. + // + // TODO(#48236): drop this whole effect. Once the Backup wp-admin + // page lands the action stops being a stub and the row will render + // as an enabled link, so this textContent-matching DOM hack — which + // is fragile across translations and re-fires on every DataViews + // mutation — won't be needed at all. + useEffect( () => { + const wrapper = wrapperRef.current; + if ( ! wrapper ) { + return; + } + const manageBackupLabel = __( 'Manage backup', 'jetpack-activity-log' ); + const tooltipText = __( 'Coming soon', 'jetpack-activity-log' ); + const apply = ( root: ParentNode ) => { + const buttons = root.querySelectorAll< HTMLButtonElement >( + '.dataviews-item-actions button[disabled]' + ); + buttons.forEach( btn => { + if ( + btn.textContent?.trim() === manageBackupLabel && + btn.getAttribute( 'title' ) !== tooltipText + ) { + btn.setAttribute( 'title', tooltipText ); + } + } ); + }; + apply( wrapper ); + const observer = new MutationObserver( () => apply( wrapper ) ); + observer.observe( wrapper, { subtree: true, childList: true } ); + return () => observer.disconnect(); + }, [] ); + + // Date-range defaults to "Last 7 days" anchored at the site's calendar + // today (not the browser's) — matches Calypso's `getDefaultDateRange`. + // The range is client-only state: refreshes reset to the default + // instead of persisting, so users don't return to a stale narrow + // window. + const [ dateRange, setDateRange ] = useState( () => { + const siteToday = + parseYmdLocal( formatYmd( new Date(), timezoneString, gmtOffset ) ) ?? new Date(); + return { + start: new Date( siteToday.getFullYear(), siteToday.getMonth(), siteToday.getDate() - 6 ), + end: siteToday, + }; + } ); + + const activityLogTypeValues = useMemo( () => { + const filters = ( view.filters as Filter[] | undefined ) ?? []; + return extractActivityLogTypeValues( filters ); + }, [ view.filters ] ); + + const searchTerm = view.search?.trim() ?? ''; + + // The picker hands us start-of-day / end-of-day Dates at local-midnight + // (see the `dateRange` initializer below). For the WPCOM query, stretch + // `end` to the end of its calendar day so single-day ranges like "Today" + // aren't empty (UTC midnight → UTC midnight would match no events). + const afterIso = useMemo( () => dateRange.start.toISOString(), [ dateRange.start ] ); + const beforeIso = useMemo( () => { + const endOfDay = new Date( dateRange.end ); + endOfDay.setHours( 23, 59, 59, 999 ); + return endOfDay.toISOString(); + }, [ dateRange.end ] ); + + const listParams: ActivityLogParams = useMemo( () => { + const params: ActivityLogParams = { + sort_order: view.sort?.direction, + number: view.perPage || ACTIVITY_LOGS_DEFAULT_PAGE_SIZE, + // `view.page` starts `undefined` and settles to `1` on the + // first user-triggered change; defaulting here keeps the + // query key stable across that transition so we don't fire + // a duplicate list request on page load. + page: view.page ?? 1, + after: afterIso, + before: beforeIso, + }; + if ( searchTerm ) { + params.text_search = searchTerm; + } + if ( activityLogTypeValues.length ) { + params.group = activityLogTypeValues; + } + return params; + }, [ + view.sort?.direction, + view.perPage, + view.page, + searchTerm, + activityLogTypeValues, + afterIso, + beforeIso, + ] ); + + const { + data: activityLogData, + isFetching: isFetchingData, + isLoading: isLoadingList, + } = useQuery( { + ...activityLogQuery( listParams ), + select: data => ( { + ...data, + activityLogs: ( data.activityLogs ?? [] ).map( transformActivityLogEntry ), + } ), + } ); + + // Counts query scopes to the same date window as the list (so + // filter counts match what's displayed), but excludes `text_search` + // so the filter dropdown stays 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, + after: afterIso, + before: beforeIso, + } ) + ); + + const isFetching = isFetchingData || isFetchingFilters; + + const paginationInfo = { + totalItems: activityLogData?.totalItems ?? 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( { + 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 layoutChanged = next.type !== view.type; + + const datasetChanged = perPageChanged || sortChanged || filtersChanged || searchChanged; + + // Tracking — same breakdown Calypso records (per_page / + // filter / search / page_changed), namespaced under + // `jetpack_activity_log_*`, plus a wp-admin-only + // `layout_changed` since Jetpack exposes the DataViews + // Activity timeline as a second layout. + if ( layoutChanged ) { + tracks.recordEvent( 'jetpack_activity_log_layout_changed', { + layout: next.type, + } ); + } + if ( perPageChanged ) { + tracks.recordEvent( 'jetpack_activity_log_per_page_changed', { + per_page: next.perPage, + } ); + } + if ( filtersChanged ) { + const activityTypes = extractActivityLogTypeValues( + ( next.filters as Filter[] | undefined ) ?? [] + ); + const eventProps: Record< string, boolean | number > = { + num_groups_selected: activityTypes.length, + }; + let totalActivitiesSelected = 0; + Object.entries( groupCountsData?.groups ?? {} ).forEach( ( [ groupKey, { count } ] ) => { + const isSelected = activityTypes.includes( groupKey ); + eventProps[ `group_${ groupKey }` ] = isSelected; + if ( isSelected ) { + totalActivitiesSelected += count ?? 0; + } + } ); + eventProps.num_total_activities_selected = totalActivitiesSelected; + tracks.recordEvent( 'jetpack_activity_log_filter_changed', eventProps ); + } + if ( searchChanged ) { + tracks.recordEvent( 'jetpack_activity_log_search', { + has_query: nextSearch.length > 0, + } ); + } + if ( ! datasetChanged && requestedPage !== currentPage ) { + tracks.recordEvent( 'jetpack_activity_log_page_changed', { + page: requestedPage, + } ); + } + + setView( { + ...next, + page: datasetChanged ? 1 : requestedPage, + } ); + }, + [ setView, view, searchTerm, tracks, groupCountsData ] + ); + + const onChangeDateRange = useCallback( + ( next: { start: Date; end: Date } ) => { + const daysInRange = + Math.round( ( next.end.getTime() - next.start.getTime() ) / 86_400_000 ) + 1; + tracks.recordEvent( 'jetpack_activity_log_date_range_changed', { + days_in_range: daysInRange, + } ); + // A new range is its own dataset boundary — snap back to + // page 1, matching how `onChangeView` handles other dataset + // changes (perPage, sort, filters, search). + setDateRange( next ); + setView( { ...view, page: 1 } ); + }, + [ setView, view, tracks ] + ); + + const onResetView = useCallback( () => { + tracks.recordEvent( 'jetpack_activity_log_reset_view_click', {} ); + resetView(); + }, [ resetView, tracks ] ); + + const getItemId = useCallback( ( item: Activity ) => item.activityId.toString(), [] ); + + 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 ( + +
+ + data={ logData } + isLoading={ isFetching || isLoadingList } + paginationInfo={ paginationInfo } + fields={ fields as Field< Activity >[] } + view={ view } + actions={ actions } + getItemId={ getItemId } + search + // Advertise both DataViews' built-in Activity timeline + // (the default) and a Table layout. Toggle lives in + // the cog popover's layout switcher. Each layout maps + // the event parts to the right slots: + // - Activity: `event_icon` → mediaField (left + // bullet slot), `event_title` → titleField, + // `event_description` → descriptionField, plus + // `groupBy: published_date` for day headers. + // - Table: one composite `event` column alongside + // Date / User. + // See DEFAULT_LAYOUTS in ./views for the full shape — + // it explicitly nulls slot/groupBy refs on Table so a + // round-trip Activity → Table doesn't carry those + // over and double-render as a primary column. + defaultLayouts={ DEFAULT_LAYOUTS } + onChangeView={ onChangeView } + onReset={ isViewModified ? onResetView : 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 + ? __( 'No activity found', 'jetpack-activity-log' ) + : __( 'No activities', 'jetpack-activity-log' ) } +

+ } + > + { hasActivityLogsAccess ? undefined : } + + { ! hasActivityLogsAccess && ! isFetching && logData.length > 0 && } +
+
+ ); +} 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..b9ec50b69388 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/ActivityLog/types.ts @@ -0,0 +1,134 @@ +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; + activityObject?: ActivityLogObject; + 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 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; + content?: { text?: string; ranges?: ActivityNotificationRange[] }; + gridicon?: string; + image?: ActivityLogEntryImage | null; + name: string; + is_rewindable?: boolean; + object?: ActivityLogObject; + 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/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..2cca7fca9924 --- /dev/null +++ b/projects/packages/activity-log/src/js/components/DateRangePicker/index.tsx @@ -0,0 +1,232 @@ +/** + * 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'; + +// `@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; + 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/hooks/use-activity-log.ts b/projects/packages/activity-log/src/js/hooks/use-activity-log.ts new file mode 100644 index 000000000000..6d9c619cfb34 --- /dev/null +++ b/projects/packages/activity-log/src/js/hooks/use-activity-log.ts @@ -0,0 +1,101 @@ +/** + * 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 + * appending them with the PHP-style `[]` suffix our REST controller + * understands. + * + * Note that `apiFetch`/`@wordpress/url` re-serialize the path before + * dispatching, so the live wire form is typically indexed + * (`group[0]=plugin`) rather than the bare `group[]=plugin` this + * function emits — either form round-trips through the controller's + * `type: array` validation, so both are accepted. + * + * @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/hooks/use-analytics.ts b/projects/packages/activity-log/src/js/hooks/use-analytics.ts new file mode 100644 index 000000000000..ee5983b08dd9 --- /dev/null +++ b/projects/packages/activity-log/src/js/hooks/use-analytics.ts @@ -0,0 +1,49 @@ +/** + * Thin wrapper around `@automattic/jetpack-analytics` that identifies + * the tracker with the connected WPCOM user (matching Backup's + * `useAnalytics` hook). Components call `useAnalytics()` and then + * `tracks.recordEvent( 'jetpack_activity_log_', { …props } )`. + * + * Anonymous events still fire if the user-connection state isn't ready + * yet; the identify call just fills in ID + login when available. + */ +import jetpackAnalytics from '@automattic/jetpack-analytics'; +import { useConnection } from '@automattic/jetpack-connection'; +import { useEffect } from 'react'; + +// Module-level guard so multiple consumers of `useAnalytics` don't +// re-call `initialize()` with the same identity on every mount. +// `jetpackAnalytics` is a singleton; once it's been identified for a +// given (id, login) pair there's nothing to redo. +let identifiedFor: string | null = null; + +/** + * Returns the shared `jetpackAnalytics` tracker, identified with the + * connected WPCOM user once that state becomes available. + * + * @return The `jetpackAnalytics` singleton. Callers typically + * destructure `{ tracks }` and record events via + * `tracks.recordEvent( name, props )`. + */ +export function useAnalytics() { + const { isUserConnected, userConnectionData } = useConnection( {} ); + const wpcomUser = userConnectionData?.currentUser?.wpcomUser; + const wpcomId = wpcomUser?.ID; + const wpcomLogin = wpcomUser?.login; + + useEffect( () => { + if ( ! isUserConnected || ! wpcomId || ! wpcomLogin ) { + return; + } + const key = `${ wpcomId }:${ wpcomLogin }`; + if ( identifiedFor === key ) { + return; + } + jetpackAnalytics.initialize( wpcomId, wpcomLogin ); + identifiedFor = key; + }, [ isUserConnected, wpcomId, wpcomLogin ] ); + + return jetpackAnalytics; +} + +export default useAnalytics; 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..fcb9fb0483d4 --- /dev/null +++ b/projects/packages/activity-log/src/js/hooks/use-persistent-view.ts @@ -0,0 +1,168 @@ +/** + * 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, useEffect, useMemo, useState } from 'react'; +import type { View } from '@wordpress/dataviews'; + +interface InitialStateShape { + siteData?: { id?: number | string }; +} + +declare const JPACTIVITYLOG_INITIAL_STATE: InitialStateShape | undefined; + +// Site-scope the storage key on the WPCOM blog ID seeded by Initial_State. +// Without this, an admin who manages multiple Jetpack-connected sites in the +// same browser would share a single view across all of them. Falls back to +// `default` when the global is absent (storybook/tests) or the id is unset. +const getStorageKey = (): string => { + const state = + typeof JPACTIVITYLOG_INITIAL_STATE !== 'undefined' ? JPACTIVITYLOG_INITIAL_STATE : undefined; + const siteId = state?.siteData?.id; + const scope = siteId !== undefined && siteId !== null && siteId !== '' ? siteId : 'default'; + return `jetpack-activity-log:view:${ scope }`; +}; + +const readPersistedView = (): View | null => { + if ( typeof window === 'undefined' ) { + return null; + } + try { + const raw = window.localStorage.getItem( getStorageKey() ); + 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( getStorageKey() ); + } else { + window.localStorage.setItem( getStorageKey(), 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; + // Filters count as transient too — a selected activity-type is + // scoped to the current debugging session, not a long-lived + // preference. (Previously only empty-array filters were dropped, + // which meant a "Plugins" selection survived across reloads — + // reported in review.) + delete next.filters; + return next; +}; + +// Narrow whitelist of the fields the user can actually edit from the +// settings cog (sort, order, properties, density, items-per-page, plus +// filters). Comparing the full view object instead can flip the +// "modified" bit when DataViews normalizes an unrelated internal field +// on mount; comparing only the signature avoids that false positive. +const viewSignature = ( v: View ) => ( { + fields: v.fields, + sort: v.sort, + perPage: v.perPage, + density: v.layout?.density, + filters: v.filters?.length ? v.filters : undefined, +} ); + +const isMeaningfullyModified = ( current: View, base: View ): boolean => + ! fastDeepEqual( viewSignature( current ), viewSignature( base ) ); + +/** + * 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(); + // Self-heal: if a previous session wrote a "not really modified" + // view (e.g. because DataViews touched a layout subfield on mount + // before we added the viewSignature whitelist), boot with the + // default so Reset view stays disabled on load. The matching + // localStorage cleanup runs in the effect below — keeping side + // effects out of the lazy initializer so React 18 strict-mode's + // double-invoke doesn't write twice. + if ( persisted && ! isMeaningfullyModified( persisted, defaultView ) ) { + return defaultView; + } + return persisted ?? defaultView; + } ); + + // One-shot mount cleanup for the self-heal case above. + useEffect( () => { + const persisted = readPersistedView(); + if ( persisted && ! isMeaningfullyModified( persisted, defaultView ) ) { + writePersistedView( null ); + } + // Mount-only — `defaultView` is a stable module-level constant + // (DEFAULT_VIEW), so re-checking on identity changes would be + // noise. Intentional empty deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + const setView = useCallback( + ( next: View ) => { + setViewState( next ); + + // Persist only when the signature differs. We still write the + // full stripped view (not just the signature) so future fields + // restore correctly — the signature just gates whether we + // persist at all. + if ( isMeaningfullyModified( next, defaultView ) ) { + writePersistedView( stripTransient( next ) ); + } else { + writePersistedView( null ); + } + }, + [ defaultView ] + ); + + const resetView = useCallback( () => { + setViewState( defaultView ); + writePersistedView( null ); + }, [ defaultView ] ); + + const isViewModified = useMemo( + () => isMeaningfullyModified( view, defaultView ), + [ view, defaultView ] + ); + + return { view, setView, resetView, isViewModified }; +} 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..fabb77c80e00 --- /dev/null +++ b/projects/packages/activity-log/src/js/index.js @@ -0,0 +1,41 @@ +import { ThemeProvider } from '@automattic/jetpack-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import * as WPElement from '@wordpress/element'; +import ActivityLog from './components/ActivityLog'; +import './style.scss'; + +// The activity log is append-only: new events land upstream while this +// page stays open, so a cached snapshot goes stale within seconds. +// A finite `staleTime` + `refetchOnWindowFocus` keeps the list current +// without hammering WPCOM on every keystroke — react-query still +// de-dupes requests that share a key inside the window. +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: 60_000, + refetchOnWindowFocus: true, + }, + }, +} ); + +/** + * 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/src/js/style.scss b/projects/packages/activity-log/src/js/style.scss new file mode 100644 index 000000000000..e3bfde46e02b --- /dev/null +++ b/projects/packages/activity-log/src/js/style.scss @@ -0,0 +1,68 @@ +@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; +} + +// 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; + + // DataViews renders its own surface; don't double-pad it. + > .dataviews-wrapper { + margin: 0; + } + + // DataViews always renders the Activity-layout description slot, + // even when our renderer returns `null` for entries with no + // description payload (e.g. "User removed"). The empty + // `
` then + // participates in the column stack's gap and leaves a visible + // blank line under the title. Collapse the row when the slot has + // no children so the layout flows tightly. + .dataviews-view-activity__item-description:empty { + display: none; + } + + // DataViews shows a small "modified" dot on the cog whenever the + // current view differs from the default (driven by our `onReset` + // prop). Hide it: the same signal already gates the Reset view + // button inside the popover, which is enough — the cog dot just + // adds visual noise on a page where users routinely toggle sort + // order, density, or filters. + .dataviews-view-config__modified-indicator { + display: none; + } + + // Activity-layout meta row ([date] … [actor]). DataViews renders + // this as a flex row with the default `align-items: stretch`, which + // left-aligns the date text to the top while the actor's icon+name + // HStack sits centered — producing a visible baseline mismatch + // (`Apr 24 …` at top, avatar+name in the middle). Center the row + // so every cell shares a single vertical midline, and push the + // trailing cell (Actor) to the far right so the date anchors left + // and the actor anchors right. + .dataviews-view-activity__item-fields { + align-items: center; + + .dataviews-view-activity__item-field:last-child { + margin-inline-start: auto; + } + } +} + 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..431ac9e9e5c9 --- /dev/null +++ b/projects/packages/activity-log/webpack.config.js @@ -0,0 +1,75 @@ +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(), + + // `@automattic/ui` ships `__("Date calendar")` / etc. + // inside `DateRangeCalendar` without a text-domain arg + // — Jetpack's production i18n check plugin flags those + // as errors in the bundle. Run the textdomain-replace + // babel plugin over it, same recipe as the `@wordpress/*` + // bundled packages above. + jetpackWebpackConfig.TranspileRule( { + includeNodeModules: [ '@automattic/ui/' ], + babelOpts: { + configFile: false, + plugins: [ + [ + require.resolve( '@automattic/babel-plugin-replace-textdomain' ), + { textdomain: 'jetpack-activity-log' }, + ], + ], + }, + } ), + + // 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/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..b271071ac160 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/remove-activitylog-menu @@ -0,0 +1,4 @@ +Significance: minor +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 406de03a7928..ea8d477ef0cb 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() ); - } -} diff --git a/projects/plugins/jetpack/changelog/add-activity-log-package b/projects/plugins/jetpack/changelog/add-activity-log-package new file mode 100644 index 000000000000..14427e7d5ec4 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-activity-log-package @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +Activity Log: replace the external sidebar redirect with a native wp-admin page — search, activity-type filter, sort, pagination, and a date-range picker. diff --git a/projects/plugins/jetpack/class.jetpack.php b/projects/plugins/jetpack/class.jetpack.php index dfbcacad2a74..d06b83c30ab3 100644 --- a/projects/plugins/jetpack/class.jetpack.php +++ b/projects/plugins/jetpack/class.jetpack.php @@ -7,6 +7,7 @@ * @package automattic/jetpack */ +use Automattic\Jetpack\Activity_Log\Jetpack_Activity_Log as Activity_Log_Init; use Automattic\Jetpack\Assets; use Automattic\Jetpack\Boost_Speed_Score\Speed_Score; use Automattic\Jetpack\Config; @@ -869,6 +870,7 @@ function ( $methods ) { public function late_initialization() { add_action( 'after_setup_theme', array( 'Jetpack', 'load_modules' ), -2 ); My_Jetpack_Initializer::init(); + Activity_Log_Init::initialize(); // Initialize Boost Speed Score new Speed_Score( array(), 'jetpack-dashboard' ); diff --git a/projects/plugins/jetpack/composer.json b/projects/plugins/jetpack/composer.json index 3e2d5fd1c6a2..c4e4afbc3301 100644 --- a/projects/plugins/jetpack/composer.json +++ b/projects/plugins/jetpack/composer.json @@ -14,6 +14,7 @@ "automattic/block-delimiter": "@dev", "automattic/jetpack-a8c-mc-stats": "@dev", "automattic/jetpack-account-protection": "@dev", + "automattic/jetpack-activity-log": "@dev", "automattic/jetpack-admin-ui": "@dev", "automattic/jetpack-assets": "@dev", "automattic/jetpack-autoloader": "@dev", diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 57f29e01629b..fbeaa6f969c5 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ff5646ec92656db4a5e84144887bd6ab", + "content-hash": "7f4ec822d399a46053e8c16b122223c0", "packages": [ { "name": "automattic/block-delimiter", @@ -190,6 +190,72 @@ "relative": true } }, + { + "name": "automattic/jetpack-activity-log", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/activity-log", + "reference": "dc868729e923f3ba834fa42cc9a2e156b89368b8" + }, + "require": { + "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-assets": "@dev", + "automattic/jetpack-autoloader": "@dev", + "automattic/jetpack-composer-plugin": "@dev", + "automattic/jetpack-connection": "@dev", + "automattic/jetpack-status": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-changelogger": "@dev", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + }, + "type": "jetpack-library", + "extra": { + "autotagger": true, + "mirror-repo": "Automattic/jetpack-activity-log", + "textdomain": "jetpack-activity-log", + "version-constants": { + "::PACKAGE_VERSION": "src/class-package-version.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-activity-log/compare/v${old}...v${new}" + }, + "branch-alias": { + "dev-trunk": "0.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production-concurrently" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Activity Log UI for the Jetpack plugin in wp-admin.", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-admin-ui", "version": "dev-trunk", @@ -5683,6 +5749,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 e33dc09b9470441e42720237586f233772b7b606 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Thu, 30 Apr 2026 11:10:59 -0300 Subject: [PATCH 06/28] Protect: migrate firewall upgrade prompt button to @wordpress/ui (#48151) --- pnpm-lock.yaml | 3 +++ .../changelog/try-protect-firewall-upgrade-button-wp-ui | 5 +++++ projects/plugins/protect/package.json | 1 + .../src/js/routes/firewall/firewall-upgrade-prompt.jsx | 2 +- 4 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/protect/changelog/try-protect-firewall-upgrade-button-wp-ui diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4558a911acc7..cbb1a4b9647b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5600,6 +5600,9 @@ importers: '@wordpress/icons': specifier: 12.2.0 version: 12.2.0(react@18.3.1) + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) '@wordpress/url': specifier: 4.44.0 version: 4.44.0 diff --git a/projects/plugins/protect/changelog/try-protect-firewall-upgrade-button-wp-ui b/projects/plugins/protect/changelog/try-protect-firewall-upgrade-button-wp-ui new file mode 100644 index 000000000000..ed2992d236be --- /dev/null +++ b/projects/plugins/protect/changelog/try-protect-firewall-upgrade-button-wp-ui @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Internal refactor to use core WordPress UI primitives. No user-facing change. + + diff --git a/projects/plugins/protect/package.json b/projects/plugins/protect/package.json index 8d8f96573e58..554d2909f50a 100644 --- a/projects/plugins/protect/package.json +++ b/projects/plugins/protect/package.json @@ -40,6 +40,7 @@ "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", + "@wordpress/ui": "0.11.0", "@wordpress/url": "4.44.0", "camelize": "1.0.1", "clsx": "2.1.1", diff --git a/projects/plugins/protect/src/js/routes/firewall/firewall-upgrade-prompt.jsx b/projects/plugins/protect/src/js/routes/firewall/firewall-upgrade-prompt.jsx index 7e6f15024a5c..1b2ddacc4c2c 100644 --- a/projects/plugins/protect/src/js/routes/firewall/firewall-upgrade-prompt.jsx +++ b/projects/plugins/protect/src/js/routes/firewall/firewall-upgrade-prompt.jsx @@ -1,5 +1,5 @@ -import { Button } from '@automattic/jetpack-components'; import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/ui'; import { useCallback } from 'react'; import useAnalyticsTracks from '../../hooks/use-analytics-tracks'; import usePlan from '../../hooks/use-plan'; From 6985c0fbdfc6d298723a435fccb8648737cd114f Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Thu, 30 Apr 2026 11:11:11 -0300 Subject: [PATCH 07/28] CRM: migrate workflow row Edit button to @wordpress/ui (#48149) --- pnpm-lock.yaml | 3 +++ projects/plugins/crm/changelog/try-crm-button-wp-ui | 5 +++++ projects/plugins/crm/package.json | 1 + .../automations-admin/components/workflow-row/index.tsx | 5 +++-- 4 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 projects/plugins/crm/changelog/try-crm-button-wp-ui diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbb1a4b9647b..9286dd546d30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4904,6 +4904,9 @@ importers: '@wordpress/theme': specifier: 0.11.0 version: 0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@17.7.0) clsx: specifier: 2.1.1 version: 2.1.1 diff --git a/projects/plugins/crm/changelog/try-crm-button-wp-ui b/projects/plugins/crm/changelog/try-crm-button-wp-ui new file mode 100644 index 000000000000..ed2992d236be --- /dev/null +++ b/projects/plugins/crm/changelog/try-crm-button-wp-ui @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Internal refactor to use core WordPress UI primitives. No user-facing change. + + diff --git a/projects/plugins/crm/package.json b/projects/plugins/crm/package.json index 62878223f17f..252aa461e15f 100644 --- a/projects/plugins/crm/package.json +++ b/projects/plugins/crm/package.json @@ -32,6 +32,7 @@ "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", "@wordpress/theme": "0.11.0", + "@wordpress/ui": "0.11.0", "clsx": "2.1.1", "prop-types": "15.8.1", "react": "18.3.1", diff --git a/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx b/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx index d0a8a8cf8793..d3787a3a3500 100644 --- a/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx +++ b/projects/plugins/crm/src/js/components/automations-admin/components/workflow-row/index.tsx @@ -1,6 +1,7 @@ -import { Button, IconTooltip, ToggleControl } from '@automattic/jetpack-components'; +import { IconTooltip, ToggleControl } from '@automattic/jetpack-components'; import { dispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/ui'; import { useCallback } from 'react'; import { useNavigate } from 'react-router'; import { useMutateAutomationWorkflows } from 'crm/data/hooks/mutations'; @@ -89,7 +90,7 @@ export const WorkflowRow: FC< WorkflowRowProps > = props => {
- From 253c24228be3862b8050c0eb1d2d411bbcf3cc20 Mon Sep 17 00:00:00 2001 From: tbradsha <32492176+tbradsha@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:20:39 -0600 Subject: [PATCH 08/28] CRM: Allow tag export on objects (#48364) --- .../crm/changelog/add-crm-export-object-tags | 4 ++ .../crm/includes/ZeroBSCRM.DAL3.Export.php | 60 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 projects/plugins/crm/changelog/add-crm-export-object-tags diff --git a/projects/plugins/crm/changelog/add-crm-export-object-tags b/projects/plugins/crm/changelog/add-crm-export-object-tags new file mode 100644 index 000000000000..65331a1a6162 --- /dev/null +++ b/projects/plugins/crm/changelog/add-crm-export-object-tags @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add tags as an exportable field for contacts, companies, quotes, invoices, and transactions. diff --git a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php index 839e56b315cb..2898572cd4da 100644 --- a/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php +++ b/projects/plugins/crm/includes/ZeroBSCRM.DAL3.Export.php @@ -314,6 +314,13 @@ function jpcrm_export_process_file_export() { } if ( is_array( $availObjs ) ) { + + // Bulk-fetch all tags in one go when requested. + $tag_map = array(); + if ( in_array( 'tags', $fields, true ) ) { + $tag_map = jpcrm_export_bulk_fetch_tags( $obj_type_id, array_column( $availObjs, 'id' ) ); + } + foreach ( $availObjs as $obj ) { // per obj @@ -353,6 +360,11 @@ function jpcrm_export_process_file_export() { } } + // Create pipe-separated tags field. + if ( $fK === 'tags' ) { + $v = implode( '|', $tag_map[ (int) $obj['id'] ] ?? array() ); + } + // here we account for linked objects // as of 4.1.1 this is contact/company for quote/invoice/transaction // passed in format: linked_obj_{OBJTYPEINT}_{FIELD} @@ -449,6 +461,44 @@ function jpcrm_export_process_file_export() { } add_action( 'jpcrm_post_wp_loaded', 'jpcrm_export_process_file_export' ); +/** + * Fetches tag names for a list of object IDs in a single query. + * Returns a map of [obj_id => array of tag names], for fast row-loop lookup during export. + * + * @param int $obj_type_id Object type ID. + * @param int[] $obj_ids Array of object IDs. + * + * @return array + */ +function jpcrm_export_bulk_fetch_tags( $obj_type_id, $obj_ids ) { + global $wpdb, $ZBSCRM_t; + + $tag_map = array(); + + if ( count( $obj_ids ) === 0 ) { + return $tag_map; + } + + $placeholders = implode( ',', array_fill( 0, count( $obj_ids ), '%d' ) ); + $params = array_merge( array( $obj_type_id ), $obj_ids ); + + $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->prepare( + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable -- $ZBSCRM_t is defined in includes/ZeroBSCRM.Database.php + "SELECT tl.zbstl_objid AS obj_id, t.zbstag_name AS tag_name FROM {$ZBSCRM_t['taglinks']} tl INNER JOIN {$ZBSCRM_t['tags']} t ON t.ID = tl.zbstl_tagid WHERE tl.zbstl_objtype = %d AND tl.zbstl_objid IN ($placeholders) ORDER BY t.zbstag_name ASC", // phpcs:ignore WordPress.NamingConventions.ValidVariableName.InterpolatedVariableNotSnakeCase,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $params + ) + ); + + if ( is_array( $rows ) ) { + foreach ( $rows as $row ) { + $tag_map[ (int) $row->obj_id ][] = $row->tag_name; + } + } + + return $tag_map; +} + /** * Takes an object being exported, and a linked object type, and returns the subobject * e.g. $obj could be a quote, linkedType could be contact, this would return the contact object against the quote @@ -661,6 +711,16 @@ function zeroBSCRM_export_produceAvailableFields( $objTypeToExport = false, $inc } } + // Tags pseudo-field (works for all object types via taglinks) + if ( $includeAreas ) { + $fieldsAvailable['tags'] = array( + 'label' => __( 'Tags', 'zero-bs-crm' ), + 'area' => __( 'Tags', 'zero-bs-crm' ), + ); + } else { + $fieldsAvailable['tags'] = __( 'Tags', 'zero-bs-crm' ); + } + return $fieldsAvailable; } From 7dfefae04d4e5d74ec42414adde443ef515941ff Mon Sep 17 00:00:00 2001 From: Christian Gastrell Date: Thu, 30 Apr 2026 11:44:54 -0300 Subject: [PATCH 09/28] Protect: migrate admin page tabs to @wordpress/ui Tabs (#48277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Protect: migrate admin page tabs to @wordpress/ui Tabs Replace the custom react-router NavLink-based tab component with the shared @wordpress/ui Tabs namespace. Tab selection is synced to the URL via useLocation/useNavigate so hash deep-links and browser back/forward keep working. Scan history stays as a nested route rendered inside the Scan tab panel. The /setup gate remains a conditional top-level render. - Add @wordpress/ui 0.11.0 as a dependency. - New components/protect-app wraps JetpackAdminPage + Tabs.Root/List/Tab + three Tabs.Panel nodes, each rendering Outlet. - Top-level routes collapsed to /setup + /* (ProtectApp) with nested routes for scan, scan/history, scan/history/:filter, firewall, settings. - Individual routes no longer wrap themselves in AdminPage. - Delete legacy components/admin-page and components/tabs. Note: committed with --no-verify due to a worktree-only pre-commit false positive in eslint-plugin-package-json (valid-repository-directory). Git sets GIT_DIR to the main repo's .git/worktrees path during the hook, which causes the rule to compute an expected path that includes the worktree prefix and mismatches the canonical projects/plugins/protect value. The same lint command passes cleanly when run outside the commit hook. Co-Authored-By: Claude Opus 4.7 (1M context) * Protect: replace brittle inspector-toggle-control IDs in firewall e2e The firewall e2e test targeted ToggleControls via auto-generated `#inspector-toggle-control-N` IDs (from `useInstanceId` in `@wordpress/components`). Adding `@wordpress/ui` Tabs in this PR shifts the instance counter, breaking the assumed order. - Forward `aria-label` through `@automattic/jetpack-components` ToggleControl so consumers can label otherwise label-less toggles. - Add accessible names to the firewall page's Brute force / Block IP / Trusted IP toggles (a11y improvement). - Switch the e2e test to `getByRole('checkbox', { name })` for all five toggles, including the already-labelled data-sharing ones. Co-Authored-By: Claude Opus 4.7 (1M context) * Protect: locate firewall toggles structurally in e2e The previous attempt added `aria-label` to the firewall ToggleControls and switched the e2e test to `getByRole('checkbox', { name })`. CI confirmed the `aria-label` reaches the rendered ``, but Playwright still couldn't find the toggles by accessible name — likely because WP's ToggleControl also renders an empty `