tremorlabs / tremor

React components to build charts and dashboards
https://npm.tremor.so
Apache License 2.0
16.1k stars 461 forks source link

[Feature]: Make Select component compatible with React Hook Form #1110

Open lucafaggianelli opened 1 month ago

lucafaggianelli commented 1 month ago

What problem does this feature solve?

React Hook Form is a convenient library to handle forms and with native HTML input elements the integration is flawless, actually also with most of Tremor input components the integration is flawless, i.e. with the TextInput I can just do (which is what I would do for the <input> element):

<TextInput
    {...register('company_name')}
    placeholder=""
    required
    minLength={3}
/>

Though the same method doesn't work for the Select component, this is the workaround I used:

const { onChange, ...registerProps } = register('role')

<Select
  {...registerProps}
  placeholder="Role"
  required
  onValueChange={(value) => onChange({ target: { value, name: 'role' } })}
>
  {roles.map((role) => (
    <SelectItem key={role} value={role}>
      {role}
    </SelectItem>
  ))}
</Select>

What does the proposed API look like?

It would be nice to make the Select component compatible with RHF so the integration would be:

<Select
  {...register('role')}
  placeholder="Role"
  required
>
  {roles.map((role) => (
    <SelectItem key={role} value={role}>
      {role}
    </SelectItem>
  ))}
</Select>
angelhodar commented 4 weeks ago

I already integrated tremor with RHF, here you have the code in case you find it useful. The key is to use the Controller class:

SelectInput.tsx

'use client'

import { Select, SelectItem, SelectProps, SelectItemProps } from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'

export interface SelectOptionProps extends SelectItemProps {
  label: string
}

export { Select, SelectItem }

type BaseSelectProps = BaseFormInputProps & Omit<SelectProps, 'children'>

export interface SelectInputProps extends BaseSelectProps {
  options: SelectOptionProps[]
}

export default function SelectInput(props: SelectInputProps) {
  const { name, control, label, description, defaultValue, options, ...rest } =
    props

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Select
              onValueChange={field.onChange}
              defaultValue={field.value?.toString() || ''}
              {...rest}
            >
              {options.map((option) => (
                <SelectItem
                  key={option.value}
                  value={option.value}
                  icon={option.icon}
                >
                  {option.label}
                </SelectItem>
              ))}
            </Select>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

SearchSelect.tsx

'use client'

import {
  SearchSelect,
  SearchSelectProps as TremorSearchSelectProps,
  SearchSelectItem,
  SearchSelectItemProps,
} from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'
import {
  Avatar,
  AvatarImage,
  AvatarFallback,
} from 'components/Primitives/Avatar'

export { SearchSelect, SearchSelectItem }
export type {
  TremorSearchSelectProps as SearchSelectProps,
  SearchSelectItemProps,
}

interface SearchSelectOptionProps extends SearchSelectItemProps {
  label: string
  picture?: string | null
}

type SearchSelectProps = Omit<TremorSearchSelectProps, 'children'> &
  BaseFormInputProps & { enableIconFallback?: boolean }

export interface SearchSelectInputProps extends SearchSelectProps {
  options: SearchSelectOptionProps[]
}

const OptionIcon = ({ option }: { option: SearchSelectOptionProps }) => {
  return (
    <Avatar className="w-7 h-7 mr-4">
      <AvatarImage src={option.picture as string} />
      <AvatarFallback>{option.label}</AvatarFallback>
    </Avatar>
  )
}

export default function SearchSelectInput(props: SearchSelectInputProps) {
  const {
    name,
    control,
    label,
    description,
    placeholder,
    enableIconFallback,
    options,
    onValueChange,
    onSearchValueChange,
    enableClear = true,
  } = props

  const onChange = (
    newValue: string,
    onFormChange: (...event: any[]) => void
  ) => {
    onFormChange(newValue)
    onValueChange?.(newValue)
  }

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <SearchSelect
              placeholder={placeholder}
              defaultValue={field.value}
              onValueChange={(value) => onChange(value, field.onChange)}
              onSearchValueChange={onSearchValueChange}
              enableClear={enableClear}
            >
              {options.map((option) => (
                <SearchSelectItem
                  key={option.value}
                  value={option.value}
                  icon={
                    option.picture || enableIconFallback
                      ? () => <OptionIcon option={option} />
                      : undefined
                  }
                >
                  {option.label}
                </SearchSelectItem>
              ))}
            </SearchSelect>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

MultiSelect.tsx

'use client'

import { useState } from 'react'
import { useFormContext } from 'react-hook-form'
import {
  MultiSelect,
  MultiSelectItem,
  MultiSelectProps as TremorMultiSelectProps,
  MultiSelectItemProps,
} from '@tremor/react'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'

// This is used to control how the multiselect input behaves
export const multiSelectWithAllOptionChangeTransformer =
  (allOptionsValue: string) => (prev: string[], newValues: string[]) => {
    // If there is no selection, reset to all option
    if (newValues.length === 0) return [allOptionsValue]
    // If we have selected the all option while it wasnt selected before, reset to all
    if (!prev.includes(allOptionsValue) && newValues.includes(allOptionsValue))
      return [allOptionsValue]
    // If we had all selected but now there is more selection, then unselect the all option
    if (prev.includes(allOptionsValue) && newValues.length > 1) {
      return newValues.filter((v: string) => v !== allOptionsValue)
    }

    return newValues
  }

interface MultiSelectOptionProps extends MultiSelectItemProps {
  label: string
}

type MultiSelectProps = Omit<TremorMultiSelectProps, 'children'> &
  BaseFormInputProps

export interface MultiSelectInputProps extends MultiSelectProps {
  options: MultiSelectOptionProps[]
  allOptionsValue?: string
}

export default function MultiSelectInput(props: MultiSelectInputProps) {
  const {
    name,
    control,
    label,
    description,
    options,
    allOptionsValue,
    ...rest
  } = props
  const { getValues } = useFormContext()
  const [values, setValues] = useState<string[]>(getValues(name) || [])

  const onSelectionChange = (
    newValues: string[],
    onChange: (...event: any[]) => void
  ) => {
    const newSelection = allOptionsValue
      ? multiSelectWithAllOptionChangeTransformer(allOptionsValue)(
          values,
          newValues
        )
      : newValues
    setValues(newSelection)
    onChange(newSelection)
  }

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <MultiSelect
              value={values}
              defaultValue={values}
              placeholderSearch="Buscar..."
              onValueChange={(newValues) =>
                onSelectionChange(newValues, field.onChange)
              }
              {...rest}
            >
              {options.map((option) => (
                <MultiSelectItem key={option.value} value={option.value}>
                  {option.label}
                </MultiSelectItem>
              ))}
            </MultiSelect>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}
lucafaggianelli commented 3 weeks ago

Hey @angelhodar thanks!