teableio/teable

View on GitHub
apps/nextjs-app/src/features/app/blocks/view/search/SearchButton.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import { Search, X } from '@teable/icons';
import { LocalStorageKeys, useView } from '@teable/sdk';
import { useFields, useSearch, useTableId } from '@teable/sdk/hooks';
import { cn, Popover, PopoverContent, PopoverTrigger, Button } from '@teable/ui-lib/shadcn';
import { isEqual } from 'lodash';
import { useTranslation } from 'next-i18next';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDebounce, useLocalStorage } from 'react-use';
import { ToolBarButton } from '../tool-bar/ToolBarButton';
import { SearchCommand } from './SearchCommand';

export function SearchButton({
  className,
  textClassName,
}: {
  className?: string;
  textClassName?: string;
}) {
  const [active, setActive] = useState(false);
  const fields = useFields();
  const tableId = useTableId();
  const { fieldId, value, setFieldId, setValue } = useSearch();
  const [inputValue, setInputValue] = useState(value);
  const [isFocused, setIsFocused] = useState(false);
  const { t } = useTranslation(['common', 'table']);
  const ref = useRef<HTMLInputElement>(null);
  const [enableGlobalSearch, setEnableGlobalSearch] = useLocalStorage(
    LocalStorageKeys.EnableGlobalSearch,
    false
  );
  const [searchFieldMapCache, setSearchFieldMap] = useLocalStorage<Record<string, string[]>>(
    LocalStorageKeys.TableSearchFieldsCache,
    {}
  );
  const view = useView();

  useEffect(() => {
    if (!fieldId || fieldId === 'all_fields') {
      return;
    }
    const selectedField = fieldId.split(',');
    const hiddenFields: string[] = [];
    const columnMeta = view?.columnMeta || {};
    Object.entries(columnMeta).forEach(([key, value]) => {
      value?.hidden && hiddenFields.push(key);
    });
    const filteredFields = selectedField.filter(
      (f) => !hiddenFields.includes(f) && fields.map((f) => f.id).includes(f)
    );
    const primaryFieldId = fields.find((f) => f.isPrimary)?.id;
    if (!isEqual(filteredFields, selectedField)) {
      tableId &&
        setSearchFieldMap({
          ...searchFieldMapCache,
          [tableId]: filteredFields,
        });
      setFieldId(filteredFields.length > 0 ? filteredFields.join(',') : primaryFieldId);
    }
  }, [
    fieldId,
    fields,
    searchFieldMapCache,
    setFieldId,
    setSearchFieldMap,
    tableId,
    value,
    view?.columnMeta,
  ]);

  useHotkeys(
    `mod+f`,
    (e) => {
      setActive(true);
      ref.current?.focus();
      ref.current?.select();
      e.preventDefault();
    },
    {
      enableOnFormTags: ['input', 'select', 'textarea'],
    }
  );

  const [, cancel] = useDebounce(
    () => {
      setValue(inputValue);
    },
    500,
    [inputValue]
  );

  const resetSearch = useCallback(() => {
    cancel();
    setValue();
    setInputValue('');
  }, [cancel, setValue]);

  useHotkeys<HTMLInputElement>(
    `esc`,
    () => {
      if (isFocused) {
        resetSearch();
        setActive(false);
      }
    },
    {
      enableOnFormTags: ['input'],
    }
  );

  useEffect(() => {
    if (active) {
      ref.current?.focus();
      if (enableGlobalSearch) {
        setFieldId('all_fields');
        return;
      }
      // init fieldId
      if (fieldId === undefined) {
        if (tableId && searchFieldMapCache?.[tableId]?.length) {
          setFieldId(searchFieldMapCache[tableId].join(','));
          return;
        }
        setFieldId(fields[0].id);
      }
    }
  }, [
    active,
    enableGlobalSearch,
    fieldId,
    fields,
    ref,
    searchFieldMapCache,
    setFieldId,
    setSearchFieldMap,
    tableId,
  ]);

  const searchHeader = useMemo(() => {
    if (fieldId === 'all_fields') {
      return t('noun.global');
    }
    const fieldIds = fieldId?.split(',') || [];
    const fieldName = fields.find((f) => f.id === fieldIds[0])?.name;
    if (fieldIds.length === 1) {
      return t('table:view.search.field_one', { name: fieldName });
    }
    if (fieldIds.length > 1) {
      return t('table:view.search.field_other', { name: fieldName, length: fieldIds?.length });
    }
  }, [fieldId, fields, t]);

  return active ? (
    <div
      className={cn(
        'left-6 top-60 flex h-7 shrink-0 items-center gap-1 overflow-hidden rounded-xl bg-background p-0 pr-[7px] text-xs border outline-muted-foreground',
        {
          outline: isFocused,
        }
      )}
    >
      <Popover modal>
        <PopoverTrigger asChild>
          <Button variant="ghost" size={'xs'} className="max-w-40 truncate rounded-none border-r">
            <span className="truncate">{searchHeader}</span>
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-64 p-1">
          {fieldId && tableId && (
            <SearchCommand
              value={fieldId}
              onChange={(fieldIds) => {
                // switch to field
                if (!fieldIds) {
                  const newIds = searchFieldMapCache?.[tableId] || [fields[0].id];
                  setFieldId(newIds.join(','));
                  setEnableGlobalSearch(false);
                  return;
                }
                const ids = fieldIds.join(',');
                if (ids === 'all_fields') {
                  setEnableGlobalSearch(true);
                } else {
                  setEnableGlobalSearch(false);
                  tableId && setSearchFieldMap({ ...searchFieldMapCache, [tableId]: fieldIds });
                }
                setFieldId(ids);
              }}
            />
          )}
        </PopoverContent>
      </Popover>
      <input
        ref={ref}
        className="placeholder:text-muted-foregrounds flex w-32 rounded-md bg-transparent px-1 outline-none"
        placeholder={t('actions.search')}
        autoComplete="off"
        autoCorrect="off"
        spellCheck="false"
        type="text"
        value={inputValue || ''}
        onChange={(e) => {
          setInputValue(e.target.value);
        }}
        onBlur={() => {
          setIsFocused(false);
        }}
        onFocus={() => {
          setIsFocused(true);
        }}
      />
      <X
        className="hover:text-primary-foregrounds size-4 cursor-pointer font-light"
        onClick={() => {
          resetSearch();
          setActive(false);
        }}
      />
      <Search className="size-4" />
    </div>
  ) : (
    <ToolBarButton
      className={className}
      textClassName={textClassName}
      onClick={() => {
        setActive(true);
      }}
    >
      <Search className="size-4" />
    </ToolBarButton>
  );
}