diff --git a/packages/core/client/src/block-provider/TableSelectorProvider.tsx b/packages/core/client/src/block-provider/TableSelectorProvider.tsx index 39a622836..8c5849785 100644 --- a/packages/core/client/src/block-provider/TableSelectorProvider.tsx +++ b/packages/core/client/src/block-provider/TableSelectorProvider.tsx @@ -3,6 +3,7 @@ import { Schema, useField, useFieldSchema } from '@formily/react'; import uniq from 'lodash/uniq'; import React, { createContext, useContext, useEffect, useState } from 'react'; import { useCollectionManager } from '../collection-manager'; +import { isInFilterFormBlock } from '../filter-provider'; import { RecordProvider, useRecord } from '../record-provider'; import { SchemaComponentOptions } from '../schema-component'; import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider'; @@ -164,7 +165,7 @@ export const TableSelectorProvider = (props: TableSelectorProviderProps) => { } let extraFilter; if (collectionField) { - if (['oho', 'o2m'].includes(collectionField.interface)) { + if (['oho', 'o2m'].includes(collectionField.interface) && !isInFilterFormBlock(fieldSchema)) { if (record?.[collectionField.sourceKey]) { extraFilter = { $or: [ @@ -188,7 +189,7 @@ export const TableSelectorProvider = (props: TableSelectorProviderProps) => { }; } } - if (['obo'].includes(collectionField.interface)) { + if (['obo'].includes(collectionField.interface) && !isInFilterFormBlock(fieldSchema)) { const fields = getCollectionFields(collectionField.target); const targetField = fields.find((f) => f.foreignKey && f.foreignKey === collectionField.foreignKey); if (targetField) { diff --git a/packages/core/client/src/block-provider/hooks/index.ts b/packages/core/client/src/block-provider/hooks/index.ts index bc78c1a83..74b550996 100644 --- a/packages/core/client/src/block-provider/hooks/index.ts +++ b/packages/core/client/src/block-provider/hooks/index.ts @@ -10,7 +10,7 @@ import { useHistory } from 'react-router-dom'; import { useReactToPrint } from 'react-to-print'; import { AssociationFilter, useFormBlockContext, useTableBlockContext } from '../..'; import { useAPIClient, useRequest } from '../../api-client'; -import { useCollection } from '../../collection-manager'; +import { useCollection, useCollectionManager } from '../../collection-manager'; import { useFilterBlock } from '../../filter-provider/FilterProvider'; import { transformToFilter } from '../../filter-provider/utils'; import { useRecord } from '../../record-provider'; @@ -241,6 +241,8 @@ export const useFilterBlockActionProps = () => { const actionField = useField(); const fieldSchema = useFieldSchema(); const { getDataBlocks } = useFilterBlock(); + const { name } = useCollection(); + const { getCollectionJoinField } = useCollectionManager(); actionField.data = actionField.data || {}; @@ -260,7 +262,9 @@ export const useFilterBlockActionProps = () => { // 保留原有的 filter const storedFilter = block.service.params?.[1]?.filters || {}; - storedFilter[uid] = removeNullCondition(transformToFilter(form.values, fieldSchema)); + storedFilter[uid] = removeNullCondition( + transformToFilter(form.values, fieldSchema, getCollectionJoinField, name), + ); const mergedFilter = mergeFilter([ ...Object.values(storedFilter).map((filter) => removeNullCondition(filter)), @@ -279,6 +283,7 @@ export const useFilterBlockActionProps = () => { ); actionField.data.loading = false; } catch (error) { + console.error(error); actionField.data.loading = false; } }, @@ -785,8 +790,6 @@ export const useRefreshActionProps = () => { }; }; - - export const useDetailsPaginationProps = () => { const ctx = useDetailsBlockContext(); const count = ctx.service?.data?.meta?.count || 0; diff --git a/packages/core/client/src/filter-provider/__tests__/transformToFilter.ts b/packages/core/client/src/filter-provider/__tests__/transformToFilter.ts new file mode 100644 index 000000000..ee3e26fe7 --- /dev/null +++ b/packages/core/client/src/filter-provider/__tests__/transformToFilter.ts @@ -0,0 +1,58 @@ +import { transformToFilter } from '../utils'; + +// TODO: 前端测试报错解决之后,把该文件重命名为 transformToFilter.test.ts +describe('transformToFilter', () => { + it('should transform to filter', () => { + const values = { + name: 'name', + email: 'email', + user: { + name: 'name', + }, + list: [{ name: 'name1' }, { name: 'name2' }, { name: 'name3' }], + }; + + const fieldSchema = { + 'x-filter-operators': { + name: '$eq', + email: '$eq', + }, + }; + + const getField = (name: string) => { + if (name === 'user' || name === 'list') { + return { + targetKey: 'name', + }; + } + return { + targetKey: undefined, + }; + }; + + expect(transformToFilter(values, fieldSchema as any, getField)).toEqual({ + $and: [ + { + name: { + $eq: 'name', + }, + }, + { + email: { + $eq: 'email', + }, + }, + { + 'user.name': { + $eq: 'name', + }, + }, + { + 'list.name': { + $eq: ['name1', 'name2', 'name3'], + }, + }, + ], + }); + }); +}); diff --git a/packages/core/client/src/filter-provider/utils.ts b/packages/core/client/src/filter-provider/utils.ts index 45acf534f..bec974c81 100644 --- a/packages/core/client/src/filter-provider/utils.ts +++ b/packages/core/client/src/filter-provider/utils.ts @@ -1,10 +1,10 @@ import { Schema, useFieldSchema } from '@formily/react'; -import { isEmpty, isPlainObject } from '@nocobase/utils/client'; +import { flatten, getValuesByPath } from '@nocobase/utils/client'; import _ from 'lodash'; import { useCallback, useEffect, useState } from 'react'; import { mergeFilter } from '../block-provider'; import { FilterTarget, findFilterTargets } from '../block-provider/hooks'; -import { Collection, FieldOptions, useCollection } from '../collection-manager'; +import { Collection, CollectionFieldOptions, FieldOptions, useCollection } from '../collection-manager'; import { removeNullCondition } from '../schema-component'; import { findFilterOperators } from '../schema-component/antd/form-item/SchemaSettingOptions'; import { useFilterBlock } from './FilterProvider'; @@ -45,38 +45,55 @@ export const useSupportedBlocks = (filterBlockType: FilterBlockType) => { } }; -/** - * Recursively flatten an object and generate a query object. - * @param result - The resulting query object. - * @param obj - The object to be flattened and queried. - * @param key - The current key in the object tree. - * @param operators - The operators to use for each field. - * @returns The resulting query object. - */ -const flattenAndQueryObject = (result: Record, obj: any, key: string, operators: Record) => { - if (!obj) return result; - - if (!isPlainObject(obj)) { - result[key] = { - [operators[key] || '$eq']: obj, - }; - } else { - Object.keys(obj).forEach((k) => { - flattenAndQueryObject(result, obj[k], `${key}.${k}`, operators); - }); - } - - return result; -}; - -export const transformToFilter = (values: Record, fieldSchema: Schema) => { +export const transformToFilter = ( + values: Record, + fieldSchema: Schema, + getCollectionJoinField: (name: string) => CollectionFieldOptions, + collectionName: string, +) => { const { operators } = findFilterOperators(fieldSchema); - return { + values = flatten(values, { + breakOn({ value, path }) { + const collectionField = getCollectionJoinField(`${collectionName}.${path}`); + if (collectionField?.target) { + if (Array.isArray(value)) { + return true; + } + const targetKey = collectionField.targetKey || 'id'; + if (value && value[targetKey] != null) { + return true; + } + } + return false; + }, + }); + + const result = { $and: Object.keys(values) - .map((key) => flattenAndQueryObject({}, values[key], key, operators)) - .filter((item) => !isEmpty(item)), + .map((key) => { + let value = _.get(values, key); + const collectionField = getCollectionJoinField(`${collectionName}.${key}`); + + if (collectionField?.target) { + value = getValuesByPath(value, collectionField.targetKey || 'id'); + key = `${key}.${collectionField.targetKey || 'id'}`; + } + + if (!value || value.length === 0) { + return null; + } + + return { + [key]: { + [operators[key] || '$eq']: value, + }, + }; + }) + .filter(Boolean), }; + + return result; }; export const useAssociatedFields = () => { @@ -86,7 +103,9 @@ export const useAssociatedFields = () => { }; export const isAssocField = (field?: FieldOptions) => { - return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo'].includes(field?.interface); + return ['o2o', 'oho', 'obo', 'm2o', 'createdBy', 'updatedBy', 'o2m', 'm2m', 'linkTo', 'chinaRegion'].includes( + field?.interface, + ); }; export const isSameCollection = (c1: Collection, c2: Collection) => { @@ -167,3 +186,13 @@ export const useFilterAPI = () => { doFilter, }; }; + +export const isInFilterFormBlock = (fieldSchema: Schema) => { + while (fieldSchema) { + if (fieldSchema['x-filter-targets']) { + return fieldSchema['x-decorator'] === 'FilterFormBlockProvider'; + } + fieldSchema = fieldSchema.parent; + } + return false; +}; diff --git a/packages/core/client/src/schema-component/antd/association-select/useServiceOptions.ts b/packages/core/client/src/schema-component/antd/association-select/useServiceOptions.ts index 6629d3f11..bf5d04fc9 100644 --- a/packages/core/client/src/schema-component/antd/association-select/useServiceOptions.ts +++ b/packages/core/client/src/schema-component/antd/association-select/useServiceOptions.ts @@ -2,6 +2,7 @@ import { useFieldSchema } from '@formily/react'; import { useCallback, useMemo } from 'react'; import { mergeFilter } from '../../../block-provider/SharedFilterProvider'; import { useCollection, useCollectionManager } from '../../../collection-manager'; +import { isInFilterFormBlock } from '../../../filter-provider'; import { useRecord } from '../../../record-provider'; export default function useServiceOptions(props) { @@ -43,7 +44,7 @@ export default function useServiceOptions(props) { return mergeFilter( [ mergeFilter([ - isOToAny + isOToAny && !isInFilterFormBlock(fieldSchema) ? { [collectionField.foreignKey]: { $is: null, @@ -52,7 +53,7 @@ export default function useServiceOptions(props) { : null, params?.filter, ]), - isOToAny && sourceValue !== undefined && sourceValue !== null + isOToAny && sourceValue !== undefined && sourceValue !== null && !isInFilterFormBlock(fieldSchema) ? { [collectionField.foreignKey]: { $eq: sourceValue, diff --git a/packages/core/client/src/schema-component/antd/record-picker/InputRecordPicker.tsx b/packages/core/client/src/schema-component/antd/record-picker/InputRecordPicker.tsx index 5f673bfd9..49c4d19aa 100644 --- a/packages/core/client/src/schema-component/antd/record-picker/InputRecordPicker.tsx +++ b/packages/core/client/src/schema-component/antd/record-picker/InputRecordPicker.tsx @@ -239,7 +239,7 @@ const Drawer: React.FunctionComponent<{ console.log('collectionField', options); const getFilter = () => { const targetKey = collectionField.targetKey || 'id'; - const list = options.map((option) => option[targetKey]); + const list = options.map((option) => option[targetKey]).filter(Boolean); const filter = list.length ? { $and: [{ [`${targetKey}.$ne`]: list }] } : {}; return filter; }; diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 59d0add65..945e5c265 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -294,10 +294,10 @@ export const useFilterFormItemInitializerFields = (options?: any) => { if (isAssocField(field)) { schema = { type: 'string', - name: field.name, + name: `${field.name}`, required: false, - 'x-designer': 'AssociationSelect.FilterDesigner', - 'x-component': 'AssociationSelect', + 'x-designer': 'FormItem.FilterFormDesigner', + 'x-component': 'CollectionField', 'x-decorator': 'FormItem', 'x-collection-field': `${name}.${field.name}`, 'x-component-props': field.uiSchema?.['x-component-props'], @@ -397,6 +397,9 @@ const getItem = ( title: field.uiSchema?.title, children: subFields .map((subField) => + // 使用 | 分隔,是为了防止 form.values 中出现 { a: { b: 1 } } 的情况 + // 使用 | 分隔后,form.values 中会出现 { 'a|b': 1 } 的情况,这种情况下 + // 就可以知道该字段是一个关系字段中的输入框,进而特殊处理 getItem(subField, `${schemaName}.${subField.name}`, collectionName, getCollectionFields, [ ...processedCollections, field.target, diff --git a/packages/core/database/src/__tests__/filter.test.ts b/packages/core/database/src/__tests__/filter.test.ts index eede209d7..3c0a7faf8 100644 --- a/packages/core/database/src/__tests__/filter.test.ts +++ b/packages/core/database/src/__tests__/filter.test.ts @@ -14,6 +14,47 @@ describe('filter', () => { await db.close(); }); + it('should filter by hasMany association field', async () => { + const DeptCollection = db.collection({ + name: 'depts', + fields: [ + { type: 'string', name: 'name' }, + { type: 'belongsTo', name: 'org', target: 'orgs' }, + ], + }); + + const OrgCollection = db.collection({ + name: 'orgs', + fields: [ + { type: 'string', name: 'name' }, + { type: 'hasMany', name: 'depts', target: 'depts' }, + ], + }); + + await db.sync(); + + await OrgCollection.repository.create({ + values: [ + { + name: 'org1', + depts: [{ name: 'dept1' }, { name: 'dept2' }], + }, + { + name: 'org2', + depts: [{ name: 'dept3' }, { name: 'dept4' }], + }, + ], + }); + + const dept1 = await DeptCollection.repository.findOne({}); + + const orgs = await OrgCollection.repository.find({ + filter: { $and: [{ depts: { id: { $eq: dept1.get('id') } } }] }, + }); + + expect(orgs.length).toBe(1); + }); + it('should filter by association field', async () => { const UserCollection = db.collection({ name: 'users', diff --git a/packages/core/utils/src/__tests__/getValuesByPath.test.ts b/packages/core/utils/src/__tests__/getValuesByPath.test.ts new file mode 100644 index 000000000..799028abe --- /dev/null +++ b/packages/core/utils/src/__tests__/getValuesByPath.test.ts @@ -0,0 +1,89 @@ +import { getValuesByPath } from '../getValuesByPath'; + +describe('getValuesByPath', () => { + it('should return correct value', () => { + const obj = { + a: { + b: 1, + }, + }; + const result = getValuesByPath(obj, 'a.b'); + expect(result).toEqual(1); + }); + + it('should return an array of values', () => { + const obj = { + a: [{ b: 1 }, { b: 2 }], + }; + const result = getValuesByPath(obj, 'a.b'); + expect(result).toEqual([1, 2]); + }); + + it('nested array', () => { + const obj = { + a: [{ b: [{ c: 1 }, { c: 2 }] }, { b: [{ c: 3 }, { c: 4 }] }], + }; + const result = getValuesByPath(obj, 'a.b.c'); + expect(result).toEqual([1, 2, 3, 4]); + }); + + it('when path is empty', () => { + const obj = { + a: { b: 1 }, + }; + const result = getValuesByPath(obj, ''); + expect(result).toEqual([]); + }); + + it('when path is not found', () => { + const obj = { + a: { b: 1 }, + }; + const result = getValuesByPath(obj, 'a.c'); + expect(result).toEqual([]); + }); + + it('when path is not found in nested array', () => { + const obj = { + a: [{ b: 1 }, { b: 2 }], + }; + const result = getValuesByPath(obj, 'a.c'); + expect(result).toEqual([]); + }); + + it('when path is not found in nested array with empty string', () => { + const obj = { + a: [{ b: 1 }, { b: 2 }], + }; + const result = getValuesByPath(obj, 'a.'); + expect(result).toEqual([]); + }); + + it('when obj is null', () => { + const obj = null; + const result = getValuesByPath(obj, 'a.b'); + expect(result).toBe(undefined); + }); + + it('default value', () => { + const obj = { + a: { b: 1 }, + }; + const result = getValuesByPath(obj, 'a.c', null); + expect(result).toEqual([]); + }); + + it('should return empty array when obj key value is undefined', () => { + const obj = { + a: undefined, + }; + const result = getValuesByPath(obj, 'a.b'); + expect(result).toEqual([]); + }); + + it('the initial value is an array', () => { + const arr = [{ b: 1 }, { b: 2 }]; + const result = getValuesByPath(arr, 'b', []); + expect(result).toEqual([1, 2]); + }); +}); diff --git a/packages/core/utils/src/client.ts b/packages/core/utils/src/client.ts index ca0cfd619..ee6477327 100644 --- a/packages/core/utils/src/client.ts +++ b/packages/core/utils/src/client.ts @@ -2,6 +2,7 @@ export * from './collections-graph'; export * from './common'; export * from './date'; export * from './forEach'; +export * from './getValuesByPath'; export * from './merge'; export * from './number'; export * from './parse-filter'; diff --git a/packages/core/utils/src/getValuesByPath.ts b/packages/core/utils/src/getValuesByPath.ts new file mode 100644 index 000000000..45a6c80e7 --- /dev/null +++ b/packages/core/utils/src/getValuesByPath.ts @@ -0,0 +1,32 @@ +export const getValuesByPath = (obj: object, path: string, defaultValue?: any) => { + if (!obj) { + return defaultValue; + } + const keys = path.split('.'); + let result: any[] = []; + let currentValue = obj; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + if (Array.isArray(currentValue)) { + for (let j = 0; j < currentValue.length; j++) { + const value = getValuesByPath(currentValue[j], keys.slice(i).join('.'), defaultValue); + result = result.concat(value); + } + break; + } + + currentValue = currentValue[key] === undefined ? defaultValue : currentValue[key]; + + if (currentValue == null) { + break; + } + + if (i === keys.length - 1) { + result.push(currentValue); + } + } + + return result.length === 1 ? result[0] : result; +}; diff --git a/packages/core/utils/src/parse-filter.ts b/packages/core/utils/src/parse-filter.ts index e9bea1ef4..111c0a0e0 100644 --- a/packages/core/utils/src/parse-filter.ts +++ b/packages/core/utils/src/parse-filter.ts @@ -12,7 +12,7 @@ function keyIdentity(key) { return key; } -function flatten(target, opts?: any) { +export function flatten(target, opts?: any) { opts = opts || {}; const delimiter = opts.delimiter || '.'; @@ -32,7 +32,7 @@ function flatten(target, opts?: any) { const newKey = prev ? prev + delimiter + transformKey(key) : transformKey(key); - if (opts.breakOn({ key })) { + if (opts.breakOn?.({ key, value, path: newKey })) { output[newKey] = transformValue(value, newKey); return; }