Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@internxt/ui",
"version": "0.1.21",
"version": "0.1.22",
"description": "Library of Internxt components",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { CloudWarningIcon, XIcon } from '@phosphor-icons/react';
import React from 'react';
import { Button } from '../../../input/button';

export interface UsageWarningBannerProps {
usage: string;
limit: string;
percentage: number;
titleLabel: string;
descriptionLabelLine1: string;
descriptionLabelLine2: string;
upgradeLabel: string;
closeButtonLabel?: string;
onUpgradeClick: () => void;
onCloseButtonClick: () => void;
isLoading?: boolean;
darkMode?: boolean;
}

export type StorageLevel = 'low warning' | 'middle warning' | 'high warning';
Comment thread
jaaaaavier marked this conversation as resolved.
Outdated

export const getStorageLevel = (percentage: number): StorageLevel => {
if (percentage >= 95) return 'high warning';
if (percentage >= 80) return 'middle warning';
return 'low warning';
};

const STORAGE_LEVEL_STYLES: Record<StorageLevel, { bar: string }> = {
'low warning': { bar: 'bg-yellow-60' },
'middle warning': { bar: 'bg-orange-60' },
'high warning': { bar: 'bg-danger' },
};


const renderWithBold = (text: string): React.ReactNode =>
text.split(/(\*\*[^*]+\*\*)/g).map((segment, index) => {
if (segment.startsWith('**') && segment.endsWith('**')) {
return <strong key={index}>{segment.slice(2, -2)}</strong>;
}
return <React.Fragment key={index}>{segment}</React.Fragment>;
});
Comment thread
jaaaaavier marked this conversation as resolved.
Outdated

const UsageWarningBanner: React.FC<UsageWarningBannerProps> = ({
usage,
limit,
percentage,
titleLabel,
descriptionLabelLine1,
descriptionLabelLine2,
upgradeLabel,
closeButtonLabel = 'Close',
onUpgradeClick,
onCloseButtonClick,
isLoading = true,
}) => {
const level = getStorageLevel(percentage);
const styles = STORAGE_LEVEL_STYLES[level];

return (
<div className="flex flex-col w-full px-4 py-3 rounded-xl bg-alert border-alert-dark border h-min">
<div className="flex flex-col w-full gap-1">
<div className="flex flex-row justify-between items-center">
<span className="flex flex-row items-center gap-2">
<CloudWarningIcon weight="fill" className="size-5 text-yellow-60" />
<p className="text-sm font-semibold text-gray-80">{titleLabel}</p>
</span>
<button
type="button"
aria-label={closeButtonLabel}
onClick={onCloseButtonClick}
className="flex items-center justify-center"
>
<XIcon className="size-5 text-gray-53 cursor-pointer" />
</button>
</div>
<span>
<p className="text-xs font-medium text-gray-52">{renderWithBold(descriptionLabelLine1)}</p>
<p className="text-xs font-medium text-gray-52">{renderWithBold(descriptionLabelLine2)}</p>
</span>
</div>
<div className="flex flex-row items-center gap-6 h-10">
<div className="flex flex-1 flex-col gap-1">
<div className="flex w-full h-1.5 bg-gray-10 rounded-full">
<div className={`${styles.bar} h-full rounded-full`} style={{ width: `${percentage}%` }} />
</div>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<div className="h-3 w-8 rounded-lg bg-gray-5 animate-pulse" />
<div className="h-3 w-2 rounded-lg bg-gray-5 animate-pulse" />
<div className="h-3 w-8 rounded-lg bg-gray-5 animate-pulse" />
</div>
) : (
<span className="flex flex-row gap-1">
<p className="text-gray-60 text-xs">{usage}</p>
<p className="text-gray-60 text-xs">/</p>
<p className="text-gray-60 text-xs">{limit}</p>
</span>
)}
</div>
<Button variant="secondary" size="medium" onClick={onUpgradeClick}>
{upgradeLabel}
</Button>
</div>
</div>
);
};

export default UsageWarningBanner;
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { UsageWarningBanner, UsageWarningBannerProps } from '../';
import { getStorageLevel } from '../UsageWarningBanner';

const renderBanner = (overrides: Partial<UsageWarningBannerProps> = {}) => {
const props: UsageWarningBannerProps = {
usage: '10 GB',
limit: '20 GB',
percentage: 50,
titleLabel: 'Storage almost full',
descriptionLabelLine1: 'You are running out of space',
descriptionLabelLine2: 'Upgrade to keep saving files',
upgradeLabel: 'Upgrade now',
closeButtonLabel: 'Close',
onUpgradeClick: vi.fn(),
onCloseButtonClick: vi.fn(),
isLoading: false,
...overrides,
};

return { props, ...render(<UsageWarningBanner {...props} />) };
};

