Skip to content

Commit

Permalink
Fix(Expense form): Update payout method form inputs
Browse files Browse the repository at this point in the history
  • Loading branch information
gustavlrsn committed Jan 28, 2025
1 parent e5675b6 commit d8f4f46
Show file tree
Hide file tree
Showing 8 changed files with 421 additions and 345 deletions.
122 changes: 122 additions & 0 deletions components/ComboSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import * as React from 'react';
import { isString } from 'lodash';
import { Check, ChevronDown } from 'lucide-react';
import { defineMessages, useIntl } from 'react-intl';

import { cn } from '@/lib/utils';

import { Button } from '@/components/ui/Button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/Command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover';

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/Select';

const Messages = defineMessages({
loading: {
id: 'Select.Loading',
defaultMessage: 'Loading...',
},
noOptions: {
id: 'Select.NoOptions',
defaultMessage: 'Nothing found',
},
placeholder: {
id: 'Select.Placeholder',
defaultMessage: 'No selection',
},
inputPlaceholder: {
id: 'Select.InputPlaceholder',
defaultMessage: 'Search...',
},
});

type ComboSelectProps = {
options: Array<{ value: string; label: string | React.JSX.Element; keywords?: string[] }>;
value: string | undefined;
onChange: (val: string) => void;
id?: string;
disabled?: boolean;
error?: boolean;
loading?: boolean;
className?: string;
placeholder?: string;
inputPlaceholder?: string;
isSearchable?: boolean;
'data-cy'?: string;
};

export function ComboSelect(props: ComboSelectProps) {
const [open, setOpen] = React.useState(false);
const intl = useIntl();
const isSearchable = props.isSearchable ?? props.options?.length > 8;
const placeholder = props.loading
? intl.formatMessage(Messages.loading)
: (props.placeholder ?? intl.formatMessage(Messages.placeholder));
const selectedOption = props.options.find(option => option.value === props.value);

const onChange = (val: string) => {
props.onChange(val);
setOpen(false);
};

if (!isSearchable) {
return (
<Select open={open} onOpenChange={setOpen} onValueChange={onChange} value={props.value}>
<SelectTrigger
className={cn({ 'text-muted-foreground': !props.value }, props.className)}
id={props.id}
data-cy={props['data-cy']}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{props.options.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id={props.id}
data-cy={props['data-cy']}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('justify-between font-normal', { 'text-muted-foreground': !props.value }, props.className)}
>
{selectedOption?.label ?? placeholder ?? intl.formatMessage(Messages.placeholder)}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder={props.inputPlaceholder || intl.formatMessage(Messages.inputPlaceholder)} />
<CommandList>
<CommandEmpty>{intl.formatMessage(Messages.noOptions)}</CommandEmpty>
<CommandGroup>
{props.options.map(option => (
<CommandItem
key={option.value}
value={option.value}
keywords={option.keywords || (isString(option.label) ? [option.label] : undefined)}
onSelect={onChange}
>
<Check className={cn('mr-2 h-4 w-4', props.value === option.value ? 'opacity-100' : 'opacity-0')} />
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
62 changes: 62 additions & 0 deletions components/CurrencyPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { getEmojiByCurrencyCode } from 'country-currency-emoji-flags';
import { truncate } from 'lodash';
import { useIntl } from 'react-intl';

import { Currency } from '../lib/constants/currency';
import { getIntlDisplayNames } from '../lib/i18n';

import { ComboSelect } from './ComboSelect';

const generateCurrencyOptions = (intl, availableCurrencies) => {
const currencyDisplayNames = getIntlDisplayNames(intl.locale, 'currency');
return availableCurrencies.map(currency => {
const currencyName = currencyDisplayNames.of(currency);
const emoji = getEmojiByCurrencyCode(currency);
return {
value: currency,
label: (
<div
className=""
// fontSize="14px" lineHeight="20px" fontWeight="500"
title={currencyName}
>
{emoji && <span>{emoji}</span>}
&nbsp;
<span className="ml-1 whitespace-nowrap">
<span className="">{currency}</span>
{` `}
<span className="align-middle text-xs text-muted-foreground">{truncate(currencyName, { length: 30 })}</span>
</span>
</div>
),
};
});
};

export default function CurrencyPicker({
availableCurrencies = Currency,
value,
onChange,
...props
}: {
/** A list of currencies presented in the currency picker */
availableCurrencies: string[];
onChange: (selectedCurrency: string) => void;
value?: string;
} & Omit<React.ComponentProps<typeof ComboSelect>, 'options'>) {
const intl = useIntl();
const currencyOptions = generateCurrencyOptions(intl, availableCurrencies);
return (
<ComboSelect
id="currency-picker"
data-cy="currency-picker"
error={!value}
isSearchable={availableCurrencies.length > 10}
options={currencyOptions}
value={value}
onChange={onChange}
{...props}
/>
);
}
14 changes: 11 additions & 3 deletions components/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useIntl } from 'react-intl';

import { isOCError } from '../lib/errors';
import { formatFormErrorMessage, RICH_ERROR_MESSAGES } from '../lib/form-utils';
import { cn } from '@/lib/utils';

import PrivateInfoIcon from './icons/PrivateInfoIcon';
import { Input } from './ui/Input';
Expand All @@ -21,6 +22,8 @@ export function FormField({
children,
error: customError,
isPrivate,
validate,
className,
...props
}: {
label?: string;
Expand All @@ -37,13 +40,16 @@ export function FormField({
htmlFor?: string;
error?: string;
isPrivate?: boolean;
validate?: any;
className?: string;
onFocus?: () => void;
}) {
const intl = useIntl();
const htmlFor = props.htmlFor || `input-${name}`;
const { schema } = useContext(FormikZodContext);

return (
<Field name={name}>
<Field name={name} validate={validate}>
{({ field, form, meta }) => {
const hasError = Boolean(meta.error && (meta.touched || form.submitCount)) || Boolean(customError);
const error = customError || meta.error;
Expand All @@ -62,6 +68,7 @@ export function FormField({
required: props.required,
error: hasError,
placeholder,
onFocus: props.onFocus,
},
value => value !== undefined,
),
Expand All @@ -74,10 +81,11 @@ export function FormField({
) {
fieldAttributes.required = true;
}

return (
<div className="flex w-full flex-col gap-1">
<div className={cn('flex w-full flex-col gap-1', className)}>
{label && (
<Label className="leading-normal">
<Label className="leading-normal" htmlFor={htmlFor}>
{label}
{isPrivate && (
<React.Fragment>
Expand Down
2 changes: 1 addition & 1 deletion components/PayoutMethodLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function PayoutMethodLabel(props: PayoutMethodLabelProps) {

if (props.showIcon) {
return (
<div className="flex items-center gap-2 whitespace-nowrap">
<div className="flex min-h-8 items-center gap-2 whitespace-nowrap">
<PayoutMethodIcon payoutMethod={pm} />
&nbsp;
{label}
Expand Down
Loading

0 comments on commit d8f4f46

Please sign in to comment.