teableio/teable

View on GitHub
packages/sdk/src/components/editor/link/EditorMain.tsx

Summary

Maintainability
A
2 hrs
Test Coverage
import type { ILinkCellValue, ILinkFieldOptions } from '@teable/core';
import { isMultiValueLink } from '@teable/core';
import { ArrowUpRight, Plus } from '@teable/icons';
import type { IGetRecordsRo } from '@teable/openapi';
import { Button, Tabs, TabsList, TabsTrigger } from '@teable/ui-lib';
import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import type { ForwardRefRenderFunction } from 'react';
import { RowCountProvider, LinkViewProvider } from '../../../context';
import { useTranslation } from '../../../context/app/i18n';
import { LinkFilterProvider } from '../../../context/query/LinkFilterProvider';
import { useBaseId, useLinkFilter, useRowCount, useSearch } from '../../../hooks';
import { CreateRecordModal } from '../../create-record';
import { SearchInput } from '../../search';
import { LinkListType } from './interface';
import type { ILinkListRef } from './LinkList';
import { LinkList } from './LinkList';

export interface ILinkEditorMainProps {
  fieldId: string;
  recordId?: string;
  options: ILinkFieldOptions;
  container?: HTMLElement;
  cellValue?: ILinkCellValue | ILinkCellValue[];
  isEditing?: boolean;
  setEditing?: (isEditing: boolean) => void;
  onChange?: (value: ILinkCellValue | ILinkCellValue[] | null) => void;
}

export interface ILinkEditorMainRef {
  onReset: () => void;
}

const LinkEditorInnerBase: ForwardRefRenderFunction<ILinkEditorMainRef, ILinkEditorMainProps> = (
  props,
  forwardRef
) => {
  const { recordId, fieldId, options, cellValue, isEditing, setEditing, onChange } = props;

  const { searchQuery } = useSearch();
  const rowCount = useRowCount() || 0;
  const baseId = useBaseId();

  useImperativeHandle(forwardRef, () => ({
    onReset,
  }));

  const { t } = useTranslation();

  const listRef = useRef<ILinkListRef>(null);
  const [values, setValues] = useState<ILinkCellValue[]>();
  const [listType, setListType] = useState<LinkListType>(LinkListType.Unselected);

  const isMultiple = isMultiValueLink(options.relationship);
  const { foreignTableId, filterByViewId } = options;

  const { selectedRecordIds, filterLinkCellCandidate, setLinkCellCandidate } = useLinkFilter();

  const recordQuery = useMemo((): IGetRecordsRo => {
    return {
      search: searchQuery,
      filterLinkCellCandidate,
      selectedRecordIds,
    };
  }, [searchQuery, filterLinkCellCandidate, selectedRecordIds]);

  useEffect(() => {
    if (!isEditing) return;
    listRef.current?.onForceUpdate();
    if (cellValue == null) return setValues(cellValue);
    setValues(Array.isArray(cellValue) ? cellValue : [cellValue]);
  }, [cellValue, isEditing]);

  const onViewShown = (type: LinkListType) => {
    if (type === listType) return;
    listRef.current?.onReset();
    setListType(type);
    if (type === LinkListType.Selected) {
      setLinkCellCandidate(undefined);
    } else {
      setLinkCellCandidate([fieldId, recordId].filter(Boolean));
    }
  };

  const onReset = () => {
    setValues(undefined);
    setEditing?.(false);
    setListType(LinkListType.Unselected);
    listRef.current?.onReset();
  };

  const onListChange = useCallback((value?: ILinkCellValue[]) => {
    setValues(value);
  }, []);

  const onConfirm = () => {
    onReset();
    if (values == null) return onChange?.(null);
    onChange?.(isMultiple ? values : values[0]);
  };

  const onNavigate = () => {
    if (!baseId) return;

    let path = `/base/${baseId}/${foreignTableId}`;

    if (filterByViewId) {
      path += `/${filterByViewId}`;
    }

    const url = new URL(path, window.location.origin);

    window.open(url.toString(), '_blank');
  };

  return (
    <>
      <div className="flex items-center space-x-0.5">
        <span className="text-lg">{t('editor.link.placeholder')}</span>
        <Button
          size="xs"
          variant="link"
          className="gap-0.5 text-[13px] text-slate-500 underline"
          onClick={onNavigate}
        >
          {t('editor.link.goToForeignTable')}
          <ArrowUpRight className="size-4" />
        </Button>
      </div>
      <div className="flex items-center justify-between">
        <SearchInput container={props.container} />
        <div className="ml-4">
          <Tabs defaultValue="unselected" orientation="horizontal" className="flex gap-4">
            <TabsList className="">
              <TabsTrigger
                className="px-4"
                value="unselected"
                onClick={() => onViewShown(LinkListType.Unselected)}
              >
                {t('editor.link.unselected')}
              </TabsTrigger>
              <TabsTrigger
                className="px-4"
                value="selected"
                onClick={() => onViewShown(LinkListType.Selected)}
              >
                {t('editor.link.selected')}
              </TabsTrigger>
            </TabsList>
          </Tabs>
        </div>
      </div>
      <div className="relative w-full flex-1 overflow-hidden rounded-md border">
        <LinkList
          ref={listRef}
          type={listType}
          rowCount={rowCount}
          cellValue={cellValue}
          isMultiple={isMultiple}
          recordQuery={recordQuery}
          onChange={onListChange}
        />
      </div>
      <div className="flex justify-between">
        <CreateRecordModal>
          <Button variant="ghost">
            <Plus className="size-4" />
            {t('editor.link.create')}
          </Button>
        </CreateRecordModal>
        <div>
          <Button variant="outline" onClick={onReset}>
            {t('common.cancel')}
          </Button>
          <Button className="ml-4" onClick={onConfirm}>
            {t('common.confirm')}
          </Button>
        </div>
      </div>
    </>
  );
};

const LinkEditorInner = forwardRef(LinkEditorInnerBase);

const LinkEditorMainBase: ForwardRefRenderFunction<ILinkEditorMainRef, ILinkEditorMainProps> = (
  props,
  forwardRef
) => {
  const { options, cellValue } = props;
  const { baseId: foreignBaseId } = options;
  const baseId = useBaseId();

  const selectedRecordIds = useMemo(() => {
    return Array.isArray(cellValue)
      ? cellValue.map((v) => v.id)
      : cellValue?.id
        ? [cellValue.id]
        : [];
  }, [cellValue]);

  return (
    <LinkViewProvider linkBaseId={foreignBaseId ?? baseId} linkFieldId={props.fieldId}>
      <LinkFilterProvider
        filterLinkCellCandidate={props.recordId ? [props.fieldId, props.recordId] : props.fieldId}
        selectedRecordIds={selectedRecordIds}
      >
        <RowCountProvider>
          <LinkEditorInner ref={forwardRef} {...props} />
        </RowCountProvider>
      </LinkFilterProvider>
    </LinkViewProvider>
  );
};

export const LinkEditorMain = forwardRef(LinkEditorMainBase);