Autocomplete와 Lookup (Lookup Editor)

같은 셀에서 자동완성 입력과 lookup 모달 아이콘을 함께 제공하고 여러 컬럼 값을 원자적으로 저장하는 방법을 설명합니다.

#autocomplete#lookup#editorIcon#multi-cell-commit
검토일: 2026-08-21
GitHub
import * as React from 'react';
import { AutoComplete, Input, Modal } from 'antd';
import {
  AXDataGrid,
  type AXDGColumn,
  type AXDGDataItem,
  type AXDGEditorIconClickParams,
  type AXDGEditorPluginProps,
} from '@axboot/datagrid';
import { defineEditorPlugin } from '@axboot/datagrid/editors';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import { SearchIcon } from './editing/editorIcons';
import {
  applyEditingDataChange,
  cloneEditingOrders,
  type EditingOrder,
  withEditingCellClasses,
} from './editing/shared';
import './LookupEditorExample.css';

type Customer = Pick<EditingOrder, 'customerCode' | 'customerName' | 'customerGrade'>;

const customers: Customer[] = [
  { customerCode: 'C001', customerName: '서울상사', customerGrade: 'VIP' },
  { customerCode: 'C002', customerName: '한빛물산', customerGrade: '우수' },
  { customerCode: 'C003', customerName: 'Northwind', customerGrade: '일반' },
  { customerCode: 'C004', customerName: 'AxisJ Studio', customerGrade: '우수' },
];

const customerRows: AXDGDataItem<Customer>[] = customers.map(customer => ({ values: customer }));

const toCustomerChanges = (customer: Customer) => [
  { key: 'customerCode', value: customer.customerCode },
  { key: 'customerName', value: customer.customerName },
  { key: 'customerGrade', value: customer.customerGrade },
];

function CustomerAutocompleteEditor({ value, commit, cancel, getPortalContainer }: AXDGEditorPluginProps<EditingOrder>) {
  const [text, setText] = React.useState(String(value ?? ''));
  const normalizedText = text.trim().toLocaleLowerCase();
  const options = customers
    .filter(customer => {
      if (!normalizedText) return true;
      return [customer.customerName, customer.customerCode, customer.customerGrade].some(candidate =>
        candidate.toLocaleLowerCase().includes(normalizedText),
      );
    })
    .map(customer => ({
      value: customer.customerName,
      customerCode: customer.customerCode,
      label: (
        <span className='lookup-editor-autocomplete-option'>
          <strong>{customer.customerName}</strong>
          <small>{customer.customerCode} · {customer.customerGrade}</small>
        </span>
      ),
    }));

  return (
    <AutoComplete
      aria-label='고객 자동완성'
      autoFocus
      className='lookup-editor-autocomplete'
      classNames={{ popup: { root: 'lookup-editor-autocomplete-popup' } }}
      defaultActiveFirstOption
      open={options.length > 0}
      options={options}
      popupMatchSelectWidth
      size='small'
      value={text}
      variant='borderless'
      getPopupContainer={getPortalContainer}
      onChange={setText}
      onSelect={(_nextValue, option) => {
        const customer = customers.find(candidate => candidate.customerCode === option.customerCode);
        if (customer) void commit(toCustomerChanges(customer));
      }}
      onKeyDown={event => {
        if (event.key === 'Escape' || event.key === 'Esc') {
          event.preventDefault();
          cancel();
        }
      }}
    />
  );
}

const customerAutocompleteEditor = defineEditorPlugin<EditingOrder>({
  id: 'customer-autocomplete',
  component: CustomerAutocompleteEditor,
});

interface CustomerLookupModalProps {
  lookup: AXDGEditorIconClickParams<EditingOrder>;
}

function CustomerLookupModal({ lookup }: CustomerLookupModalProps) {
  const [query, setQuery] = React.useState('');
  const [selectedCode, setSelectedCode] = React.useState<string>(() => String(lookup.values.customerCode ?? ''));
  const [confirming, setConfirming] = React.useState(false);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  const filteredRows = React.useMemo(() => {
    const normalizedQuery = query.trim().toLocaleLowerCase();
    if (!normalizedQuery) return customerRows;
    return customerRows.filter(({ values }) =>
      [values.customerName, values.customerCode, values.customerGrade].some(candidate =>
        candidate.toLocaleLowerCase().includes(normalizedQuery),
      ),
    );
  }, [query]);

  const columns = React.useMemo<AXDGColumn<Customer>[]>(
    () => [
      { key: 'customerCode', label: '고객 코드', width: 130 },
      { key: 'customerName', label: '고객명', width: 260 },
      { key: 'customerGrade', label: '고객 등급', width: 120 },
    ],
    [],
  );

  const confirmSelection = async () => {
    const customer = customers.find(candidate => candidate.customerCode === selectedCode);
    if (!customer) return;
    setConfirming(true);
    try {
      await lookup.commit(toCustomerChanges(customer));
    } finally {
      setConfirming(false);
    }
  };

  return (
    <Modal
      open
      title='고객 선택'
      width={720}
      className='lookup-editor-modal'
      okText='확인'
      cancelText='취소'
      okButtonProps={{ disabled: !selectedCode }}
      confirmLoading={confirming}
      onCancel={lookup.cancel}
      onOk={() => void confirmSelection()}
    >
      <div className='lookup-editor-modal-content'>
        <Input.Search
          allowClear
          autoFocus
          aria-label='고객 검색'
          placeholder='고객명, 고객 코드 또는 등급 검색'
          value={query}
          onChange={event => setQuery(event.currentTarget.value)}
        />
        <DataGridContainer ref={containerRef} className='lookup-editor-grid' style={{ height: 280 }}>
          <AXDataGrid<Customer>
            width={width}
            height={height}
            data={filteredRows}
            columns={columns}
            rowKey='customerCode'
            selectedRowKey={selectedCode}
            rowChecked={{
              isRadio: true,
              checkedRowKeys: selectedCode ? [selectedCode] : [],
              onChange: (_checkedIndexes, checkedRowKeys) => {
                const nextCode = checkedRowKeys[0];
                setSelectedCode(nextCode === undefined ? '' : String(nextCode));
              },
            }}
            onClick={({ item }) => setSelectedCode(item.customerCode)}
            variant='vertical-bordered'
            msg={{ emptyList: '검색 결과가 없습니다.' }}
          />
        </DataGridContainer>
      </div>
    </Modal>
  );
}

