Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/views/NewSettings/components/Usage/UsageBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import UsageBar from './UsageBar';

vi.mock('@internxt/ui', () => ({
Tooltip: ({
title,
subtitle,
className,
children,
}: {
title: string;
subtitle: string;
className: string;
children: React.ReactNode;
}) => (
<div data-testid={`segment-${title}`} className={className}>
<span>{`${title}: ${subtitle}`}</span>
{children}
</div>
),
}));

const GB = 1024 ** 3;

const renderBar = (props = {}) =>
render(<UsageBar driveUsage={0} backupsUsage={0} spaceLimit={GB} height="h-8" {...props} />);

const getSegmentElement = (title: string) => screen.getByTestId(`segment-${title}`).parentElement as HTMLElement;

const getSegmentPercentage = (title: string) => Number.parseFloat(getSegmentElement(title).style.width);

describe('UsageBar', () => {
test('when usage is over the limit, then both segments are red', () => {
renderBar({ driveUsage: 30 * GB, backupsUsage: 10 * GB });

expect(screen.getByTestId('segment-Drive').className).toContain('bg-red');
expect(screen.getByTestId('segment-Backup').className).toContain('bg-red');
});

test('when usage is over the limit, then the segments together fill exactly 100%', () => {
renderBar({ driveUsage: 30 * GB, backupsUsage: 10 * GB });

expect(getSegmentPercentage('Drive') + getSegmentPercentage('Backup')).toBeCloseTo(100);
});

test('when usage is under the limit, then the segments keep their regular colors and percentages', () => {
renderBar({ driveUsage: GB / 4, backupsUsage: GB / 4 });

expect(screen.getByTestId('segment-Drive').className).toContain('bg-primary');
expect(screen.getByTestId('segment-Backup').className).toContain('bg-indigo');
expect(getSegmentPercentage('Drive')).toBe(25);
});

test('when drive usage is non-zero, then the drive segment has a 5px minimum width', () => {
renderBar({ driveUsage: 1 });

expect(getSegmentElement('Drive').className).toContain('min-w-[5px]');
});

test('when drive usage is zero, then the drive segment has no minimum width', () => {
renderBar();

expect(getSegmentElement('Drive').className).not.toContain('min-w-[5px]');
});

test('when drive and backups usage differ, then the backup tooltip shows the backups usage', () => {
renderBar({ driveUsage: 2 * GB, backupsUsage: 3 * GB, spaceLimit: 10 * GB });

expect(screen.getByText('Drive: 2GB')).toBeInTheDocument();
expect(screen.getByText('Backup: 3GB')).toBeInTheDocument();
});

test('when only drive has usage, then the drive segment has no divider margin and is rounded on both sides', () => {
renderBar({ driveUsage: 2 * GB });

const { className } = screen.getByTestId('segment-Drive');
expect(className).not.toContain('mr-0.5');
expect(className).toContain('rounded-lg');
});

test('when only backups have usage, then the backup segment is rounded on both sides', () => {
renderBar({ backupsUsage: GB / 2 });

expect(screen.getByTestId('segment-Backup').className).toContain('rounded-lg');
});

test('when drive and backups have usage, then the divider margin is kept on the drive segment', () => {
renderBar({ driveUsage: GB / 4, backupsUsage: GB / 4 });

expect(screen.getByTestId('segment-Drive').className).toContain('mr-0.5');
expect(screen.getByTestId('segment-Drive').className).toContain('rounded-l-lg');
expect(screen.getByTestId('segment-Backup').className).toContain('rounded-r-lg');
});
});
79 changes: 37 additions & 42 deletions src/views/NewSettings/components/Usage/UsageBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { bytesToString } from 'app/drive/services/size.service';
import { Tooltip } from '@internxt/ui';
import { getUsageBarPercentages } from 'views/NewSettings/utils';

