diff --git a/client/components/activity-card/toolbar/actions-button.tsx b/client/components/activity-card/toolbar/actions-button.tsx index f988b668999f5..f0e730bd4a3bc 100644 --- a/client/components/activity-card/toolbar/actions-button.tsx +++ b/client/components/activity-card/toolbar/actions-button.tsx @@ -1,32 +1,28 @@ import { Gridicon } from '@automattic/components'; -import { Icon, download as downloadIcon } from '@wordpress/icons'; import { useTranslate } from 'i18n-calypso'; import { useRef, useState } from 'react'; import * as React from 'react'; -import ExternalLink from 'calypso/components/external-link'; +import CredentialsPrompt from 'calypso/components/activity-card/toolbar/actions/credentials-prompt'; +import DownloadButton from 'calypso/components/activity-card/toolbar/actions/download-button'; +import RestoreButton from 'calypso/components/activity-card/toolbar/actions/restore-button'; +import ViewFilesButton from 'calypso/components/activity-card/toolbar/actions/view-files-button'; import Button from 'calypso/components/forms/form-button'; -import missingCredentialsIcon from 'calypso/components/jetpack/daily-backup-status/missing-credentials.svg'; import PopoverMenu from 'calypso/components/popover-menu'; import { getActionableRewindId } from 'calypso/lib/jetpack/actionable-rewind-id'; import { SUCCESSFUL_BACKUP_ACTIVITIES } from 'calypso/lib/jetpack/backup-utils'; -import { settingsPath } from 'calypso/lib/jetpack/paths'; -import { backupDownloadPath, backupRestorePath } from 'calypso/my-sites/backup/paths'; +import { backupDownloadPath } from 'calypso/my-sites/backup/paths'; import { useDispatch, useSelector } from 'calypso/state'; -import { rewindRequestBackup } from 'calypso/state/activity-log/actions'; import { recordTracksEvent } from 'calypso/state/analytics/actions/record'; -import { areJetpackCredentialsInvalid } from 'calypso/state/jetpack/credentials/selectors'; import getDoesRewindNeedCredentials from 'calypso/state/selectors/get-does-rewind-need-credentials'; -import getIsRestoreInProgress from 'calypso/state/selectors/get-is-restore-in-progress'; -import isSiteAutomatedTransfer from 'calypso/state/selectors/is-site-automated-transfer'; import { getSiteSlug, isJetpackSiteMultiSite } from 'calypso/state/sites/selectors'; import { Activity } from '../types'; -import ViewFilesButton from './buttons/view-files-button'; type SingleSiteOwnProps = { siteId: number; siteSlug: string; rewindId: string; isSuccessfulBackup: boolean; + useSplitButton?: boolean; }; const SingleSiteActionsButton: React.FC< SingleSiteOwnProps > = ( { @@ -34,6 +30,7 @@ const SingleSiteActionsButton: React.FC< SingleSiteOwnProps > = ( { siteSlug, rewindId, isSuccessfulBackup, + useSplitButton = false, } ) => { const translate = useTranslate(); const dispatch = useDispatch(); @@ -48,33 +45,26 @@ const SingleSiteActionsButton: React.FC< SingleSiteOwnProps > = ( { setPopoverVisible( false ); }; - const doesRewindNeedCredentials = useSelector( ( state ) => + const needsCredentials = useSelector( ( state ) => getDoesRewindNeedCredentials( state, siteId ) ); - const areCredentialsInvalid = useSelector( ( state ) => - areJetpackCredentialsInvalid( state, siteId, 'main' ) - ); - - const isRestoreInProgress = useSelector( ( state ) => getIsRestoreInProgress( state, siteId ) ); - - const isAtomic = useSelector( ( state ) => isSiteAutomatedTransfer( state, siteId ) ); + if ( useSplitButton ) { + const secondaryActions = [ + needsCredentials && , + isSuccessfulBackup && , + , + ]; - const isRestoreDisabled = - doesRewindNeedCredentials || isRestoreInProgress || ( ! isAtomic && areCredentialsInvalid ); - - const onRestoreClick = () => { - dispatch( recordTracksEvent( 'calypso_jetpack_backup_actions_restore_click' ) ); - }; - - const onDownloadClick = () => { - dispatch( - recordTracksEvent( 'calypso_jetpack_backup_actions_download_click', { - rewind_id: rewindId, - } ) + return ( + ); - dispatch( rewindRequestBackup( siteId, rewindId ) ); - }; + } return ( <> @@ -94,41 +84,10 @@ const SingleSiteActionsButton: React.FC< SingleSiteOwnProps > = ( { onClose={ closePopoverMenu } className="toolbar__actions-popover" > - - { ! isAtomic && ( doesRewindNeedCredentials || areCredentialsInvalid ) && ( -
- -
- { translate( - '{{a}}Enter your server credentials{{/a}} to enable one-click restores from your backups.', - { - components: { - a: , - }, - } - ) } -
-
- ) } + + { needsCredentials && } { isSuccessfulBackup && } - + ); @@ -179,6 +138,7 @@ type OwnProps = { activity: Activity; availableActions?: Array< string >; onClickClone?: ( period: string ) => void; + useSplitButton?: boolean; }; const ActionsButton: React.FC< OwnProps > = ( { @@ -186,6 +146,7 @@ const ActionsButton: React.FC< OwnProps > = ( { activity, availableActions, onClickClone, + useSplitButton = false, } ) => { const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); @@ -220,6 +181,7 @@ const ActionsButton: React.FC< OwnProps > = ( { siteSlug={ siteSlug ?? '' } rewindId={ actionableRewindId ?? '' } isSuccessfulBackup={ isSuccessfulBackup } + useSplitButton={ useSplitButton } /> ); }; diff --git a/client/components/activity-card/toolbar/actions/credentials-prompt.tsx b/client/components/activity-card/toolbar/actions/credentials-prompt.tsx new file mode 100644 index 0000000000000..bae033d5348b4 --- /dev/null +++ b/client/components/activity-card/toolbar/actions/credentials-prompt.tsx @@ -0,0 +1,37 @@ +import { useTranslate } from 'i18n-calypso'; +import { FunctionComponent } from 'react'; +import missingCredentialsIcon from 'calypso/components/jetpack/daily-backup-status/missing-credentials.svg'; +import { settingsPath } from 'calypso/lib/jetpack/paths'; +import { useDispatch } from 'calypso/state'; +import { recordTracksEvent } from 'calypso/state/analytics/actions/record'; + +type CredentialsPromptProps = { + siteSlug: string; +}; + +const CredentialsPrompt: FunctionComponent< CredentialsPromptProps > = ( { siteSlug } ) => { + const dispatch = useDispatch(); + const translate = useTranslate(); + + const onClick = () => { + dispatch( recordTracksEvent( 'calypso_jetpack_backup_actions_credentials_click' ) ); + }; + + return ( +
+ +
+ { translate( + '{{a}}Enter your server credentials{{/a}} to enable one-click restores from your backups.', + { + components: { + a: , + }, + } + ) } +
+
+ ); +}; + +export default CredentialsPrompt; diff --git a/client/components/activity-card/toolbar/actions/download-button.tsx b/client/components/activity-card/toolbar/actions/download-button.tsx new file mode 100644 index 0000000000000..34752a856c2f9 --- /dev/null +++ b/client/components/activity-card/toolbar/actions/download-button.tsx @@ -0,0 +1,46 @@ +import { download as downloadIcon, Icon } from '@wordpress/icons'; +import { useTranslate } from 'i18n-calypso'; +import { FunctionComponent } from 'react'; +import Button from 'calypso/components/forms/form-button'; +import { backupDownloadPath } from 'calypso/my-sites/backup/paths'; +import { useDispatch } from 'calypso/state'; +import { rewindRequestBackup } from 'calypso/state/activity-log/actions'; +import { recordTracksEvent } from 'calypso/state/analytics/actions/record'; + +type DownloadButtonProps = { + siteId: number; + siteSlug: string; + rewindId: string; +}; + +const DownloadButton: FunctionComponent< DownloadButtonProps > = ( { + siteId, + siteSlug, + rewindId, +} ) => { + const translate = useTranslate(); + const dispatch = useDispatch(); + const onDownloadClick = () => { + dispatch( + recordTracksEvent( 'calypso_jetpack_backup_actions_download_click', { + rewind_id: rewindId, + } ) + ); + dispatch( rewindRequestBackup( siteId, rewindId ) ); + }; + return ( + + ); +}; + +export default DownloadButton; diff --git a/client/components/activity-card/toolbar/actions/restore-button.tsx b/client/components/activity-card/toolbar/actions/restore-button.tsx new file mode 100644 index 0000000000000..5d55288fae1ea --- /dev/null +++ b/client/components/activity-card/toolbar/actions/restore-button.tsx @@ -0,0 +1,61 @@ +import { useTranslate } from 'i18n-calypso'; +import { FunctionComponent, ReactNode } from 'react'; +import Button from 'calypso/components/forms/form-button'; +import SplitButton from 'calypso/components/split-button'; +import { backupRestorePath } from 'calypso/my-sites/backup/paths'; +import { useDispatch, useSelector } from 'calypso/state'; +import { recordTracksEvent } from 'calypso/state/analytics/actions/record'; +import canRestoreSite from 'calypso/state/rewind/selectors/can-restore-site'; + +type RestoreButtonProps = { + siteId: number; + siteSlug: string; + rewindId: string; + secondaryActions?: ReactNode[]; +}; + +const RestoreButton: FunctionComponent< RestoreButtonProps > = ( { + siteId, + siteSlug, + rewindId, + secondaryActions = [], +} ) => { + const translate = useTranslate(); + const dispatch = useDispatch(); + const isRestoreDisabled = useSelector( ( state ) => ! canRestoreSite( state, siteId ) ); + const onRestoreClick = () => { + dispatch( recordTracksEvent( 'calypso_jetpack_backup_actions_restore_click' ) ); + }; + + const buttonLabel = translate( 'Restore to this point' ); + + // Conditionally render as a SplitButton if there are secondary actions + if ( secondaryActions.length > 0 ) { + return ( + + { secondaryActions } + + ); + } + + return ( + + ); +}; + +export default RestoreButton; diff --git a/client/components/activity-card/toolbar/buttons/view-files-button.tsx b/client/components/activity-card/toolbar/actions/view-files-button.tsx similarity index 100% rename from client/components/activity-card/toolbar/buttons/view-files-button.tsx rename to client/components/activity-card/toolbar/actions/view-files-button.tsx diff --git a/client/components/activity-card/toolbar/index.tsx b/client/components/activity-card/toolbar/index.tsx index d471a2bb698aa..a066e43e6fc60 100644 --- a/client/components/activity-card/toolbar/index.tsx +++ b/client/components/activity-card/toolbar/index.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx'; import * as React from 'react'; import { isSuccessfulRealtimeBackup } from 'calypso/lib/jetpack/backup-utils'; import { Activity } from '../types'; @@ -13,6 +14,8 @@ type OwnProps = { onToggleContent?: () => void; availableActions?: Array< string >; onClickClone?: ( period: string ) => void; + hideExpandedContent?: boolean; + useSplitButton?: boolean; }; const Toolbar: React.FC< OwnProps > = ( { @@ -22,6 +25,8 @@ const Toolbar: React.FC< OwnProps > = ( { onToggleContent, availableActions, onClickClone, + hideExpandedContent = false, + useSplitButton = false, } ) => { const isRewindable = isSuccessfulRealtimeBackup( activity ); const { streams } = activity; @@ -30,18 +35,27 @@ const Toolbar: React.FC< OwnProps > = ( { return null; } + const showStreams = streams && ! hideExpandedContent; + + const classNames = clsx( { + // force the actions to stay in the left if we aren't showing the content link + 'activity-card__toolbar': showStreams || useSplitButton, + 'activity-card__toolbar--reverse': ! showStreams && ! useSplitButton, + 'activity-card__split-button': useSplitButton, + } ); + return ( -
- { streams && } +
+ { showStreams && ( + + ) } { isRewindable && ( ) }
diff --git a/client/components/activity-card/toolbar/style.scss b/client/components/activity-card/toolbar/style.scss index 1e600cf51e59f..59402b0246a6c 100644 --- a/client/components/activity-card/toolbar/style.scss +++ b/client/components/activity-card/toolbar/style.scss @@ -33,6 +33,24 @@ } } +.activity-card__split-button { + border-top: 0; + padding-top: 0; + padding-bottom: 0; + top: 0; + + .split-button { + a, button { + display: inline-block; + font-size: 0.875rem; + } + + &.toolbar__restore-button { + margin: 0; + } + } +} + .activity-card__toolbar--reverse { flex-direction: row-reverse; } diff --git a/client/components/jetpack/backup-schedule-setting/hooks.ts b/client/components/jetpack/backup-schedule-setting/hooks.ts new file mode 100644 index 0000000000000..b05fd6194434f --- /dev/null +++ b/client/components/jetpack/backup-schedule-setting/hooks.ts @@ -0,0 +1,64 @@ +import { useLocalizedMoment } from 'calypso/components/localized-moment'; +import useScheduledTimeQuery from 'calypso/data/jetpack-backup/use-scheduled-time-query'; +import { applySiteOffset } from 'calypso/lib/site/timezone'; +import { useSelector } from 'calypso/state'; +import getSiteGmtOffset from 'calypso/state/selectors/get-site-gmt-offset'; +import getSiteTimezoneValue from 'calypso/state/selectors/get-site-timezone-value'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import { convertHourToRange } from './utils'; + +const useNextBackupSchedule = () => { + const moment = useLocalizedMoment(); + const siteId = useSelector( getSelectedSiteId ) as number; + const timezone = useSelector( ( state ) => getSiteTimezoneValue( state, siteId ) ); + const gmtOffset = useSelector( ( state ) => getSiteGmtOffset( state, siteId ) ); + + const { data, isSuccess } = useScheduledTimeQuery( siteId ); + + const getNextBackupDate = () => { + if ( ! data || data.scheduledHour === null ) { + return null; + } + + const currentTime = moment(); + const backupTimeUtc = moment.utc().startOf( 'day' ).hour( data.scheduledHour ); + + let nextBackupDate = backupTimeUtc; + + // Apply site offset if available + if ( timezone && gmtOffset ) { + nextBackupDate = applySiteOffset( backupTimeUtc, { timezone, gmtOffset } ); + } else { + nextBackupDate = backupTimeUtc.local(); + } + + const nextBackupDateEnd = nextBackupDate.clone().add( 59, 'minutes' ).add( 59, 'seconds' ); + + // Only move to the next day if the current time is after the backup window + if ( currentTime.isAfter( nextBackupDateEnd ) ) { + nextBackupDate.add( 1, 'day' ); // Move to next day + } + + return nextBackupDate; + }; + + const nextBackupDate = getNextBackupDate(); + + if ( ! nextBackupDate ) { + return { + hasLoaded: isSuccess, + date: null, + timeRange: null, + }; + } + + const timeRange = convertHourToRange( moment, nextBackupDate.hour() ); + + return { + hasLoaded: isSuccess, + date: nextBackupDate, + timeRange: timeRange, + }; +}; + +export default useNextBackupSchedule; diff --git a/client/components/jetpack/backup-schedule-setting/index.tsx b/client/components/jetpack/backup-schedule-setting/index.tsx index 9515f198124ad..13a5a0b8ff6df 100644 --- a/client/components/jetpack/backup-schedule-setting/index.tsx +++ b/client/components/jetpack/backup-schedule-setting/index.tsx @@ -11,6 +11,7 @@ import { errorNotice, successNotice } from 'calypso/state/notices/actions'; import getSiteGmtOffset from 'calypso/state/selectors/get-site-gmt-offset'; import getSiteTimezoneValue from 'calypso/state/selectors/get-site-timezone-value'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import { convertHourToRange } from './utils'; import type { FunctionComponent } from 'react'; import './style.scss'; @@ -23,19 +24,6 @@ const BackupScheduleSetting: FunctionComponent = () => { const timezone = useSelector( ( state ) => getSiteTimezoneValue( state, siteId ) ); const gmtOffset = useSelector( ( state ) => getSiteGmtOffset( state, siteId ) ); - const convertHourToRange = ( hour: number, isUtc: boolean = false ): string => { - const time = isUtc - ? moment.utc().startOf( 'day' ).hour( hour ) - : moment().startOf( 'day' ).hour( hour ); - - const formatString = isUtc ? 'HH:mm' : 'LT'; // 24-hour format for UTC, 12-hour for local - - const startTime = time.format( formatString ); - const endTime = time.add( 59, 'minutes' ).format( formatString ); - - return `${ startTime } - ${ endTime }`; - }; - const generateTimeSlots = (): { label: string; value: string }[] => { const options = []; for ( let hour = 0; hour < 24; hour++ ) { @@ -45,7 +33,7 @@ const BackupScheduleSetting: FunctionComponent = () => { ? applySiteOffset( utcTime, { timezone, gmtOffset } ) : utcTime.local(); const localHour = localTime.hour(); - const timeRange = convertHourToRange( localHour ); + const timeRange = convertHourToRange( moment, localHour ); options.push( { label: timeRange, @@ -92,7 +80,7 @@ const BackupScheduleSetting: FunctionComponent = () => { const getScheduleInfoMessage = (): TranslateResult => { const hour = data?.scheduledHour || 0; - const range = convertHourToRange( hour, true ); + const range = convertHourToRange( moment, hour, true ); if ( ! data || ! data.scheduledBy ) { return `${ translate( 'Default time' ) }. UTC: ${ range }`; diff --git a/client/components/jetpack/backup-schedule-setting/utils.ts b/client/components/jetpack/backup-schedule-setting/utils.ts new file mode 100644 index 0000000000000..3896e7a177296 --- /dev/null +++ b/client/components/jetpack/backup-schedule-setting/utils.ts @@ -0,0 +1,31 @@ +/** + * Converts a given hour into a time range string, either in UTC or local time. + * The resulting time range shows a start time and an end time 59 minutes later. + * + * - For local times, the start time is displayed in 12-hour format without AM/PM, + * while the end time includes AM/PM. + * - For UTC, the time range is displayed in 24-hour format for both start and end times. + * + * @param {any} momentInstance - A moment.js instance used for time manipulation. + * @param {number} hour - The hour of the day (0-23) for which to generate the time range. + * @param {boolean} isUtc - Whether to generate the time range in UTC (true) or local time (false). + * + * @returns {string} - A formatted string representing the time range. + */ +export const convertHourToRange = ( + momentInstance: any, + hour: number, + isUtc: boolean = false +): string => { + const time = isUtc + ? momentInstance.utc().startOf( 'day' ).hour( hour ) + : momentInstance().startOf( 'day' ).hour( hour ); + + const startTimeFormat = isUtc ? 'HH:mm' : 'h:mm'; + const endTimeFormat = isUtc ? 'HH:mm' : 'h:mm A'; + + const startTime = time.format( startTimeFormat ); + const endTime = time.add( 59, 'minutes' ).format( endTimeFormat ); + + return `${ startTime }-${ endTime }`; +}; diff --git a/client/components/jetpack/daily-backup-status/status-card/backup-successful.jsx b/client/components/jetpack/daily-backup-status/status-card/backup-successful.jsx index b5f702ebce7a7..af4996ff80d09 100644 --- a/client/components/jetpack/daily-backup-status/status-card/backup-successful.jsx +++ b/client/components/jetpack/daily-backup-status/status-card/backup-successful.jsx @@ -1,9 +1,11 @@ +import config from '@automattic/calypso-config'; import { useTranslate } from 'i18n-calypso'; import { useSelector } from 'react-redux'; import { default as ActivityCard, useToggleContent } from 'calypso/components/activity-card'; import { default as Toolbar } from 'calypso/components/activity-card/toolbar'; import ExternalLink from 'calypso/components/external-link'; import BackupWarningRetry from 'calypso/components/jetpack/backup-warnings/backup-warning-retry'; +import NextScheduledBackup from 'calypso/components/jetpack/daily-backup-status/status-card/parts/next-scheduled-backup'; import { useLocalizedMoment } from 'calypso/components/localized-moment'; import { preventWidows } from 'calypso/lib/formatting'; import { useActionableRewindId } from 'calypso/lib/jetpack/actionable-rewind-id'; @@ -79,19 +81,9 @@ const BackupSuccessful = ( {
{ isToday ? translate( 'Latest backup' ) : translate( 'Latest backup on this day' ) }
- - { ! isCloneFlow && ( -
- -
- ) } + { isToday && config.isEnabled( 'jetpack/backup-schedule-setting' ) ? ( + + ) : null }
{ displayDate }
@@ -134,13 +126,30 @@ const BackupSuccessful = ( {

) } - + + { isCloneFlow && ( + + ) } + + { ! isCloneFlow && ( + + ) } + { showBackupDetails && (
diff --git a/client/components/jetpack/daily-backup-status/status-card/parts/next-scheduled-backup.tsx b/client/components/jetpack/daily-backup-status/status-card/parts/next-scheduled-backup.tsx new file mode 100644 index 0000000000000..43b062c1e8aaf --- /dev/null +++ b/client/components/jetpack/daily-backup-status/status-card/parts/next-scheduled-backup.tsx @@ -0,0 +1,54 @@ +import { LoadingPlaceholder } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import { FunctionComponent } from 'react'; +import useNextBackupSchedule from 'calypso/components/jetpack/backup-schedule-setting/hooks'; +import { settingsPath } from 'calypso/lib/jetpack/paths'; +import { useSelector } from 'calypso/state'; +import { getSiteSlug } from 'calypso/state/sites/selectors'; + +type Props = { + siteId: number; +}; + +const NextScheduledBackup: FunctionComponent< Props > = ( { siteId } ) => { + const translate = useTranslate(); + const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); + + const { hasLoaded, date, timeRange } = useNextBackupSchedule(); + + if ( ! hasLoaded ) { + return ( +
+ +
+ ); + } + + if ( ! date || ! timeRange ) { + return null; + } + + return ( +
+ ); +}; + +export default NextScheduledBackup; diff --git a/client/components/jetpack/daily-backup-status/status-card/style.scss b/client/components/jetpack/daily-backup-status/status-card/style.scss index 671f994cb9c8e..a858de13451ae 100644 --- a/client/components/jetpack/daily-backup-status/status-card/style.scss +++ b/client/components/jetpack/daily-backup-status/status-card/style.scss @@ -44,6 +44,19 @@ font-weight: 600; } + .status-card__scheduled-backup { + margin-left: auto; + + a, span { + font-size: 0.875rem; + font-weight: normal; + } + + &.placeholder { + min-width: 300px; + } + } + .status-card__toolbar { margin-left: auto; diff --git a/client/components/jetpack/daily-backup-status/style.scss b/client/components/jetpack/daily-backup-status/style.scss index 17ce3e93aaa11..34e7cfa55a5a5 100644 --- a/client/components/jetpack/daily-backup-status/style.scss +++ b/client/components/jetpack/daily-backup-status/style.scss @@ -3,12 +3,16 @@ text-align: center; background: #fff; box-shadow: 0 0 0 1px #dcdcde; + + p { + font-size: 1rem; + } } .daily-backup-status .form-button { float: none; display: block; - width: 100%; + width: auto; text-align: center; margin-left: 0; }