1909 object edit add icon section (#1995)
* wip * wip * wip * wip * wip * remove hardcoded values and use theme values * add styles to StyledContainer * fix iconPicker bug * wip * refactor IconPicker to include IconButton * close IconPicker on click outside * close IconPicker on escape and enter * refactor to use DropDownMenu * refactor to use DropDownMenu * modify default icon * Refactor to use useIconPicker hook * fix WithSearch story * reinitialized searchString state on close * create and update stories for the iconPicker * remove comments * use theme for gap * remove align-self * fix typo in icon * fix type any * fix merge conflicts * remove experimental css properties
This commit is contained in:
3
front/src/modules/settings/assets/ArrowRight.svg
Normal file
3
front/src/modules/settings/assets/ArrowRight.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="34" height="16" viewBox="0 0 34 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 7C0.447715 7 0 7.44772 0 8C0 8.55228 0.447715 9 1 9V7ZM33.7071 8.70711C34.0976 8.31658 34.0976 7.68342 33.7071 7.29289L27.3431 0.928932C26.9526 0.538408 26.3195 0.538408 25.9289 0.928932C25.5384 1.31946 25.5384 1.95262 25.9289 2.34315L31.5858 8L25.9289 13.6569C25.5384 14.0474 25.5384 14.6805 25.9289 15.0711C26.3195 15.4616 26.9526 15.4616 27.3431 15.0711L33.7071 8.70711ZM1 9H33V7H1V9Z" fill="#EBEBEB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
42
front/src/modules/settings/components/IconWithLabel.tsx
Normal file
42
front/src/modules/settings/components/IconWithLabel.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||
|
||||
type IconWithLabelProps = {
|
||||
Icon: IconComponent;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledSubContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
const StyledItemLabel = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-style: normal;
|
||||
font-weight: ${({ theme }) => theme.font.size.md};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.md};
|
||||
`;
|
||||
|
||||
export const IconWithLabel = ({ Icon, label }: IconWithLabelProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledSubContainer>
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
<StyledItemLabel>{label}</StyledItemLabel>
|
||||
</StyledSubContainer>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||
import { IconPicker } from '@/ui/input/components/IconPicker';
|
||||
import { H2Title } from '@/ui/typography/components/H2Title';
|
||||
|
||||
import ArrowRight from '../assets/ArrowRight.svg';
|
||||
|
||||
import { IconWithLabel } from './IconWithLabel';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const StyledArrowContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
type SettingsIconSectionProps = {
|
||||
Icon: IconComponent;
|
||||
iconKey: string;
|
||||
setIconPicker: (icon: { Icon: IconComponent; iconKey: string }) => void;
|
||||
};
|
||||
|
||||
export const SettingsIconSection = ({
|
||||
Icon,
|
||||
iconKey,
|
||||
setIconPicker,
|
||||
}: SettingsIconSectionProps) => {
|
||||
return (
|
||||
<section>
|
||||
<H2Title
|
||||
title="Icon"
|
||||
description="The icon that will be displayed in the sidebar."
|
||||
/>
|
||||
<StyledContainer>
|
||||
<IconPicker
|
||||
selectedIconKey={iconKey}
|
||||
onChange={(icon) => {
|
||||
setIconPicker({ Icon: icon.Icon, iconKey: icon.iconKey });
|
||||
}}
|
||||
/>
|
||||
<StyledArrowContainer>
|
||||
<img src={ArrowRight} alt="Arrow right" width={32} height={16} />
|
||||
</StyledArrowContainer>
|
||||
<IconWithLabel Icon={Icon} label="Workspaces" />
|
||||
</StyledContainer>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -1,28 +1,36 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { IconButton } from '@/ui/button/components/IconButton';
|
||||
import { LightIconButton } from '@/ui/button/components/LightIconButton';
|
||||
import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/dropdown/components/DropdownMenuSearchInput';
|
||||
import { StyledDropdownMenu } from '@/ui/dropdown/components/StyledDropdownMenu';
|
||||
import { StyledDropdownMenuSeparator } from '@/ui/dropdown/components/StyledDropdownMenuSeparator';
|
||||
import { useDropdown } from '@/ui/dropdown/hooks/useDropdown';
|
||||
import { DropdownScope } from '@/ui/dropdown/scopes/DropdownScope';
|
||||
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||
|
||||
import { IconApps } from '../constants/icons';
|
||||
import { DropdownMenuSkeletonItem } from '../relation-picker/components/skeletons/DropdownMenuSkeletonItem';
|
||||
import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
|
||||
|
||||
type IconPickerProps = {
|
||||
onChange: (params: { iconKey: string; Icon: IconComponent }) => void;
|
||||
selectedIconKey?: string;
|
||||
onClickOutside?: () => void;
|
||||
onClose?: () => void;
|
||||
onOpen?: () => void;
|
||||
};
|
||||
|
||||
const StyledIconPickerDropdownMenu = styled(StyledDropdownMenu)`
|
||||
const StyledContainer = styled.div`
|
||||
width: 176px;
|
||||
`;
|
||||
|
||||
const StyledMenuIconItemsContainer = styled(DropdownMenuItemsContainer)`
|
||||
const StyledMenuIconItemsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
`;
|
||||
|
||||
const StyledLightIconButton = styled(LightIconButton)<{ isSelected?: boolean }>`
|
||||
@ -33,11 +41,19 @@ const StyledLightIconButton = styled(LightIconButton)<{ isSelected?: boolean }>`
|
||||
const convertIconKeyToLabel = (iconKey: string) =>
|
||||
iconKey.replace(/[A-Z]/g, (letter) => ` ${letter}`).trim();
|
||||
|
||||
export const IconPicker = ({ onChange, selectedIconKey }: IconPickerProps) => {
|
||||
export const IconPicker = ({
|
||||
onChange,
|
||||
selectedIconKey,
|
||||
onClickOutside,
|
||||
onClose,
|
||||
onOpen,
|
||||
}: IconPickerProps) => {
|
||||
const [searchString, setSearchString] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [icons, setIcons] = useState<Record<string, IconComponent>>({});
|
||||
|
||||
const { closeDropdown } = useDropdown({ dropdownScopeId: 'icon-picker' });
|
||||
|
||||
useEffect(() => {
|
||||
import('../constants/icons').then((lazyLoadedIcons) => {
|
||||
setIcons(lazyLoadedIcons);
|
||||
@ -63,28 +79,52 @@ export const IconPicker = ({ onChange, selectedIconKey }: IconPickerProps) => {
|
||||
}, [icons, searchString, selectedIconKey]);
|
||||
|
||||
return (
|
||||
<StyledIconPickerDropdownMenu>
|
||||
<DropdownMenuSearchInput
|
||||
placeholder="Search icon"
|
||||
autoFocus
|
||||
onChange={(event) => setSearchString(event.target.value)}
|
||||
/>
|
||||
<StyledDropdownMenuSeparator />
|
||||
<StyledMenuIconItemsContainer>
|
||||
{isLoading ? (
|
||||
<DropdownMenuSkeletonItem />
|
||||
) : (
|
||||
iconKeys.map((iconKey) => (
|
||||
<StyledLightIconButton
|
||||
aria-label={convertIconKeyToLabel(iconKey)}
|
||||
isSelected={selectedIconKey === iconKey}
|
||||
size="medium"
|
||||
Icon={icons[iconKey]}
|
||||
onClick={() => onChange({ iconKey, Icon: icons[iconKey] })}
|
||||
<DropdownScope dropdownScopeId="icon-picker">
|
||||
<DropdownMenu
|
||||
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
|
||||
clickableComponent={
|
||||
<IconButton
|
||||
Icon={selectedIconKey ? icons[selectedIconKey] : IconApps}
|
||||
variant="secondary"
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<StyledContainer>
|
||||
<DropdownMenuSearchInput
|
||||
placeholder="Search icon"
|
||||
autoFocus
|
||||
onChange={(event) => setSearchString(event.target.value)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</StyledMenuIconItemsContainer>
|
||||
</StyledIconPickerDropdownMenu>
|
||||
<StyledDropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
{isLoading ? (
|
||||
<DropdownMenuSkeletonItem />
|
||||
) : (
|
||||
<StyledMenuIconItemsContainer>
|
||||
{iconKeys.map((iconKey) => (
|
||||
<StyledLightIconButton
|
||||
aria-label={convertIconKeyToLabel(iconKey)}
|
||||
isSelected={selectedIconKey === iconKey}
|
||||
size="medium"
|
||||
Icon={icons[iconKey]}
|
||||
onClick={() => {
|
||||
onChange({ iconKey, Icon: icons[iconKey] });
|
||||
closeDropdown();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</StyledMenuIconItemsContainer>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</StyledContainer>
|
||||
}
|
||||
onClickOutside={onClickOutside}
|
||||
onClose={() => {
|
||||
onClose?.();
|
||||
setSearchString('');
|
||||
}}
|
||||
onOpen={onOpen}
|
||||
></DropdownMenu>
|
||||
</DropdownScope>
|
||||
);
|
||||
};
|
||||
|
||||
@ -22,15 +22,40 @@ export const WithSelectedIcon: Story = {
|
||||
args: { selectedIconKey: 'IconCalendarEvent' },
|
||||
};
|
||||
|
||||
export const WithOpen: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const iconPickerButton = await canvas.findByRole('button');
|
||||
|
||||
userEvent.click(iconPickerButton);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOpenAndSelectedIcon: Story = {
|
||||
args: { selectedIconKey: 'IconCalendarEvent' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const iconPickerButton = await canvas.findByRole('button');
|
||||
|
||||
userEvent.click(iconPickerButton);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSearch: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const searchInput = canvas.getByRole('textbox');
|
||||
const iconPickerButton = await canvas.findByRole('button');
|
||||
|
||||
userEvent.click(iconPickerButton);
|
||||
|
||||
const searchInput = await canvas.findByRole('textbox');
|
||||
|
||||
await userEvent.type(searchInput, 'Building skyscraper');
|
||||
|
||||
await sleep(1000);
|
||||
await sleep(100);
|
||||
|
||||
const searchedIcon = canvas.getByRole('button', {
|
||||
name: 'Icon Building Skyscraper',
|
||||
@ -39,3 +64,37 @@ export const WithSearch: Story = {
|
||||
expect(searchedIcon).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSearchAndClose: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const iconPickerButton = await canvas.findByRole('button');
|
||||
|
||||
userEvent.click(iconPickerButton);
|
||||
|
||||
let searchInput = await canvas.findByRole('textbox');
|
||||
|
||||
await userEvent.type(searchInput, 'Building skyscraper');
|
||||
|
||||
await sleep(100);
|
||||
|
||||
const searchedIcon = canvas.getByRole('button', {
|
||||
name: 'Icon Building Skyscraper',
|
||||
});
|
||||
|
||||
expect(searchedIcon).toBeInTheDocument();
|
||||
|
||||
userEvent.click(searchedIcon);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
userEvent.click(iconPickerButton);
|
||||
|
||||
await sleep(100);
|
||||
|
||||
searchInput = await canvas.findByRole('textbox');
|
||||
|
||||
expect(searchInput).toHaveValue('');
|
||||
},
|
||||
};
|
||||
|
||||
13
front/src/modules/ui/input/hooks/useIconPicker.ts
Normal file
13
front/src/modules/ui/input/hooks/useIconPicker.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { iconPickerState } from '../states/iconPickerState';
|
||||
|
||||
export const useIconPicker = () => {
|
||||
const [iconPicker, setIconPicker] = useRecoilState(iconPickerState);
|
||||
|
||||
return {
|
||||
Icon: iconPicker.Icon,
|
||||
iconKey: iconPicker.iconKey,
|
||||
setIconPicker,
|
||||
};
|
||||
};
|
||||
15
front/src/modules/ui/input/states/iconPickerState.ts
Normal file
15
front/src/modules/ui/input/states/iconPickerState.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { IconComponent } from '@/ui/icon/types/IconComponent';
|
||||
|
||||
import { IconApps } from '../constants/icons';
|
||||
|
||||
type IconPickerState = {
|
||||
Icon: IconComponent;
|
||||
iconKey: string;
|
||||
};
|
||||
|
||||
export const iconPickerState = atom<IconPickerState>({
|
||||
key: 'iconPickerState',
|
||||
default: { Icon: IconApps, iconKey: 'IconApps' },
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
export enum IconPickerHotkeyScope {
|
||||
IconPicker = 'icon-picker',
|
||||
}
|
||||
@ -1,25 +1,41 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SettingsIconSection } from '@/settings/components/SettingsIconSection';
|
||||
import { objectSettingsWidth } from '@/settings/objects/constants/objectSettings';
|
||||
import { Breadcrumb } from '@/ui/breadcrumb/components/Breadcrumb';
|
||||
import { IconSettings } from '@/ui/icon';
|
||||
import { useIconPicker } from '@/ui/input/hooks/useIconPicker';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/components/SubMenuTopBarContainer';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(8)};
|
||||
height: fit-content;
|
||||
padding: ${({ theme }) => theme.spacing(8)};
|
||||
width: ${objectSettingsWidth};
|
||||
`;
|
||||
|
||||
export const SettingsNewObject = () => (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<StyledContainer>
|
||||
<Breadcrumb
|
||||
links={[
|
||||
{ children: 'Objects', href: '/settings/objects' },
|
||||
{ children: 'New' },
|
||||
]}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
export const SettingsNewObject = () => {
|
||||
const { Icon, iconKey, setIconPicker } = useIconPicker();
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
|
||||
<StyledContainer>
|
||||
<Breadcrumb
|
||||
links={[
|
||||
{ children: 'Objects', href: '/settings/objects' },
|
||||
{ children: 'New' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SettingsIconSection
|
||||
Icon={Icon}
|
||||
iconKey={iconKey}
|
||||
setIconPicker={setIconPicker}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user