Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
5 changes: 5 additions & 0 deletions .github/workflows/deploy-storybook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v2

- name: Set Git user identity
run: |
git config --global user.email "[email protected]"
git config --global user.name "GitHub Actions"

- name: Setup Node.js
uses: actions/setup-node@v2
with:
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^1.2.25",
"@internxt/css-config": "^1.0.2",
"@internxt/eslint-config-internxt": "^1.0.9",
"@internxt/prettier-config": "^1.0.2",
"@storybook/addon-essentials": "^8.0.4",
Expand All @@ -32,7 +33,6 @@
"@storybook/addon-onboarding": "^8.0.4",
"@storybook/addon-themes": "^8.0.4",
"@storybook/blocks": "^8.0.4",
"@internxt/css-config": "^1.0.2",
"@storybook/react": "^8.0.4",
"@storybook/react-vite": "^8.0.4",
"@storybook/test": "^8.0.4",
Expand Down Expand Up @@ -89,7 +89,9 @@
"dependencies": {
"@phosphor-icons/react": "^2.1.5",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/themes": "^3.0.0"
"@radix-ui/themes": "^3.0.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1"
},
"lint-staged": {
"*.{ts,tsx}": [
Expand Down
143 changes: 143 additions & 0 deletions src/components/breadcrumbs/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { CaretRight, DotsThree } from '@phosphor-icons/react';
import { forwardRef, FunctionComponent, ReactNode, SVGProps } from 'react';
import { Dispatch } from 'redux';
import Dropdown from '../dropdown/Dropdown';
import { DropTargetMonitor } from 'react-dnd';
import BreadcrumbsItem, { BreadcrumbItemData, BreadcrumbsMenuProps } from './BreadcrumbsItem';

export interface BreadcrumbsProps<T extends Dispatch> {
items: BreadcrumbItemData[];
rootBreadcrumbItemDataCy?: string;
menu?: (props: BreadcrumbsMenuProps) => JSX.Element;
namePath: {
name: string;
uuid: string;
}[];
isSomeItemSelected: boolean;
selectedItems: [];
onItemDropped: (
item: BreadcrumbItemData,
namePath: {
name: string;
uuid: string;
}[],
isSomeItemSelected: boolean,
selectedItems: [],
dispatch: T,
) => (draggedItem: unknown, monitor: DropTargetMonitor) => Promise<void>;
canItemDrop: (
item: BreadcrumbItemData,
) => (draggedItem: unknown, monitor: DropTargetMonitor<unknown, unknown>) => boolean;
itemComponent?: FunctionComponent<SVGProps<SVGSVGElement>>;
acceptedTypes: string[];
dispatch: T;
}

const Breadcrumbs = <T extends Dispatch>(props: Readonly<BreadcrumbsProps<T>>): JSX.Element => {
const MenuItem = forwardRef<HTMLDivElement, { children: ReactNode }>((props, ref) => {
return (
<div
ref={ref}
className="flex cursor-pointer items-center hover:bg-gray-5 hover:text-gray-80 dark:hover:bg-gray-10"
>
{props.children}
</div>
);
});

const getItemsList = (): JSX.Element[] => {
const items = props.items;
const itemsList = [] as JSX.Element[];
const hiddenItemsList = [] as JSX.Element[];
const breadcrumbSeparator = (key: React.Key) => {
return (
<div key={key} className="text-dgray-50 flex items-center">
<CaretRight weight="bold" className="h-4 w-4" data-testid="caret-right" />
</div>
);
};

for (let i = 0; i < items.length; i++) {
const separatorKey = 'breadcrumbSeparator-' + items[i].uuid + i.toString();
const itemKey = 'breadcrumbItem-' + items[i].uuid + i.toString();

if (items.length > 3 && i !== 0 && i < items.length - 2) {
if (i === 1) {
itemsList.push(breadcrumbSeparator(separatorKey));
}
hiddenItemsList.push(
<MenuItem>
<BreadcrumbsItem
key={itemKey}
item={items[i]}
isHiddenInList
totalBreadcrumbsLength={items.length}
items={items}
namePath={props.namePath}
isSomeItemSelected={props.isSomeItemSelected}
selectedItems={props.selectedItems}
onItemDropped={props.onItemDropped}
canItemDrop={props.canItemDrop}
itemComponent={props.itemComponent}
acceptedTypes={props.acceptedTypes}
dispatch={props.dispatch}
/>
</MenuItem>,
);
} else {
itemsList.push(
<BreadcrumbsItem
breadcrumbButtonDataCy={i === 0 ? props?.rootBreadcrumbItemDataCy : undefined}
key={itemKey}
item={items[i]}
totalBreadcrumbsLength={items.length}
items={items}
menu={props.menu}
namePath={props.namePath}
isSomeItemSelected={props.isSomeItemSelected}
selectedItems={props.selectedItems}
onItemDropped={props.onItemDropped}
canItemDrop={props.canItemDrop}
acceptedTypes={props.acceptedTypes}
dispatch={props.dispatch}
/>,
);
if (i < items.length - 1) {
itemsList.push(breadcrumbSeparator(separatorKey));
}
}
}

if (hiddenItemsList.length > 0) {
const menu = (
<Dropdown
key="breadcrumbDropdownItems"
openDirection="left"
classMenuItems="left-0 top-1 w-max max-h-80 overflow-y-auto
rounded-md border border-gray-10 bg-surface dark:bg-gray-5 shadow-subtle-hard z-10"
menuItems={hiddenItemsList}
>
{({ open }: { open: boolean }) => {
return (
<div
className={`flex h-8 w-8 items-center justify-center
rounded-full text-gray-60 transition-all duration-75 ease-in-out hover:bg-gray-5 hover:text-gray-80 ${
open ? 'bg-gray-5' : ''
}`}
>
<DotsThree weight="bold" className="h-5 w-5" />
</div>
);
}}
</Dropdown>
);
itemsList.splice(2, 0, menu);
}

return itemsList;
};

return <div className="flex w-full items-center">{getItemsList()}</div>;
};

export default Breadcrumbs;
116 changes: 116 additions & 0 deletions src/components/breadcrumbs/BreadcrumbsItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { DropTargetMonitor, useDrop } from 'react-dnd';
import { Dispatch } from 'redux';
import { FunctionComponent, SVGProps } from 'react';

export interface BreadcrumbItemData {
uuid: string;
label: string;
icon: JSX.Element | null;
active: boolean;
isFirstPath?: boolean;
dialog?: boolean;
isBackup?: boolean;
onClick?: () => void;
}

export interface BreadcrumbsMenuProps {
item: BreadcrumbItemData;
items: BreadcrumbItemData[];
onItemClicked: (item: BreadcrumbItemData) => void;
}

export interface BreadcrumbsItemProps<T extends Dispatch> {
item: BreadcrumbItemData;
totalBreadcrumbsLength: number;
isHiddenInList?: boolean;
items: BreadcrumbItemData[];
breadcrumbButtonDataCy?: string;
menu?: (props: BreadcrumbsMenuProps) => JSX.Element;
namePath: {
name: string;
uuid: string;
}[];
isSomeItemSelected: boolean;
selectedItems: [];
onItemDropped: (
item: BreadcrumbItemData,
namePath: {
name: string;
uuid: string;
}[],
isSomeItemSelected: boolean,
selectedItems: [],
dispatch: T,
) => (draggedItem: unknown, monitor: DropTargetMonitor) => Promise<void>;
canItemDrop: (
item: BreadcrumbItemData,
) => (draggedItem: unknown, monitor: DropTargetMonitor<unknown, unknown>) => boolean;
itemComponent?: FunctionComponent<SVGProps<SVGSVGElement>>;
acceptedTypes: string[];
dispatch: T;
}

const BreadcrumbsItem = <T extends Dispatch>(props: BreadcrumbsItemProps<T>): JSX.Element => {
const [{ isOver, canDrop }, drop] = useDrop(
() => ({
accept: props.acceptedTypes,
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
canDrop: props.canItemDrop(props.item),
drop: props.onItemDropped(
props.item,
props.namePath,
props.isSomeItemSelected,
props.selectedItems,
props.dispatch,
),
}),
[props.selectedItems],
);

const onItemClicked = (item: BreadcrumbItemData): void => {
if (item.active) {
item.onClick && item.onClick();
}
};
const isDraggingOverClassNames = isOver && canDrop ? 'drag-over-effect' : '';

return (
<>
{!props.item.active && !props.item.dialog && props.menu ? (
<props.menu item={props.item} items={props.items} onItemClicked={onItemClicked} />
) : (
<div
ref={drop}
className={`flex ${props.isHiddenInList ? 'w-full' : 'max-w-fit'} ${
props.item.isFirstPath ? 'shrink-0 pr-1' : 'min-w-breadcrumb flex-1 px-1.5 py-1.5'
} cursor-pointer flex-row items-center truncate font-medium ${isDraggingOverClassNames}
${
!props.item.active || (props.item.isFirstPath && props.totalBreadcrumbsLength === 1)
? 'text-gray-80'
: 'text-gray-50 hover:text-gray-80'
}`}
key={props.item.uuid}
onClick={() => onItemClicked(props.item)}
onKeyDown={() => {}}
data-cy={props?.breadcrumbButtonDataCy}
>
{props.itemComponent && <props.itemComponent className="h-5 w-5" />}
{props.item.icon ? props.item.icon : null}
{props.item.label ? (
<span
className={`max-w-sm flex-1 cursor-pointer truncate ${props.isHiddenInList && 'pl-3 text-base'}`}
title={props.item.label}
>
{props.item.label}
</span>
) : null}
</div>
)}
</>
);
};

export default BreadcrumbsItem;
96 changes: 96 additions & 0 deletions src/components/breadcrumbs/__test__/Breadcrumbs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { act } from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Breadcrumbs, { BreadcrumbsProps } from '../Breadcrumbs';
import { Dispatch, AnyAction } from 'redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

describe('Breadcrumbs Component', () => {
const mockDispatch: Dispatch<AnyAction> = vi.fn();
const mockProps: BreadcrumbsProps<typeof mockDispatch> = {
items: [
{
uuid: '1',
label: 'Home',
icon: null,
active: false,
},
{
uuid: '2',
label: 'Category',
icon: null,
active: false,
},
{
uuid: '3',
label: 'Subcategory',
icon: null,
active: false,
},
{
uuid: '4',
label: 'Product',
icon: null,
active: false,
},
],
rootBreadcrumbItemDataCy: 'breadcrumb-root',
namePath: [
{ name: 'Home', uuid: '1' },
{ name: 'Category', uuid: '2' },
],
isSomeItemSelected: false,
selectedItems: [],
onItemDropped: vi.fn(),
canItemDrop: vi.fn(),
itemComponent: undefined,
acceptedTypes: ['type1', 'type2'],
dispatch: vi.fn(),
};

afterEach(() => {
vi.clearAllMocks();
});

const renderBreadcrumbs = () =>
render(
<DndProvider backend={HTML5Backend}>
<Breadcrumbs {...mockProps} />
</DndProvider>,
);

it('should match snapshot', () => {
const breadcrumbs = renderBreadcrumbs();
expect(breadcrumbs).toMatchSnapshot();
});

it('renders a dropdown menu when more than 4 items are provided', () => {
const { getByTestId } = renderBreadcrumbs();
const dropdown = getByTestId('menu-dropdown');
expect(dropdown).toBeInTheDocument();
});

it('renders a dropdown menu for hidden items when there are more than 3 items', () => {
const { getByRole } = renderBreadcrumbs();
const dropdownButton = getByRole('button');
expect(dropdownButton).toBeInTheDocument();
});

it('renders separators between breadcrumb items', () => {
const { getAllByTestId } = renderBreadcrumbs();
const separators = getAllByTestId('caret-right');
expect(separators).toHaveLength(3);
});

it('opens the dropdown menu when clicked', async () => {
const { getByRole, getByText } = renderBreadcrumbs();
const dropdownButton = getByRole('button');
await act(async () => {
userEvent.click(dropdownButton);
});
const hiddenItem = getByText('Subcategory');
expect(hiddenItem).toBeInTheDocument();
});
});
Loading