From 2dbf9b8599737dac810d31e6e85cd6a8fb9367b6 Mon Sep 17 00:00:00 2001 From: Nate Weller Date: Mon, 1 Apr 2024 07:51:23 -0600 Subject: [PATCH] Add onboarding tours --- .../components/guided-tour-step/index.tsx | 81 ++++ .../components/guided-tour/index.tsx | 166 ++++++++ .../components/guided-tour/style.scss | 49 +++ .../components/layout/index.tsx | 28 +- .../data/guided-tours/guided-tour-context.tsx | 168 ++++++++ .../data/guided-tours/tours/index.tsx | 0 .../tours/sites-walkthrough-tour.tsx | 0 .../tours/use-sites-walkthrough-tour.tsx | 105 +++++ .../data/guided-tours/use-tours.tsx | 13 + .../sections/sites/sites-dashboard/index.tsx | 5 +- .../sections/sites/sites-dataviews/index.tsx | 401 ++++++++++++++++++ .../sites/sites-dataviews/interfaces.ts | 45 ++ .../sites/sites-dataviews/site-data-field.tsx | 28 ++ .../sections/sites/sites-dataviews/style.scss | 215 ++++++++++ .../sections/sites/sites-dataviews/types.d.ts | 1 + 15 files changed, 1291 insertions(+), 14 deletions(-) create mode 100644 client/a8c-for-agencies/components/guided-tour-step/index.tsx create mode 100644 client/a8c-for-agencies/components/guided-tour/index.tsx create mode 100644 client/a8c-for-agencies/components/guided-tour/style.scss create mode 100644 client/a8c-for-agencies/data/guided-tours/guided-tour-context.tsx create mode 100644 client/a8c-for-agencies/data/guided-tours/tours/index.tsx create mode 100644 client/a8c-for-agencies/data/guided-tours/tours/sites-walkthrough-tour.tsx create mode 100644 client/a8c-for-agencies/data/guided-tours/tours/use-sites-walkthrough-tour.tsx create mode 100644 client/a8c-for-agencies/data/guided-tours/use-tours.tsx create mode 100644 client/a8c-for-agencies/sections/sites/sites-dataviews/index.tsx create mode 100644 client/a8c-for-agencies/sections/sites/sites-dataviews/interfaces.ts create mode 100644 client/a8c-for-agencies/sections/sites/sites-dataviews/site-data-field.tsx create mode 100644 client/a8c-for-agencies/sections/sites/sites-dataviews/style.scss create mode 100644 client/a8c-for-agencies/sections/sites/sites-dataviews/types.d.ts diff --git a/client/a8c-for-agencies/components/guided-tour-step/index.tsx b/client/a8c-for-agencies/components/guided-tour-step/index.tsx new file mode 100644 index 00000000000000..0128a5e4e20037 --- /dev/null +++ b/client/a8c-for-agencies/components/guided-tour-step/index.tsx @@ -0,0 +1,81 @@ +import { Button, Popover } from '@automattic/components'; +import classNames from 'classnames'; +import { useTranslate } from 'i18n-calypso'; +import { LegacyRef, useContext, useEffect } from 'react'; +import { GuidedTourContext } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context'; + +type Props = { + id: string; + context: LegacyRef< HTMLSpanElement | null >; + className?: string; + hideSteps?: boolean; +}; + +/** + * Renders a single step in a guided tour. + */ +export function GuidedTourStep( { id, context, hideSteps, className }: Props ) { + const translate = useTranslate(); + + const { currentStep, currentStepCount, stepsCount, nextStep, endTour, tourDismissed } = + useContext( GuidedTourContext ); + + const lastTourLabel = stepsCount === 1 ? translate( 'Got it' ) : translate( 'Done' ); + + // Keep track of which tour steps are currently rendered/mounted. + useEffect( () => { + // Register on mount. + registerRenderedStep( id ); + // Unregister on unmount. + return () => unregisterRenderedStep( id ); + }, [ id, registerRenderedStep, unregisterRenderedStep ] ); + + // Do not render unless this is the current step in the active tour. + if ( ! currentStep || id !== currentStep.id || tourDismissed ) { + return null; + } + + return ( + +

{ currentStep.title }

+

{ currentStep.description }

