export function DataTable()

in desktop/flipper-plugin/src/ui/data-table/DataTable.tsx [133:612]


export function DataTable<T extends object>(
  props: DataTableProps<T>,
): React.ReactElement {
  const {onRowStyle, onSelect, onCopyRows, onContextMenu} = props;
  const dataSource = normalizeDataSourceInput(props);
  useAssertStableRef(dataSource, 'dataSource');
  useAssertStableRef(onRowStyle, 'onRowStyle');
  useAssertStableRef(props.onSelect, 'onRowSelect');
  useAssertStableRef(props.columns, 'columns');
  useAssertStableRef(onCopyRows, 'onCopyRows');
  useAssertStableRef(onContextMenu, 'onContextMenu');

  const isUnitTest = useInUnitTest();

  // eslint-disable-next-line
  const scope = isUnitTest ? '' : usePluginInstanceMaybe()?.definition.id ?? '';
  const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
  const [tableState, dispatch] = useReducer(
    dataTableManagerReducer as DataTableReducer<T>,
    undefined,
    () =>
      createInitialState({
        dataSource,
        defaultColumns: props.columns,
        onSelect,
        scope,
        virtualizerRef,
        autoScroll: props.enableAutoScroll,
        enablePersistSettings: props.enablePersistSettings,
      }),
  );

  const stateRef = useRef(tableState);
  stateRef.current = tableState;
  const lastOffset = useRef(0);
  const dragging = useRef(false);

  const [tableManager] = useState(() =>
    createDataTableManager(dataSource, dispatch, stateRef),
  );
  if (props.tableManagerRef) {
    (props.tableManagerRef as MutableRefObject<any>).current = tableManager;
  }

  const {columns, selection, searchValue, sorting} = tableState;

  const visibleColumns = useMemo(
    () => columns.filter((column) => column.visible),
    [columns],
  );

  const renderingConfig = useMemo<TableRowRenderContext<T>>(() => {
    let startIndex = 0;

    return {
      columns: visibleColumns,
      onMouseEnter(e, _item, index) {
        if (dragging.current && e.buttons === 1 && props.enableMultiSelect) {
          // by computing range we make sure no intermediate items are missed when scrolling fast
          tableManager.addRangeToSelection(startIndex, index);
        }
      },
      onMouseDown(e, _item, index) {
        if (!props.enableMultiSelect && e.buttons > 1) {
          tableManager.selectItem(index, false, true);
          return;
        }
        if (!dragging.current) {
          if (e.buttons > 1) {
            // for right click we only want to add if needed, not deselect
            tableManager.addRangeToSelection(index, index, false);
          } else if (e.ctrlKey || e.metaKey) {
            tableManager.addRangeToSelection(index, index, true);
          } else if (e.shiftKey) {
            tableManager.selectItem(index, true, true);
          } else {
            tableManager.selectItem(index, false, true);
          }

          dragging.current = true;
          startIndex = index;

          function onStopDragSelecting() {
            dragging.current = false;
            document.removeEventListener('mouseup', onStopDragSelecting);
          }

          document.addEventListener('mouseup', onStopDragSelecting);
        }
      },
      onRowStyle,
      onContextMenu: props.enableContextMenu
        ? () => {
            // using a ref keeps the config stable, so that a new context menu doesn't need
            // all rows to be rerendered, but rather shows it conditionally
            return contextMenuRef.current?.()!;
          }
        : undefined,
    };
  }, [
    visibleColumns,
    tableManager,
    onRowStyle,
    props.enableContextMenu,
    props.enableMultiSelect,
  ]);

  const itemRenderer = useCallback(
    function itemRenderer(
      record: T,
      index: number,
      renderContext: TableRowRenderContext<T>,
    ) {
      return (
        <TableRow
          key={index}
          config={renderContext}
          record={record}
          itemIndex={index}
          highlighted={
            index === selection.current || selection.items.has(index)
          }
          style={onRowStyle?.(record)}
        />
      );
    },
    [selection, onRowStyle],
  );

  /**
   * Keyboard / selection handling
   */
  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<any>) => {
      let handled = true;
      const shiftPressed = e.shiftKey;
      const outputSize = dataSource.view.size;
      const windowSize = props.scrollable
        ? virtualizerRef.current?.virtualItems.length ?? 0
        : dataSource.view.size;
      if (!windowSize) {
        return;
      }
      switch (e.key) {
        case 'ArrowUp':
          tableManager.selectItem(
            (idx) => (idx > 0 ? idx - 1 : 0),
            shiftPressed,
          );
          break;
        case 'ArrowDown':
          tableManager.selectItem(
            (idx) => (idx < outputSize - 1 ? idx + 1 : idx),
            shiftPressed,
          );
          break;
        case 'Home':
          tableManager.selectItem(0, shiftPressed);
          break;
        case 'End':
          tableManager.selectItem(outputSize - 1, shiftPressed);
          break;
        case ' ': // yes, that is a space
        case 'PageDown':
          tableManager.selectItem(
            (idx) => Math.min(outputSize - 1, idx + windowSize - 1),
            shiftPressed,
          );
          break;
        case 'PageUp':
          tableManager.selectItem(
            (idx) => Math.max(0, idx - windowSize + 1),
            shiftPressed,
          );
          break;
        case 'Escape':
          tableManager.clearSelection();
          break;
        case 'Control':
          tableManager.setSelectedSearchRecord();
          break;
        default:
          handled = false;
      }
      if (handled) {
        e.stopPropagation();
        e.preventDefault();
      }
    },
    [dataSource, tableManager, props.scrollable],
  );

  const [debouncedSetFilter] = useState(() => {
    // we don't want to trigger filter changes too quickly, as they can be pretty expensive
    // and would block the user from entering text in the search bar for example
    // (and in the future would really benefit from concurrent mode here :))
    const setFilter = (
      search: string,
      useRegex: boolean,
      columns: DataTableColumn<T>[],
    ) => {
      dataSource.view.setFilter(
        computeDataTableFilter(search, useRegex, columns),
      );
    };
    return isUnitTest ? setFilter : debounce(setFilter, 250);
  });
  useEffect(
    function updateFilter() {
      debouncedSetFilter(
        tableState.searchValue,
        tableState.useRegex,
        tableState.columns,
      );
    },
    // Important dep optimization: we don't want to recalc filters if just the width or visibility changes!
    // We pass entire state.columns to computeDataTableFilter, but only changes in the filter are a valid cause to compute a new filter function
    // eslint-disable-next-line
    [
      tableState.searchValue,
      tableState.useRegex,
      // eslint-disable-next-line react-hooks/exhaustive-deps
      ...tableState.columns.map((c) => c.filters),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      ...tableState.columns.map((c) => c.inversed),
    ],
  );

  useEffect(
    function updateSorting() {
      if (tableState.sorting === undefined) {
        dataSource.view.setSortBy(undefined);
        dataSource.view.setReversed(false);
      } else {
        dataSource.view.setSortBy(tableState.sorting.key);
        dataSource.view.setReversed(tableState.sorting.direction === 'desc');
      }
    },
    [dataSource, tableState.sorting],
  );

  const isMounted = useRef(false);
  useEffect(
    function triggerSelection() {
      if (isMounted.current) {
        onSelect?.(
          getSelectedItem(dataSource, tableState.selection),
          getSelectedItems(dataSource, tableState.selection),
        );
      }
      isMounted.current = true;
    },
    [onSelect, dataSource, tableState.selection],
  );

  // The initialScrollPosition is used to both capture the initial px we want to scroll to,
  // and whether we performed that scrolling already (if so, it will be 0)
  useLayoutEffect(
    function scrollSelectionIntoView() {
      if (tableState.initialOffset) {
        virtualizerRef.current?.scrollToOffset(tableState.initialOffset);
        dispatch({
          type: 'appliedInitialScroll',
        });
      } else if (selection && selection.current >= 0) {
        dispatch({type: 'setAutoScroll', autoScroll: false});
        virtualizerRef.current?.scrollToIndex(selection!.current, {
          align: 'auto',
        });
      }
    },
    // initialOffset is relevant for the first run,
    // but should not trigger the efffect in general
    // eslint-disable-next-line
    [selection],
  );

  /** Range finder */
  const [range, setRange] = useState('');
  const hideRange = useRef<any>();

  const onRangeChange = useCallback(
    (start: number, end: number, total: number, offset) => {
      setRange(`${start} - ${end} / ${total}`);
      lastOffset.current = offset;
      clearTimeout(hideRange.current!);
      hideRange.current = setTimeout(() => {
        setRange('');
      }, 1000);
    },
    [],
  );

  const onUpdateAutoScroll = useCallback(
    (autoScroll: boolean) => {
      if (props.enableAutoScroll) {
        dispatch({type: 'setAutoScroll', autoScroll});
      }
    },
    [props.enableAutoScroll],
  );

  /** Context menu */
  const contexMenu = isUnitTest
    ? undefined
    : // eslint-disable-next-line
      useCallback(
        () =>
          tableContextMenuFactory(
            dataSource,
            dispatch,
            selection,
            tableState.columns,
            visibleColumns,
            onCopyRows,
            onContextMenu,
          ),
        [
          dataSource,
          dispatch,
          selection,
          tableState.columns,
          visibleColumns,
          onCopyRows,
          onContextMenu,
        ],
      );

  const contextMenuRef = useRef(contexMenu);
  contextMenuRef.current = contexMenu;

  useEffect(function initialSetup() {
    return function cleanup() {
      // write current prefs to local storage
      savePreferences(stateRef.current, lastOffset.current);
      // if the component unmounts, we reset the SFRW pipeline to
      // avoid wasting resources in the background
      dataSource.view.reset();
      // clean ref
      if (props.tableManagerRef) {
        (props.tableManagerRef as MutableRefObject<any>).current = undefined;
      }
    };
    // one-time setup and cleanup effect, everything in here is asserted to be stable:
    // dataSource, tableManager, tableManagerRef
    // eslint-disable-next-line
  }, []);

  useEffect(
    function findMappedIndex() {
      // Hardcoded delay to give dataSource.view time to update, otherwise
      // the entries we loop over here won't be the list of unfiltered records
      // the user sees, so there won't be a match found
      const delay = 300;

      if (tableState.selectedSearchRecord) {
        const timer = setTimeout(() => {
          for (let i = 0; i < dataSource.view.size; i++) {
            if (dataSource.view.get(i) === tableState.selectedSearchRecord) {
              tableManager.clearSelectedSearchRecord();
              tableManager.selectItem(i, false, true);
              break;
            }
          }
        }, delay);

        return () => clearTimeout(timer);
      }
    },
    [dataSource, selection, tableManager, tableState.selectedSearchRecord],
  );

  const header = (
    <Layout.Container>
      {props.enableSearchbar && (
        <TableSearch
          searchValue={searchValue}
          useRegex={tableState.useRegex}
          dispatch={dispatch as any}
          contextMenu={props.enableContextMenu ? contexMenu : undefined}
          extraActions={props.extraActions}
        />
      )}
    </Layout.Container>
  );
  const columnHeaders = (
    <Layout.Container>
      {props.enableColumnHeaders && (
        <TableHead
          visibleColumns={visibleColumns}
          dispatch={dispatch as any}
          sorting={sorting}
          scrollbarSize={
            props.scrollable
              ? 0
              : 15 /* width on MacOS: TODO, determine dynamically */
          }
        />
      )}
    </Layout.Container>
  );

  const emptyRenderer =
    props.onRenderEmpty === undefined
      ? props.onRenderEmpty
      : props.onRenderEmpty;
  let mainSection: JSX.Element;
  if (props.scrollable) {
    const dataSourceRenderer = (
      <DataSourceRendererVirtual<T, TableRowRenderContext<T>>
        dataSource={dataSource}
        autoScroll={tableState.autoScroll && !dragging.current}
        useFixedRowHeight={!tableState.usesWrapping}
        defaultRowHeight={DEFAULT_ROW_HEIGHT}
        context={renderingConfig}
        itemRenderer={itemRenderer}
        onKeyDown={onKeyDown}
        virtualizerRef={virtualizerRef}
        onRangeChange={onRangeChange}
        onUpdateAutoScroll={onUpdateAutoScroll}
        emptyRenderer={emptyRenderer}
      />
    );

    mainSection = props.enableHorizontalScroll ? (
      <Layout.Top>
        {header}
        <Layout.ScrollContainer horizontal vertical={false}>
          <Layout.Top>
            {columnHeaders}
            {dataSourceRenderer}
          </Layout.Top>
        </Layout.ScrollContainer>
      </Layout.Top>
    ) : (
      <Layout.Top>
        <div>
          {header}
          {columnHeaders}
        </div>
        {dataSourceRenderer}
      </Layout.Top>
    );
  } else {
    mainSection = (
      <Layout.Container>
        {header}
        {columnHeaders}
        <DataSourceRendererStatic<T, TableRowRenderContext<T>>
          dataSource={dataSource}
          useFixedRowHeight={!tableState.usesWrapping}
          defaultRowHeight={DEFAULT_ROW_HEIGHT}
          context={renderingConfig}
          itemRenderer={itemRenderer}
          onKeyDown={onKeyDown}
          emptyRenderer={emptyRenderer}
        />
      </Layout.Container>
    );
  }

  return (
    <Layout.Container grow={props.scrollable}>
      {mainSection}
      {props.enableAutoScroll && (
        <AutoScroller>
          <PushpinFilled
            style={{
              color: tableState.autoScroll ? theme.successColor : undefined,
            }}
            onClick={() => {
              dispatch({type: 'toggleAutoScroll'});
            }}
          />
        </AutoScroller>
      )}
      {range && !isUnitTest && <RangeFinder>{range}</RangeFinder>}
    </Layout.Container>
  );
}