Skip to content
80 changes: 80 additions & 0 deletions src/components/progress-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,83 @@ export function ProgressBarWithAutoIncrement( {

return <ProgressBar value={ animatedValue } maxValue={ maxValue } />;
}

type TwoColorProgressBarProps = {
value: number;
maxValue: number;
normalColorClass?: string;
overLimitColorClass?: string;
trackColorClass?: string;
showLabels?: boolean;
valueLabel?: string;
limitLabel?: string;
overLimitLabel?: string;
};

export function TwoColorProgressBar( {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to achieve this with ProgressBar but that component is too simple so I opted for this custom simple implementation.

value,
maxValue,
normalColorClass = 'bg-a8c-blue-50',
overLimitColorClass = 'bg-a8c-red-50',
trackColorClass = 'bg-a8c-gray-5',
showLabels = false,
valueLabel,
limitLabel,
overLimitLabel,
}: TwoColorProgressBarProps ) {
const isOverLimit = value > maxValue;
const percentage = Math.min( ( value / maxValue ) * 100, 100 );

return (
<div>
{ showLabels && ( valueLabel || limitLabel || overLimitLabel ) && (
<div className="flex justify-between items-center text-xs mb-2">
<div className="text-a8c-gray-90 font-medium uppercase">{ valueLabel }</div>
<div>
{ isOverLimit && overLimitLabel ? (
<span className="text-a8c-gray-700 text-xs">{ overLimitLabel }</span>
) : (
limitLabel && <span className="text-a8c-gray-700 text-xs">{ limitLabel }</span>
) }
</div>
</div>
) }
<div
className={ cx(
'relative w-full h-[1.5px] rounded-full overflow-hidden',
trackColorClass
) }
>
{ isOverLimit ? (
<>
<div
className={ cx(
'absolute left-0 top-0 h-full transition-all duration-300',
normalColorClass
) }
style={ { width: `${ ( maxValue / value ) * 100 }%` } }
/>
<div
className={ cx(
'absolute top-0 h-full transition-all duration-300',
overLimitColorClass
) }
style={ {
left: `${ ( maxValue / value ) * 100 }%`,
width: `${ ( ( value - maxValue ) / value ) * 100 }%`,
} }
/>
</>
) : (
<div
className={ cx(
'absolute left-0 top-0 h-full rounded-full transition-all duration-300',
normalColorClass
) }
style={ { width: `${ percentage }%` } }
/>
) }
</div>
</div>
);
}
41 changes: 34 additions & 7 deletions src/modules/sync/components/sync-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ArrowIcon } from 'src/components/arrow-icon';
import Button from 'src/components/button';
import { RightArrowIcon } from 'src/components/icons/right-arrow';
import Modal from 'src/components/modal';
import { TwoColorProgressBar } from 'src/components/progress-bar';
import { Tooltip } from 'src/components/tooltip';
import { TreeView, TreeNode, updateNodeById } from 'src/components/tree-view';
import { SYNC_PUSH_SIZE_LIMIT_GB } from 'src/constants';
Expand Down Expand Up @@ -140,11 +141,15 @@ export function SyncDialog( {
const [ showAllFiles, setShowAllFiles ] = useState( false );
const [ treeState, setTreeState ] = useState< TreeNode[] >( defaultTree );
const isSubmitDisabled = treeState.every( ( node ) => ! node.checked && ! node.indeterminate );
const { isPushSelectionOverLimit, isLoading: isSizeCheckLoading } = useSelectedItemsPushSize(
localSite.id,
treeState,
type
);
const {
isPushSelectionOverLimit,
isLoading: isSizeCheckLoading,
totalSize,
limitBytes,
formattedSize,
formattedLimit,
formattedOverAmount,
} = useSelectedItemsPushSize( localSite.id, treeState, type );

const { fetchChildren, rewindId, isLoadingRewindId, isErrorRewindId, isLoadingLocalFileTree } =
useDynamicTreeState( type, localSite.id, remoteSite.id, setTreeState );
Expand Down Expand Up @@ -218,13 +223,23 @@ export function SyncDialog( {
onRequestClose();
};

const getBottomPadding = () => {
if ( type === 'pull' ) {
return 'pb-[70px]'; // Original padding for pull

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left these comments for the ease of reading the function. They could be removed if they seem self-explanatory

}
if ( isPushSelectionOverLimit ) {
return 'pb-[200px]'; // Progress bar + warning notice
}
return 'pb-[110px]'; // Just progress bar
};

return (
<Modal
className="w-3/5 min-w-[550px] max-h-[84vh] [&>div]:!p-0"
onRequestClose={ onRequestClose }
title={ syncTexts.title }
>
<div className={ isPushSelectionOverLimit ? 'pb-[140px]' : 'pb-[70px]' }>
<div className={ getBottomPadding() }>
<div className="px-8 pb-6 pt-1">{ syncTexts.description }</div>
<div className="px-8">
<span className="sr-only">
Expand Down Expand Up @@ -321,7 +336,19 @@ export function SyncDialog( {
</div>
</Tooltip>

<div className="px-8 py-4 absolute left-0 right-0 bottom-0 bg-white z-10">
<div className="px-8 py-4 absolute left-0 right-0 bottom-0 bg-white z-10 border-t border-a8c-gray-5">
{ type === 'push' && (
<div className="mb-4">
<TwoColorProgressBar
value={ totalSize }
maxValue={ limitBytes }
showLabels
valueLabel={ formattedSize }
limitLabel={ formattedLimit }
overLimitLabel={ sprintf( __( '%s over' ), formattedOverAmount ) }
/>
</div>
) }
{ type === 'push' && isPushSelectionOverLimit && (
<Notice status="warning" isDismissible={ false } className="mb-4">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left the notice as warning even though the designs had it in blue. I tried the info version and this is what it looked like:

Image

To me it looked a bit too pale to be well noticed but we can change it if that's the preference.

<p data-testid="push-selection-over-limit-notice">
Expand Down
79 changes: 48 additions & 31 deletions src/modules/sync/hooks/use-selected-items-push-size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@ import { TreeNode } from 'src/components/tree-view';
import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants';
import { getIpcApi } from 'src/lib/get-ipc-api';

const formatFileSize = ( bytes: number ) => {
if ( bytes === 0 ) return '0 B';
const k = 1024;
const sizes = [ 'B', 'KB', 'MB', 'GB' ];
const i = Math.floor( Math.log( bytes ) / Math.log( k ) );
return Math.round( ( bytes / Math.pow( k, i ) ) * 100 ) / 100 + ' ' + sizes[ i ];
};

export function useSelectedItemsPushSize(
siteId: string,
treeState: TreeNode[],
type: 'push' | 'pull'
) {
const [ isPushSelectionOverLimit, setIsPushSelectionOverLimit ] = useState( false );
const [ isLoading, setIsLoading ] = useState( false );
const [ totalSize, setTotalSize ] = useState( 0 );

const checkSelectedItemsSize = useCallback( async () => {
if ( ! siteId || ! treeState.length || type !== 'push' ) {
setIsPushSelectionOverLimit( false );
setTotalSize( 0 );
return;
}

Expand All @@ -24,6 +34,7 @@ export function useSelectedItemsPushSize(

if ( isEverythingSelected ) {
const size = await getIpcApi().getDirectorySize( siteId, [ 'wp-content' ] );
setTotalSize( size );
setIsPushSelectionOverLimit( size > SYNC_PUSH_SIZE_LIMIT_BYTES );
return;
}
Expand All @@ -40,52 +51,47 @@ export function useSelectedItemsPushSize(
( node ) => node.id === 'wp-content'
);

if ( wpContentNode?.checked ) {
sizePromises.push( getIpcApi().getDirectorySize( siteId, [ 'wp-content' ] ) );
} else if ( wpContentNode?.children ) {
for ( const child of wpContentNode.children ) {
if ( child.checked ) {
if ( child.type === 'file' ) {
sizePromises.push( getIpcApi().getFileSize( siteId, [ 'wp-content', child.name ] ) );
const processNodeRecursively = ( node: TreeNode, pathPrefix: string[] ): void => {
if ( node.checked ) {
// If the node is fully checked, get its entire size
if ( node.type === 'file' ) {
sizePromises.push( getIpcApi().getFileSize( siteId, [ ...pathPrefix, node.name ] ) );
} else {
if ( node.name === 'mu-plugins' && node.children ) {
for ( const child of node.children ) {
processNodeRecursively( child, [ ...pathPrefix, node.name ] );
}
} else {
sizePromises.push(
getIpcApi().getDirectorySize( siteId, [ 'wp-content', child.name ] )
getIpcApi().getDirectorySize( siteId, [ ...pathPrefix, node.name ] )
);
}
} else if ( child.indeterminate && child.children ) {
for ( const subChild of child.children ) {
if ( subChild.checked || subChild.indeterminate ) {
let sizePromise: Promise< number >;
if ( subChild.type === 'file' ) {
sizePromise = getIpcApi().getFileSize( siteId, [
'wp-content',
child.name,
subChild.name,
] );
} else {
sizePromise = getIpcApi().getDirectorySize( siteId, [
'wp-content',
child.name,
subChild.name,
] );
}
sizePromises.push( sizePromise );
}
}
}
} else if ( node.indeterminate && node.children ) {
// If the node is indeterminate, process its children recursively
for ( const child of node.children ) {
processNodeRecursively( child, [ ...pathPrefix, node.name ] );
}
}
};

if ( wpContentNode ) {
processNodeRecursively( wpContentNode, [] );
}

if ( sizePromises.length > 0 ) {
const sizes = await Promise.all( sizePromises );
const totalSize = sizes.reduce( ( sum, size ) => sum + size, 0 );
setIsPushSelectionOverLimit( totalSize > SYNC_PUSH_SIZE_LIMIT_BYTES );
const calculatedSize = sizes.reduce( ( sum, size ) => sum + size, 0 );
setTotalSize( calculatedSize );
setIsPushSelectionOverLimit( calculatedSize > SYNC_PUSH_SIZE_LIMIT_BYTES );
} else {
setTotalSize( 0 );
setIsPushSelectionOverLimit( false );
}
} catch ( error ) {
console.error( 'Error checking selected items size:', error );
setIsPushSelectionOverLimit( false );
setTotalSize( 0 );
} finally {
setIsLoading( false );
}
Expand All @@ -96,5 +102,16 @@ export function useSelectedItemsPushSize(
void checkSelectedItemsSize();
}, [ checkSelectedItemsSize ] );

return { isPushSelectionOverLimit, isLoading };
const limitBytes = SYNC_PUSH_SIZE_LIMIT_BYTES;
const overAmount = totalSize > limitBytes ? totalSize - limitBytes : 0;

return {
isPushSelectionOverLimit,
isLoading,
totalSize,
limitBytes,
formattedSize: formatFileSize( totalSize ),
formattedLimit: formatFileSize( limitBytes ),
formattedOverAmount: formatFileSize( overAmount ),
};
}
12 changes: 10 additions & 2 deletions src/modules/sync/tests/use-selected-items-push-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ const createMockTreeState = (
allSelected = false,
} = options;

const wpContentIndeterminate =
! filesAndFoldersSelected &&
! allSelected &&
( options.pluginsSelected || options.themesSelected || options.uploadsSelected );

return [
{
id: 'filesAndFolders',
Expand All @@ -37,7 +42,7 @@ const createMockTreeState = (
name: 'wp-content',
label: 'wp-content',
checked: allSelected || filesAndFoldersSelected,
indeterminate: false,
indeterminate: wpContentIndeterminate,
type: 'folder',
children: [
{
Expand Down Expand Up @@ -151,7 +156,10 @@ describe( 'useSelectedItemsPushSize Hook Tests', () => {
getDirectorySize: mockGetDirectorySize,
} );

const treeState = createMockTreeState( { pluginsSelected: true, themesSelected: true } );
const treeState = createMockTreeState( {
pluginsSelected: true,
themesSelected: true,
} );

const { result } = renderHook( () =>
useSelectedItemsPushSize( mockSiteId, treeState, 'push' )
Expand Down