+
+
+ { + // Show the step count if there are multiple steps and we're not on the last step, unless we explicitly choose to hide them + stepsCount > 1 && ! hideSteps && ( + + { translate( 'Step %(currentStep)d of %(totalSteps)d', { + args: { currentStep: currentStepCount, totalSteps: stepsCount }, + } ) } + + ) + } +
+
+ <> + { ( ( ! currentStep.nextStepOnTargetClick && + stepsCount > 1 && + currentStepCount < stepsCount ) || + currentStep.forceShowSkipButton ) && ( + // Show the skip button if there are multiple steps and we're not on the last step, unless we explicitly choose to add them + + ) } + { ! currentStep.nextStepOnTargetClick && ( + + ) } + +
+
+
+ ); +} diff --git a/client/a8c-for-agencies/components/guided-tour/index.tsx b/client/a8c-for-agencies/components/guided-tour/index.tsx new file mode 100644 index 00000000000000..1166c257f4eeaa --- /dev/null +++ b/client/a8c-for-agencies/components/guided-tour/index.tsx @@ -0,0 +1,166 @@ +import page from '@automattic/calypso-router'; +import { Popover, Button } from '@automattic/components'; +import classNames from 'classnames'; +import { useTranslate } from 'i18n-calypso'; +import { useState, useEffect, useCallback, useContext } from 'react'; +import { GuidedTourContext } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context'; +import { useDispatch, useSelector } from 'calypso/state'; +import { recordTracksEvent } from 'calypso/state/analytics/actions'; +import { getJetpackDashboardPreference as getPreference } from 'calypso/state/jetpack-agency-dashboard/selectors'; +import { savePreference } from 'calypso/state/preferences/actions'; +import { preferencesLastFetchedTimestamp } from 'calypso/state/preferences/selectors'; + +import './style.scss'; + +export interface Tour { + id: string; + target: string; + title: string; + description: string | JSX.Element; + popoverPosition?: + | 'top' + | 'top right' + | 'right' + | 'bottom right' + | 'bottom' + | 'bottom left' + | 'left' + | 'top left'; + nextStepOnTargetClick?: string; + forceShowSkipButton?: boolean; +} + +type Props = { + className?: string; +}; + +const GuidedTour = ( { className }: Props ) => { + const translate = useTranslate(); + const dispatch = useDispatch(); + + const { currentTourId, currentStep, currentStepCount, nextStep, stepsCount, targetElement } = + useContext( GuidedTourContext ); + + if ( ! currentTourId || ! currentStep ) { + throw new Error( 'GuidedTour requires a current tour ID to be available in context.' ); + } + + const preference = useSelector( ( state ) => getPreference( state, currentTourId ) ); + const hasFetched = !! useSelector( preferencesLastFetchedTimestamp ); + + const [ isVisible, setIsVisible ] = useState( false ); + + const isDismissed = preference?.dismiss; + + const { + title, + description, + popoverPosition, + nextStepOnTargetClick, + forceShowSkipButton = false, + } = currentStep; + + const redirectAfterTourEnds = false; + const hideSteps = false; + + useEffect( () => { + if ( targetElement && ! isDismissed && hasFetched ) { + setIsVisible( true ); + dispatch( + recordTracksEvent( 'calypso_a4a_start_tour', { + tour: currentTourId, + } ) + ); + } + }, [ dispatch, isDismissed, currentTourId, targetElement, hasFetched ] ); + + const endTour = useCallback( () => { + dispatch( savePreference( currentTourId, { ...preference, dismiss: true } ) ); + dispatch( + recordTracksEvent( 'calypso_a4a_end_tour', { + tour: currentTourId, + } ) + ); + if ( redirectAfterTourEnds ) { + page.redirect( redirectAfterTourEnds ); + } + }, [ dispatch, currentTourId, preference, redirectAfterTourEnds ] ); + + useEffect( () => { + if ( nextStepOnTargetClick && targetElement && ! isDismissed && hasFetched ) { + // Find the target element using the nextStepOnTargetClick selector + targetElement.addEventListener( 'click', nextStep ); + } + + // Cleanup function to remove the event listener + return () => { + if ( targetElement ) { + targetElement.removeEventListener( 'click', nextStep ); + } + }; + }, [ nextStepOnTargetClick, nextStep, targetElement, isDismissed, hasFetched ] ); + + const lastTourLabel = stepsCount === 1 ? translate( 'Got it' ) : translate( 'Done' ); + + return ( + +

{ title }

+

{ description }

+
+
+ { + // Show the step count if there are multiple steps and we're not on the last step, unless we explicitly choose to hide them + stepsCount > 1 && ! hideSteps && ( + + { translate( 'Step %(currentStep)d of %(totalSteps)d', { + args: { currentStep: currentStepCount + 1, totalSteps: stepsCount }, + } ) } + + ) + } +
+
+ <> + { ( ( ! nextStepOnTargetClick && + stepsCount > 1 && + currentStepCount < stepsCount - 1 ) || + forceShowSkipButton ) && ( + // Show the skip button if there are multiple steps and we're not on the last step, unless we explicitly choose to add them + + ) } + { ! nextStepOnTargetClick && ( + + ) } + +
+
+
+ ); +}; + +const GuidedTourSlot = ( { defaultTourId } ) => { + const { currentTourId, currentStep, startTour } = useContext( GuidedTourContext ); + + useEffect( () => { + if ( ! currentTourId && defaultTourId ) { + startTour( defaultTourId ); + } + }, [ currentTourId, defaultTourId, startTour ] ); + + if ( ! currentTourId || ! currentStep ) { + return null; + } + + return ; +}; + +export default GuidedTourSlot; diff --git a/client/a8c-for-agencies/components/guided-tour/style.scss b/client/a8c-for-agencies/components/guided-tour/style.scss new file mode 100644 index 00000000000000..b6e05793fddd3c --- /dev/null +++ b/client/a8c-for-agencies/components/guided-tour/style.scss @@ -0,0 +1,49 @@ +.guided-tour__popover { + .popover__inner { + display: flex; + gap: 16px; + padding: 16px; + flex-direction: column; + align-items: flex-start; + /* stylelint-disable-next-line scales/radii */ + border-radius: 8px; + } +} + +.guided-tour__popover-heading { + color: var(--studio-gray-100); + font-size: rem(14px); + line-height: 1.5; + font-weight: 500; +} + +.guided-tour__popover-description { + color: var(--studio-gray-80); + font-size: rem(12px); + line-height: 1.5; + max-width: 220px; + text-align: left; + margin-block-end: 0; +} + +.guided-tour__popover-footer { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + .guided-tour__popover-step-count { + color: var(--studio-gray-60); + font-size: rem(12px); + } + + .guided-tour__popover-footer-right-content { + display: flex; + gap: 8px; + + button { + padding: 4px 8px; + font-size: rem(12px); + } + } +} diff --git a/client/a8c-for-agencies/components/layout/index.tsx b/client/a8c-for-agencies/components/layout/index.tsx index be00122563d82b..35058afab348f4 100644 --- a/client/a8c-for-agencies/components/layout/index.tsx +++ b/client/a8c-for-agencies/components/layout/index.tsx @@ -1,5 +1,7 @@ import classNames from 'classnames'; import React, { ReactNode } from 'react'; +import GuidedTour from 'calypso/a8c-for-agencies/components/guided-tour'; +import { GuidedTourContextProvider } from 'calypso/a8c-for-agencies/data/guided-tours/guided-tour-context'; import DocumentHead from 'calypso/components/data/document-head'; import Main from 'calypso/components/main'; import LayoutColumn from './column'; @@ -33,18 +35,20 @@ export default function Layout( { : 'a4a-layout__container'; return ( -
- - { sidebarNavigation } + +
+ + { sidebarNavigation } -
{ children }
-
+
{ children }
+
+ ); } diff --git a/client/a8c-for-agencies/data/guided-tours/guided-tour-context.tsx b/client/a8c-for-agencies/data/guided-tours/guided-tour-context.tsx new file mode 100644 index 00000000000000..41fa0c1b8d5212 --- /dev/null +++ b/client/a8c-for-agencies/data/guided-tours/guided-tour-context.tsx @@ -0,0 +1,168 @@ +import { ReactNode, createContext, useCallback, useMemo, useState } from 'react'; +import useGuidedTour, { TourId } from './use-tours'; + +type TourStep = { + id: string; + target: string | HTMLElement; + popoverPosition: string; + title: string; + description: string; + nextStepOnTargetClick: boolean; + forceShowSkipButton: boolean; +}; + +type GuidedTourContextType = { + currentTourId: TourId | null; + startTour: ( id: TourId ) => void; + stopTour: () => void; + currentStep: TourStep | null; + currentStepCount: number; + stepsCount: number; + nextStep: () => void; + endTour: () => void; + tourDismissed: boolean; + guidedTourRef: ( id: string, ref: HTMLElement | null ) => void; + targetElement: HTMLElement | null; +}; + +const defaultGuidedTourContextValue = { + currentTourId: null, + startTour: () => {}, + stopTour: () => {}, + currentStep: null, + currentStepCount: 0, + stepsCount: 0, + nextStep: () => {}, + endTour: () => {}, + registerRenderedStep: () => {}, + unregisterRenderedStep: () => {}, + tourDismissed: false, + guidedTourRef: () => {}, + targetElement: null, +}; + +export const GuidedTourContext = createContext< GuidedTourContextType >( + defaultGuidedTourContextValue +); + +/** + * Guided Tour Context Provider + * Provides access to interact with the contextually relevant guided tour. + */ +export const GuidedTourContextProvider = ( { children }: { children?: ReactNode } ) => { + const [ currentTourId, setCurrentTourId ] = useState< TourId | null >( null ); + const currentTour = useGuidedTour( currentTourId ); + + const [ anchorRefs, setAnchorRefs ] = useState< Record< string, HTMLElement | null > >( {} ); + const [ completedSteps, setCompletedSteps ] = useState< string[] >( [] ); + const [ tourDismissed, setTourDismissed ] = useState< boolean >( false ); + + /** + * Keep track of which tour steps have available anchor elements in the DOM. + */ + const renderedStepIds = useMemo( () => { + return Object.keys( anchorRefs ).filter( ( key ) => anchorRefs[ key ] !== null ); + }, [ anchorRefs ] ); + + /** + * Store a reference to a step's target/anchor element. + * Provide this function as a ref on the target element for the step's popover component. + * @example
guidedTourRef( 'step-id', ref ) }> + */ + const guidedTourRef = useCallback( ( id: string, ref: HTMLElement | null ) => { + setAnchorRefs( ( currentRefs ) => { + if ( ! currentRefs[ id ] ) { + return { ...currentRefs, [ id ]: ref }; + } + return currentRefs; + } ); + }, [] ); + + /** + * Derive current tour state from the available vs completed steps. + */ + type DerivedTourState = { + currentStep: TourStep | null; + currentStepCount: number; + stepsCount: number; + }; + const { currentStep, currentStepCount, stepsCount } = useMemo< DerivedTourState >( () => { + return currentTour.reduce( + ( carry: DerivedTourState, step: TourStep ) => { + if ( renderedStepIds.includes( step.id ) ) { + carry.stepsCount++; + if ( ! carry.currentStep && ! completedSteps.includes( step.id ) ) { + carry.currentStep = step; + carry.currentStepCount = carry.stepsCount; + } + } + return carry; + }, + { + currentStep: null, + currentStepCount: 0, + stepsCount: 0, + } as DerivedTourState + ); + }, [ currentTour, renderedStepIds, completedSteps ] ); + + const targetElement = useMemo( + () => ( currentStep ? anchorRefs[ currentStep.id ] : null ), + [ currentStep, anchorRefs ] + ); + + const startTour = useCallback( ( id: TourId ) => { + setCurrentTourId( id ); + }, [] ); + + const stopTour = useCallback( () => { + setCurrentTourId( null ); + }, [] ); + + /** + * Proceed to the next available step in the tour. + */ + const nextStep = useCallback( () => { + if ( currentStep ) { + setCompletedSteps( ( currentSteps ) => [ ...currentSteps, currentStep.id ] ); + } + }, [ currentStep ] ); + + /** + * Dismiss all steps in the tour. + */ + const endTour = () => setTourDismissed( true ); + + const contextValue = useMemo( + () => ( { + startTour, + stopTour, + currentTourId, + stepsCount, + currentStep, + currentStepCount, + nextStep, + endTour, + tourDismissed, + guidedTourRef, + targetElement, + } ), + [ + startTour, + stopTour, + currentTourId, + stepsCount, + currentStep, + currentStepCount, + nextStep, + endTour, + tourDismissed, + guidedTourRef, + targetElement, + ] + ); + + return ( + { children } + ); +}; diff --git a/client/a8c-for-agencies/data/guided-tours/tours/index.tsx b/client/a8c-for-agencies/data/guided-tours/tours/index.tsx new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/client/a8c-for-agencies/data/guided-tours/tours/sites-walkthrough-tour.tsx b/client/a8c-for-agencies/data/guided-tours/tours/sites-walkthrough-tour.tsx new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/client/a8c-for-agencies/data/guided-tours/tours/use-sites-walkthrough-tour.tsx b/client/a8c-for-agencies/data/guided-tours/tours/use-sites-walkthrough-tour.tsx new file mode 100644 index 00000000000000..09e99c22d3532d --- /dev/null +++ b/client/a8c-for-agencies/data/guided-tours/tours/use-sites-walkthrough-tour.tsx @@ -0,0 +1,105 @@ +import { useTranslate } from 'i18n-calypso'; + +export default function useSitesWalkthroughTour() { + const translate = useTranslate(); + + return [ + { + id: 'sites-walkthrough-intro', + // target: '.sites-dataview__site-header', + popoverPosition: 'bottom right', + title: translate( 'Manage all your sites' ), + description: translate( 'Here you can find your sites and detailed overview about each.' ), + }, + { + id: 'sites-walkthrough-stats', + popoverPosition: 'bottom right', + title: translate( '📊 Stats' ), + description: translate( + 'Here you can see page view metrics and how they evolved over the last 7 days.' + ), + }, + { + id: 'sites-walkthrough-boost', + popoverPosition: 'bottom right', + title: translate( '🚀 Boost score rating' ), + description: translate( + "Here's a score reflecting your website's load speed. Click 'Get Score' to know your site's speed rating – it's free!" + ), + }, + { + id: 'sites-walkthrough-backup', + popoverPosition: 'bottom right', + title: translate( '🛡️ Backups' ), + description: translate( + 'We automatically back up your site and safeguard your data. Restoring is as simple as a single click.' + ), + }, + { + id: 'sites-walkthrough-monitor', + popoverPosition: 'bottom left', + title: translate( '⏲️ Uptime Monitor' ), + description: ( + <> + { translate( + "We keep tabs on your site's uptime. Simply toggle this on, and we'll alert you if your site goes down." + ) } +
+
+ { translate( + '🟢 With the premium plan, you can tweak notification settings to alert multiple recipients simultaneously.' + ) } + + ), + }, + { + id: 'sites-walkthrough-scan', + popoverPosition: 'bottom right', + title: translate( '🔍 Scan' ), + description: translate( + 'We scan your site and flag any detected issues using a traffic light warning system – 🔴 for severe or 🟡 for a warning.' + ), + }, + { + id: 'sites-walkthrough-plugins', + popoverPosition: 'bottom right', + title: translate( '🔌 Plugin updates' ), + description: ( + <> + { translate( + "We keep an eye on the status of your plugins for every site. If any plugins require updates, we'll let you know." + ) } +
+
+ { translate( + "From here, you can update individually, enable auto-updates, or update all plugins simultaneously. Oh, and it's all free." + ) } + + ), + }, + { + id: 'sites-walkthrough-site-preview', + nextStepOnTargetClick: '.site-preview__open', + popoverPosition: 'bottom right', + title: translate( '🔍 Detailed views' ), + description: translate( + 'Click the arrow for detailed insights on stats, site speed performance, recent backups, and monitoring activity trends. Handy, right?' + ), + }, + { + id: 'sites-walkthrough-site-preview-tabs', + popoverPosition: 'bottom right', + title: translate( '🔍 Detailed site view' ), + description: ( + <> + { translate( "Great! You're now viewing detailed insights." ) } +
+
+ { translate( + 'Use the tabs to navigate between site speed, backups, uptime monitor, activity trends, and plugins.' + ) } + + ), + }, + ]; +} diff --git a/client/a8c-for-agencies/data/guided-tours/use-tours.tsx b/client/a8c-for-agencies/data/guided-tours/use-tours.tsx new file mode 100644 index 00000000000000..40c32c27a3071f --- /dev/null +++ b/client/a8c-for-agencies/data/guided-tours/use-tours.tsx @@ -0,0 +1,13 @@ +import useSitesWalkthroughTour from './tours/use-sites-walkthrough-tour'; + +export type TourId = 'sitesWalkthrough' | 'addSiteStep1'; + +export default function useGuidedTour( id: TourId | null ) { + const sitesWalkthrough = useSitesWalkthroughTour(); + + const tours = { + 'sites-walkthrough': sitesWalkthrough, + }; + + return id ? tours[ id ] : []; +} diff --git a/client/a8c-for-agencies/sections/sites/sites-dashboard/index.tsx b/client/a8c-for-agencies/sections/sites/sites-dashboard/index.tsx index a428df0e917604..28e751cb8ce05a 100644 --- a/client/a8c-for-agencies/sections/sites/sites-dashboard/index.tsx +++ b/client/a8c-for-agencies/sections/sites/sites-dashboard/index.tsx @@ -2,6 +2,7 @@ import { isWithinBreakpoint } from '@automattic/viewport'; import classNames from 'classnames'; import { translate } from 'i18n-calypso'; import React, { useContext, useEffect, useCallback, useState } from 'react'; +import GuidedTour from 'calypso/a8c-for-agencies/components/guided-tour'; import Layout from 'calypso/a8c-for-agencies/components/layout'; import LayoutColumn from 'calypso/a8c-for-agencies/components/layout/column'; import LayoutHeader, { @@ -15,10 +16,10 @@ import LayoutTop from 'calypso/a8c-for-agencies/components/layout/top'; import MobileSidebarNavigation from 'calypso/a8c-for-agencies/components/sidebar/mobile-sidebar-navigation'; import useNoActiveSite from 'calypso/a8c-for-agencies/hooks/use-no-active-site'; import { OverviewFamily } from 'calypso/a8c-for-agencies/sections/sites/features/overview'; +import SitesDataViews from 'calypso/a8c-for-agencies/sections/sites/sites-dataviews'; import useFetchDashboardSites from 'calypso/data/agency-dashboard/use-fetch-dashboard-sites'; import useFetchMonitorVerifiedContacts from 'calypso/data/agency-dashboard/use-fetch-monitor-verified-contacts'; import DashboardDataContext from 'calypso/jetpack-cloud/sections/agency-dashboard/sites-overview/dashboard-data-context'; -import SitesDataViews from 'calypso/jetpack-cloud/sections/agency-dashboard/sites-overview/sites-dataviews'; import { SitesViewState } from 'calypso/jetpack-cloud/sections/agency-dashboard/sites-overview/sites-dataviews/interfaces'; import { AgencyDashboardFilter, @@ -35,7 +36,6 @@ import SiteNotifications from '../sites-notifications'; import EmptyState from './empty-state'; import { getSelectedFilters } from './get-selected-filters'; import { updateSitesDashboardUrl } from './update-sites-dashboard-url'; - import './style.scss'; export default function SitesDashboard() { @@ -218,6 +218,7 @@ export default function SitesDashboard() { + { + const translate = useTranslate(); + const { showOnlyFavorites } = useContext( SitesDashboardContext ); + const totalSites = showOnlyFavorites ? data?.totalFavorites || 0 : data?.total || 0; + const sitesPerPage = sitesViewState.perPage > 0 ? sitesViewState.perPage : 20; + const totalPages = Math.ceil( totalSites / sitesPerPage ); + const sites = useFormattedSites( data?.sites ?? [] ); + const isA4AEnabled = isEnabled( 'a8c-for-agencies' ); + + const { guidedTourRef } = useContext( GuidedTourContext ); + + const openSitePreviewPane = useCallback( + ( site: Site ) => { + onSitesViewChange( { + ...sitesViewState, + selectedSite: site, + type: 'list', + } ); + }, + [ onSitesViewChange, sitesViewState ] + ); + + const renderField = useCallback( + ( column: AllowedTypes, item: SiteInfo ) => { + if ( isLoading ) { + return ; + } + + if ( column ) { + return ( + + ); + } + }, + [ isLoading, isLargeScreen ] + ); + + // todo - refactor: extract fields, along actions, to the upper component + const fields = useMemo( + () => [ + { + id: 'status', + header: translate( 'Status' ), + getValue: ( { item }: { item: SiteInfo } ) => + item.site.error || item.scan.status === 'critical', + render: () => {}, + type: 'enumeration', + elements: [ + { value: 1, label: translate( 'Needs Attention' ) }, + { value: 2, label: translate( 'Backup Failed' ) }, + { value: 3, label: translate( 'Backup Warning' ) }, + { value: 4, label: translate( 'Threat Found' ) }, + { value: 5, label: translate( 'Site Disconnected' ) }, + { value: 6, label: translate( 'Site Down' ) }, + { value: 7, label: translate( 'Plugins Needing Updates' ) }, + ], + filterBy: { + operators: [ 'in' ], + }, + enableHiding: false, + enableSorting: false, + }, + { + id: 'site', + header: ( + <> + + guidedTourRef( 'sites-walkthrough-intro', element ) } + > + { translate( 'Site' ).toUpperCase() } + + + + ), + getValue: ( { item }: { item: SiteInfo } ) => item.site.value.url, + render: ( { item }: { item: SiteInfo } ) => { + if ( isLoading ) { + return ; + } + const site = item.site.value; + return ( + + ); + }, + enableHiding: false, + enableSorting: false, + }, + { + id: 'stats', + header: ( + guidedTourRef( 'sites-walkthrough-stats', element ) } + > + STATS + + ), + getValue: () => '-', + render: ( { item }: { item: SiteInfo } ) => renderField( 'stats', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'boost', + header: ( + guidedTourRef( 'sites-walkthrough-boost', element ) } + > + BOOST + + ), + getValue: ( { item }: { item: SiteInfo } ) => item.boost.status, + render: ( { item }: { item: SiteInfo } ) => renderField( 'boost', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'backup', + header: ( + guidedTourRef( 'sites-walkthrough-backup', element ) } + > + BACKUP + + ), + getValue: () => '-', + render: ( { item }: { item: SiteInfo } ) => renderField( 'backup', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'monitor', + header: ( + guidedTourRef( 'sites-walkthrough-monitor', element ) } + > + MONITOR + + ), + getValue: () => '-', + render: ( { item }: { item: SiteInfo } ) => renderField( 'monitor', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'scan', + header: ( + guidedTourRef( 'sites-walkthrough-scan', element ) } + > + SCAN + + ), + getValue: () => '-', + render: ( { item }: { item: SiteInfo } ) => renderField( 'scan', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'plugins', + header: ( + guidedTourRef( 'sites-walkthrough-plugins', element ) } + > + PLUGINS + + ), + getValue: () => '-', + render: ( { item }: { item: SiteInfo } ) => renderField( 'plugin', item ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'favorite', + header: ( + + ), + getValue: ( { item }: { item: SiteInfo } ) => item.isFavorite, + render: ( { item }: { item: SiteInfo } ) => { + if ( isLoading ) { + return ; + } + return ( + + { isA4AEnabled ? ( + + ) : ( + + ) } + + ); + }, + enableHiding: false, + enableSorting: false, + }, + { + id: 'actions', + getValue: ( { item }: { item: SiteInfo } ) => item.isFavorite, + render: ( { item }: { item: SiteInfo } ) => { + if ( isLoading ) { + return ; + } + return ( +
+ + +
+ ); + }, + enableHiding: false, + enableSorting: false, + }, + ], + [ + translate, + guidedTourRef, + isLoading, + openSitePreviewPane, + renderField, + isA4AEnabled, + isLargeScreen, + ] + ); + + // Actions: Pause Monitor, Resume Monitor, Custom Notification, Reset Notification + // todo - refactor: extract actions, along fields, to the upper component + // Currently not in use until bulk selections are properly implemented. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const actions = useMemo( + () => [ + { + id: 'pause-monitor', + label: translate( 'Pause Monitor' ), + supportsBulk: true, + isEligible( site: SiteInfo ) { + return site.monitor.status === 'active'; + }, + callback() { + // todo: pause monitor. Param: sites: SiteInfo[] + }, + }, + { + id: 'resume-monitor', + label: translate( 'Resume Monitor' ), + supportsBulk: true, + isEligible( site: SiteInfo ) { + return site.monitor.status === 'inactive'; + }, + callback() { + // todo: resume monitor. Param: sites: SiteInfo[] + }, + }, + { + id: 'custom-notification', + label: translate( 'Custom Notification' ), + supportsBulk: true, + isEligible( site: SiteInfo ) { + return site.monitor.status === 'active'; + }, + callback() { + // todo: custom notification. Param: sites: SiteInfo[] + }, + }, + { + id: 'reset-notification', + label: translate( 'Reset Notification' ), + supportsBulk: true, + isEligible( site: SiteInfo ) { + return site.monitor.status === 'active'; + }, + callback() { + // todo: reset notification. Param: sites: SiteInfo[] + }, + }, + ], + [ translate ] + ); + + // Until the DataViews package is updated to support the spinner, we need to manually add the (loading) spinner to the table wrapper for now. + const SpinnerWrapper = () => { + return ( +
+ +
+ ); + }; + + const dataviewsWrapper = document.getElementsByClassName( 'dataviews-wrapper' )[ 0 ]; + if ( dataviewsWrapper ) { + // Remove any existing spinner if present + const existingSpinner = dataviewsWrapper.querySelector( '.spinner-wrapper' ); + if ( existingSpinner ) { + existingSpinner.remove(); + } + + const spinnerWrapper = dataviewsWrapper.appendChild( document.createElement( 'div' ) ); + spinnerWrapper.classList.add( 'spinner-wrapper' ); + // Render the SpinnerWrapper component inside the spinner wrapper + ReactDOM.hydrate( , spinnerWrapper ); + //} + } + + const urlParams = new URLSearchParams( window.location.search ); + const isOnboardingTourActive = urlParams.get( 'tour' ) !== null; + const useExampleDataForTour = + forceTourExampleSite || ( isOnboardingTourActive && ( ! sites || sites.length === 0 ) ); + + return ( +
+ { + item.id = item.site.value.blog_id; // setting the id because of a issue with the DataViews component + return item.id; + } } + onChangeView={ onSitesViewChange } + supportedLayouts={ [ 'table' ] } + actions={ [] } // Replace with actions when bulk selections are implemented. + isLoading={ isLoading } + /> +
+ ); +}; + +export default SitesDataViews; diff --git a/client/a8c-for-agencies/sections/sites/sites-dataviews/interfaces.ts b/client/a8c-for-agencies/sections/sites/sites-dataviews/interfaces.ts new file mode 100644 index 00000000000000..038ecdc253a02c --- /dev/null +++ b/client/a8c-for-agencies/sections/sites/sites-dataviews/interfaces.ts @@ -0,0 +1,45 @@ +import { Site, SiteData } from '../types'; + +export interface SitesDataResponse { + sites: Array< Site >; + total: number; + perPage: number; + totalFavorites: number; +} + +export interface SitesDataViewsProps { + className?: string; + data: SitesDataResponse | undefined; + forceTourExampleSite?: boolean; + isLargeScreen: boolean; + isLoading: boolean; + onSitesViewChange: ( view: SitesViewState ) => void; + sitesViewState: SitesViewState; +} + +export interface Sort { + field: string; + direction: 'asc' | 'desc'; +} + +export interface Filter { + field: string; + operator: string; + value: number; +} + +export interface SitesViewState { + type: 'table' | 'list' | 'grid'; + perPage: number; + page: number; + sort: Sort; + search: string; + filters: Filter[]; + hiddenFields: string[]; + layout: object; + selectedSite?: Site | undefined; +} + +export interface SiteInfo extends SiteData { + id: number; +} diff --git a/client/a8c-for-agencies/sections/sites/sites-dataviews/site-data-field.tsx b/client/a8c-for-agencies/sections/sites/sites-dataviews/site-data-field.tsx new file mode 100644 index 00000000000000..6ad07b765366db --- /dev/null +++ b/client/a8c-for-agencies/sections/sites/sites-dataviews/site-data-field.tsx @@ -0,0 +1,28 @@ +import { Button } from '@automattic/components'; +import TextPlaceholder from 'calypso/jetpack-cloud/sections/partner-portal/text-placeholder'; +import SiteFavicon from '../site-favicon'; +import { Site } from '../types'; + +interface SiteDataFieldProps { + isLoading: boolean; + site: Site; + onSiteTitleClick: ( site: Site ) => void; +} + +const SiteDataField = ( { isLoading, site, onSiteTitleClick }: SiteDataFieldProps ) => { + if ( isLoading ) { + return ; + } + + return ( + + ); +}; + +export default SiteDataField; diff --git a/client/a8c-for-agencies/sections/sites/sites-dataviews/style.scss b/client/a8c-for-agencies/sections/sites/sites-dataviews/style.scss new file mode 100644 index 00000000000000..5b03eb1f83b200 --- /dev/null +++ b/client/a8c-for-agencies/sections/sites/sites-dataviews/style.scss @@ -0,0 +1,215 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@wordpress/base-styles/mixins"; + +.sites-overview__content { + + .components-checkbox-control__input[type="checkbox"]:focus { + box-shadow: 0 0 0 var(--studio-jetpack-green-50) #fff, 0 0 0 calc(2* var(--studio-jetpack-green-50)) var(--studio-jetpack-green-50); + outline: 2px solid transparent; + outline-offset: 2px; + } + .components-checkbox-control__input[type="checkbox"]:checked, + .components-checkbox-control__input[type="checkbox"]:indeterminate { + background: var(--wp-components-color-accent, var(--studio-jetpack-green-50)); + border-color: var(--wp-components-color-accent, var(--studio-jetpack-green-50)); + } + + .dataviews-bulk-edit-button.components-button.is-tertiary:active:not(:disabled), + .dataviews-bulk-edit-button.components-button.is-tertiary:hover:not(:disabled) { + box-shadow: none; + background-color: transparent; + color: var(--studio-jetpack-green-50); + } + + .dataviews-view-table__actions-column { + display: none; + } + + thead { + th.sites-dataviews__site { + background: var(--studio-white); + td { + padding: 16px 0; + border-bottom: 1px solid var(--studio-gray-5); + } + } + } + + tr.dataviews-view-table__row { + background: var(--studio-white); + + .components-checkbox-control__input { + opacity: 0; + } + .components-checkbox-control__input:checked, + .components-checkbox-control__input:indeterminate, { + opacity: 1; + } + + .dataviews-view-table-selection-checkbox { + padding-left: 12px; + &.is-selected { + .components-checkbox-control__input { + opacity: 1; + } + } + } + + &:hover { + background: var(--studio-gray-0); + + .site-set-favorite__favorite-icon, + .components-checkbox-control__input { + opacity: 1; + } + } + + th { + padding: 16px 4px; + border-bottom: 1px solid var(--studio-gray-5); + font-size: rem(13px); + font-weight: 400; + } + + td { + padding: 16px 4px; + border-bottom: 1px solid var(--studio-gray-5); + vertical-align: middle; + } + + td:has(.sites-dataviews__site) { + padding: 8px 16px 8px 4px; + width: 20%; + } + + .site-sort__clickable { + cursor: pointer; + padding-left: 1rem; + + } + + .dataviews-view-table__checkbox-column, + .components-checkbox-control__input { + label.components-checkbox-control__label { + display: none; + } + &[type="checkbox"] { + border-color: var(--studio-gray-5); + } + } + } + + ul.dataviews-view-list { + li:hover { + background: var(--studio-gray-0); + } + .is-selected { + background-color: var(--studio-jetpack-green-0); + } + } +} + +.components-search-control input[type="search"].components-search-control__input { + background: var(--studio-white); + border: 1px solid var(--studio-gray-5); +} + +.dataviews-filters__view-actions { + margin-bottom: 8px; +} + +.sites-dataviews__site { + display: flex; + flex-direction: row; + + .button { + padding: 0; + } +} + +.sites-dataviews__site-name { + display: inline-block; + text-align: left; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 180px; + font-weight: 500; + font-size: rem(14px); +} + +.sites-dataviews__site-url { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: rem(12px); + color: var(--studio-gray-60); + font-weight: 400; +} + +.preview-hidden { + @media (min-width: 2040px) { + .sites-dataviews__site-name { + width: 250px; + } + } +} + +.sites-dataviews__actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + + @media (min-width: 1080px) { + .site-actions__actions-large-screen { + float: none; + margin-inline-end: 20px; + } + } + + .site-preview__open { + .gridicon.gridicons-chevron-right { + width: 18px; + height: 18px; + } + } +} + +.dataviews-pagination { + margin-bottom: 2rem; +} + + +.dataviews-wrapper { + .dataviews-no-results, + .dataviews-loading { + padding-top: 1rem; + text-align: center; + } + + .spinner-wrapper { + display: none; + } + +} + +.dataviews-wrapper:has(.dataviews-loading) { + .spinner-wrapper { + display: block; + } + .dataviews-loading p { + display: none; + } + + .dataviews-view-table-wrapper { + height: 0 !important; + } +} + +.dataviews-wrapper:has(.dataviews-no-results) { + .spinner-wrapper { + display: none; + } +} diff --git a/client/a8c-for-agencies/sections/sites/sites-dataviews/types.d.ts b/client/a8c-for-agencies/sections/sites/sites-dataviews/types.d.ts new file mode 100644 index 00000000000000..9d670dfed75ded --- /dev/null +++ b/client/a8c-for-agencies/sections/sites/sites-dataviews/types.d.ts @@ -0,0 +1 @@ +declare module '@wordpress/dataviews';