mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	Added CurrencyFieldInput design (#4254)
* #4123 CurrencyFieldInput design is ready * resolved comment and currency code * resolved design comment --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
		| @@ -1,5 +1,5 @@ | ||||
| import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; | ||||
| import { TextInput } from '@/ui/field/input/components/TextInput'; | ||||
| import { CurrencyInput } from '@/ui/field/input/components/CurrencyInput'; | ||||
|  | ||||
| import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay'; | ||||
| import { useCurrencyField } from '../../hooks/useCurrencyField'; | ||||
| @@ -79,10 +79,18 @@ export const CurrencyFieldInput = ({ | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const handleSelect = (newValue: string) => { | ||||
|     setDraftValue({ | ||||
|       amount: draftValue?.amount ?? '', | ||||
|       currencyCode: newValue as CurrencyCode, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <FieldInputOverlay> | ||||
|       <TextInput | ||||
|       <CurrencyInput | ||||
|         value={draftValue?.amount?.toString() ?? ''} | ||||
|         currencyCode={draftValue?.currencyCode ?? CurrencyCode.USD} | ||||
|         autoFocus | ||||
|         placeholder="Currency" | ||||
|         onClickOutside={handleClickOutside} | ||||
| @@ -90,8 +98,9 @@ export const CurrencyFieldInput = ({ | ||||
|         onEscape={handleEscape} | ||||
|         onShiftTab={handleShiftTab} | ||||
|         onTab={handleTab} | ||||
|         hotkeyScope={hotkeyScope} | ||||
|         onChange={handleChange} | ||||
|         onSelect={handleSelect} | ||||
|         hotkeyScope={hotkeyScope} | ||||
|       /> | ||||
|     </FieldInputOverlay> | ||||
|   ); | ||||
|   | ||||
| @@ -1,4 +1,3 @@ | ||||
| import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; | ||||
| import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; | ||||
| import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; | ||||
| import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; | ||||
| @@ -31,7 +30,7 @@ export const computeDraftValueFromFieldValue = <FieldValue>({ | ||||
|  | ||||
|     return { | ||||
|       amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '', | ||||
|       currenyCode: CurrencyCode.USD, | ||||
|       currencyCode: fieldValue?.currencyCode ?? '', | ||||
|     } as unknown as FieldInputDraftValue<FieldValue>; | ||||
|   } | ||||
|   if (isFieldRelation(fieldDefinition)) { | ||||
|   | ||||
| @@ -0,0 +1,152 @@ | ||||
| import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { useTheme } from '@emotion/react'; | ||||
| import styled from '@emotion/styled'; | ||||
|  | ||||
| import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; | ||||
| import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes'; | ||||
| import { IconComponent } from '@/ui/display/icon/types/IconComponent'; | ||||
| import { CurrencyPickerDropdownButton } from '@/ui/input/components/internal/currency/components/CurrencyPickerDropdownButton'; | ||||
| import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle'; | ||||
|  | ||||
| export const StyledInput = styled.input` | ||||
|   margin: 0; | ||||
|   ${TEXT_INPUT_STYLE} | ||||
|   width: 100%; | ||||
|   padding: ${({ theme }) => `${theme.spacing(0)} ${theme.spacing(1)}`}; | ||||
| `; | ||||
|  | ||||
| const StyledContainer = styled.div` | ||||
|   align-items: center; | ||||
|  | ||||
|   border: none; | ||||
|   border-radius: ${({ theme }) => theme.border.radius.sm}; | ||||
|   box-shadow: ${({ theme }) => theme.boxShadow.strong}; | ||||
|  | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| `; | ||||
|  | ||||
| const StyledIcon = styled.div` | ||||
|   align-items: center; | ||||
|   display: flex; | ||||
|  | ||||
|   & > svg { | ||||
|     padding-left: ${({ theme }) => theme.spacing(1)}; | ||||
|     color: ${({ theme }) => theme.font.color.tertiary}; | ||||
|     height: ${({ theme }) => theme.icon.size.md}px; | ||||
|     width: ${({ theme }) => theme.icon.size.md}px; | ||||
|   } | ||||
| `; | ||||
|  | ||||
| export type CurrencyInputProps = { | ||||
|   placeholder?: string; | ||||
|   autoFocus?: boolean; | ||||
|   value: string; | ||||
|   currencyCode: string; | ||||
|   onEnter: (newText: string) => void; | ||||
|   onEscape: (newText: string) => void; | ||||
|   onTab?: (newText: string) => void; | ||||
|   onShiftTab?: (newText: string) => void; | ||||
|   onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void; | ||||
|   onChange?: (newText: string) => void; | ||||
|   onSelect?: (newText: string) => void; | ||||
|   hotkeyScope: string; | ||||
| }; | ||||
|  | ||||
| type Currency = { | ||||
|   label: string; | ||||
|   value: string; | ||||
|   Icon: any; | ||||
| }; | ||||
|  | ||||
| export const CurrencyInput = ({ | ||||
|   autoFocus, | ||||
|   value, | ||||
|   currencyCode, | ||||
|   placeholder, | ||||
|   onEnter, | ||||
|   onEscape, | ||||
|   onTab, | ||||
|   onShiftTab, | ||||
|   onClickOutside, | ||||
|   onChange, | ||||
|   onSelect, | ||||
|   hotkeyScope, | ||||
| }: CurrencyInputProps) => { | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   const [internalText, setInternalText] = useState(value); | ||||
|   const [internalCurrency, setInternalCurrency] = useState<Currency | null>( | ||||
|     null, | ||||
|   ); | ||||
|  | ||||
|   const wrapperRef = useRef<HTMLInputElement>(null); | ||||
|  | ||||
|   const handleChange = (event: ChangeEvent<HTMLInputElement>) => { | ||||
|     setInternalText(event.target.value); | ||||
|     onChange?.(event.target.value); | ||||
|   }; | ||||
|  | ||||
|   const handleCurrencyChange = (currency: Currency) => { | ||||
|     setInternalCurrency(currency); | ||||
|     onSelect?.(currency.value); | ||||
|   }; | ||||
|  | ||||
|   useRegisterInputEvents({ | ||||
|     inputRef: wrapperRef, | ||||
|     inputValue: internalText, | ||||
|     onEnter, | ||||
|     onEscape, | ||||
|     onClickOutside, | ||||
|     onTab, | ||||
|     onShiftTab, | ||||
|     hotkeyScope, | ||||
|   }); | ||||
|  | ||||
|   const currencies = useMemo<Currency[]>( | ||||
|     () => | ||||
|       Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map( | ||||
|         ([key, { Icon, label }]) => ({ | ||||
|           value: key, | ||||
|           Icon, | ||||
|           label, | ||||
|         }), | ||||
|       ), | ||||
|     [], | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const currency = currencies.find(({ value }) => value === currencyCode); | ||||
|     if (currency) { | ||||
|       setInternalCurrency(currency); | ||||
|     } | ||||
|   }, [currencies, currencyCode]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setInternalText(value); | ||||
|   }, [value]); | ||||
|  | ||||
|   const Icon: IconComponent = internalCurrency?.Icon; | ||||
|  | ||||
|   return ( | ||||
|     <StyledContainer ref={wrapperRef}> | ||||
|       <CurrencyPickerDropdownButton | ||||
|         valueCode={internalCurrency?.value ?? ''} | ||||
|         onChange={handleCurrencyChange} | ||||
|         currencies={currencies} | ||||
|       /> | ||||
|       <StyledIcon> | ||||
|         {Icon && ( | ||||
|           <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} /> | ||||
|         )} | ||||
|       </StyledIcon> | ||||
|       <StyledInput | ||||
|         autoComplete="off" | ||||
|         placeholder={placeholder} | ||||
|         onChange={handleChange} | ||||
|         autoFocus={autoFocus} | ||||
|         value={value} | ||||
|       /> | ||||
|     </StyledContainer> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,108 @@ | ||||
| import { useEffect, useState } from 'react'; | ||||
| import { useTheme } from '@emotion/react'; | ||||
| import styled from '@emotion/styled'; | ||||
|  | ||||
| import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; | ||||
| import { IconChevronDown } from '@/ui/display/icon'; | ||||
| import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; | ||||
| import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; | ||||
|  | ||||
| import { CurrencyPickerHotkeyScope } from '../types/CurrencyPickerHotkeyScope'; | ||||
|  | ||||
| import { CurrencyPickerDropdownSelect } from './CurrencyPickerDropdownSelect'; | ||||
|  | ||||
| type StyledDropdownButtonProps = { | ||||
|   isUnfolded: boolean; | ||||
| }; | ||||
|  | ||||
| export const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>` | ||||
|   align-items: center; | ||||
|   color: ${({ color }) => color ?? 'none'}; | ||||
|   cursor: pointer; | ||||
|   display: flex; | ||||
|   border-right: ${({ theme }) => `1px solid ${theme.border.color.medium}`}; | ||||
|   height: 32px; | ||||
|   padding-left: ${({ theme }) => theme.spacing(2)}; | ||||
|   padding-right: ${({ theme }) => theme.spacing(2)}; | ||||
|   user-select: none; | ||||
|   &:hover { | ||||
|     filter: brightness(0.95); | ||||
|   } | ||||
| `; | ||||
|  | ||||
| const StyledIconContainer = styled.div` | ||||
|   align-items: center; | ||||
|   color: ${({ theme }) => theme.font.color.tertiary}; | ||||
|   display: flex; | ||||
|   gap: ${({ theme }) => theme.spacing(1)}; | ||||
|   font-weight: ${({ theme }) => theme.font.weight.medium}; | ||||
|   justify-content: center; | ||||
|  | ||||
|   svg { | ||||
|     align-items: center; | ||||
|     display: flex; | ||||
|     height: 16px; | ||||
|     justify-content: center; | ||||
|   } | ||||
| `; | ||||
|  | ||||
| export type Currency = { | ||||
|   label: string; | ||||
|   value: string; | ||||
|   Icon: any; | ||||
| }; | ||||
|  | ||||
| export const CurrencyPickerDropdownButton = ({ | ||||
|   valueCode, | ||||
|   onChange, | ||||
|   currencies, | ||||
| }: { | ||||
|   valueCode: string; | ||||
|   onChange: (currency: Currency) => void; | ||||
|   currencies: Currency[]; | ||||
| }) => { | ||||
|   const theme = useTheme(); | ||||
|  | ||||
|   const [selectedCurrency, setSelectedCurrency] = useState<Currency>(); | ||||
|  | ||||
|   const { isDropdownOpen, closeDropdown } = useDropdown( | ||||
|     CurrencyPickerHotkeyScope.CurrencyPicker, | ||||
|   ); | ||||
|  | ||||
|   const handleChange = (currency: Currency) => { | ||||
|     onChange(currency); | ||||
|     closeDropdown(); | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const currency = currencies.find(({ value }) => value === valueCode); | ||||
|     if (currency) { | ||||
|       setSelectedCurrency(currency); | ||||
|     } | ||||
|   }, [valueCode, currencies]); | ||||
|  | ||||
|   return ( | ||||
|     <Dropdown | ||||
|       dropdownMenuWidth={200} | ||||
|       dropdownId="currncy-picker-dropdown-id" | ||||
|       dropdownHotkeyScope={{ scope: CurrencyPickerHotkeyScope.CurrencyPicker }} | ||||
|       clickableComponent={ | ||||
|         <StyledDropdownButtonContainer isUnfolded={isDropdownOpen}> | ||||
|           <StyledIconContainer> | ||||
|             {selectedCurrency ? selectedCurrency.value : CurrencyCode.USD} | ||||
|             <IconChevronDown size={theme.icon.size.sm} /> | ||||
|           </StyledIconContainer> | ||||
|         </StyledDropdownButtonContainer> | ||||
|       } | ||||
|       dropdownComponents={ | ||||
|         <CurrencyPickerDropdownSelect | ||||
|           currencies={currencies} | ||||
|           selectedCurrency={selectedCurrency} | ||||
|           onChange={handleChange} | ||||
|         /> | ||||
|       } | ||||
|       dropdownPlacement="bottom-start" | ||||
|       dropdownOffset={{ x: 0, y: 4 }} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,71 @@ | ||||
| import { useMemo, useState } from 'react'; | ||||
|  | ||||
| import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; | ||||
| import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; | ||||
| import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; | ||||
| import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; | ||||
| import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | ||||
| import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar'; | ||||
|  | ||||
| import { Currency } from './CurrencyPickerDropdownButton'; | ||||
|  | ||||
| export const CurrencyPickerDropdownSelect = ({ | ||||
|   currencies, | ||||
|   selectedCurrency, | ||||
|   onChange, | ||||
| }: { | ||||
|   currencies: Currency[]; | ||||
|   selectedCurrency?: Currency; | ||||
|   onChange: (currency: Currency) => void; | ||||
| }) => { | ||||
|   const [searchFilter, setSearchFilter] = useState<string>(''); | ||||
|  | ||||
|   const filteredCurrencies = useMemo( | ||||
|     () => | ||||
|       currencies.filter( | ||||
|         ({ value, label }) => | ||||
|           value | ||||
|             .toLocaleLowerCase() | ||||
|             .includes(searchFilter.toLocaleLowerCase()) || | ||||
|           label.toLocaleLowerCase().includes(searchFilter.toLocaleLowerCase()), | ||||
|       ), | ||||
|     [currencies, searchFilter], | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <DropdownMenu width="200px" disableBlur> | ||||
|       <DropdownMenuSearchInput | ||||
|         value={searchFilter} | ||||
|         onChange={(event) => setSearchFilter(event.target.value)} | ||||
|         autoFocus | ||||
|       /> | ||||
|       <DropdownMenuSeparator /> | ||||
|       <DropdownMenuItemsContainer hasMaxHeight> | ||||
|         {filteredCurrencies.length === 0 ? ( | ||||
|           <MenuItem text="No result" /> | ||||
|         ) : ( | ||||
|           <> | ||||
|             {selectedCurrency && ( | ||||
|               <MenuItemSelectAvatar | ||||
|                 key={selectedCurrency.value} | ||||
|                 selected={true} | ||||
|                 onClick={() => onChange(selectedCurrency)} | ||||
|                 text={`${selectedCurrency.label} (${selectedCurrency.value})`} | ||||
|               /> | ||||
|             )} | ||||
|             {filteredCurrencies.map((item) => | ||||
|               selectedCurrency?.value === item.value ? null : ( | ||||
|                 <MenuItemSelectAvatar | ||||
|                   key={item.value} | ||||
|                   selected={selectedCurrency?.value === item.value} | ||||
|                   onClick={() => onChange(item)} | ||||
|                   text={`${item.label} (${item.value})`} | ||||
|                 /> | ||||
|               ), | ||||
|             )} | ||||
|           </> | ||||
|         )} | ||||
|       </DropdownMenuItemsContainer> | ||||
|     </DropdownMenu> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,3 @@ | ||||
| export enum CurrencyPickerHotkeyScope { | ||||
|   CurrencyPicker = 'currency-picker', | ||||
| } | ||||
| @@ -12,7 +12,7 @@ import { | ||||
| import { StyledMenuItemSelect } from './MenuItemSelect'; | ||||
|  | ||||
| type MenuItemSelectAvatarProps = { | ||||
|   avatar: ReactNode; | ||||
|   avatar?: ReactNode; | ||||
|   selected: boolean; | ||||
|   text: string; | ||||
|   className?: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Jeet Desai
					Jeet Desai