양파개발자 실바의 블로그

ApiSelect - React 컴포넌트

Select 방식의 UI를 제공하지만 이를 백엔드 endpoint와 연결하여 사용할 수 있도록 한 컴포넌트입니다. 다양한 유틸 기능을 탑재하였으며, Django DRF의 특정 View와 호환시켜야만 사용이 가능합니다. snippets/django/api-select.md와 함께 사용할 수 있습니다.


1. ApiSelect 컴포넌트

ForeignKey 데이터 입력용 컴포넌트입니다. 백엔드 API와 쉽게 연동 가능한 select box UI Component입니다.

/**
ForeignKey 데이터 입력용 컴포넌트

- 백엔드 API 와 아주 쉽게 연동 가능한 select box UI Component
- api-v2 에서 BaseApiSelectAPIView 를 상속받은 APIView 를 구현한뒤 endpoint 연동

<ApiSelect
  title={'도매'}
  required={true}
  showTotal
  value={formData.wholesaler_id || ''}
  onChange={(v) => {
    setData('wholesaler_id', v)
  }}
  endpoint={'/wholesalers/api-select'}
  isNullable
  helpText="- 도움말입니다.<br/>- 2번째 도움말입니다."
  helpTextContainsHTML
></ApiSelect>
*/
import ApiController from 'libraries/ApiController'
import useApiSelectData from 'libraries/ApiSelectData'
import { useEffect, useId, useRef, useState } from 'react'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import Select from 'react-select'
import { useToasts } from 'react-toast-notifications'

const ApiSelect = ({
  title, // 필드명
  endpoint, // api-v2 URL path
  value, // 값
  onChange = () => {},
  required, // true 일때 title 옆에 빨간 별표 표시
  disabled = false,
  isClearable = true,
  helpText = '', // 도움말 (입력칸 하단에 파란글씨로 표시)
  helpTextContainsHTML = false, // 도움말에 html 입력 허용
  baseParams, // API request default query params
  valueField = 'id', // value 에 해당되는 모델 필드명 (기본값=id, 초기값 불러올때 query_param 에 사용)
  placeholder = '검색&선택',
  isNullable = false, // true 일때 "미지정" 선택지 표시 (value=null), optional 과 유사한 의미
  showTotal = false,
}) => {
  const isDebug = false

  const { addToast } = useToasts()
  const instanceId = useId()
  const helpTextDivClassName = 'text-sm text-blue-500 ml-1 flex'
  const { options, loading, nextPageParams, total, loadOptions } =
    useApiSelectData(endpoint, baseParams, isNullable)
  const [inputValue, setInputValue] = useState(value)
  const [copyValue, setCopyValue] = useState(value)
  const [initOption, setInitOption] = useState(null)
  const [isClient, setIsClient] = useState(false)
  const typingCollectMillisecond = 400 // API call 횟수를 줄이기 위해 검색어 타이핑 버퍼를 둠
  const timerRef = useRef(null)

  if (isDebug)
    console.log(`[ApiSelect] title=${title}, init value=${inputValue}`)

  useEffect(() => {
    setIsClient(true)
    loadInitOption()
    loadOptions()

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current)
      }
    }
  }, [])

  const elemOnChange = (option) => {
    setCopyValue(option?.value)
    onChange(option?.value)
  }

  const loadInitOption = async () => {
    if (
      inputValue === null ||
      inputValue === undefined ||
      (typeof inputValue == 'string' && inputValue.trim() === '')
    ) {
      return
    }

    try {
      const params = { [valueField]: inputValue }
      if (isDebug)
        console.log('[ApiSelect] loadInitOption: Request params=', params)

      const response = await ApiController.get(endpoint, {
        params: params,
      })
      if (isDebug)
        console.log('[ApiSelect] loadInitOption: Response Data', response.data)

      if (response.data.count === 1) {
        if (isDebug)
          console.log('[ApiSelect] setInitOption: ', response.data.results[0])

        setInitOption(response.data.results[0]) // 초기값 옵션 세팅
      } else {
        console.error(
          `[ApiSelect] 초기값 불러오기 실패: endpoint=${endpoint}, params=${params}`
        )
      }
    } catch (error) {
      console.error('[ApiSelect] Failed to fetch data:', error)
    }
  }

  const handleInputChange = (newValue) => {
    setInputValue(newValue)

    // 검색어 타이핑 타이머 reset
    if (timerRef.current) {
      clearTimeout(timerRef.current)
    }
    timerRef.current = setTimeout(() => {
      loadOptions({ ...baseParams, search: newValue })
    }, typingCollectMillisecond)
  }

  const handleScrollToEnd = () => {
    const hasNextPage = nextPageParams !== null
    if (!loading && hasNextPage) {
      loadOptions(nextPageParams, true)
    }
  }

  // 초기값과 불러온값을 적절하게 합쳐서 최종 옵션리스트 생성
  let finalOptions = options
  if (inputValue !== null && inputValue !== undefined && initOption !== null) {
    const optionHasValue = options.some(
      (item) => String(item.value) === String(inputValue)
    )
    if (!optionHasValue) finalOptions = [initOption, ...options]
  }
  if (isDebug) console.log('Final Options:', finalOptions)

  if (!isClient) {
    // Warning: Extra attributes from the server: aria-activedescendant 경고 해결
    // SSR을 사용하는 프로젝트에서 클라이언트 측에서만 Select 컴포넌트를 렌더링하도록 처리
    return <></>
  }

  return (
    <>
      <div className="flex flex-col space-y-1">
        {title && (
          <div className="text-xs text-gray-400 font-bold">
            {title}
            {required && <span className="text-red-500"> *</span>}
            {showTotal && ` | total: ${total}`}
            {copyValue && (
              <CopyToClipboard
                text={copyValue}
                onCopy={() => {
                  addToast(`${title} ID 가 클립보드에 복사되었습니다.`, {
                    autoDismiss: true,
                  })
                }}
              >
                <i className="cursor-pointer far fa-clipboard px-2"></i>
              </CopyToClipboard>
            )}
          </div>
        )}
        <div className="w-full">
          <Select
            instanceId={instanceId}
            // options={initOption === null ? options : [initOption, ...options]}
            options={finalOptions}
            value={finalOptions.find(
              (option) => String(option.value) === String(value)
            )}
            isDisabled={disabled}
            onChange={elemOnChange}
            onInputChange={handleInputChange}
            onMenuScrollToBottom={handleScrollToEnd}
            isLoading={loading}
            placeholder={placeholder}
            isClearable={isClearable}
          />
        </div>
        {helpText && helpTextContainsHTML ? (
          <div
            className={helpTextDivClassName}
            dangerouslySetInnerHTML={ { __html: helpText } }
          />
        ) : (
          <div className={helpTextDivClassName}>{helpText}</div>
        )}
      </div>
    </>
  )
}