export default function LookupEditorExample() {
  const [data, setData] = React.useState(cloneEditingOrders);
  const [lookup, setLookup] = React.useState<AXDGEditorIconClickParams<EditingOrder> | null>(null);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width, height } = useContainerSize(containerRef);

  const columns = React.useMemo<AXDGColumn<EditingOrder>[]>(
    () => withEditingCellClasses<EditingOrder>([
      { key: 'orderCode', label: '주문 코드', width: 145, editable: false },
      { key: 'customerCode', label: '고객 코드', width: 130, editable: false },
      {
        key: 'customerName',
        label: '고객명 · 자동완성/lookup',
        width: 240,
        editable: true,
        editTrigger: 'click',
        editor: customerAutocompleteEditor,
        editorIcon: {
          render: <SearchIcon />,
          ariaLabel: ({ values }) => `${values.orderCode} 고객 lookup 열기`,
          visibility: 'always',
          onClick: params => {
            setLookup(params);
            return () => setLookup(null);
          },
        },
        onChangeValue: async ({ changes, commit }) => {
          await commit(changes);
        },
      },
      { key: 'customerGrade', label: '고객 등급', width: 130, editable: false },
    ]),
    [],
  );

  return (
    <div className='relative flex min-h-0 flex-col gap-3'>
      <p className='m-0 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm leading-6 text-slate-700'>
        고객명 셀을 클릭하면 Ant Design AutoComplete가 열리고, 같은 셀의 검색 아이콘은 검색과 단일 선택 그리드를 갖춘
        Ant Design Modal을 엽니다. 고객을 확정하면 코드·이름·등급을 하나의 변경 목록으로 원자적으로 저장합니다.
      </p>
      <DataGridContainer ref={containerRef} style={{ height: 340 }}>
        <AXDataGrid<EditingOrder>
          width={width}
          height={height}
          data={data}
          columns={columns}
          rowKey='id'
          editable
          variant='vertical-bordered'
          onChangeData={(sourceIndex, _columnIndex, values, _column, meta) => {
            setData(current => applyEditingDataChange(current, sourceIndex, values, meta));
          }}
        />
      </DataGridContainer>

      {lookup && <CustomerLookupModal lookup={lookup} />}
    </div>
  );
}

FACEDM 형태의 고객 입력은 두 진입 경로를 한 컬럼에 함께 구성할 수 있습니다. 셀 영역은 Ant Design AutoComplete plugin을 열고, 같은 셀의 검색 아이콘은 검색바와 단일 선택 DataGrid를 포함한 Ant Design Modal을 엽니다.

{
  key: 'customerName',
  editable: true,
  editTrigger: 'click',
  editor: customerAutocompleteEditor,
  editorIcon: {
    render: <SearchIcon />,
    ariaLabel: '고객 lookup 열기',
    onClick: ({ commit, cancel }) => {
      const close = openCustomerLookup({
        onSelect: customer => commit([
          { key: 'customerCode', value: customer.code },
          { key: 'customerName', value: customer.name },
          { key: 'customerGrade', value: customer.grade },
        ]),
        onCancel: cancel,
      });
      return close;
    },
  },
}

역할 분담

  • editor: 입력 문자열, 후보 조회, 키보드 선택을 담당합니다.
  • editorIcon: lookup 모달의 열기와 수명주기를 담당합니다.
  • commit(changes[]): 어느 경로에서 선택하든 동일한 저장 트랜잭션으로 보냅니다.
  • onChangeValue: 두 경로가 제안한 값을 공통으로 검증·보정합니다.

브라우저 기본 자동완성 속성인 text editor의 inputProps.autoComplete와 후보 목록 UI는 다릅니다. 이 예제처럼 Ant Design AutoComplete를 plugin editor로 연결하면 셀 입력과 후보 목록을 하나의 편집 UI로 구성할 수 있습니다. 실제 서버 검색에서는 입력값으로 후보를 비동기 조회하되 최종 선택값은 동일한 commit(changes[]) 경로로 저장하세요.

비동기 lookup 주의사항

callback 세션이 끝난 뒤 늦게 도착한 결과의 commit은 무시됩니다. 그래도 애플리케이션은 cleanup에서 진행 중인 요청을 중단하고 모달을 닫아 불필요한 작업과 화면 깜빡임을 방지해야 합니다.

값 선택에 실패하면 commit() Promise가 reject되고 현재 icon 세션은 종료됩니다. Promise 오류를 사용자에게 표시하고 필요하면 lookup을 다시 열 수 있도록 처리하세요.