const UsageBar = ({
backupsUsage,
Expand All @@ -13,52 +13,47 @@ const UsageBar = ({
spaceLimit: string | number;
height: string | number;
}) => {
const [barWidth, setBarWidth] = useState(0);
const barRef = useRef<HTMLDivElement>(null);
const spaceLimitBytes = Number(spaceLimit);
const driveUsageBytes = Number(driveUsage);
const backupsUsageBytes = Number(backupsUsage);
const percentageDriveUsed = (driveUsageBytes * 100) / spaceLimitBytes;
const percentageBackupUsed = (backupsUsageBytes * 100) / spaceLimitBytes;
const driveWidth = (barWidth * percentageDriveUsed) / 100;
const backupWidth = (barWidth * percentageBackupUsed) / 100;

useEffect(() => {
const bar = barRef.current;

if (bar) {
const listener = () => {
setBarWidth(bar.getBoundingClientRect().width);
};
bar.addEventListener('resize', listener);

listener();

return () => bar.removeEventListener('resize', listener);
}
}, []);
const { drivePercentage, backupPercentage, isOverLimit } = getUsageBarPercentages({
driveUsageBytes,
backupsUsageBytes,
spaceLimitBytes,
});
const driveColor = isOverLimit ? 'bg-red' : 'bg-primary';
const backupColor = isOverLimit ? 'bg-red' : 'bg-indigo';
const hasDriveSegment = drivePercentage > 0;
const hasBackupsSegment = backupPercentage > 0;
const driveShapeClass = hasBackupsSegment ? 'mr-0.5 rounded-l-lg' : 'rounded-lg';
const backupShapeClass = hasDriveSegment ? 'rounded-r-lg' : 'rounded-lg';
const driveMinWidthClass = drivePercentage > 0 ? 'min-w-[5px]' : '';

return (
<div className={`flex w-full rounded-lg border border-gray-10 bg-gray-5 ${height}`} ref={barRef}>
<Tooltip
key="Drive"
title="Drive"
subtitle={bytesToString(driveUsageBytes)}
popsFrom="top"
className="mr-0.5 rounded-l-lg bg-primary"
>
<div style={{ width: `${driveWidth > 0 && driveWidth < 5 ? 5 : driveWidth}px` }}></div>
</Tooltip>

<Tooltip
key="Backup"
title="Backup"
subtitle={bytesToString(driveUsageBytes)}
popsFrom="top"
className="rounded-r-lg bg-indigo"
>
<div style={{ width: `${backupWidth}px` }}></div>
</Tooltip>
<div data-cy="usageBar" className={`flex w-full rounded-lg border border-gray-10 bg-gray-5 ${height}`}>
<div className={`flex ${driveMinWidthClass}`} style={{ width: `${drivePercentage}%` }}>
<Tooltip
key="Drive"
title="Drive"
subtitle={bytesToString(driveUsageBytes)}
popsFrom="top"
className={`${driveShapeClass} !w-full ${driveColor}`}
>
<div></div>
</Tooltip>
</div>

<div className="flex" style={{ width: `${backupPercentage}%` }}>
<Tooltip
key="Backup"
title="Backup"
subtitle={bytesToString(backupsUsageBytes)}
popsFrom="top"
className={`${backupShapeClass} !w-full ${backupColor}`}
>
<div></div>
</Tooltip>
</div>
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/views/NewSettings/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './LogsUtils';
export * from './membersUtils';
export * from './productUtils';
export * from './suscriptionUtils';
export * from './usageBarUtils';
61 changes: 61 additions & 0 deletions src/views/NewSettings/utils/usageBarUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'vitest';
import { getUsageBarPercentages } from './usageBarUtils';

const GB = 1024 ** 3;

const arrange = (overrides = {}) =>
getUsageBarPercentages({
driveUsageBytes: 0,
backupsUsageBytes: 0,
spaceLimitBytes: 10 * GB,
...overrides,
});

describe('getUsageBarPercentages', () => {
test('when usage is under the limit, then percentages are relative to the limit and not over limit', () => {
const result = arrange({ driveUsageBytes: 2.5 * GB, backupsUsageBytes: 5 * GB });

expect(result).toEqual({ drivePercentage: 25, backupPercentage: 50, isOverLimit: false });
});

test('when usage is exactly the limit, then the bar is full and not over limit', () => {
const result = arrange({ driveUsageBytes: 4 * GB, backupsUsageBytes: 6 * GB });

expect(result).toEqual({ drivePercentage: 40, backupPercentage: 60, isOverLimit: false });
});

test('when drive usage is far over the limit, then the drive segment takes the whole bar', () => {
const result = arrange({ driveUsageBytes: 33.8 * GB, spaceLimitBytes: GB });

expect(result).toEqual({ drivePercentage: 100, backupPercentage: 0, isOverLimit: true });
});

test('when drive plus backups are over the limit, then they are scaled to 100% keeping their ratio', () => {
const result = arrange({ driveUsageBytes: 15 * GB, backupsUsageBytes: 5 * GB });

expect(result).toEqual({ drivePercentage: 75, backupPercentage: 25, isOverLimit: true });
});

test('when the limit is zero and there is usage, then the bar is full and over limit', () => {
const result = arrange({ driveUsageBytes: GB, spaceLimitBytes: 0 });

expect(result).toEqual({ drivePercentage: 100, backupPercentage: 0, isOverLimit: true });
});

test('when the limit is invalid and there is no usage, then nothing is drawn', () => {
const result = arrange({ spaceLimitBytes: Number.NaN });

expect(result).toEqual({ drivePercentage: 0, backupPercentage: 0, isOverLimit: false });
});

test('when values are not finite, then percentages are never NaN or Infinity', () => {
const result = arrange({ driveUsageBytes: Number.NaN, backupsUsageBytes: Infinity, spaceLimitBytes: Infinity });

expect(Number.isFinite(result.drivePercentage)).toBe(true);
expect(Number.isFinite(result.backupPercentage)).toBe(true);
});

test('when usage is zero, then both segments are empty', () => {
expect(arrange()).toEqual({ drivePercentage: 0, backupPercentage: 0, isOverLimit: false });
});
});
42 changes: 42 additions & 0 deletions src/views/NewSettings/utils/usageBarUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
interface UsageBarPercentagesParams {
driveUsageBytes: number;
backupsUsageBytes: number;
spaceLimitBytes: number;
}

interface UsageBarPercentages {
drivePercentage: number;
backupPercentage: number;
isOverLimit: boolean;
}

const toNonNegativeNumber = (value: number): number => (Number.isFinite(value) && value > 0 ? value : 0);

/**
* Computes the share of the bar (0-100) taken by the Drive and Backups segments.
* Over the limit (or with an invalid limit and some usage) both segments are scaled
* proportionally so together they fill exactly 100%.
*/
export const getUsageBarPercentages = ({
driveUsageBytes,
backupsUsageBytes,
spaceLimitBytes,
}: UsageBarPercentagesParams): UsageBarPercentages => {
const driveUsage = toNonNegativeNumber(driveUsageBytes);
const backupsUsage = toNonNegativeNumber(backupsUsageBytes);
const spaceLimit = toNonNegativeNumber(spaceLimitBytes);
const totalUsage = driveUsage + backupsUsage;
const isOverLimit = totalUsage > spaceLimit;

if (totalUsage === 0) {
return { drivePercentage: 0, backupPercentage: 0, isOverLimit };
}

const denominator = isOverLimit ? totalUsage : spaceLimit;

return {
drivePercentage: (driveUsage * 100) / denominator,
backupPercentage: (backupsUsage * 100) / denominator,
isOverLimit,
};
};
Loading