diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c795514741650..108e5ab1b09b7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2778,6 +2778,9 @@ importers:
projects/packages/my-jetpack:
dependencies:
+ '@automattic/babel-plugin-replace-textdomain':
+ specifier: workspace:*
+ version: link:../../js-packages/babel-plugin-replace-textdomain
'@automattic/format-currency':
specifier: 1.0.1
version: 1.0.1
@@ -2820,6 +2823,9 @@ importers:
'@wordpress/data':
specifier: 10.17.0
version: 10.17.0(react@18.3.1)
+ '@wordpress/dataviews':
+ specifier: 4.12.0
+ version: 4.12.0(patch_hash=uzs6glhpt3sq2uqjvqzk6vk2ze)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@wordpress/date':
specifier: 5.17.0
version: 5.17.0
@@ -8462,6 +8468,12 @@ packages:
peerDependencies:
react: ^18.0.0
+ '@wordpress/dataviews@4.12.0':
+ resolution: {integrity: sha512-hxUJ7OyXL131r1nY0Fm5PiN12+oMclCVooON3hwlh5/x0t/FydcsMp0toGrLmtQQz38VVKl9dIpjjLgUmpSbEw==}
+ engines: {node: '>=18.12.0', npm: '>=8.19.2'}
+ peerDependencies:
+ react: ^18.0.0
+
'@wordpress/dataviews@4.13.0':
resolution: {integrity: sha512-fJyHzNBvI/mivZh5z5+XC3tOSHojNOYVbSA9ifPB6hNcZjFJ+fsNt/I8tmOQdmOOb4dUESkOOKmk6RlPKCjErg==}
engines: {node: '>=18.12.0', npm: '>=8.19.2'}
@@ -14597,6 +14609,9 @@ packages:
third-party-web@0.26.2:
resolution: {integrity: sha512-taJ0Us0lKoYBqcbccMuDElSUPOxmBfwlHe1OkHQ3KFf+RwovvBHdXhbFk9XJVQE2vHzxbTwvwg5GFsT9hbDokQ==}
+ third-party-web@0.26.4:
+ resolution: {integrity: sha512-cH8Y2deNWtUan3u7DM8Z9aPIMll3ATwFBpuYFo7IXfz58X/hwz3Re+nUEpu6stViCeDvgkuR7RjeyNr495cULA==}
+
thread-loader@3.0.4:
resolution: {integrity: sha512-ByaL2TPb+m6yArpqQUZvP+5S1mZtXsEP7nWKKlAUTm7fCml8kB5s1uI3+eHRP2bk5mVYfRSBI7FFf+tWEyLZwA==}
engines: {node: '>= 10.13.0'}
@@ -17327,7 +17342,7 @@ snapshots:
'@paulirish/trace_engine@0.0.39':
dependencies:
- third-party-web: 0.26.2
+ third-party-web: 0.26.4
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -20627,6 +20642,28 @@ snapshots:
rememo: 4.0.2
use-memo-one: 1.1.3(react@18.3.1)
+ '@wordpress/dataviews@4.12.0(patch_hash=uzs6glhpt3sq2uqjvqzk6vk2ze)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@ariakit/react': 0.4.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@babel/runtime': 7.25.7
+ '@wordpress/components': 29.3.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/compose': 7.17.0(react@18.3.1)
+ '@wordpress/data': 10.17.0(react@18.3.1)
+ '@wordpress/element': 6.17.0
+ '@wordpress/i18n': 5.17.0
+ '@wordpress/icons': 10.17.0(react@18.3.1)
+ '@wordpress/primitives': 4.17.0(react@18.3.1)
+ '@wordpress/private-apis': 1.17.0
+ '@wordpress/warning': 3.17.0
+ clsx: 2.1.1
+ react: 18.3.1
+ remove-accents: 0.5.0
+ transitivePeerDependencies:
+ - '@emotion/is-prop-valid'
+ - '@types/react'
+ - react-dom
+ - supports-color
+
'@wordpress/dataviews@4.13.0(patch_hash=uzs6glhpt3sq2uqjvqzk6vk2ze)(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@ariakit/react': 0.4.15(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -29231,6 +29268,8 @@ snapshots:
third-party-web@0.26.2: {}
+ third-party-web@0.26.4: {}
+
thread-loader@3.0.4(webpack@5.94.0):
dependencies:
json-parse-better-errors: 1.0.2
diff --git a/projects/packages/my-jetpack/_inc/components/action-button/index.tsx b/projects/packages/my-jetpack/_inc/components/action-button/index.tsx
index 7b20c13bc6991..37219a19268a0 100644
--- a/projects/packages/my-jetpack/_inc/components/action-button/index.tsx
+++ b/projects/packages/my-jetpack/_inc/components/action-button/index.tsx
@@ -138,7 +138,7 @@ const ActionButton: FC< ActionButtonProps > = ( {
installStandalonePlugin();
}, [ slug, installStandalonePlugin, recordEvent, tracksIdentifier ] );
- const getStatusAction = useCallback( (): SecondaryButtonProps => {
+ const statusAction: SecondaryButtonProps = useMemo( () => {
switch ( status ) {
case PRODUCT_STATUSES.ABSENT: {
const buttonText = __( 'Learn more', 'jetpack-my-jetpack' );
@@ -322,9 +322,8 @@ const ActionButton: FC< ActionButtonProps > = ( {
] );
const allActions = useMemo(
- () =>
- hasAdditionalActions ? [ ...additionalActions, getStatusAction() ] : [ getStatusAction() ],
- [ additionalActions, getStatusAction, hasAdditionalActions ]
+ () => ( hasAdditionalActions ? [ ...additionalActions, statusAction ] : [ statusAction ] ),
+ [ additionalActions, statusAction, hasAdditionalActions ]
);
const recordDropdownStateChange = useCallback( () => {
@@ -374,7 +373,7 @@ const ActionButton: FC< ActionButtonProps > = ( {
const dropdown = hasAdditionalActions && (
- { [ ...additionalActions, getStatusAction() ].map( ( { label, isExternalLink }, index ) => {
+ { [ ...additionalActions, statusAction ].map( ( { label, isExternalLink }, index ) => {
const onDropdownMenuItemClick = () => {
setCurrentAction( allActions[ index ] );
setIsDropdownOpen( false );
diff --git a/projects/packages/my-jetpack/_inc/components/product-card/index.tsx b/projects/packages/my-jetpack/_inc/components/product-card/index.tsx
index 108d08bbbd338..f6f3f41a313a0 100644
--- a/projects/packages/my-jetpack/_inc/components/product-card/index.tsx
+++ b/projects/packages/my-jetpack/_inc/components/product-card/index.tsx
@@ -19,7 +19,7 @@ import type { FC, MouseEventHandler, ReactNode, MouseEvent } from 'react';
export type ProductCardProps = {
children?: ReactNode;
name: string;
- Description: FC;
+ Description: FC | string;
admin: boolean;
recommendation?: boolean;
isDataLoading?: boolean;
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/all.ts b/projects/packages/my-jetpack/_inc/components/product-cards-section/all.ts
index 19a5c3cf4fd5a..70cae5812fa87 100644
--- a/projects/packages/my-jetpack/_inc/components/product-cards-section/all.ts
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/all.ts
@@ -36,6 +36,7 @@ export const JetpackModuleToProductCard: {
extras: null,
scan: null,
creator: null,
+ 'brute-force': null,
// Features:
newsletter: NewsletterCard,
'related-posts': RelatedPostsCard,
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/index.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/index.tsx
index bf9f9724c6a16..05b63ea5138fd 100644
--- a/projects/packages/my-jetpack/_inc/components/product-cards-section/index.tsx
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/index.tsx
@@ -1,9 +1,10 @@
import { Container, Col, Text, AdminSectionHero } from '@automattic/jetpack-components';
import { __ } from '@wordpress/i18n';
-import { useMemo } from 'react';
+import { useMemo, useCallback } from 'react';
import { PRODUCT_SLUGS } from '../../data/constants';
import useProductsByOwnership from '../../data/products/use-products-by-ownership';
import { getMyJetpackWindowInitialState } from '../../data/utils/get-my-jetpack-window-state';
+import ProductsTableView from '../products-table-view';
import StatsSection from '../stats-section';
import AiCard from './ai-card';
import AntiSpamCard from './anti-spam-card';
@@ -27,19 +28,7 @@ type DisplayItemType = Record<
// 'jetpack-ai' is the official slug for the AI module, so we also exclude 'ai'.
// The backend still supports the 'ai' slug, so it is part of the JetpackModule type.
// Related-posts, newsletter, and site-accelerator are features, not products.
- Exclude<
- JetpackModule,
- | 'extras'
- | 'scan'
- | 'security'
- | 'ai'
- | 'creator'
- | 'growth'
- | 'complete'
- | 'site-accelerator'
- | 'newsletter'
- | 'related-posts'
- >,
+ JetpackModuleWithCard,
FC< { admin: boolean } >
>;
@@ -115,15 +104,27 @@ const ProductCardsSection: FC< ProductCardsSectionProps > = ( { noticeMessage }
: __( 'Discover all Jetpack Products', 'jetpack-my-jetpack' );
}, [ ownedProducts.length ] );
- const filterProducts = ( products: JetpackModule[] ) => {
- const productsWithNoCard = [ 'scan', 'security', 'growth', 'extras', 'complete' ];
+ const filterProducts = useCallback( ( products: JetpackModule[] ) => {
+ const productsWithNoCard = [
+ 'extras',
+ 'scan',
+ 'security',
+ 'ai',
+ 'creator',
+ 'growth',
+ 'complete',
+ 'site-accelerator',
+ 'newsletter',
+ 'related-posts',
+ 'brute-force',
+ ];
return products.filter( product => {
if ( productsWithNoCard.includes( product ) ) {
return false;
}
return true;
} );
- };
+ }, [] );
const filteredOwnedProducts = filterProducts( ownedProducts );
const filteredUnownedProducts = filterProducts( unownedProducts );
@@ -150,8 +151,7 @@ const ProductCardsSection: FC< ProductCardsSectionProps > = ( { noticeMessage }
{ unownedSectionTitle }
-
-
+
) }
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/constants.ts b/projects/packages/my-jetpack/_inc/components/products-table-view/constants.ts
new file mode 100644
index 0000000000000..355ab243a3977
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/constants.ts
@@ -0,0 +1,5 @@
+export const PRODUCT_TABLE_TITLE = 'title';
+export const PRODUCT_TABLE_STATUS = 'status';
+export const PRODUCT_TABLE_CATEGORY = 'category';
+export const PRODUCT_TABLE_DESCRIPTION = 'description';
+export const PRODUCT_TABLE_ICON = 'icon';
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/anti-spam.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/anti-spam.tsx
new file mode 100644
index 0000000000000..7fb8de6b01f80
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/anti-spam.tsx
@@ -0,0 +1,37 @@
+const AntiSpamIcon = () => (
+
+);
+
+export default AntiSpamIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/backup.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/backup.tsx
new file mode 100644
index 0000000000000..c46fb8b465b9a
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/backup.tsx
@@ -0,0 +1,20 @@
+const BackupIcon = () => (
+
+);
+
+export default BackupIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/boost.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/boost.tsx
new file mode 100644
index 0000000000000..8e3821d2b89dd
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/boost.tsx
@@ -0,0 +1,20 @@
+const BoostIcon = () => (
+
+);
+
+export default BoostIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/crm.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/crm.tsx
new file mode 100644
index 0000000000000..eed0253dd3074
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/crm.tsx
@@ -0,0 +1,34 @@
+const CrmIcon = () => (
+
+);
+
+export default CrmIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/jetpack-ai.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/jetpack-ai.tsx
new file mode 100644
index 0000000000000..d55bb32ecdc56
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/jetpack-ai.tsx
@@ -0,0 +1,26 @@
+const JetpackAiIcon = () => (
+
+);
+
+export default JetpackAiIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/protect.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/protect.tsx
new file mode 100644
index 0000000000000..b881e74a05608
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/protect.tsx
@@ -0,0 +1,20 @@
+const ProtectIcon = () => (
+
+);
+
+export default ProtectIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/search.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/search.tsx
new file mode 100644
index 0000000000000..099ec04ebc1f5
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/search.tsx
@@ -0,0 +1,20 @@
+const SearchIcon = () => (
+
+);
+
+export default SearchIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/social.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/social.tsx
new file mode 100644
index 0000000000000..6665d4d05f97c
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/social.tsx
@@ -0,0 +1,22 @@
+const SocialIcon = () => (
+
+);
+
+export default SocialIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/stats.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/stats.tsx
new file mode 100644
index 0000000000000..d9864aef610bd
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/stats.tsx
@@ -0,0 +1,17 @@
+const StatsIcon = () => (
+
+);
+
+export default StatsIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/icons/videopress.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/videopress.tsx
new file mode 100644
index 0000000000000..8a81bc02fa958
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/icons/videopress.tsx
@@ -0,0 +1,20 @@
+const VideopressIcon = () => (
+
+);
+
+export default VideopressIcon;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/index.tsx b/projects/packages/my-jetpack/_inc/components/products-table-view/index.tsx
new file mode 100644
index 0000000000000..820e84ab516d7
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/index.tsx
@@ -0,0 +1,236 @@
+import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews';
+import { __ } from '@wordpress/i18n';
+import { useCallback, useState, useMemo } from 'react';
+import { useAllProducts } from '../../data/products/use-all-products';
+import ActionButton from '../action-button';
+import {
+ PRODUCT_TABLE_TITLE,
+ PRODUCT_TABLE_DESCRIPTION,
+ PRODUCT_TABLE_STATUS,
+ PRODUCT_TABLE_ICON,
+ PRODUCT_TABLE_CATEGORY,
+} from './constants';
+import AntiSpamIcon from './icons/anti-spam';
+import BackupIcon from './icons/backup';
+import BoostIcon from './icons/boost';
+import CrmIcon from './icons/crm';
+import JetpackAiIcon from './icons/jetpack-ai';
+import ProtectIcon from './icons/protect';
+import SearchIcon from './icons/search';
+import SocialIcon from './icons/social';
+import StatsIcon from './icons/stats';
+import VideopressIcon from './icons/videopress';
+import type { ProductsTableViewProps, ProductData } from './types';
+import type { ProductCamelCase } from '../../data/types';
+import type {
+ ViewList,
+ SupportedLayouts,
+ SortDirection,
+ View,
+ Operator,
+ Option,
+} from '@wordpress/dataviews';
+import type { FC } from 'react';
+
+import './style.scss';
+
+const PRODUCT_ICONS = {
+ backup: BackupIcon,
+ protect: ProtectIcon,
+ 'anti-spam': AntiSpamIcon,
+ 'jetpack-ai': JetpackAiIcon,
+ boost: BoostIcon,
+ search: SearchIcon,
+ videopress: VideopressIcon,
+ stats: StatsIcon,
+ crm: CrmIcon,
+ social: SocialIcon,
+};
+
+const compileData: (
+ products: JetpackModule[],
+ allProductData: {
+ [ key: string ]: ProductCamelCase;
+ }
+) => ProductData[] = ( products, allProductData ) => {
+ const data = products.map( product => {
+ const productData = allProductData[ product ];
+ const { description, name, status, slug, category } = productData;
+ return {
+ product: {
+ description,
+ name,
+ slug,
+ category,
+ },
+ status,
+ };
+ } );
+
+ return data;
+};
+
+const getCategories: (
+ products: JetpackModule[],
+ allProductData: {
+ [ key: string ]: ProductCamelCase;
+ }
+) => Option[] = ( products, allProductData ) => {
+ const categories = [
+ ...new Set(
+ products.map( product => {
+ const productData = allProductData[ product ];
+ return productData.category;
+ } )
+ ),
+ ];
+
+ const categoryOptions = categories.map( category => ( {
+ value: category,
+ label: category,
+ } ) );
+
+ return categoryOptions;
+};
+
+const ProductsTableView: FC< ProductsTableViewProps > = ( { products } ) => {
+ const getItemId = useCallback( ( item: ProductData ) => item.product.slug, [] );
+ const onChangeView = useCallback( ( newView: View ) => {
+ setView( newView );
+ }, [] );
+ const isItemClickable = useCallback( () => false, [] );
+ const allProductData = useAllProducts();
+
+ const baseView: ViewList = {
+ sort: {
+ field: PRODUCT_TABLE_TITLE,
+ direction: 'asc' as SortDirection,
+ },
+ type: 'list',
+ filters: [],
+ page: 1,
+ perPage: 10,
+ };
+
+ const defaultLayouts: SupportedLayouts = {
+ list: {
+ ...baseView,
+ fields: [ PRODUCT_TABLE_DESCRIPTION, PRODUCT_TABLE_STATUS ],
+ titleField: PRODUCT_TABLE_TITLE,
+ mediaField: PRODUCT_TABLE_ICON,
+ showMedia: true,
+ },
+ };
+
+ const categories = useMemo(
+ () => getCategories( products, allProductData ),
+ [ products, allProductData ]
+ );
+
+ const fields = useMemo( () => {
+ return [
+ {
+ id: PRODUCT_TABLE_TITLE,
+ label: __( 'Title', 'jetpack-my-jetpack' ),
+ enableGlobalSearch: true,
+ enableHiding: false,
+ getValue( { item }: { item: ProductData } ) {
+ return item.product.name;
+ },
+ render: ( { item }: { item: ProductData } ) => {
+ const { product } = item;
+ return { product.name }
;
+ },
+ },
+ {
+ id: PRODUCT_TABLE_DESCRIPTION,
+ label: __( 'Description', 'jetpack-my-jetpack' ),
+ enableGlobalSearch: true,
+ enableHiding: false,
+ getValue( { item }: { item: ProductData } ) {
+ return item.product.description;
+ },
+ render: ( { item }: { item: ProductData } ) => {
+ const { product } = item;
+ return { product.description }
;
+ },
+ },
+ {
+ id: PRODUCT_TABLE_CATEGORY,
+ label: __( 'Category', 'jetpack-my-jetpack' ),
+ enableGlobalSearch: true,
+ enableHiding: true,
+ filterBy: {
+ isPrimary: true,
+ operators: [ 'is' ] as Operator[],
+ },
+ elements: categories.length > 1 ? categories : [],
+ isVisible: () => false,
+ getValue( { item }: { item: ProductData } ) {
+ return item.product.category;
+ },
+ },
+ {
+ id: PRODUCT_TABLE_ICON,
+ label: __( 'Icon', 'jetpack-my-jetpack' ),
+ enableGlobalSearch: false,
+ enableHiding: false,
+ render( { item }: { item: ProductData } ) {
+ const { product } = item;
+ const Icon = PRODUCT_ICONS[ product.slug ];
+ return ;
+ },
+ },
+ {
+ id: PRODUCT_TABLE_STATUS,
+ label: 'Status',
+ enableGlobalSearch: false,
+ enableHiding: false,
+ getValue( { item }: { item: ProductData } ) {
+ return item.status;
+ },
+ render: ( { item }: { item: ProductData } ) => {
+ const { product } = item;
+ const { slug } = product;
+
+ return ;
+ },
+ },
+ ];
+ // Having this re-calculate on every change of "categories" was causing unnecessary re-renders
+ // and a 'jumping' of the CTA buttons. Having categories as a dependency here is unnecessary
+ // and leaving it out doesn't cause the values to be incorrect.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
+ const [ view, setView ] = useState< View >( {
+ type: 'list',
+ ...defaultLayouts.list,
+ } );
+
+ const data = useMemo(
+ () => compileData( products, allProductData ),
+ [ allProductData, products ]
+ );
+
+ const { data: processedData, paginationInfo } = useMemo(
+ () => filterSortAndPaginate( data, view, fields ),
+ [ data, fields, view ]
+ );
+
+ return (
+
+ );
+};
+
+export default ProductsTableView;
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/style.scss b/projects/packages/my-jetpack/_inc/components/products-table-view/style.scss
new file mode 100644
index 0000000000000..85916a4eeecc5
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/style.scss
@@ -0,0 +1,100 @@
+@import '@wordpress/dataviews/build-style/style.css';
+@import '@wordpress/components/build-style/style.css';
+
+:root {
+ --tag-color: #DAFFDC;
+}
+
+div.dataviews-wrapper {
+ button.dataviews-view-list__item {
+ display: none;
+ }
+
+ div.dataviews-filters__container {
+ padding-left: 24px;
+ }
+
+ svg.table-view-icon {
+ width: 52px;
+ height: 52px;
+ }
+
+ button[aria-controls="dataviews-view-config-dropdown-0"] {
+ display: none;
+ }
+
+ div.dataviews__view-actions {
+ padding-left: 24px;
+ padding-right: 24px;
+ justify-content: flex-start;
+ align-items: center;
+ }
+
+ button.components-button.is-secondary {
+ padding: calc( var(--spacing-base) / 2 ) var(--spacing-base);
+ font-weight: normal;
+ }
+
+ div.components-base-control__field {
+ margin-bottom: 0;
+ }
+
+ div.components-toggle-group-control {
+ padding: 0;
+ min-height: 32px;
+ }
+
+ div.dataviews-title-field {
+ font-size: var(--font-title-small);
+ }
+
+ span.dataviews-view-list__field-value {
+ font-size: var(--font-body-small);
+ }
+
+ div.dataviews-view-list {
+ div[role="row"] {
+ border: none;
+
+ .dataviews-view-list__fields {
+ justify-content: space-between;
+ }
+
+ &:not(.is-selected).is-hovered,
+ &:not(.is-selected):hover {
+ background-color: transparent;
+
+ .dataviews-view-list__fields {
+ color: #757575;
+ }
+ }
+ }
+ }
+}
+
+div.dataviews-filters__search-widget-listitem {
+ span {
+ text-transform: capitalize;
+ }
+}
+
+span.dataviews-filters__summary-filter-text-value {
+ text-transform: capitalize;
+}
+
+div.dataviews-filters__search-widget-listitem:hover,
+div.dataviews-filters__search-widget-listitem[data-active-item] {
+ background-color: var(--tag-color);
+ color: var(--jp-gray-70);
+}
+
+div.dataviews-filters__search-widget-listitem:hover {
+ span.dataviews-filters__search-widget-listitem-check {
+ fill: var(--jp-gray-70);
+ }
+}
+div.dataviews-filters__summary-chip-container div.dataviews-filters__summary-chip.has-values[aria-expanded=true],
+div.dataviews-filters__summary-chip-container div.dataviews-filters__summary-chip.has-values:hover,
+div.dataviews-filters__summary-chip-container button.dataviews-filters__summary-chip-remove.has-values:hover {
+ background-color: var(--tag-color);
+}
\ No newline at end of file
diff --git a/projects/packages/my-jetpack/_inc/components/products-table-view/types.ts b/projects/packages/my-jetpack/_inc/components/products-table-view/types.ts
new file mode 100644
index 0000000000000..2343952df598b
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/products-table-view/types.ts
@@ -0,0 +1,14 @@
+import type { ProductCamelCase } from '../../data/types';
+
+export interface ProductsTableViewProps {
+ products: JetpackModule[];
+}
+
+export type ProductData = {
+ product: Pick< ProductCamelCase, 'description' | 'name' | 'slug' | 'category' >;
+ status: ProductStatus;
+};
+
+export interface ListButtonProps {
+ slug: JetpackModule;
+}
diff --git a/projects/packages/my-jetpack/changelog/add-filter-to-unowned-list b/projects/packages/my-jetpack/changelog/add-filter-to-unowned-list
new file mode 100644
index 0000000000000..3464c9914b2bc
--- /dev/null
+++ b/projects/packages/my-jetpack/changelog/add-filter-to-unowned-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Add filter to unowned list of products
diff --git a/projects/packages/my-jetpack/changelog/update-unowned-section-to-list b/projects/packages/my-jetpack/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/packages/my-jetpack/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/packages/my-jetpack/global.d.ts b/projects/packages/my-jetpack/global.d.ts
index eb8fc175024ce..37a7d20290204 100644
--- a/projects/packages/my-jetpack/global.d.ts
+++ b/projects/packages/my-jetpack/global.d.ts
@@ -50,6 +50,18 @@ type JetpackModule =
| 'related-posts'
| 'brute-force';
+type JetpackModuleWithCard =
+ | 'anti-spam'
+ | 'backup'
+ | 'boost'
+ | 'crm'
+ | 'jetpack-ai'
+ | 'protect'
+ | 'search'
+ | 'social'
+ | 'stats'
+ | 'videopress';
+
type ThreatItem = {
// Protect API properties (free plan)
id: string;
@@ -172,6 +184,7 @@ interface Window {
[ key: string ]: {
class: string;
description: string;
+ category: 'security' | 'performance' | 'growth' | 'create' | 'management';
disclaimers: Array< string[] >;
features: string[];
has_free_offering: boolean;
@@ -241,7 +254,7 @@ interface Window {
};
purchase_url?: string;
requires_user_connection: boolean;
- slug: string;
+ slug: JetpackModule;
standalone_plugin_info: {
has_standalone_plugin: boolean;
is_standalone_installed: boolean;
diff --git a/projects/packages/my-jetpack/package.json b/projects/packages/my-jetpack/package.json
index 1a790f77c48dd..904ed1616b9ef 100644
--- a/projects/packages/my-jetpack/package.json
+++ b/projects/packages/my-jetpack/package.json
@@ -23,6 +23,7 @@
"test-coverage": "pnpm run test --coverage"
},
"dependencies": {
+ "@automattic/babel-plugin-replace-textdomain": "workspace:*",
"@automattic/format-currency": "1.0.1",
"@automattic/jetpack-analytics": "workspace:*",
"@automattic/jetpack-api": "workspace:*",
@@ -42,6 +43,7 @@
"@wordpress/i18n": "5.17.0",
"@wordpress/icons": "10.17.0",
"@wordpress/url": "4.17.0",
+ "@wordpress/dataviews": "4.12.0",
"clsx": "2.1.1",
"debug": "4.4.0",
"gridicons": "3.4.1",
diff --git a/projects/packages/my-jetpack/src/class-rest-products.php b/projects/packages/my-jetpack/src/class-rest-products.php
index e6dd8f2f2e8c5..c997c2280a510 100644
--- a/projects/packages/my-jetpack/src/class-rest-products.php
+++ b/projects/packages/my-jetpack/src/class-rest-products.php
@@ -172,6 +172,7 @@ public static function check_products_string( $value ) {
return true;
}
+
/**
* Check Products argument.
*
diff --git a/projects/packages/my-jetpack/src/products/class-anti-spam.php b/projects/packages/my-jetpack/src/products/class-anti-spam.php
index 7cec298d57aaf..1fa5b0a68ea3c 100644
--- a/projects/packages/my-jetpack/src/products/class-anti-spam.php
+++ b/projects/packages/my-jetpack/src/products/class-anti-spam.php
@@ -36,6 +36,13 @@ class Anti_Spam extends Product {
*/
public static $plugin_slug = 'akismet';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'security';
+
/**
* The feature slug that identifies the paid plan
*
diff --git a/projects/packages/my-jetpack/src/products/class-backup.php b/projects/packages/my-jetpack/src/products/class-backup.php
index b4b267a255ad2..05a40be71d772 100644
--- a/projects/packages/my-jetpack/src/products/class-backup.php
+++ b/projects/packages/my-jetpack/src/products/class-backup.php
@@ -43,6 +43,13 @@ class Backup extends Hybrid_Product {
*/
public static $plugin_slug = 'jetpack-backup';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'security';
+
/**
* Backup has a standalone plugin
*
diff --git a/projects/packages/my-jetpack/src/products/class-boost.php b/projects/packages/my-jetpack/src/products/class-boost.php
index ca2b18f034763..34d6f429521a2 100644
--- a/projects/packages/my-jetpack/src/products/class-boost.php
+++ b/projects/packages/my-jetpack/src/products/class-boost.php
@@ -44,6 +44,13 @@ class Boost extends Product {
*/
public static $plugin_slug = 'jetpack-boost';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'performance';
+
/**
* Boost has a standalone plugin
*
diff --git a/projects/packages/my-jetpack/src/products/class-crm.php b/projects/packages/my-jetpack/src/products/class-crm.php
index dceeb0b2ae889..71aa29a8a1014 100644
--- a/projects/packages/my-jetpack/src/products/class-crm.php
+++ b/projects/packages/my-jetpack/src/products/class-crm.php
@@ -39,6 +39,13 @@ class Crm extends Product {
*/
public static $plugin_slug = 'zero-bs-crm';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'management';
+
/**
* Whether this product requires a user connection
*
diff --git a/projects/packages/my-jetpack/src/products/class-jetpack-ai.php b/projects/packages/my-jetpack/src/products/class-jetpack-ai.php
index a317de5e5272c..1004db673d77d 100644
--- a/projects/packages/my-jetpack/src/products/class-jetpack-ai.php
+++ b/projects/packages/my-jetpack/src/products/class-jetpack-ai.php
@@ -28,6 +28,13 @@ class Jetpack_Ai extends Product {
*/
public static $slug = 'jetpack-ai';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'create';
+
/**
* Whether this product has a free offering
*
diff --git a/projects/packages/my-jetpack/src/products/class-newsletter.php b/projects/packages/my-jetpack/src/products/class-newsletter.php
index 61a5736b77c61..cd24c8431008d 100644
--- a/projects/packages/my-jetpack/src/products/class-newsletter.php
+++ b/projects/packages/my-jetpack/src/products/class-newsletter.php
@@ -22,6 +22,13 @@ class Newsletter extends Module_Product {
*/
public static $slug = 'newsletter';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'growth';
+
/**
* The slug of the plugin associated with this product.
* Newsletter is a feature available as part of the Jetpack plugin.
diff --git a/projects/packages/my-jetpack/src/products/class-product.php b/projects/packages/my-jetpack/src/products/class-product.php
index 4c6c76dc0dd93..136be00789ebf 100644
--- a/projects/packages/my-jetpack/src/products/class-product.php
+++ b/projects/packages/my-jetpack/src/products/class-product.php
@@ -48,6 +48,13 @@ abstract class Product {
*/
public static $plugin_slug = null;
+ /**
+ * The category of the product in the Jetpack ecosystem. The options are performance, growth, security, management, and create
+ *
+ * @var string
+ */
+ public static $category = null;
+
/**
* The Jetpack plugin slug
*
@@ -183,6 +190,7 @@ public static function get_info() {
'plugin_slug' => static::get_plugin_slug(),
'name' => static::get_name(),
'title' => static::get_title(),
+ 'category' => static::$category,
'description' => static::get_description(),
'long_description' => static::get_long_description(),
'tiers' => static::get_tiers(),
diff --git a/projects/packages/my-jetpack/src/products/class-protect.php b/projects/packages/my-jetpack/src/products/class-protect.php
index 36a1496f55a16..5a0980a344e83 100644
--- a/projects/packages/my-jetpack/src/products/class-protect.php
+++ b/projects/packages/my-jetpack/src/products/class-protect.php
@@ -52,6 +52,13 @@ class Protect extends Hybrid_Product {
*/
public static $plugin_slug = 'jetpack-protect';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'security';
+
/**
* Whether this product requires a user connection
*
diff --git a/projects/packages/my-jetpack/src/products/class-related-posts.php b/projects/packages/my-jetpack/src/products/class-related-posts.php
index a12a5715b2a91..2f1818bfee1fa 100644
--- a/projects/packages/my-jetpack/src/products/class-related-posts.php
+++ b/projects/packages/my-jetpack/src/products/class-related-posts.php
@@ -44,6 +44,13 @@ class Related_Posts extends Module_Product {
*/
public static $module_name = 'related-posts';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'growth';
+
/**
* Whether this module is a Jetpack feature
*
diff --git a/projects/packages/my-jetpack/src/products/class-scan.php b/projects/packages/my-jetpack/src/products/class-scan.php
index dde156a84c3ca..6f8cc05dc7f29 100644
--- a/projects/packages/my-jetpack/src/products/class-scan.php
+++ b/projects/packages/my-jetpack/src/products/class-scan.php
@@ -33,6 +33,13 @@ class Scan extends Module_Product {
*/
public static $module_name = 'scan';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'security';
+
/**
* The feature slug that identifies the paid plan
*
diff --git a/projects/packages/my-jetpack/src/products/class-search.php b/projects/packages/my-jetpack/src/products/class-search.php
index bb1bc87045743..17054376bd934 100644
--- a/projects/packages/my-jetpack/src/products/class-search.php
+++ b/projects/packages/my-jetpack/src/products/class-search.php
@@ -41,6 +41,13 @@ class Search extends Hybrid_Product {
*/
public static $plugin_slug = 'jetpack-search';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'performance';
+
/**
* Search has a standalone plugin
*
diff --git a/projects/packages/my-jetpack/src/products/class-site-accelerator.php b/projects/packages/my-jetpack/src/products/class-site-accelerator.php
index 72e65b9ff3d49..9e711a7f90695 100644
--- a/projects/packages/my-jetpack/src/products/class-site-accelerator.php
+++ b/projects/packages/my-jetpack/src/products/class-site-accelerator.php
@@ -22,6 +22,13 @@ class Site_Accelerator extends Module_Product {
*/
public static $slug = 'site-accelerator';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'performance';
+
/**
* The slug of the plugin associated with this product.
* Site Accelerator is a feature available as part of the Jetpack plugin.
diff --git a/projects/packages/my-jetpack/src/products/class-social.php b/projects/packages/my-jetpack/src/products/class-social.php
index 8ba40dc889e4f..6eff8ce2adc5e 100644
--- a/projects/packages/my-jetpack/src/products/class-social.php
+++ b/projects/packages/my-jetpack/src/products/class-social.php
@@ -38,6 +38,13 @@ class Social extends Hybrid_Product {
*/
public static $plugin_slug = 'jetpack-social';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'growth';
+
/**
* Social has a standalone plugin
*
diff --git a/projects/packages/my-jetpack/src/products/class-stats.php b/projects/packages/my-jetpack/src/products/class-stats.php
index 4ae6f485b981a..e838ecc67e58e 100644
--- a/projects/packages/my-jetpack/src/products/class-stats.php
+++ b/projects/packages/my-jetpack/src/products/class-stats.php
@@ -32,6 +32,13 @@ class Stats extends Module_Product {
*/
public static $module_name = 'stats';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'growth';
+
/**
* The Plugin slug associated with stats
*
@@ -292,6 +299,10 @@ public static function has_trial_support() {
* @return ?string
*/
public static function get_purchase_url() {
+ $status = static::get_status();
+ if ( $status === Products::STATUS_NEEDS_FIRST_SITE_CONNECTION ) {
+ return null;
+ }
// The returning URL could be customized by changing the `redirect_uri` param with relative path.
return sprintf(
'%s#!/stats/purchase/%d?from=jetpack-my-jetpack%s&redirect_uri=%s',
diff --git a/projects/packages/my-jetpack/src/products/class-videopress.php b/projects/packages/my-jetpack/src/products/class-videopress.php
index f3eab30b844fc..051e27514038b 100644
--- a/projects/packages/my-jetpack/src/products/class-videopress.php
+++ b/projects/packages/my-jetpack/src/products/class-videopress.php
@@ -36,6 +36,13 @@ class Videopress extends Hybrid_Product {
*/
public static $plugin_slug = 'jetpack-videopress';
+ /**
+ * The category of the product
+ *
+ * @var string
+ */
+ public static $category = 'performance';
+
/**
* The filename (id) of the plugin associated with this product.
*
diff --git a/projects/packages/my-jetpack/webpack.config.js b/projects/packages/my-jetpack/webpack.config.js
index 36fc920c8f668..f9ae3d5f9c396 100644
--- a/projects/packages/my-jetpack/webpack.config.js
+++ b/projects/packages/my-jetpack/webpack.config.js
@@ -33,6 +33,20 @@ module.exports = [
includeNodeModules: [ '@automattic/jetpack-' ],
} ),
+ // Add textdomains (but no other optimizations) for @wordpress/dataviews.
+ jetpackWebpackConfig.TranspileRule( {
+ includeNodeModules: [ '@wordpress/dataviews/' ],
+ babelOpts: {
+ configFile: false,
+ plugins: [
+ [
+ require.resolve( '@automattic/babel-plugin-replace-textdomain' ),
+ { textdomain: 'jetpack-my-jetpack' },
+ ],
+ ],
+ },
+ } ),
+
// Handle CSS.
jetpackWebpackConfig.CssRule( {
extensions: [ 'css', 'sass', 'scss' ],
diff --git a/projects/plugins/backup/changelog/update-unowned-section-to-list b/projects/plugins/backup/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/backup/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/boost/changelog/update-unowned-section-to-list b/projects/plugins/boost/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/boost/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/jetpack/changelog/update-unowned-section-to-list b/projects/plugins/jetpack/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..50c7015d741a1
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/protect/changelog/update-unowned-section-to-list b/projects/plugins/protect/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/protect/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/search/changelog/update-unowned-section-to-list b/projects/plugins/search/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/search/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/social/changelog/update-unowned-section-to-list b/projects/plugins/social/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/social/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/starter-plugin/changelog/update-unowned-section-to-list b/projects/plugins/starter-plugin/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/starter-plugin/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list
diff --git a/projects/plugins/videopress/changelog/update-unowned-section-to-list b/projects/plugins/videopress/changelog/update-unowned-section-to-list
new file mode 100644
index 0000000000000..ffec2d2f389a2
--- /dev/null
+++ b/projects/plugins/videopress/changelog/update-unowned-section-to-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: changed
+
+Update the unowned section from a product grid to a product list