export default ApiSelect

2. ApiMultiSelect 컴포넌트

여러 개의 값을 선택할 수 있는 멀티 셀렉트 컴포넌트입니다.

import ApiController from 'libraries/ApiController'
import useApiSelectData from 'libraries/ApiSelectData'
import { useEffect, useId, useRef, useState } from 'react'
import Select from 'react-select'

const ApiMultiSelect = ({
  title, // 필드명
  endpoint, // api-v2 URL path
  value = [], // 값 (리스트 형태로 받음)
  onChange = () => {},
  required, // true 일때 title 옆에 빨간 별표 표시
  disabled = false,
  isClearable = true,
  helpText = '', // 도움말 (입력칸 하단에 파란글씨로 표시)
  helpTextContainsHTML = false, // 도움말에 html 입력 허용
  baseParams, // API request default query params
  valueField = 'id', // value 에 해당되는 모델 필드명 (기본값=id, 초기값 불러올때 query_param 에 사용)
  placeholder = '검색&선택',
  isNullable = false, // true 일때 "미지정" 선택지 표시 (value=null), optional 과 유사한 의미
  showTotal = false, // 검색된 모든 option 의 전체 개수 표시 여부
  showOptionLabelList = false, // 하단에 지정한 옵션들의 label 을 리스트 형태로 별도로 제공 (직접 복.붙할때 용이하도록 처리)
}) => {
  const cleanValue = (value) => {
    if (Array.isArray(value)) {
      return value
    }

    if (typeof value === 'number') {
      return [val]
    }

    if (typeof value === 'string') {
      if (value.includes(',')) {
        return value.split(',')
      } else {
        return [value]
      }
    }

    console.warn(`[ApiMultiSelect] 유효하지 않은 value=${value}`)
    return []
  }

  const isDebug = false
  const instanceId = useId()
  const helpTextDivClassName = 'text-sm text-blue-500 ml-1 flex'
  const { options, loading, nextPageParams, total, loadOptions } =
    useApiSelectData(endpoint, baseParams, isNullable)
  const [initOptions, setInitOptions] = useState([])
  const [inputValueArray, setInputValueArray] = useState(cleanValue(value))
  const [isClient, setIsClient] = useState(false)
  const typingCollectMillisecond = 400
  const timerRef = useRef(null)

  useEffect(() => {
    setIsClient(true)
    loadInitOptions()
    loadOptions()

    return () => {
      if (timerRef.current) {
        clearTimeout(timerRef.current)
      }
    }
  }, [])

  const elemOnChange = (selectedOptions) => {
    if (isDebug) console.log('elemOnChange():', selectedOptions)
    const selectedValues = selectedOptions
      ? selectedOptions.map((opt) => opt.value)
      : []
    setInputValueArray(selectedValues)
    setInitOptions([...initOptions, ...selectedOptions])
    onChange(selectedValues)
  }

  const loadInitOptions = async () => {
    if (!inputValueArray.length) return

    try {
      // inputValue 가 리스트일때는 콤마로 구분하여 전송
      if (isDebug) console.log('[ApiMultiSelect] inputValue:', inputValueArray)
      const params = { [valueField]: inputValueArray.join(',') }
      const response = await ApiController.get(endpoint, {
        params: params,
      })
      setInitOptions(response.data.results)
    } catch (error) {
      console.error('[ApiMultiSelect] Failed to fetch data:', error)
    }
  }

  const handleInputChange = (newValue) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current)
    }
    timerRef.current = setTimeout(() => {
      loadOptions({ ...baseParams, search: newValue })
    }, typingCollectMillisecond)
  }

  const handleScrollToEnd = () => {
    const hasNextPage = nextPageParams !== null
    if (!loading && hasNextPage) {
      loadOptions(nextPageParams, true)
    }
  }

  let finalOptions = [...initOptions, ...options]
  finalOptions = Array.from(
    // value 중복 아이템 제거
    new Map(finalOptions.map((item) => [item.value, item])).values()
  )

  if (isDebug) console.log('Final Options:', finalOptions)

  if (!isClient) {
    return <></>
  }

  return (
    <>
      <div className="flex flex-col space-y-1">
        {title && (
          <div className="text-xs text-gray-400 font-bold">
            {title}
            {required && <span className="text-red-500"> *</span>}
            {showTotal && ` | total: ${total}`}
          </div>
        )}
        <div className="w-full">
          <Select
            instanceId={instanceId}
            options={finalOptions}
            value={finalOptions.filter((option) => {
              if (
                inputValueArray.length > 0 &&
                typeof inputValueArray[0] === 'string'
              ) {
                return inputValueArray.includes(String(option.value))
              } else {
                return inputValueArray.includes(option.value)
              }
            })}
            isMulti
            isDisabled={disabled}
            onChange={elemOnChange}
            onInputChange={handleInputChange}
            onMenuScrollToBottom={handleScrollToEnd}
            isLoading={loading}
            placeholder={placeholder}
            isClearable={isClearable}
          />
        </div>
        {helpText && helpTextContainsHTML ? (
          <div
            className={helpTextDivClassName}
            dangerouslySetInnerHTML={ { __html: helpText } }
          />
        ) : (
          <div className={helpTextDivClassName}>{helpText}</div>
        )}
        {showOptionLabelList && (
          <div>
            {finalOptions
              .filter((option) => {
                if (
                  inputValueArray.length > 0 &&
                  typeof inputValueArray[0] === 'string'
                ) {
                  return inputValueArray.includes(String(option.value))
                } else {
                  return inputValueArray.includes(option.value)
                }
              })
              .map((option) => {
                return (
                  <>
                    {option.label}
                    <br />
                  </>
                )
              })}
          </div>
        )}
      </div>
    </>
  )
}

export default ApiMultiSelect