Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@internxt/ui",
"version": "0.1.15",
"version": "0.1.16",
"description": "Library of Internxt components",
"repository": {
"type": "git",
Expand Down Expand Up @@ -98,4 +98,4 @@
"prettier --write"
]
}
}
}
67 changes: 54 additions & 13 deletions src/components/navigation/sidenav/Sidenav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { WarningIcon } from '@phosphor-icons/react';
import SidenavOptions, { SidenavOption } from './SidenavOptions';
import SidenavHeader from './SidenavHeader';
import SidenavStorage from './SidenavStorage';
Expand Down Expand Up @@ -39,6 +40,12 @@ export interface SidenavProps {
showSubsections?: boolean;
isCollapsed?: boolean;
storage?: SidenavStorageProps;
notification?: {
message: string;
actionLabel: string;
onAction: () => void;
type?: 'warning';
};
onToggleCollapse?: () => void;
}

Expand All @@ -55,6 +62,7 @@ export interface SidenavProps {
* @property {boolean} showSubsections - Determines whether to display the subsections of the sidenav
* @property {boolean} isCollapsed - Determines whether the sidenav is collapsed or not
* @property {SidenavStorage} storage - The storage information displayed at the bottom of the sidenav
* @property {object} notification - Optional structured notification rendered above the storage section (hidden when collapsed). Accepts message, actionLabel, onAction, and an optional type ('warning').
* @property {() => void} onToggleCollapse - A callback function triggered when the collapse button is clicked
*/
const Sidenav = ({
Expand All @@ -66,8 +74,24 @@ const Sidenav = ({
showSubsections,
isCollapsed = false,
storage,
notification,
onToggleCollapse,
}: SidenavProps) => {
const [showContent, setShowContent] = useState(!isCollapsed);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
if (isCollapsed) {
setShowContent(false);
} else {
timerRef.current = setTimeout(() => setShowContent(true), 300);
}
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [isCollapsed]);

return (
<div
className={`relative flex flex-col p-2 h-full justify-between bg-gray-1 border-r border-gray-10 transition-all duration-300 group ${
Expand All @@ -94,18 +118,35 @@ const Sidenav = ({
</div>
</div>

{storage && (
<div
className={`transition-all overflow-hidden duration-300 ${isCollapsed ? 'opacity-0 invisible delay-200' : 'opacity-100 delay-0'}`}
>
<SidenavStorage
usage={storage.usage}
limit={storage.limit}
percentage={storage.percentage}
onUpgradeClick={storage.onUpgradeClick}
upgradeLabel={storage.upgradeLabel}
isLoading={storage.isLoading}
/>
{(notification || storage) && showContent && (
<div className="flex flex-col">
{notification && (
<div className="px-2 pb-2">
<div className="flex gap-1.5 items-start p-3 rounded-lg bg-yellow/10 border border-yellow/20">
<WarningIcon className="size-5 shrink-0 text-yellow-dark" weight="fill" />
<div className="flex flex-col gap-0.5 min-w-0">
<p className="text-xs leading-tight text-gray-100">{notification.message}</p>
<button
type="button"
onClick={notification.onAction}
className="self-start text-xs font-medium text-primary hover:underline"
>
{notification.actionLabel}
</button>
</div>
</div>
</div>
)}
{storage && (
<SidenavStorage
usage={storage.usage}
limit={storage.limit}
percentage={storage.percentage}
onUpgradeClick={storage.onUpgradeClick}
upgradeLabel={storage.upgradeLabel}
isLoading={storage.isLoading}
/>
)}
</div>
)}
</div>
Expand Down
68 changes: 62 additions & 6 deletions src/components/navigation/sidenav/__test__/Sidenav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { render, fireEvent, act } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import '@testing-library/jest-dom';
import Sidenav, { SidenavProps } from '../Sidenav';
Expand Down Expand Up @@ -217,7 +217,7 @@ describe('Sidenav Component', () => {
});

it('hides storage when collapsed', () => {
const { getByText } = renderSidenav({
const { queryByText } = renderSidenav({
isCollapsed: true,
storage: {
usage: '2.8 GB',
Expand All @@ -228,10 +228,8 @@ describe('Sidenav Component', () => {
isLoading: false,
},
});
const usageText = getByText('2.8 GB');
const storageContainer = usageText.closest('div')?.parentElement?.parentElement?.parentElement;
expect(storageContainer).toHaveClass('opacity-0');
expect(storageContainer).toHaveClass('invisible');
expect(queryByText('2.8 GB')).not.toBeInTheDocument();
expect(queryByText('4 GB')).not.toBeInTheDocument();
});

it('hides subsections when collapsed even if showSubsections is true', () => {
Expand Down Expand Up @@ -260,6 +258,64 @@ describe('Sidenav Component', () => {
});
});

describe('Notification', () => {
const onAction = vi.fn();
const notification = {
message: 'Your account will be deleted in 14 days.',
actionLabel: 'Upgrade plan',
onAction,
};

it('should match snapshot with notification', () => {
const { container } = renderSidenav({ notification });
expect(container).toMatchSnapshot();
});

it('renders notification message and action label', () => {
const { getByText } = renderSidenav({ notification });
expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();
expect(getByText('Upgrade plan')).toBeInTheDocument();
});

it('calls onAction when action button is clicked', () => {
const { getByText } = renderSidenav({ notification });
fireEvent.click(getByText('Upgrade plan'));
expect(onAction).toHaveBeenCalledOnce();
});

it('does not render notification when isCollapsed is true', () => {
const { queryByText } = renderSidenav({ notification, isCollapsed: true });
expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();
});

it('hides notification immediately when sidenav collapses', () => {
vi.useFakeTimers();
const { getByText, queryByText, rerender } = renderSidenav({ notification, isCollapsed: false });
expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();

rerender(<Sidenav {...defaultProps} notification={notification} isCollapsed={true} />);
expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();

vi.useRealTimers();
});

it('shows notification after 300ms when sidenav expands', () => {
vi.useFakeTimers();
const { getByText, queryByText, rerender } = renderSidenav({ notification, isCollapsed: true });
expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();

rerender(<Sidenav {...defaultProps} notification={notification} isCollapsed={false} />);
expect(queryByText('Your account will be deleted in 14 days.')).not.toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(300);
});
expect(getByText('Your account will be deleted in 14 days.')).toBeInTheDocument();

vi.useRealTimers();
});
});

describe('Collapse toggle button', () => {
it('does not render collapse button when onToggleCollapse is not provided', () => {
const { container } = renderSidenav();
Expand Down
Loading
Loading