diff --git a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts index e97f8b95df..b410db78a8 100644 --- a/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts +++ b/apps/nestjs-backend/src/features/base/base-query/base-query.service.ts @@ -73,7 +73,7 @@ export class BaseQueryService { .$queryRawUnsafe<{ [key in string]: unknown }[]>(query) .catch((e) => { this.logger.error(e); - throw new BadRequestException(`Query failed: ${query}`); + throw new BadRequestException(`Query failed: ${query}, ${e.message}`); }); return { diff --git a/apps/nestjs-backend/src/features/base/base-query/parse/select.ts b/apps/nestjs-backend/src/features/base/base-query/parse/select.ts index 0217793618..651e472d1d 100644 --- a/apps/nestjs-backend/src/features/base/base-query/parse/select.ts +++ b/apps/nestjs-backend/src/features/base/base-query/parse/select.ts @@ -39,6 +39,7 @@ export class QuerySelect { if (field && getQueryColumnTypeByFieldInstance(field) === BaseQueryColumnType.Field) { if (cur.alias) { acc[cur.alias] = field.dbFieldName; + currentFieldMap[cur.column].name = cur.alias; currentFieldMap[cur.column].dbFieldName = cur.alias; } else { const alias = `${field.id}_${field.name}`; diff --git a/packages/common-i18n/src/locales/en/sdk.json b/packages/common-i18n/src/locales/en/sdk.json index afe6f216d5..3ba6d79694 100644 --- a/packages/common-i18n/src/locales/en/sdk.json +++ b/packages/common-i18n/src/locales/en/sdk.json @@ -619,7 +619,9 @@ "add": "Add", "error": { "invalidCol": "Invalid column, please reselect", - "invalidCols": "Invalid columns: {{colNames}}" + "invalidCols": "Invalid columns: {{colNames}}", + "invalidTable": "Invalid table, please reselect", + "requiredSelect": "You must select one" }, "from": { "title": "From", diff --git a/packages/common-i18n/src/locales/zh/sdk.json b/packages/common-i18n/src/locales/zh/sdk.json index 30fee829b8..892ae1ecb9 100644 --- a/packages/common-i18n/src/locales/zh/sdk.json +++ b/packages/common-i18n/src/locales/zh/sdk.json @@ -620,7 +620,9 @@ "add": "添加", "error": { "invalidCol": "无效的列,请重新选择", - "invalidCols": "无效的列:{{colNames}}" + "invalidCols": "无效的列:{{colNames}}", + "invalidTable": "无效的表格,请重新选择", + "requiredSelect": "必须选择一个" }, "from": { "title": "来源", diff --git a/packages/sdk/src/components/base-query/QueryBuilder.tsx b/packages/sdk/src/components/base-query/QueryBuilder.tsx index 3ef394bbc2..f14ea22079 100644 --- a/packages/sdk/src/components/base-query/QueryBuilder.tsx +++ b/packages/sdk/src/components/base-query/QueryBuilder.tsx @@ -1,12 +1,12 @@ import { X } from '@teable/icons'; -import { BaseQueryColumnType, baseQuerySchema, getFields } from '@teable/openapi'; +import { BaseQueryColumnType, getFields } from '@teable/openapi'; import type { IBaseQueryColumn, IBaseQuery, IBaseQueryJoin, IQueryAggregation, } from '@teable/openapi'; -import { Badge, Button } from '@teable/ui-lib'; +import { Button, cn } from '@teable/ui-lib'; import { forwardRef, useCallback, @@ -17,16 +17,14 @@ import { useRef, useState, } from 'react'; -import { useTranslation } from '../../context/app/i18n'; -import { useTables } from '../../hooks'; 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 { FormItem } from './FormItem'; +import { QueryFrom } from './query-from/QueryFrom'; +import { QueryFromTableValue } from './query-from/QueryFromValue'; import { QueryEditorContainer } from './QueryEditorContainer'; -import { QueryFrom } from './QueryFom'; import { QueryOperators } from './QueryOperators'; export interface IBaseQueryBuilderRef { @@ -38,6 +36,7 @@ export const BaseQueryBuilder = forwardRef< { className?: string; query?: IBaseQuery; + maxDepth?: number; onChange: (query?: IBaseQuery) => void; } >((props, ref) => { @@ -58,36 +57,32 @@ const QueryBuilderContainer = forwardRef< onChange: (query?: IBaseQuery) => void; getContextFromChild?: (context: IBaseQueryColumn[]) => void; depth?: number; + maxDepth?: number; } >((props, ref) => { - const { query, onChange, depth = 0, getContextFromChild } = props; - const { t } = useTranslation(); + const { className, query, onChange, depth = 0, getContextFromChild, maxDepth = 3 } = props; const [fromType, setFromType] = useState<'table' | 'query'>(); const [childContext, setChildContext] = useState([]); const [joinContext, setJoinContext] = useState([]); const [aggregationContext, setAggregationContext] = useState([]); const [canSelectedColumnIds, setCanSelectedColumnIds] = useState(); - const tables = useTables(); const formQueryRef = useRef(null); const { validators } = useContext(QueryFormContext); useImperativeHandle(ref, () => ({ validateQuery: () => { // validate from - // zod - if ( - !query?.from || - (typeof query.from === 'string' && !tables.some((table) => table.id === query.from)) || - (typeof query.from !== 'string' && !baseQuerySchema.safeParse(query.from).success) - ) { - return false; - } // context validators if (formQueryRef.current && !formQueryRef.current.validateQuery()) { return false; } // validate all keys - if (QuerySortedKeys.some((key) => validators[key] && !validators[key]?.())) return false; + if ( + (['from', ...QuerySortedKeys] as const).some( + (key) => validators[key] && !validators[key]?.() + ) + ) + return false; return true; }, @@ -97,16 +92,27 @@ const QueryBuilderContainer = forwardRef< 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 = [...childContext, ...aggregationColumns, ...joinContext]; + 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); } @@ -118,102 +124,114 @@ const QueryBuilderContainer = forwardRef< .filter(Boolean) as IBaseQueryColumn[] ) ); - }, [aggregationContext, childContext, getContextFromChild, joinContext, query?.select]); - - const onFormChange = async (type: string, tableId?: string) => { - if (type === 'table' && tableId) { - setFromType('table'); - const context = await getContextWithTableIds([tableId]); - setChildContext(context); - return onChange({ ...query, from: tableId }); - } - setFromType('query'); - }; - - const clearFrom = () => { - setFromType(undefined); - onChange({ from: '' }); - }; + }, [ + aggregationContext, + childContext, + getContextFromChild, + joinContext, + query?.select, + canSelectedColumnIds, + ]); const getContextWithTableIds = async (tableIds: string[]) => { - const fields = await Promise.all( - tableIds.map((tableId) => - getFields(tableId).then((res) => res.data.map((v) => ({ ...v, tableId }))) - ) + const tableFields = await Promise.all( + tableIds.map((tableId) => getFields(tableId).then((res) => res.data)) ); - return fields.flat().map( - (field) => - ({ - column: field.id, - type: BaseQueryColumnType.Field, - name: field.name, - fieldSource: field, - tableId: field.tableId, - }) as IBaseQueryColumn & { tableId: string } + return tableFields.map((fields) => + fields.map( + (field) => + ({ + column: field.id, + type: BaseQueryColumnType.Field, + name: field.name, + fieldSource: field, + }) as IBaseQueryColumn + ) ); }; - const onSourceChange = async (source?: IBaseQuery) => { - if (!source) { - return clearFrom(); + const collectContext = async (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; } - onChange({ ...query, from: source ?? '' }); }; const onQueryChange = async (key: T, value: IBaseQuery[T]) => { - if (!query) return; // collect context - if (key === 'join' && value) { - const join = value as IBaseQueryJoin[]; - const context = await getContextWithTableIds( - join.map((v) => v.table).filter((v) => !!v) as string[] - ); - setJoinContext( - context.map((v) => ({ - ...v, - group: { - id: v.tableId, - name: tables.find((table) => table.id === v.tableId)?.name ?? v.tableId, - }, - })) - ); - } - if (key === 'aggregation' && value) { - 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[] - ); - } + collectContext(key, value); console.log(depth, 'onQueryChange', key, value); - onChange({ ...query, [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 validatedFrom = useMemo( - () => - query?.from && - (typeof query.from === 'string' - ? tables.some((table) => table.id === query.from) - : baseQuerySchema.safeParse(query.from).success), - [query?.from, tables] - ); - const providerContextColumns = useMemo(() => { return { from: childContext, @@ -221,8 +239,31 @@ const QueryBuilderContainer = forwardRef< }; }, [childContext, joinContext]); + const onFromChange = async (type: string, tableId?: string) => { + if (type === 'query') { + onQueryChange('from', ''); + setFromType('query'); + return; + } + if (tableId) { + onQueryChange('from', tableId); + setFromType('table'); + return; + } + setFromType(undefined); + onQueryChange('from', ''); + }; + + const onFromQueryChange = (query?: IBaseQuery) => { + if (!query) { + onQueryChange('from', ''); + setFromType(undefined); + return; + } + onQueryChange('from', query ?? ''); + }; return ( -
+
{depth > 0 && ( )} -
- -
- {!query?.from && !fromType && } - {query?.from && fromType === 'table' && ( - - {tables.find((table) => table.id === query.from)?.name} - - - )} - {fromType === 'query' && ( + + onFromChange('from', from)} + component={ + fromType === 'query' ? ( - )} -
-
-
- {validatedFrom && ( + ) : undefined + } + /> + + {query?.from && ( { } return ( -
+
{queryButtons.map( (button) => !status[button.key] && ( diff --git a/packages/sdk/src/components/base-query/common/ContextColumnCommand.tsx b/packages/sdk/src/components/base-query/common/ContextColumnCommand.tsx index 57b211ee6d..655555228e 100644 --- a/packages/sdk/src/components/base-query/common/ContextColumnCommand.tsx +++ b/packages/sdk/src/components/base-query/common/ContextColumnCommand.tsx @@ -12,6 +12,7 @@ import { import { groupBy } from 'lodash'; import { useMemo } from 'react'; import { useTranslation } from '../../../context/app/i18n'; +import { useTables } from '../../../hooks'; import { useAllColumns } from './useAllColumns'; export const ContextColumnsCommand = (props: { @@ -28,16 +29,17 @@ export const ContextColumnsCommand = (props: { const { checked, onClick, isFilter } = props; const { t } = useTranslation(); const columns = useAllColumns(isFilter); + const tables = useTables(); const checkedArray = useMemo( () => (typeof checked === 'string' && checked ? [checked] : checked) as string[], [checked] ); const { noGroupedColumns, groupedColumns, columnMap } = useMemo(() => { - const noGroupedColumns = columns.filter((column) => !column.group); + const noGroupedColumns = columns.filter((column) => !column.groupTableId); const groupedColumns = groupBy( - columns.filter((column) => column.group), - (column) => column.group?.id + columns.filter((column) => column.groupTableId), + 'groupTableId' ); const columnMap = columns.reduce( (pre, cur) => { @@ -49,6 +51,16 @@ export const ContextColumnsCommand = (props: { return { noGroupedColumns, groupedColumns, columnMap }; }, [columns]); + const groupMap = useMemo(() => { + return tables.reduce( + (pre, cur) => { + pre[cur.id] = cur; + return pre; + }, + {} as Record + ); + }, [tables]); + return ( { @@ -87,7 +99,7 @@ export const ContextColumnsCommand = (props: { })} {Object.keys(groupedColumns).map((group) => ( - + {groupedColumns[group].map((column) => { const isSelected = checkedArray?.some((item) => item === column.column); return ( @@ -97,7 +109,10 @@ export const ContextColumnsCommand = (props: { onSelect={() => { onClick?.(column, { preSelected: isSelected, - group: column.group, + group: { + id: group, + name: groupMap[group].name, + }, }); }} > diff --git a/packages/sdk/src/components/base-query/context/QueryEditorContext.tsx b/packages/sdk/src/components/base-query/context/QueryEditorContext.tsx index 03f358d2a1..813670d69f 100644 --- a/packages/sdk/src/components/base-query/context/QueryEditorContext.tsx +++ b/packages/sdk/src/components/base-query/context/QueryEditorContext.tsx @@ -4,7 +4,7 @@ import React from 'react'; export type QueryEditorKey = Exclude; -export type IContextColumns = (IBaseQueryColumn & { group?: { id: string; name: string } })[]; +export type IContextColumns = (IBaseQueryColumn & { groupTableId?: string })[]; export interface IQueryEditorContext { columns: { diff --git a/packages/sdk/src/components/base-query/context/QueryFormContext.tsx b/packages/sdk/src/components/base-query/context/QueryFormContext.tsx index 127cb0d57f..3fb20e5327 100644 --- a/packages/sdk/src/components/base-query/context/QueryFormContext.tsx +++ b/packages/sdk/src/components/base-query/context/QueryFormContext.tsx @@ -2,7 +2,7 @@ import { noop } from 'lodash'; import { createContext } from 'react'; import type { QuerySortedKeys } from '../constant'; -export type IQueryValidatorKey = (typeof QuerySortedKeys)[number]; +export type IQueryValidatorKey = (typeof QuerySortedKeys)[number] | 'from'; export interface IQueryFormContext { validators: Record boolean) | undefined>; @@ -11,6 +11,7 @@ export interface IQueryFormContext { export const QueryFormContext = createContext({ validators: { + from: undefined, join: undefined, select: undefined, groupBy: undefined, diff --git a/packages/sdk/src/components/base-query/context/QueryFormProvider.tsx b/packages/sdk/src/components/base-query/context/QueryFormProvider.tsx index 8274928272..4b6b3dc51c 100644 --- a/packages/sdk/src/components/base-query/context/QueryFormProvider.tsx +++ b/packages/sdk/src/components/base-query/context/QueryFormProvider.tsx @@ -12,6 +12,7 @@ export const QueryFormProvider = (props: { children: React.ReactNode }) => { limit: undefined, offset: undefined, aggregation: undefined, + from: undefined, }); const registerValidator = useCallback((key: IQueryValidatorKey, fn?: () => boolean) => { diff --git a/packages/sdk/src/components/base-query/query-from/QueryFrom.tsx b/packages/sdk/src/components/base-query/query-from/QueryFrom.tsx new file mode 100644 index 0000000000..72e0a4edad --- /dev/null +++ b/packages/sdk/src/components/base-query/query-from/QueryFrom.tsx @@ -0,0 +1,78 @@ +import { Plus } from '@teable/icons'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@teable/ui-lib'; +import { useTranslation } from '../../../context/app/i18n'; +import { useTables } from '../../../hooks'; +import { FormItem } from '../FormItem'; + +export const QueryFrom = (props: { + addButton?: boolean; + children?: React.ReactNode; + maxDepth?: boolean; + onClick?: (type: 'table' | 'query', tableId?: string) => void; +}) => { + const tables = useTables(); + const { addButton, children, onClick, maxDepth } = props; + const { t } = useTranslation(); + + return ( +
+ +
+ {addButton && ( + + + + + + {maxDepth ? ( + tables.map((table) => ( + onClick?.('table', table.id)}> + {table.name} + + )) + ) : ( + <> + + + {t('baseQuery.from.fromTable')} + + + + {tables.map((table) => ( + onClick?.('table', table.id)} + > + {table.name} + + ))} + + + + onClick?.('query')}> + {t('baseQuery.from.fromQuery')} + + + )} + + + )} + {children} +
+
+
+ ); +}; diff --git a/packages/sdk/src/components/base-query/query-from/QueryFromValue.tsx b/packages/sdk/src/components/base-query/query-from/QueryFromValue.tsx new file mode 100644 index 0000000000..b80f12834c --- /dev/null +++ b/packages/sdk/src/components/base-query/query-from/QueryFromValue.tsx @@ -0,0 +1,68 @@ +import { X } from '@teable/icons'; +import type { IBaseQuery } from '@teable/openapi'; +import { Badge, Error } from '@teable/ui-lib'; +import { useCallback, useContext, useEffect, useState } from 'react'; +import { useTranslation } from '../../../context/app/i18n'; +import { useTables } from '../../../hooks'; +import { QueryFormContext } from '../context/QueryFormContext'; + +export const QueryFromTableValue = ({ + from, + onChange, + component, +}: { + from?: IBaseQuery['from']; + onChange: (from?: string) => void; + component?: React.ReactNode; +}) => { + const tables = useTables(); + const [error, setError] = useState(); + const { registerValidator } = useContext(QueryFormContext); + const { t } = useTranslation(); + const needValidator = !from || typeof from === 'string'; + + useEffect(() => { + if (from) { + setError(undefined); + } + }, [from]); + + const validator = useCallback(() => { + setError(undefined); + if (!from) { + setError(t('baseQuery.error.requiredSelect')); + return false; + } + if (!tables.some((table) => table.id === from)) { + setError(t('baseQuery.error.invalidTable')); + return false; + } + return true; + }, [from, tables, t]); + + useEffect(() => { + if (needValidator) { + registerValidator('from', validator); + } + return () => { + registerValidator('from', undefined); + }; + }, [registerValidator, validator, needValidator]); + + const clearFrom = () => { + onChange(undefined); + }; + + return ( +
+ {component || + (from && ( + + {tables.find((table) => table.id === from)?.name} + + + ))} + +
+ ); +}; diff --git a/packages/sdk/src/components/base-query/query-from/useQueryFromTableValidation.ts b/packages/sdk/src/components/base-query/query-from/useQueryFromTableValidation.ts new file mode 100644 index 0000000000..6c888733bb --- /dev/null +++ b/packages/sdk/src/components/base-query/query-from/useQueryFromTableValidation.ts @@ -0,0 +1,35 @@ +import { useCallback, useContext, useEffect, useState } from 'react'; +import { useTranslation } from '../../../context/app/i18n'; +import { useTables } from '../../../hooks'; +import { QueryFormContext } from '../context/QueryFormContext'; + +export const useQueryFromTableValidation = (from?: string, needValidator?: boolean) => { + const tables = useTables(); + const [error, setError] = useState(); + const { registerValidator } = useContext(QueryFormContext); + const { t } = useTranslation(); + + const validator = useCallback(() => { + setError(undefined); + if (!from) { + setError(t('baseQuery.error.requiredSelect')); + return false; + } + if (!tables.some((table) => table.id === from)) { + setError(t('baseQuery.error.invalidTable')); + return false; + } + return true; + }, [from, tables, t]); + + useEffect(() => { + if (needValidator) { + registerValidator('from', validator); + } + return () => { + registerValidator('from', undefined); + }; + }, [registerValidator, validator, needValidator]); + + return error; +}; diff --git a/packages/sdk/src/components/base-query/useQueryContext.ts b/packages/sdk/src/components/base-query/useQueryContext.ts new file mode 100644 index 0000000000..9bd31f9b6a --- /dev/null +++ b/packages/sdk/src/components/base-query/useQueryContext.ts @@ -0,0 +1,33 @@ +import { getFields, BaseQueryColumnType } from '@teable/openapi'; +import type { IBaseQueryColumn } from '@teable/openapi'; +import { useState, useEffect } from 'react'; + +export const useQueryContext = (tableIds: string[]) => { + const [context, setContext] = useState([]); + + useEffect(() => { + const fetchContext = async () => { + const fields = await Promise.all( + tableIds.map((tableId) => + getFields(tableId).then((res) => res.data.map((v) => ({ ...v, tableId }))) + ) + ); + setContext( + fields.flat().map( + (field) => + ({ + column: field.id, + type: BaseQueryColumnType.Field, + name: field.name, + fieldSource: field, + tableId: field.tableId, + }) as IBaseQueryColumn & { tableId: string } + ) + ); + }; + + fetchContext(); + }, [tableIds]); + + return context; +};