diff --git a/changelogs/unreleased/86490_Breadcrumb.json b/changelogs/unreleased/86490_Breadcrumb.json new file mode 100644 index 000000000..6ee10ee08 --- /dev/null +++ b/changelogs/unreleased/86490_Breadcrumb.json @@ -0,0 +1,5 @@ +{ + "title": "Breadcrumb: add new component", + "type": "feat", + "packages": "ui" +} diff --git a/changelogs/unreleased/86490_SearchTreeView.json b/changelogs/unreleased/86490_SearchTreeView.json new file mode 100644 index 000000000..69e522d0a --- /dev/null +++ b/changelogs/unreleased/86490_SearchTreeView.json @@ -0,0 +1,5 @@ +{ + "title": "SearchTreeView: add Breadcrumb on view", + "type": "feat", + "packages": "core" +} diff --git a/packages/core/src/components/templates/SearchTreeView/SearchTreeView.tsx b/packages/core/src/components/templates/SearchTreeView/SearchTreeView.tsx index 5b0026153..810e13056 100644 --- a/packages/core/src/components/templates/SearchTreeView/SearchTreeView.tsx +++ b/packages/core/src/components/templates/SearchTreeView/SearchTreeView.tsx @@ -22,6 +22,7 @@ import { ActionCardType, ActionType, AutoCompleteSearch, + Breadcrumb, HeaderContainer, TreeView, } from '@axelor/aos-mobile-ui'; @@ -64,6 +65,7 @@ interface SearchTreeViewProps { renderLeaf: (item: any) => any; actionList?: ActionType[]; verticalActions?: boolean; + displayBreadcrumb?: boolean; } const SearchTreeView = ({ @@ -100,13 +102,28 @@ const SearchTreeView = ({ renderLeaf, actionList, verticalActions, + displayBreadcrumb = false, }: SearchTreeViewProps) => { const I18n = useTranslator(); const dispatch = useDispatch(); const isFocused = useIsFocused(); const [filter, setFilter] = useState(null); - const [parent, setParent] = useState(null); + const [parent, setParent] = useState([]); + + const handleChangeParent = value => { + setParent(current => { + const _parent = [...current]; + + if (value) { + _parent.at(-1)?.id !== value?.id && _parent.push(value); + } else { + _parent.pop(); + } + + return _parent; + }); + }; const fetchParentSearchAPI = useCallback( ({page = 0, searchValue}) => { @@ -126,9 +143,9 @@ const SearchTreeView = ({ dispatch( sliceFunction({ ...(sliceFunctionData ?? {}), - [sliceFunctionDataParentIdName]: parent?.id, + [sliceFunctionDataParentIdName]: parent.at(-1)?.id, [sliceFunctionDataNoParentName]: - parent == null && searchValue == null, + parent.length === 0 && searchValue == null, searchValue: searchValue, page: page, }), @@ -169,8 +186,8 @@ const SearchTreeView = ({ return ( + {displayBreadcrumb && ( + ({ + title: displayParentSearchValue(item), + onPress: () => setParent(current => current.slice(0, index + 1)), + }))} + onHomePress={() => setParent([])} + /> + )} ). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import {TouchableOpacity, View} from 'react-native'; +import {shallow} from 'enzyme'; +import {Icon, Text, Breadcrumb} from '@axelor/aos-mobile-ui'; +import {getGlobalStyles} from '../../tools'; + +describe('Breadcrumb Component', () => { + const mockOnHomePress = jest.fn(); + const mockOnPress = jest.fn(); + const props = { + items: [ + {title: 'Home', onPress: mockOnPress}, + {title: 'Products'}, + {title: 'Electronics'}, + ], + onHomePress: mockOnHomePress, + }; + + it('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.exists()).toBe(true); + }); + + it('renders the home icon with correct props', () => { + const wrapper = shallow(); + const homeIcon = wrapper.find(Icon).first(); + + expect(homeIcon.exists()).toBeTruthy(); + expect(homeIcon.prop('name')).toBe('house-door-fill'); + expect(homeIcon.prop('touchable')).toBe(true); + homeIcon.simulate('press'); + expect(mockOnHomePress).toHaveBeenCalled(); + }); + + it('renders the correct number of breadcrumb items', () => { + const wrapper = shallow(); + const breadcrumbItems = wrapper.find(Text); + + expect(breadcrumbItems.length).toBe(props.items.length); + }); + + it('renders the chevron icon between breadcrumb items', () => { + const wrapper = shallow(); + const chevrons = wrapper.findWhere( + node => node.type() === Icon && node.prop('name') === 'chevron-right', + ); + + expect(chevrons.length).toBe(props.items.length); + }); + + it('renders breadcrumb items with onPress handlers when provided', () => { + const wrapper = shallow(); + const clickableItem = wrapper.findWhere( + node => + node.type() === TouchableOpacity && + node.find(Text).prop('children') === 'Home', + ); + + expect(clickableItem.exists()).toBeTruthy(); + clickableItem.simulate('press'); + expect(mockOnPress).toHaveBeenCalled(); + }); + + it('does not render onPress for items without handlers', () => { + const wrapper = shallow(); + const nonClickableItem = wrapper.findWhere( + node => node.type() === Text && node.prop('children') === 'Products', + ); + + expect(nonClickableItem.exists()).toBeTruthy(); + expect(nonClickableItem.prop('onPress')).toBeUndefined(); + }); + + it('should apply custom styles to the container when style prop is provided', () => { + const customStyle = { + fontSize: 25, + width: '90%', + height: 25, + flexDirection: 'row', + alignSelf: 'center', + }; + + const wrapper = shallow(); + + expect(getGlobalStyles(wrapper.find(View).at(0))).toMatchObject( + customStyle, + ); + }); +}); diff --git a/packages/ui/src/components/molecules/Breadcrumb/Breadcrumb.tsx b/packages/ui/src/components/molecules/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 000000000..c185c92fa --- /dev/null +++ b/packages/ui/src/components/molecules/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,124 @@ +/* + * Axelor Business Solutions + * + * Copyright (C) 2024 Axelor (). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, {useEffect, useState} from 'react'; +import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import {useThemeColor} from '../../../theme/ThemeContext'; +import {Icon, Text} from '../../atoms'; + +const ICON_WIDTH = 18; +const EMPTY_ITEM_WIDTH = 31; +const EMPTY_ITEM = {title: '...'}; + +interface Item { + title: string; + onPress?: () => void; +} + +interface BreadcrumbProps { + style?: any; + items: Item[]; + onHomePress: () => void; +} + +const Breadcrumb = ({style, items, onHomePress}: BreadcrumbProps) => { + const Colors = useThemeColor(); + + const [visibleItems, setVisibleItems] = useState(items); + const [containerWidth, setContainerWidth] = useState(0); + const [itemsWidth, setItemsWidth] = useState([]); + + useEffect(() => { + setVisibleItems(items); + }, [items]); + + const handleItemLayout = (event, index) => { + const {width} = event.nativeEvent.layout; + setItemsWidth(prevWidths => { + const newWidths = [...prevWidths]; + newWidths[index] = width; + return newWidths; + }); + }; + + useEffect(() => { + let totalWidth = itemsWidth.reduce((acc, width) => acc + width, ICON_WIDTH); + + if ( + items.length > 0 && + itemsWidth.length === items.length && + totalWidth > containerWidth + ) { + let newVisibleItems = [...items]; + + let idx = 0; + while (totalWidth > containerWidth) { + if (idx === 0) { + newVisibleItems = [EMPTY_ITEM, ...newVisibleItems.slice(1)]; + totalWidth += EMPTY_ITEM_WIDTH; + } else { + newVisibleItems = [EMPTY_ITEM, ...newVisibleItems.slice(2)]; + } + totalWidth -= itemsWidth[idx]; + idx++; + } + + setVisibleItems(newVisibleItems); + } + }, [containerWidth, items, itemsWidth]); + + return ( + setContainerWidth(e?.nativeEvent?.layout?.width)}> + + {visibleItems.map((item, index) => ( + handleItemLayout(e, index)} + key={index}> + + + {item.title} + + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '90%', + height: 25, + flexDirection: 'row', + alignSelf: 'center', + }, + contentContainer: { + flexDirection: 'row', + }, + textContainer: { + justifyContent: 'center', + }, +}); + +export default Breadcrumb; diff --git a/packages/ui/src/components/molecules/index.tsx b/packages/ui/src/components/molecules/index.tsx index aa1483c4a..3186f6f7f 100644 --- a/packages/ui/src/components/molecules/index.tsx +++ b/packages/ui/src/components/molecules/index.tsx @@ -20,6 +20,7 @@ export {default as Alert} from './Alert/Alert'; export {default as AttachmentCard} from './AttachmentCard/AttachmentCard'; export {default as Badge} from './Badge/Badge'; export {default as BlockInteractionMessage} from './BlockInteractionMessage/BlockInteractionMessage'; +export {default as Breadcrumb} from './Breadcrumb/Breadcrumb'; export {default as Button} from './Button/Button'; export {default as CardIconButton} from './CardIconButton/CardIconButton'; export {default as CardIndicator} from './CardIndicator/CardIndicator'; diff --git a/packages/ui/stories/molecules/Breadcrumb.stories.tsx b/packages/ui/stories/molecules/Breadcrumb.stories.tsx new file mode 100644 index 000000000..182ec5a4d --- /dev/null +++ b/packages/ui/stories/molecules/Breadcrumb.stories.tsx @@ -0,0 +1,53 @@ +/* + * Axelor Business Solutions + * + * Copyright (C) 2024 Axelor (). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {useEffect, useState} from 'react'; +import type {Meta} from '@storybook/react'; +import {Breadcrumb as Component} from '../../src/components'; +import {disabledControl, Story} from '../utils/control-type.helpers'; + +const meta: Meta = { + title: 'ui/molecules/Breadcrumb', + component: Component, +}; + +export default meta; + +export const Breadcrumb: Story = { + args: { + numberItems: 5, + }, + argTypes: { + items: disabledControl, + onHomePress: disabledControl, + }, + render: function ComponentRender(args) { + const [items, setItems] = useState([]); + + useEffect(() => { + setItems( + Array.from({length: args.numberItems}).map((_, idx) => ({ + title: `Title ${idx + 1}`, + onPress: () => {}, + })), + ); + }, [args]); + + return ; + }, +};