diff --git a/package.json b/package.json index 28bfc1c..d56fe73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internxt/ui", - "version": "0.1.21", + "version": "0.1.22", "description": "Library of Internxt components", "repository": { "type": "git", diff --git a/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx new file mode 100644 index 0000000..e1e89f6 --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/UsageWarningBanner.tsx @@ -0,0 +1,73 @@ +import { CloudWarningIcon, XIcon } from '@phosphor-icons/react'; +import React, { ReactNode } from 'react'; +import { Button } from '../../../input/button'; +import Skeleton from '../../skeleton/Skeleton'; + +export interface UsageWarningBannerProps { + title: string; + description: ReactNode; + usage: string; + limit: string; + percentage: number; + upgradeLabel: string; + closeButtonLabel: string; + onUpgradeClick: () => void; + onCloseButtonClick: () => void; + barClassName?: string; + isLoading?: boolean; +} + +const UsageWarningBanner: React.FC = ({ + title, + description, + usage, + limit, + percentage, + upgradeLabel, + closeButtonLabel, + onUpgradeClick, + onCloseButtonClick, + barClassName = 'bg-yellow-60', + isLoading = false, +}) => ( +
+
+
+ + +

{title}

+
+ +
+
{description}
+
+
+
+
+
+
+ {isLoading ? ( + + ) : ( + +

{usage}

+

/

+

{limit}

+
+ )} +
+ +
+
+); + +export default UsageWarningBanner; diff --git a/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx new file mode 100644 index 0000000..6176ee9 --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/__test__/UsageWarningBanner.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { UsageWarningBanner, UsageWarningBannerProps } from '../'; + +const renderBanner = (overrides: Partial = {}) => { + const props: UsageWarningBannerProps = { + title: 'Storage almost full', + description: 'You are running out of space', + usage: '10 GB', + limit: '20 GB', + percentage: 50, + upgradeLabel: 'Upgrade now', + closeButtonLabel: 'Close', + onUpgradeClick: vi.fn(), + onCloseButtonClick: vi.fn(), + isLoading: false, + ...overrides, + }; + + return { props, ...render() }; +}; + +describe('UsageWarningBanner', () => { + it('colours the progress bar with the style chosen by the consumer', () => { + const { container } = renderBanner({ barClassName: 'bg-danger' }); + + expect(container.querySelector('.bg-danger')).toBeTruthy(); + }); + + it('fills the bar in proportion to the storage used', () => { + const { container } = renderBanner({ barClassName: 'bg-yellow-60', percentage: 73 }); + + const bar = container.querySelector('.bg-yellow-60'); + expect(bar?.style.width).toBe('73%'); + }); + + it('renders the rich description provided by the consumer', () => { + renderBanner({ + description: ( +

+ 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(); + }); +}); diff --git a/src/components/feedback/notifications/usageBanner/index.ts b/src/components/feedback/notifications/usageBanner/index.ts new file mode 100644 index 0000000..5d2c01a --- /dev/null +++ b/src/components/feedback/notifications/usageBanner/index.ts @@ -0,0 +1,2 @@ +export { default as UsageWarningBanner } from './UsageWarningBanner'; +export type { UsageWarningBannerProps } from './UsageWarningBanner'; \ No newline at end of file diff --git a/src/components/feedback/skeleton/Skeleton.tsx b/src/components/feedback/skeleton/Skeleton.tsx new file mode 100644 index 0000000..abeb415 --- /dev/null +++ b/src/components/feedback/skeleton/Skeleton.tsx @@ -0,0 +1,14 @@ +import SkeletonItem from './SkeletonItem'; + +/** + * Loading placeholder for the "usage / limit" amount (e.g. "8GB / 100GB"). + */ +const Skeleton = (): React.ReactElement => ( +
+ + + +
+); + +export default Skeleton; diff --git a/src/components/feedback/skeleton/SkeletonItem.tsx b/src/components/feedback/skeleton/SkeletonItem.tsx new file mode 100644 index 0000000..0ce58ab --- /dev/null +++ b/src/components/feedback/skeleton/SkeletonItem.tsx @@ -0,0 +1,9 @@ +export interface SkeletonItemProps { + className?: string; +} + +const SkeletonItem = ({ className }: SkeletonItemProps): React.ReactElement => ( +
+); + +export default SkeletonItem; diff --git a/src/components/feedback/skeleton/__test__/Skeleton.test.tsx b/src/components/feedback/skeleton/__test__/Skeleton.test.tsx new file mode 100644 index 0000000..6800bea --- /dev/null +++ b/src/components/feedback/skeleton/__test__/Skeleton.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { Skeleton } from '../'; + +describe('Skeleton', () => { + it('should match snapshot', () => { + const skeleton = render(); + expect(skeleton).toMatchSnapshot(); + }); + + it('should render three skeleton items', () => { + const { container } = render(); + + const items = container.querySelectorAll('.animate-pulse'); + expect(items).toHaveLength(3); + }); +}); diff --git a/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx b/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx new file mode 100644 index 0000000..3899216 --- /dev/null +++ b/src/components/feedback/skeleton/__test__/SkeletonItem.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { SkeletonItem } from '../'; + +describe('SkeletonItem', () => { + it('should match snapshot', () => { + const skeleton = render(); + expect(skeleton).toMatchSnapshot(); + }); + + it('should render a pulsing placeholder', () => { + const { container } = render(); + + const item = container.firstChild as HTMLElement; + expect(item).toHaveClass('animate-pulse'); + expect(item).toHaveClass('rounded-lg'); + expect(item).toHaveClass('bg-gray-5'); + }); + + it('should append the provided className', () => { + const { container } = render(); + + const item = container.firstChild as HTMLElement; + expect(item).toHaveClass('h-3'); + expect(item).toHaveClass('w-8'); + expect(item).toHaveClass('animate-pulse'); + }); +}); diff --git a/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap b/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap new file mode 100644 index 0000000..f941e05 --- /dev/null +++ b/src/components/feedback/skeleton/__test__/__snapshots__/Skeleton.test.tsx.snap @@ -0,0 +1,90 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Skeleton > should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+
+
+
+
+ , + "container":
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap b/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap new file mode 100644 index 0000000..78997dd --- /dev/null +++ b/src/components/feedback/skeleton/__test__/__snapshots__/SkeletonItem.test.tsx.snap @@ -0,0 +1,70 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SkeletonItem > should match snapshot 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+
+
+ , + "container":
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/src/components/feedback/skeleton/index.ts b/src/components/feedback/skeleton/index.ts new file mode 100644 index 0000000..9bd49b7 --- /dev/null +++ b/src/components/feedback/skeleton/index.ts @@ -0,0 +1,3 @@ +export { default as SkeletonItem } from './SkeletonItem'; +export type { SkeletonItemProps } from './SkeletonItem'; +export { default as Skeleton } from './Skeleton'; diff --git a/src/components/index.ts b/src/components/index.ts index db5baa0..def516b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,7 +7,9 @@ export * from './data-display/table/Table'; // feedback export * from './feedback/empty'; export * from './feedback/loader'; +export * from './feedback/skeleton'; export * from './feedback/skeletonLoader'; +export * from './feedback/notifications/usageBanner'; // input export * from './input/button'; diff --git a/src/components/navigation/sidenav/Sidenav.tsx b/src/components/navigation/sidenav/Sidenav.tsx index 68f8c89..855861c 100644 --- a/src/components/navigation/sidenav/Sidenav.tsx +++ b/src/components/navigation/sidenav/Sidenav.tsx @@ -18,6 +18,9 @@ export interface SidenavStorageProps { onUpgradeClick: () => void; upgradeLabel?: string; isLoading?: boolean; + barClassName?: string; + containerClassName?: string; + advertisement?: ReactNode; } export interface SidenavProps { @@ -145,6 +148,9 @@ const Sidenav = ({ onUpgradeClick={storage.onUpgradeClick} upgradeLabel={storage.upgradeLabel} isLoading={storage.isLoading} + barClassName={storage.barClassName} + containerClassName={storage.containerClassName} + advertisement={storage.advertisement} /> )}
diff --git a/src/components/navigation/sidenav/SidenavStorage.tsx b/src/components/navigation/sidenav/SidenavStorage.tsx index 94a8c00..20819af 100644 --- a/src/components/navigation/sidenav/SidenavStorage.tsx +++ b/src/components/navigation/sidenav/SidenavStorage.tsx @@ -1,11 +1,5 @@ -interface SidenavStorageProps { - usage: string; - limit: string; - percentage: number; - onUpgradeClick: () => void; - upgradeLabel?: string; - isLoading?: boolean; -} +import Skeleton from '../../feedback/skeleton/Skeleton'; +import { SidenavStorageProps } from './Sidenav'; const SidenavStorage = ({ usage, @@ -13,42 +7,40 @@ const SidenavStorage = ({ percentage, onUpgradeClick, upgradeLabel, - isLoading = true, -}: SidenavStorageProps): JSX.Element => { - return ( -
-
-
- {isLoading ? ( -
-
-
-
-
- ) : ( - <> -

{usage}

-

/

-

{limit}

- - )} -
- {upgradeLabel && ( - + isLoading = false, + barClassName = 'bg-gray-60', + containerClassName, + advertisement, +}: SidenavStorageProps): JSX.Element => ( +
+ {advertisement} +
+
+ {isLoading ? ( + + ) : ( + <> +

{usage}

+

/

+

{limit}

+ )}
-
-
-
+ {upgradeLabel && ( + + )} +
+
+
- ); -}; +
+); export default SidenavStorage; diff --git a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap index b944554..c135431 100644 --- a/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap +++ b/src/components/navigation/sidenav/__test__/__snapshots__/Sidenav.test.tsx.snap @@ -930,7 +930,7 @@ exports[`Sidenav Component > should match snapshot with storage 1`] = ` class="flex flex-col" >
= { + title: 'Navigation/SidenavStorage', + component: SidenavStorage, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + percentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + onUpgradeClick: { action: 'upgradeClick' }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + usage: '2.8 GB', + limit: '4 GB', + percentage: 70, + upgradeLabel: 'Upgrade', + isLoading: false, + onUpgradeClick: () => console.log('Upgrade clicked'), + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +}; + +export const HighUsage: Story = { + args: { + ...Default.args, + usage: '9.5 GB', + limit: '10 GB', + percentage: 95, + upgradeLabel: 'Upgrade now', + barClassName: 'bg-danger', + containerClassName: 'bg-alert rounded-xl border border-alert-dark gap-2', + advertisement: ( +
+ +

Buy space 85% off

+
+ ), + }, +}; + +export const LowUsage: Story = { + args: { + ...Default.args, + usage: '500 MB', + limit: '4 GB', + percentage: 12, + }, +}; + +export const Full: Story = { + args: { + ...Default.args, + usage: '10 GB', + limit: '10 GB', + percentage: 100, + }, +}; + +export const WithoutUpgradeButton: Story = { + args: { + ...Default.args, + upgradeLabel: undefined, + }, +}; diff --git a/src/stories/components/usageBanner/Banner.stories.tsx b/src/stories/components/usageBanner/Banner.stories.tsx new file mode 100644 index 0000000..399e79e --- /dev/null +++ b/src/stories/components/usageBanner/Banner.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { fn } from 'storybook/test'; +import { UsageWarningBanner } from '@/components/feedback/notifications/usageBanner'; + +const meta: Meta = { + title: 'Feedback/Banner', + component: UsageWarningBanner, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + percentage: { + control: { type: 'range', min: 0, max: 100, step: 1 }, + }, + isLoading: { + control: { type: 'boolean' }, + }, + }, + args: { + onUpgradeClick: fn(), + onCloseButtonClick: fn(), + closeButtonLabel: 'Close', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + usage: '600MB', + limit: '1GB', + percentage: 60, + barClassName: 'bg-yellow-60', + title: 'Get more space for your files', + description: ( + <> +

+ Unlock additional storage with an exclusive 85% discount on your upgrade +

+

Access advanced features like file version history, Rclone, NAS support, premium support, and more

+ + ), + upgradeLabel: 'Get offer', + isLoading: false, + }, +}; + +export const LowUsage: Story = { + args: { + ...Default.args, + usage: '800MB', + limit: '1GB', + percentage: 80, + barClassName: 'bg-orange-60', + title: 'Your storage is filling up', + description: ( + <> +

+ Upgrade today with an exclusive 85% discount and keep uploading without interruptions +

+

+ Get more storage plus advanced features like file version history, NAS support, Rclone integration, and + premium support +

+ + ), + }, +}; + +export const AlmostFull: Story = { + args: { + ...Default.args, + usage: '950MB', + limit: '1GB', + percentage: 95, + barClassName: 'bg-danger', + title: 'Your storage is almost full', + description: ( + <> +

You may soon be unable to upload new files

+

+ Upgrade now with an exclusive 85% discount to continue storing and syncing your files + without limits +

+ + ), + }, +}; + +export const Loading: Story = { + args: { + ...Default.args, + isLoading: true, + }, +};