teableio/teable

View on GitHub
packages/sdk/src/components/base-query/QueryBuilder.tsx

Summary

Maintainability
B
6 hrs
Test Coverage
import { X } from '@teable/icons';
import { BaseQueryColumnType, getFields } from '@teable/openapi';
import type {
  IBaseQueryColumn,
  IBaseQuery,
  IBaseQueryJoin,
  IQueryAggregation,
} from '@teable/openapi';
import { Button, cn } from '@teable/ui-lib';
import {
  forwardRef,
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useMemo,
  useRef,
  useState,
} from 'react';
import { QuerySortedKeys } from './constant';
import type { IContextColumns } from './context/QueryEditorContext';
import { QueryEditorProvider } from './context/QueryEditorProvider';
import { QueryFormContext } from './context/QueryFormContext';
import { QueryFormProvider } from './context/QueryFormProvider';
import { QueryFrom } from './query-from/QueryFrom';
import { QueryFromTableValue } from './query-from/QueryFromValue';
import { QueryEditorContainer } from './QueryEditorContainer';
import { QueryOperators } from './QueryOperators';

export interface IBaseQueryBuilderRef {
  validateQuery: () => boolean;
  initContext: (query?: IBaseQuery) => void;
}

export const BaseQueryBuilder = forwardRef<
  IBaseQueryBuilderRef,
  {
    className?: string;
    query?: IBaseQuery;
    maxDepth?: number;
    onChange: (query?: IBaseQuery) => void;
  }
>((props, ref) => {
  return (
    <QueryFormProvider>
      <QueryBuilderContainer {...props} ref={ref} />
    </QueryFormProvider>
  );
});

BaseQueryBuilder.displayName = 'QueryBuilder';

// TODO: Refactor this component context generation
const QueryBuilderContainer = forwardRef<
  IBaseQueryBuilderRef,
  {
    className?: string;
    query?: IBaseQuery;
    onChange: (query?: IBaseQuery) => void;
    getContextFromChild?: (context: IBaseQueryColumn[]) => void;
    depth?: number;
    maxDepth?: number;
  }
