diff --git a/front-packages/akeneo-design-system/example/src/__snapshots__/App.test.js.snap b/front-packages/akeneo-design-system/example/src/__snapshots__/App.test.js.snap index e4db26d74dda..d2ac74552048 100644 --- a/front-packages/akeneo-design-system/example/src/__snapshots__/App.test.js.snap +++ b/front-packages/akeneo-design-system/example/src/__snapshots__/App.test.js.snap @@ -5,7 +5,7 @@ exports[`renders the expected elements 1`] = `
Success diff --git a/front-packages/akeneo-design-system/jest-puppeteer.config.js b/front-packages/akeneo-design-system/jest-puppeteer.config.js index a8453deb5356..2424d597b124 100644 --- a/front-packages/akeneo-design-system/jest-puppeteer.config.js +++ b/front-packages/akeneo-design-system/jest-puppeteer.config.js @@ -2,6 +2,7 @@ module.exports = { launch: { dumpio: true, headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'] }, server: { command: 'yarn http-server storybook-static -p 6006', diff --git a/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-list-correctly-1-snap.png b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-list-correctly-1-snap.png new file mode 100644 index 000000000000..9b3c79354d0a Binary files /dev/null and b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-list-correctly-1-snap.png differ diff --git a/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-size-correctly-1-snap.png b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-size-correctly-1-snap.png new file mode 100644 index 000000000000..1e69520884d1 Binary files /dev/null and b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-avatar-size-correctly-1-snap.png differ diff --git a/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-background-colors-correctly-1-snap.png b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-background-colors-correctly-1-snap.png new file mode 100644 index 000000000000..4f63adac7037 Binary files /dev/null and b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-background-colors-correctly-1-snap.png differ diff --git a/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-standard-correctly-1-snap.png b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-standard-correctly-1-snap.png new file mode 100644 index 000000000000..84053f15b813 Binary files /dev/null and b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-standard-correctly-1-snap.png differ diff --git a/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-with-image-correctly-1-snap.png b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-with-image-correctly-1-snap.png new file mode 100644 index 000000000000..d5ac6fa368fc Binary files /dev/null and b/front-packages/akeneo-design-system/src/__image_snapshots__/all-visual-tsx-visual-tests-renders-components-avatar-with-image-correctly-1-snap.png differ diff --git a/front-packages/akeneo-design-system/src/components/Avatar/Avatar.stories.mdx b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.stories.mdx new file mode 100644 index 000000000000..0ec0bfd22941 --- /dev/null +++ b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.stories.mdx @@ -0,0 +1,147 @@ +import {Meta, Story, ArgsTable, Canvas} from '@storybook/addon-docs'; +import {Avatar} from './Avatar.tsx'; +import {Avatars} from './Avatars.tsx'; + + + +# Avatar + +## Usage + +This component is used to display users avatars. If no avatar is available, first letters of the first and last name are +displayed with a dedicated color. + +## Playground + + + + {args => { + return ; + }} + + + + + +## Variation on background colors + +The background color is based from the username. + + + + {args => { + return ( + <> + + + + + + + + + + + + + + ); + }} + + + +## Variation with image + + + + {args => { + return ( + <> + + + ); + }} + + + +## Variation on List + +You can use a dedicated component to display avatar list. After a defined maximum, other avatars are not displayed. + + + + {args => { + return ( + <> + + + + + + + + + + + + + + + + ); + }} + + + +## Variation on Size + + + + {args => { + return ( + <> + + + + ); + }} + + diff --git a/front-packages/akeneo-design-system/src/components/Avatar/Avatar.tsx b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.tsx new file mode 100644 index 000000000000..7d79ef3399ac --- /dev/null +++ b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.tsx @@ -0,0 +1,102 @@ +import React, {useMemo} from 'react'; +import styled, {css} from 'styled-components'; +import {useTheme} from '../../hooks'; +import {Override} from '../../shared'; +import {AkeneoThemedProps, getColor} from '../../theme'; + +const AvatarContainer = styled.span` + ${({size}) => + size === 'default' + ? css` + height: 32px; + width: 32px; + line-height: 32px; + font-size: 15px; + border-radius: 32px; + ` + : css` + height: 140px; + width: 140px; + line-height: 140px; + font-size: 66px; + border-radius: 140px; + `} + display: inline-block; + color: ${getColor('white')}; + text-align: center; + background-position: center; + background-repeat: no-repeat; + background-size: cover; + text-transform: uppercase; + cursor: ${({onClick}) => (onClick ? 'pointer' : 'default')}; +`; + +type AvatarProps = Override< + React.HTMLAttributes, + { + /** + * Username to use as fallback if the avatar is not provided and the Firstname and Lastname are empty. + */ + username: string; + + /** + * Firstname to use as fallback with the Lastname if the avatar is not provided. + */ + firstName: string; + + /** + * Lastname to use as fallback with the Firstname if the avatar is not provided. + */ + lastName: string; + + /** + * Url of the avatar image. + */ + avatarUrl?: string; + + /** + * Size of the avatar. + */ + size?: 'default' | 'big'; + } +>; + +const Avatar = ({username, firstName, lastName, avatarUrl, size = 'default', ...rest}: AvatarProps) => { + const theme = useTheme(); + + const fallback = ( + firstName.trim().charAt(0) + lastName.trim().charAt(0) || username.substring(0, 2) + ).toLocaleUpperCase(); + const title = `${firstName} ${lastName}`.trim() || username; + + const backgroundColor = useMemo(() => { + const colorId = username.split('').reduce((s, l) => s + l.charCodeAt(0), 0); + const colors = [ + theme.colorAlternative.green120, + theme.colorAlternative.darkCyan120, + theme.colorAlternative.forestGreen120, + theme.colorAlternative.oliveGreen120, + theme.colorAlternative.blue120, + theme.colorAlternative.darkBlue120, + theme.colorAlternative.hotPink120, + theme.colorAlternative.red120, + theme.colorAlternative.coralRed120, + theme.colorAlternative.yellow120, + theme.colorAlternative.orange120, + theme.colorAlternative.chocolate120, + ]; + + return colors[colorId % colors.length]; + }, [theme, username]); + + const style = avatarUrl ? {backgroundImage: `url(${avatarUrl})`} : {backgroundColor}; + + return ( + + {avatarUrl ? '' : fallback} + + ); +}; + +export {Avatar}; +export type {AvatarProps}; diff --git a/front-packages/akeneo-design-system/src/components/Avatar/Avatar.unit.tsx b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.unit.tsx new file mode 100644 index 000000000000..816f0397f650 --- /dev/null +++ b/front-packages/akeneo-design-system/src/components/Avatar/Avatar.unit.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import {render, screen} from '../../storybook/test-util'; +import {Avatar} from './Avatar'; + +test('renders', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toBeInTheDocument(); +}); + +test('avatar image', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toHaveStyle('background-image: url(path/to/image)'); +}); + +test('deterministic fallback color', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toHaveStyle('background-color: rgb(68, 31, 0)'); +}); + +test('fallback to firstname + lastname', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toHaveTextContent('JD'); +}); + +test('fallback to firstname only', () => { + render(); + + const avatar = screen.getByTitle('John'); + expect(avatar).toHaveTextContent('J'); +}); + +test('fallback to lastname only', () => { + render(); + + const avatar = screen.getByTitle('Doe'); + expect(avatar).toHaveTextContent('D'); +}); + +test('fallback to username', () => { + render(); + + const avatar = screen.getByTitle('john'); + expect(avatar).toHaveTextContent('JO'); +}); + +test('initial are converted to uppercase', () => { + render(); + + const avatar = screen.getByTitle('john doe'); + expect(avatar).toHaveTextContent('JD'); +}); + +test('size default', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toHaveStyle('width: 32px'); +}); + +test('size big', () => { + render(); + + const avatar = screen.getByTitle('John Doe'); + expect(avatar).toHaveStyle('width: 140px'); +}); + +test('supports ...rest props', () => { + render(); + + expect(screen.getByTestId('my_value')).toBeInTheDocument(); +}); diff --git a/front-packages/akeneo-design-system/src/components/Avatar/Avatars.tsx b/front-packages/akeneo-design-system/src/components/Avatar/Avatars.tsx new file mode 100644 index 000000000000..dbd5a1178d82 --- /dev/null +++ b/front-packages/akeneo-design-system/src/components/Avatar/Avatars.tsx @@ -0,0 +1,50 @@ +import React, {Children} from 'react'; +import styled from 'styled-components'; +import {Override} from '../../shared'; +import {AkeneoThemedProps, getColor} from '../../theme'; + +const AvatarListContainer = styled.div` + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + & > * { + margin-right: -4px; + position: relative; + } +`; + +const RemainingAvatar = styled.span` + height: 32px; + width: 32px; + display: inline-block; + border: 1px solid ${getColor('grey', 10)}; + line-height: 32px; + text-align: center; + font-size: 15px; + border-radius: 32px; + background-color: ${getColor('white')}; +`; + +type AvatarsProps = Override< + React.HTMLAttributes, + { + max: number; + } +>; + +const Avatars = ({max, children, ...rest}: AvatarsProps) => { + const childrenArray = Children.toArray(children); + const displayedChildren = childrenArray.slice(0, max); + const remainingChildrenCount = childrenArray.length - max; + const reverseChildren = displayedChildren.reverse(); + + return ( + + {remainingChildrenCount > 0 && +{remainingChildrenCount}} + {reverseChildren} + + ); +}; + +export {Avatars}; +export type {AvatarsProps}; diff --git a/front-packages/akeneo-design-system/src/components/Avatar/Avatars.unit.tsx b/front-packages/akeneo-design-system/src/components/Avatar/Avatars.unit.tsx new file mode 100644 index 000000000000..2f8ac9ea39b4 --- /dev/null +++ b/front-packages/akeneo-design-system/src/components/Avatar/Avatars.unit.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {render, screen} from '../../storybook/test-util'; +import {Avatar} from './Avatar'; +import {Avatars} from './Avatars'; + +test('renders multiple avatars', () => { + render( + + + + + ); + + expect(screen.getByTitle('John Doe')).toBeInTheDocument(); + expect(screen.getByTitle('Leonard Doe')).toBeInTheDocument(); +}); + +test('renders a maximum number of avatars', () => { + render( + + + + + ); + + expect(screen.getByTitle('John Doe')).toBeInTheDocument(); + expect(screen.queryByTitle('Leonard Doe')).not.toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); +}); + +test('renders no avatar', () => { + render(); + + expect(screen.queryByTitle('+1')).not.toBeInTheDocument(); +}); + +test('supports ...rest props', () => { + render(); + + expect(screen.getByTestId('my_value')).toBeInTheDocument(); +}); diff --git a/front-packages/akeneo-design-system/src/components/Input/BooleanInput/BooleanInput.tsx b/front-packages/akeneo-design-system/src/components/Input/BooleanInput/BooleanInput.tsx index 214cd7e077e1..f5b78c4172aa 100644 --- a/front-packages/akeneo-design-system/src/components/Input/BooleanInput/BooleanInput.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/BooleanInput/BooleanInput.tsx @@ -149,7 +149,7 @@ type BooleanInputProps = Override< clearLabel?: string; } ) & { - readOnly: boolean; + readOnly?: boolean; yesLabel: string; noLabel: string; invalid?: boolean; @@ -165,7 +165,7 @@ const BooleanInput = React.forwardRef( ( { value, - readOnly, + readOnly = false, onChange, clearable = false, yesLabel, diff --git a/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.tsx b/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.tsx index 05d0b6b42538..7b196793ecd3 100644 --- a/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.tsx @@ -150,17 +150,24 @@ type MultiMultiSelectInputProps = Override< * Callback called when the user hit enter on the field. */ onSubmit?: () => void; - - /** - * Handler called when the next page is almost reached. - */ - onNextPage?: () => void; - - /** - * Handler called when the search value changed - */ - onSearchChange?: (searchValue: string) => void; - } + } & ( + | { + /** + * Handler called when the next page is almost reached. + */ + onNextPage?: () => void; + /** + * Handler called when the search value changed + */ + onSearchChange?: (searchValue: string) => void; + disableInternalSearch?: false; + } + | { + onNextPage: () => void; + onSearchChange: (searchValue: string) => void; + disableInternalSearch: true; + } + ) >; /** @@ -183,6 +190,7 @@ const MultiSelectInput = ({ verticalPosition, onNextPage, onSearchChange, + disableInternalSearch = false, 'aria-labelledby': ariaLabelledby, ...rest }: MultiMultiSelectInputProps) => { @@ -211,12 +219,14 @@ const MultiSelectInput = ({ return indexedChips; }, {}); - const filteredChildren = validChildren.filter(({props}) => { - const childValue = props.value; - const optionValue = childValue + props.children; + const filteredChildren = disableInternalSearch + ? validChildren + : validChildren.filter(({props}) => { + const childValue = props.value; + const optionValue = childValue + props.children; - return !value.includes(childValue) && optionValue.toLowerCase().includes(searchValue.toLowerCase()); - }); + return !value.includes(childValue) && optionValue.toLowerCase().includes(searchValue.toLowerCase()); + }); const handleEnter = () => { if (filteredChildren.length > 0 && dropdownIsOpen) { diff --git a/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.unit.tsx b/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.unit.tsx index c0e9ff2438ed..b7ae72c0aa30 100644 --- a/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.unit.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/MultiSelectInput/MultiSelectInput.unit.tsx @@ -77,6 +77,51 @@ test('it handles search', () => { expect(onChange).toHaveBeenCalledTimes(2); }); +test('it handles external search', () => { + const onChange = jest.fn(); + const onNextPage = jest.fn(); + const onSearchChange = jest.fn(); + + const observe = jest.fn(); + const unobserve = jest.fn(); + window.IntersectionObserver = jest.fn(() => ({ + observe, + unobserve, + })) as unknown as jest.Mock; + + render( + + English + French + German + Spanish + + ); + + const input = screen.getByRole('textbox'); + fireEvent.click(input); + fireEvent.change(input, {target: {value: 'Fr'}}); + + const germanOption = screen.queryByText('German'); + expect(germanOption).toBeInTheDocument(); + const usOption = screen.queryByText('English'); + expect(usOption).toBeInTheDocument(); + const spanishOption = screen.queryByText('Spanish'); + expect(spanishOption).toBeInTheDocument(); + const frenchOption = screen.getByText('French'); + expect(frenchOption).toBeInTheDocument(); +}); + test('it handles empty cases', () => { const onChange = jest.fn(); render( diff --git a/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.tsx b/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.tsx index f83c1aa897c3..cc675ffddfe1 100644 --- a/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.tsx @@ -178,17 +178,24 @@ type SelectInputProps = Override< * Force the vertical position of the overlay. */ verticalPosition?: VerticalPosition; - - /** - * Handler called when the next page is almost reached. - */ - onNextPage?: () => void; - - /** - * Handler called when the search value changed - */ - onSearchChange?: (searchValue: string) => void; - } + } & ( + | { + /** + * Handler called when the next page is almost reached. + */ + onNextPage?: () => void; + /** + * Handler called when the search value changed + */ + onSearchChange?: (searchValue: string) => void; + disableInternalSearch?: false; + } + | { + onNextPage: () => void; + onSearchChange: (searchValue: string) => void; + disableInternalSearch: true; + } + ) >; /** @@ -209,6 +216,7 @@ const SelectInput = ({ verticalPosition, onNextPage, onSearchChange, + disableInternalSearch = false, 'aria-labelledby': ariaLabelledby, ...rest }: SelectInputProps) => { @@ -235,14 +243,16 @@ const SelectInput = ({ return optionCodes; }, []); - const filteredChildren = validChildren.filter(child => { - const content = typeof child.props.children === 'string' ? child.props.children : ''; - const title = child.props.title ?? ''; - const value = child.props.value; - const optionValue = value + content + title; + const filteredChildren = disableInternalSearch + ? validChildren + : validChildren.filter(child => { + const content = typeof child.props.children === 'string' ? child.props.children : ''; + const title = child.props.title ?? ''; + const value = child.props.value; + const optionValue = value + content + title; - return optionValue.toLowerCase().includes(searchValue.toLowerCase()); - }); + return optionValue.toLowerCase().includes(searchValue.toLowerCase()); + }); const currentValueElement = validChildren.find(child => { diff --git a/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.unit.tsx b/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.unit.tsx index b27153f57588..da1abe9ae486 100644 --- a/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.unit.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/SelectInput/SelectInput.unit.tsx @@ -95,6 +95,58 @@ test('it handles search', () => { expect(onChange).toHaveBeenCalledWith('es_ES'); }); +test('it handles external search', () => { + const onChange = jest.fn(); + const onNextPage = jest.fn(); + const onSearchChange = jest.fn(); + + const observe = jest.fn(); + const unobserve = jest.fn(); + window.IntersectionObserver = jest.fn(() => ({ + observe, + unobserve, + })) as unknown as jest.Mock; + + render( + + + + + + Français + + + + + + + + + ); + + const input = screen.getByRole('textbox'); + fireEvent.click(input); + fireEvent.change(input, {target: {value: 'Français'}}); + + const germanOption = screen.queryByText('German'); + expect(germanOption).toBeInTheDocument(); + const usOption = screen.queryByText('English'); + expect(usOption).toBeInTheDocument(); + const spanishOption = screen.queryByText('Spanish'); + expect(spanishOption).toBeInTheDocument(); + const frenchOption = screen.getByText('Français'); + expect(frenchOption).toBeInTheDocument(); +}); + test('it handles empty cases', () => { const onChange = jest.fn(); render( diff --git a/front-packages/akeneo-design-system/src/components/Input/TableInput/TableInputRow/TableInputRow.tsx b/front-packages/akeneo-design-system/src/components/Input/TableInput/TableInputRow/TableInputRow.tsx index f694f095f46d..a01608ff78a0 100644 --- a/front-packages/akeneo-design-system/src/components/Input/TableInput/TableInputRow/TableInputRow.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/TableInput/TableInputRow/TableInputRow.tsx @@ -96,15 +96,10 @@ const TableInputTr = styled.tr< height: 0; margin-top: -1px; } - &:has(div) { - background: red !important; - } border-bottom-color: ${getColor('blue', 100)}; - &:first-child { - border-left: 1px solid ${getColor('blue', 100)}; - } + border-left-color: ${getColor('blue', 100)}; &:last-child { - border-right: 1px solid ${getColor('blue', 100)}; + border-right-color: ${getColor('blue', 100)}; } } `} diff --git a/front-packages/akeneo-design-system/src/components/Input/TagInput/TagInput.tsx b/front-packages/akeneo-design-system/src/components/Input/TagInput/TagInput.tsx index 1a6a21392b3b..8d1994ae5d3b 100644 --- a/front-packages/akeneo-design-system/src/components/Input/TagInput/TagInput.tsx +++ b/front-packages/akeneo-design-system/src/components/Input/TagInput/TagInput.tsx @@ -55,12 +55,12 @@ const TagText = styled.span` white-space: nowrap; `; -const InputContainer = styled.li` +const InputContainer = styled.li` list-style-type: none; color: ${getColor('grey', 120)}; border: 0; flex: 1; - padding: 0; + padding: ${({hasTags}) => (hasTags ? '0' : '0 11px')}; align-items: center; display: flex; @@ -255,7 +255,7 @@ const TagInput: FC = ({ ); })} - + 0}>