mirror of
https://github.com/lingble/twenty.git
synced 2025-10-31 20:57: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 { 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 { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay';
|
||||||
import { useCurrencyField } from '../../hooks/useCurrencyField';
|
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 (
|
return (
|
||||||
<FieldInputOverlay>
|
<FieldInputOverlay>
|
||||||
<TextInput
|
<CurrencyInput
|
||||||
value={draftValue?.amount?.toString() ?? ''}
|
value={draftValue?.amount?.toString() ?? ''}
|
||||||
|
currencyCode={draftValue?.currencyCode ?? CurrencyCode.USD}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Currency"
|
placeholder="Currency"
|
||||||
onClickOutside={handleClickOutside}
|
onClickOutside={handleClickOutside}
|
||||||
@@ -90,8 +98,9 @@ export const CurrencyFieldInput = ({
|
|||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
onShiftTab={handleShiftTab}
|
onShiftTab={handleShiftTab}
|
||||||
onTab={handleTab}
|
onTab={handleTab}
|
||||||
hotkeyScope={hotkeyScope}
|
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
hotkeyScope={hotkeyScope}
|
||||||
/>
|
/>
|
||||||
</FieldInputOverlay>
|
</FieldInputOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode';
|
|
||||||
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
||||||
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
|
import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue';
|
||||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
@@ -31,7 +30,7 @@ export const computeDraftValueFromFieldValue = <FieldValue>({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '',
|
amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '',
|
||||||
currenyCode: CurrencyCode.USD,
|
currencyCode: fieldValue?.currencyCode ?? '',
|
||||||
} as unknown as FieldInputDraftValue<FieldValue>;
|
} as unknown as FieldInputDraftValue<FieldValue>;
|
||||||
}
|
}
|
||||||
if (isFieldRelation(fieldDefinition)) {
|
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';
|
import { StyledMenuItemSelect } from './MenuItemSelect';
|
||||||
|
|
||||||
type MenuItemSelectAvatarProps = {
|
type MenuItemSelectAvatarProps = {
|
||||||
avatar: ReactNode;
|
avatar?: ReactNode;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user