describe('UsageWarningBanner', () => {
it('warns with a danger-coloured bar when storage is critically full', () => {
const { container } = renderBanner({ percentage: 96 });

expect(container.querySelector('.bg-danger')).toBeTruthy();
});

it('warns with an orange bar when storage is nearly full', () => {
const { container } = renderBanner({ percentage: 85 });

expect(container.querySelector('.bg-orange-60')).toBeTruthy();
});

it('warns with a yellow bar when storage still has room', () => {
const { container } = renderBanner({ percentage: 40 });

expect(container.querySelector('.bg-yellow-60')).toBeTruthy();
});

it('fills the bar in proportion to the storage used', () => {
const { container } = renderBanner({ percentage: 73 });

const bar = container.querySelector<HTMLElement>('.bg-yellow-60');
expect(bar?.style.width).toBe('73%');
});

it('emphasises the portions of the description wrapped in double asterisks', () => {
renderBanner({ descriptionLabelLine1: 'You have used **90%** of your storage' });

expect(screen.getByText('90%').tagName).toBe('STRONG');
});

it('shows a loading placeholder instead of the usage figures while data loads', () => {
const { container } = renderBanner({ isLoading: true });

expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
expect(screen.queryByText('10 GB')).toBeNull();
expect(screen.queryByText('20 GB')).toBeNull();
});

it('shows the used and total storage once data has loaded', () => {
renderBanner({ isLoading: false });

expect(screen.getByText('10 GB')).toBeTruthy();
expect(screen.getByText('20 GB')).toBeTruthy();
});

it('notifies the parent when the user chooses to upgrade', () => {
const onUpgradeClick = vi.fn();
renderBanner({ onUpgradeClick });

screen.getByRole('button', { name: 'Upgrade now' }).click();

expect(onUpgradeClick).toHaveBeenCalledOnce();
});

it('notifies the parent when the user dismisses the banner', () => {
const onCloseButtonClick = vi.fn();
renderBanner({ onCloseButtonClick, closeButtonLabel: 'Close' });

screen.getByRole('button', { name: 'Close' }).click();

expect(onCloseButtonClick).toHaveBeenCalledOnce();
});
});

describe('getStorageLevel', () => {
it('flags critical usage from ninety-five percent upwards', () => {
expect(getStorageLevel(95)).toBe('high warning');
expect(getStorageLevel(100)).toBe('high warning');
});

it('flags near-full usage between eighty and ninety-five percent', () => {
expect(getStorageLevel(80)).toBe('middle warning');
expect(getStorageLevel(94)).toBe('middle warning');
});

it('keeps usage below eighty percent as a low warning', () => {
expect(getStorageLevel(0)).toBe('low warning');
expect(getStorageLevel(79)).toBe('low warning');
});
});
2 changes: 2 additions & 0 deletions src/components/feedback/notifications/usageBanner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as UsageWarningBanner } from './UsageWarningBanner';
export type { UsageWarningBannerProps } from './UsageWarningBanner';
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from './data-display/table/Table';
export * from './feedback/empty';
export * from './feedback/loader';
export * from './feedback/skeletonLoader';
export * from './feedback/notifications/usageBanner';

// input
export * from './input/button';
Expand Down
38 changes: 36 additions & 2 deletions src/components/navigation/sidenav/SidenavStorage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,56 @@
import { CloudWarningIcon } from '@phosphor-icons/react';
interface SidenavStorageProps {
usage: string;
limit: string;
percentage: number;
onUpgradeClick: () => void;
upgradeLabel?: string;
isLoading?: boolean;
advertisementMessage?: string;
}

type StorageLevel = 'normal' | 'low warning' | 'middle warning' | 'high warning';
Comment thread
jaaaaavier marked this conversation as resolved.
Outdated

const getStorageLevel = (percentage: number): StorageLevel => {
if (percentage >= 95) return 'high warning';
if (percentage >= 80) return 'middle warning';
if (percentage >= 60) return 'low warning';
return 'normal';
};

Comment thread
jaaaaavier marked this conversation as resolved.
Outdated
const STORAGE_LEVEL_STYLES: Record<StorageLevel, { bar: string; container: string }> = {
normal: { bar: 'bg-gray-60', container: '' },
'low warning': { bar: 'bg-yellow-60', container: '' },
'middle warning': { bar: 'bg-orange-60', container: '' },
'high warning': { bar: 'bg-danger', container: 'bg-alert rounded-xl border border-alert-dark p-3 gap-2' },
};

const SidenavStorage = ({
usage,
limit,
percentage,
onUpgradeClick,
upgradeLabel,
isLoading = true,
advertisementMessage,
}: SidenavStorageProps): JSX.Element => {

const level = getStorageLevel(percentage);
const styles = STORAGE_LEVEL_STYLES[level];
const showWarning = level === 'middle warning' || level === 'high warning';

return (
<div className="flex flex-col w-full gap-2.5 px-2 pb-5">

<div className={`flex flex-col w-full gap-2.5 p-3 ${styles.container}`}>
{showWarning && advertisementMessage && (
Comment thread
jaaaaavier marked this conversation as resolved.
Outdated
<div className="flex flex-row gap-0.5 items-center">
<CloudWarningIcon
className="size-5 inline-block text-yellow-60 mr-1"
weight={level === 'high warning' ? 'fill' : 'regular'}
/>
<p className="text-sm font-semibold">{advertisementMessage}</p>
</div>
)}
<div className="flex flex-row w-full justify-between">
<div className="flex flex-row items-center gap-2">
{isLoading ? (
Expand All @@ -41,7 +75,7 @@ const SidenavStorage = ({
</div>
<div className="flex w-full h-1.5 bg-gray-10 rounded-full">
<div
className="bg-gray-60 rounded-full"
className={`${styles.bar} rounded-full`}
style={{
width: `${percentage}%`,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,7 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = `
class="flex flex-col"
>
<div
class="flex flex-col w-full gap-2.5 px-2 pb-5"
class="flex flex-col w-full gap-2.5 p-3 "
>
<div
class="flex flex-row w-full justify-between"
Expand Down Expand Up @@ -964,7 +964,7 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = `
class="flex w-full h-1.5 bg-gray-10 rounded-full"
>
<div
class="bg-gray-60 rounded-full"
class="bg-yellow-60 rounded-full"
style="width: 70%;"
/>
</div>
Expand Down
Loading
Loading