Vizzuality/landgriffon

View on GitHub
client/src/components/tree-select/utils.ts

Summary

Maintainability
A
35 mins
Test Coverage
import Fuse from 'fuse.js';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useId, useMemo } from 'react';
import { sortBy } from 'lodash-es';
import { flattenTreeData } from 'rc-tree/lib/utils/treeUtil';

import { FIELD_NAMES } from './constants';

import { splitStringByIndexes } from 'utils/string';

import type { DeepKeys } from '@tanstack/react-table';
import type { TreeDataNode, TreeSelectOption } from './types';
import type { DataNode, Key } from 'rc-tree/lib/interface';

interface HasChildren<T> {
  children?: T[];
}

const ALL = (checkedKeys: Key[], checkedNodes: TreeDataNode[]): DataNode['key'][] =>
  checkedNodes.map(({ value }) => value);

const PARENT = (checkedKeys: Key[], checkedNodes: TreeDataNode[]): TreeDataNode['value'][] => {
  // 1. Extracting parents selected
  const parentsWithChildren = checkedNodes.filter((node) => !!node?.children);
  // 2. Extracting children ids from parents selected above
  const childrenWithParents = [];
  if (parentsWithChildren && parentsWithChildren.length) {
    parentsWithChildren.forEach(({ children }) =>
      children.forEach(({ value }) => childrenWithParents.push(value)),
    );
  }
  // 3. Filtering checkedKeys with children ids to not send unnecessary values
  const filteredValues =
    childrenWithParents && childrenWithParents.length
      ? checkedKeys.filter((key) => !childrenWithParents.includes(key))
      : checkedKeys;
  return filteredValues;
};

const CHILD = (checkedKeys: Key[], checkedNodes: TreeDataNode[]): TreeDataNode['value'][] => {
  const onlyChildren = checkedNodes
    .filter((node) => !node.children || node.children.length === 0)
    .map(({ value }) => value);
  return onlyChildren;
};

export const CHECKED_STRATEGIES = { ALL, PARENT, CHILD };

export const flattenTree = <T extends HasChildren<T>>(tree: T) => {
  const flattenedTree = [tree];
  if (tree.children) {
    flattenedTree.push(...tree.children.flatMap((child) => flattenTree(child)));
  }

  return flattenedTree;
};

export const getParents = <T extends { parent?: T }>(node: T): T[] => {
  if (!node.parent) {
    return [];
  }
  const parent = node.parent;
  return [parent, ...getParents(parent)];
};

const filterTree = <T extends HasChildren<T>>(
  tree: T,
  filter: (node: T) => boolean,
  depth = 0,
): T | null => {
  const isValid = filter(tree);

  // base case: filter a leaf node
  if (!tree.children || tree.children.length === 0) {
    const value = isValid ? tree : null;
    return value;
  }

  // filter all children, get the valid ones
  const filteredChildren = tree.children
    .map((child) => filterTree(child, filter, depth + 1))
    .filter(Boolean);

  // if no children is valid, return the parent without children, ONLY if it's valid itself
  if (filteredChildren.length === 0) {
    return isValid ? { ...tree, index: depth, children: [] } : null;
  }

  return {
    ...tree,
    children: filteredChildren,
  };
};

const recursiveSortHelper = <T extends HasChildren<T>>(
  value: T,
  selector: ((value: T) => unknown) | DeepKeys<T>,
): T => {
  const children = value.children?.map((child) => recursiveSortHelper(child, selector));
  return { ...value, children: sortBy(children, selector) };
};

export const recursiveSort = <T extends HasChildren<T>>(
  values: T[],
  selector: ((value: T) => unknown) | DeepKeys<T>,
): T[] => {
  return sortBy(values, selector).map((value) => recursiveSortHelper(value, selector));
};

export const recursiveMap = <T extends HasChildren<T>, V extends Record<keyof unknown, unknown>>(
  value: T,
  mapper: (value: T) => V & HasChildren<V>,
): V => {
  const mappedValue = mapper(value);

  const children = value.children
    ? value.children?.map((child) => recursiveMap(child, mapper))
    : [];

  return {
    ...mappedValue,
    children,
  };
};

const getFilteredOptions = (options: TreeSelectOption[], search?: string) => {
  const filteredOptions = search
    ? options
        .map((opt) =>
          filterTree(opt, (node) => {
            const fuse = new Fuse([node], {
              includeScore: false,
              keys: ['label'],
              threshold: 0.4,
            });
            return fuse.search(search).length > 0;
          }),
        )
        .filter(Boolean)
    : options;

  return filteredOptions;
};

export interface UseTreeOptions {
  render: (node: Omit<TreeDataNode, 'className'>) => TreeDataNode;
  isOptionSelected: (id: Key) => boolean;
}

const optionToTreeData = (
  option: TreeSelectOption,
  render: UseTreeOptions['render'],
  depth = 0,
): TreeDataNode => {
  const children = option.children?.map((option) => optionToTreeData(option, render, depth + 1));
  return render({
    ...option,
    style: { paddingLeft: 16 * depth },
    children,
  });
};

export const useTree = (
  options: TreeSelectOption[],
  search = '',
  { render, isOptionSelected }: UseTreeOptions,
) => {
  const id = useId();
  const { data: filteredKeys } = useQuery({
    queryKey: ['filtered-keys', id, options, search] as const,
    queryFn: ({ queryKey: [, , options, search] }) => {
      const filteredOptions = getFilteredOptions(options, search);
      const keys = filteredOptions.flatMap((opt) => flattenTree(opt).map((opt) => opt.value));
      return keys;
    },
  });

  const { data: treeData, refetch } = useQuery({
    queryKey: ['tree-data', id, options, render] as const,
    queryFn: ({ queryKey: [, , options] }) => {
      return options.map((option) => optionToTreeData(option, render));
    },
    placeholderData: [],
  });

  // TODO: this is a hack to force the tree to re-render when the current selection changes. Looks like even adding render as part of the query key doesn't trigger it
  useEffect(() => {
    refetch();
  }, [refetch, render]);

  const { data: flatTreeData } = useQuery({
    // treeData is a circular dependency
    queryKey: ['flattened-options', id, treeData.map((opt) => opt.value)],
    queryFn: () => {
      const flattened = flattenTreeData(treeData, true, FIELD_NAMES);
      return flattened;
    },
    placeholderData: [],
    enabled: !!treeData?.length,
  });

  const fuse = useMemo(
    () =>
      new Fuse(flatTreeData, {
        includeScore: false,
        keys: ['title'],
        threshold: 0.4,
        includeMatches: true,
      }),
    [flatTreeData],
  );

  const { data: filteredOptions } = useQuery({
    queryKey: ['filtered-options', id, search, flatTreeData.length, isOptionSelected] as const,
    queryFn: ({ queryKey: [, , search] }) => {
      const result = fuse.search(search);

      return result.map(({ item, matches }) => ({
        ...item,
        isSelected: isOptionSelected(item.key),
        matchingParts: splitStringByIndexes(matches[0].value, matches[0].indices),
      }));
    },
    placeholderData: [],
    enabled: !!flatTreeData?.length,
    refetchOnReconnect: false,
    refetchOnMount: false,
    refetchOnWindowFocus: false,
  });

  return { treeData, filteredKeys, flatTreeData, filteredOptions };
};