>((props, ref) => {
  const { className, query, onChange, depth = 0, getContextFromChild, maxDepth = 3 } = props;
  const [fromType, setFromType] = useState<'table' | 'query' | undefined>();
  const [childContext, setChildContext] = useState<IBaseQueryColumn[]>([]);
  const [joinContext, setJoinContext] = useState<IContextColumns>([]);
  const [aggregationContext, setAggregationContext] = useState<IBaseQueryColumn[]>([]);
  const [canSelectedColumnIds, setCanSelectedColumnIds] = useState<string[]>();
  const formQueryRef = useRef<IBaseQueryBuilderRef>(null);
  const { validators } = useContext(QueryFormContext);

  useEffect(() => {
    if (query) {
      if (query.from == undefined) {
        setFromType(undefined);
        return;
      }
      setFromType(
        typeof query.from === 'string' && fromType !== 'query' && query.from ? 'table' : 'query'
      );
    } else {
      setFromType(undefined);
    }
  }, [query, fromType]);

  useImperativeHandle(ref, () => ({
    validateQuery: () => {
      // validate from
      // context validators
      if (formQueryRef.current && !formQueryRef.current.validateQuery()) {
        return false;
      }
      // validate all keys
      if (
        (['from', ...QuerySortedKeys] as const).some(
          (key) => validators[key] && !validators[key]?.()
        )
      )
        return false;

      return true;
    },
    initContext: (innerQuery?: IBaseQuery) => {
      const query = innerQuery || props.query;
      collectContext('from', query?.from);
      collectContext('join', query?.join);
      collectContext('aggregation', query?.aggregation);
      formQueryRef.current?.initContext(query?.from as IBaseQuery);
    },
  }));

  const hasAggregation = !!query?.aggregation?.length;
  useEffect(() => {
    if (hasAggregation) {
      setCanSelectedColumnIds(query?.groupBy?.map((group) => group.column) || []);
    } else {
      setCanSelectedColumnIds(undefined);
    }
  }, [hasAggregation, query?.groupBy]);

  useEffect(() => {
    if (childContext.length === 0) {
      return getContextFromChild?.([]);
    }
    const aggregationColumns = aggregationContext.map((aggregation) => ({
      column: aggregation.column,
      type: BaseQueryColumnType.Aggregation,
      name: aggregation.name,
    }));
    const allColumns = canSelectedColumnIds
      ? [
          ...childContext.filter(({ column }) => canSelectedColumnIds.includes(column)),
          ...aggregationColumns,
          ...joinContext.filter(({ column }) => canSelectedColumnIds.includes(column)),
        ]
      : [...childContext, ...aggregationColumns, ...joinContext];
    if (!query?.select) {
      return getContextFromChild?.(allColumns);
    }
    const selectCols = query?.select;
    getContextFromChild?.(
      aggregationColumns.concat(
        selectCols
          .map((selectCol) => allColumns.find((v) => v.column === selectCol.column))
          .filter(Boolean) as IBaseQueryColumn[]
      )
    );
  }, [
    aggregationContext,
    childContext,
    getContextFromChild,
    joinContext,
    query?.select,
    canSelectedColumnIds,
  ]);

  const getContextWithTableIds = async (tableIds: string[]) => {
    const tableFields = await Promise.all(
      tableIds.map((tableId) => getFields(tableId).then((res) => res.data))
    );
    return tableFields.map((fields) =>
      fields.map(
        (field) =>
          ({
            column: field.id,
            type: BaseQueryColumnType.Field,
            name: field.name,
            fieldSource: field,
          }) as IBaseQueryColumn
      )
    );
  };

  const collectContext = async <T extends keyof IBaseQuery>(key: T, value?: IBaseQuery[T]) => {
    switch (key) {
      case 'join':
        {
          if (!value) {
            return setJoinContext([]);
          }
          const join = value as IBaseQueryJoin[];
          const tableIds = join.map((v) => v.table).filter((v) => !!v) as string[];
          const tablesContext = await getContextWithTableIds(tableIds);
          setJoinContext(
            tablesContext
              .map((context, i) =>
                context.map((v) => ({
                  ...v,
                  groupTableId: tableIds[i],
                }))
              )
              .flat()
          );
        }
        break;
      case 'aggregation':
        {
          if (!value) {
            return setAggregationContext([]);
          }
          const aggregations = value as IQueryAggregation;
          setAggregationContext(
            aggregations
              .map((aggregation) => {
                const column = [...joinContext, ...childContext].find(
                  (v) => v.column === aggregation.column
                );
                if (!column) return;
                return {
                  name: `${column.name}_${aggregation.statisticFunc}`,
                  type: column.type,
                  column: `${aggregation.column}_${aggregation.statisticFunc}`,
                  fieldSource: column.fieldSource,
                };
              })
              .filter(Boolean) as IBaseQueryColumn[]
          );
        }
        break;
      case 'from':
        {
          if (!value) {
            setChildContext([]);
            return;
          }
          if (typeof value === 'string') {
            const context = await getContextWithTableIds([value]);
            setChildContext(context.flat());
          }
        }
        break;
    }
  };

  const onQueryChange = async <T extends keyof IBaseQuery>(key: T, value: IBaseQuery[T]) => {
    console.log(depth, 'onQueryChange', key, value);
    collectContext(key, value);
    if (!query) {
      key === 'from' &&
        onChange({
          from: value as IBaseQuery['from'],
        });
      return;
    }
    onChange({
      ...query,
      [key]: value,
    });
  };

  const handleGetContextFromChild = useCallback((childContext: IBaseQueryColumn[]) => {
    setChildContext(childContext);
  }, []);

  const providerContextColumns = useMemo(() => {
    return {
      from: childContext,
      join: joinContext,
    };
  }, [childContext, joinContext]);

  const onFromChange = async (type: string, tableId?: string) => {
    console.log(depth, 'onFromChange', type, tableId);
    if (type === 'query') {
      onQueryChange('from', '');
      setFromType('query');
      return;
    }
    if (tableId) {
      onQueryChange('from', tableId);
      setFromType('table');
      return;
    }
    // if tableId is undefined, clear from
    if (!tableId) {
      setFromType(undefined);
      onChange(undefined);
      return;
    }
    setFromType(undefined);
    onQueryChange('from', '');
  };

  const onFromQueryChange = (query?: IBaseQuery) => {
    if (!query) {
      setChildContext([]);
      setFromType(undefined);
      // if tableId is undefined, clear from
      onChange(undefined);
      return;
    }
    onQueryChange('from', query ?? '');
  };

  return (
    <div className={cn('relative rounded border py-4 px-2', className)}>
      {depth > 0 && (
        <Button
          className="absolute right-1 top-1 h-auto rounded-full p-0.5 text-[13px]"
          size={'xs'}
          variant={'outline'}
          onClick={() => onChange(undefined)}
        >
          <X />
        </Button>
      )}
      <QueryFrom addButton={!fromType} maxDepth={maxDepth <= depth + 1} onClick={onFromChange}>
        <QueryFromTableValue
          from={query?.from}
          onChange={(from) => onFromChange('from', from)}
          component={
            fromType === 'query' ? (
              <QueryFormProvider>
                <QueryBuilderContainer
                  ref={formQueryRef}
                  className="py-6"
                  query={query?.from as IBaseQuery}
                  onChange={onFromQueryChange}
                  depth={depth + 1}
                  maxDepth={maxDepth}
                  getContextFromChild={handleGetContextFromChild}
                />
              </QueryFormProvider>
            ) : undefined
          }
        />
      </QueryFrom>
      {query?.from && (
        <QueryEditorProvider
          columns={providerContextColumns}
          canSelectedColumnIds={canSelectedColumnIds}
          defaultStatus={{
            join: !!query?.join,
            limit: !!query?.limit,
            where: !!query?.where,
            offset: !!query?.offset,
            select: !!query?.select,
            groupBy: !!query?.groupBy,
            orderBy: !!query?.orderBy,
            aggregation: !!query?.aggregation,
          }}
        >
          <QueryOperators />
          <QueryEditorContainer query={query!} onChange={onQueryChange} />
        </QueryEditorProvider>
      )}
    </div>
  );
});

QueryBuilderContainer.displayName = 'QueryBuilderContainer';