diff --git a/packages/core/client/src/schema-component/antd/association-field/components/CreateRecordAction.tsx b/packages/core/client/src/schema-component/antd/association-field/components/CreateRecordAction.tsx index e6323c28cce..6a8f294323b 100644 --- a/packages/core/client/src/schema-component/antd/association-field/components/CreateRecordAction.tsx +++ b/packages/core/client/src/schema-component/antd/association-field/components/CreateRecordAction.tsx @@ -7,15 +7,15 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { observer, useField, useFieldSchema } from '@formily/react'; -import React, { useState } from 'react'; +import { observer, Schema, useField, useFieldSchema } from '@formily/react'; +import React, { useMemo, useState } from 'react'; import { CollectionProvider_deprecated, useCollectionManager_deprecated } from '../../../../collection-manager'; import { NocoBaseRecursionField } from '../../../../formily/NocoBaseRecursionField'; import { CreateAction } from '../../../../schema-initializer/components'; import { ActionContextProvider, useActionContext } from '../../action'; +import { TabsContextProvider } from '../../tabs/context'; import { useAssociationFieldContext, useInsertSchema } from '../hooks'; import schema from '../schema'; -import { TabsContextProvider } from '../../tabs/context'; export const CreateRecordAction = observer( (props) => { @@ -31,12 +31,27 @@ export const CreateRecordAction = observer( const [currentDataSource, setCurrentDataSource] = useState(targetCollection?.dataSource); const [formValueChanged, setFormValueChanged] = useState(false); + const addNewerSchema = useMemo(() => { + return fieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === 'AssociationField.AddNewer') { + Object.keys(s.parent.properties).forEach((key) => { + if (key !== s.name) { + s.parent.removeProperty(key); + } + }); + return s.parent; + } + return buf; + }) as Schema; + }, [fieldSchema]); + const addbuttonClick = (collectionData) => { insertAddNewer(schema.AddNewer); setVisibleAddNewer(true); setCurrentCollection(collectionData.name); setCurrentDataSource(collectionData.dataSource); }; + return ( addbuttonClick(arg)} /> @@ -51,14 +66,7 @@ export const CreateRecordAction = observer( > - { - return s['x-component'] === 'AssociationField.AddNewer'; - }} - /> + diff --git a/packages/core/database/src/index.ts b/packages/core/database/src/index.ts index 9aa69669cde..fcd42a72d36 100644 --- a/packages/core/database/src/index.ts +++ b/packages/core/database/src/index.ts @@ -61,3 +61,4 @@ export { default as fieldTypeMap } from './view/field-type-map'; export * from './view/view-inference'; export * from './update-guard'; export { default as operators } from './operators'; +export { filterIncludes, mergeIncludes } from './utils/filter-include'; diff --git a/packages/core/database/src/utils/filter-include.ts b/packages/core/database/src/utils/filter-include.ts new file mode 100644 index 00000000000..61899d6f365 --- /dev/null +++ b/packages/core/database/src/utils/filter-include.ts @@ -0,0 +1,192 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { snakeCase } from '../utils'; + +/** + * Traverse the `where` clause and collect association alias paths referenced by "$...$" keys. + * Example: "$posts.comments.id$" -> collects "posts.comments" (drops the last segment which is the field). + * - Supports nested objects, arrays, and symbol-keyed properties. + * - Returned paths are unique (Set-based collection). + */ +const collectAssociationPathsFromWhere = (where: any): string[] => { + const aliasPaths = new Set(); + + const traverse = (value: any) => { + if (value == null) return; + + if (Array.isArray(value)) { + for (const item of value) traverse(item); + return; + } + + if (typeof value === 'object') { + for (const [rawKey, child] of Object.entries(value as Record)) { + // Keys in the format "$a.b.c$" indicate a path across associations, ending with a field name. + if (typeof rawKey === 'string' && rawKey.startsWith('$') && rawKey.endsWith('$')) { + const inner = rawKey.slice(1, -1); + const segments = inner.split('.'); + if (segments.length > 1) { + // Keep only the association path (drop the last segment which represents a concrete field) + aliasPaths.add(segments.slice(0, -1).join('.')); + } + } + traverse(child); + } + + // Also traverse symbol-keyed properties to be exhaustive + for (const sym of Object.getOwnPropertySymbols(value)) { + traverse((value as any)[sym]); + } + } + }; + + traverse(where); + return Array.from(aliasPaths); +}; + +/** + * Prune an include tree by a list of normalized (snake_case) association paths. + * It keeps only the branches whose association alias matches any of the paths (or their prefixes), + * and recursively passes down the remainder of each matched path to child includes. + * + * Example: + * paths = ["posts.comments", "profile"] + * - For an include with association "posts", it matches "posts.comments" and passes "comments" to its children. + * - For an include with association "profile", it keeps the node as-is (no child path remainder). + */ +const pruneIncludeTreeByPaths = ( + includes = [], + requiredPathsRaw: string[], + options: { underscored: boolean }, +): any[] => { + const normalizedPaths = options.underscored + ? requiredPathsRaw.map((p) => + p + .split('.') + .map((s) => snakeCase(s)) + .join('.'), + ) + : requiredPathsRaw; + if (!includes?.length || !normalizedPaths?.length) return []; + + const pruned = []; + + for (const inc of includes) { + const association = inc?.association; + if (!association) continue; + + const assocKey = options.underscored ? snakeCase(String(association)) : String(association); + + // Keep current node only if any path equals the alias or starts with "alias." + const matched = normalizedPaths.filter((p) => p === assocKey || p.startsWith(assocKey + '.')); + if (!matched.length) continue; + + // Compute child path remainders (the part after the current alias) + const childRemainders = matched + .map((p) => (p === assocKey ? null : p.slice(assocKey.length + 1))) + .filter(Boolean) as string[]; + + const children = pruneIncludeTreeByPaths(inc.include ?? [], childRemainders, options); + + const copy = { ...inc }; + if (children.length) { + copy.include = children; + } else if ('include' in copy) { + // Remove empty include to keep payload minimal and consistent + delete copy.include; + } + + pruned.push(copy); + } + + return pruned; +}; + +/** + * Merge a flat list of include descriptors by association (alias) key. + * - Nodes with the same association are merged into one. + * - `required` is OR-ed (true if any source is true). + * - Child includes are recursively merged with the same rules. + * - Empty `include` arrays are removed to keep the payload minimal. + * + * Notes: + * - Association keys are normalized via snake_case to ensure consistent merging. + * - This function is idempotent and order-insensitive for equivalent input sets. + * + * Usage example (input and output): + * + * const includesA = [ + * { association: 'posts', required: true, include: [{ association: 'comments' }] }, + * { association: 'profile' }, + * ]; + * + * const includesB = [ + * { association: 'posts', include: [ + * { association: 'comments', required: true }, + * { association: 'tags' }, + * ] + * }, + * { association: 'roles', required: true }, + * ]; + * + * const merged = mergeIncludes([...includesA, ...includesB]); + * + * Result: + * [ + * { + * association: 'posts', + * required: true, + * include: [ + * { association: 'comments', required: true }, + * { association: 'tags' }, + * ], + * }, + * { association: 'profile' }, + * { association: 'roles', required: true }, + * ] + */ +export const mergeIncludes = (includes = []): any[] => { + const byAssociation = new Map(); + + const mergeAll = (list = []) => { + for (const inc of list) { + const association = inc?.association; + if (!association) continue; + + const key = snakeCase(String(association)); + if (!byAssociation.has(key)) { + byAssociation.set(key, { ...inc, include: undefined }); + } + + const target = byAssociation.get(key)!; + + // Required: union (any true => true) + if (inc.required) target.required = true; + + // Merge children recursively + const mergedChildren = mergeIncludes([...(target.include ?? []), ...(inc.include ?? [])]); + + if (mergedChildren.length) { + target.include = mergedChildren; + } else if ('include' in target) { + delete target.include; + } + } + }; + + mergeAll(includes); + return Array.from(byAssociation.values()); +}; + +export const filterIncludes = (where, includes, options: { underscored: boolean }) => { + const path = collectAssociationPathsFromWhere(where); + const result = pruneIncludeTreeByPaths(includes, path, options); + return result; +}; diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/with-acl-meta.ts b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/with-acl-meta.ts index 5ffca4fbe40..b11e8306055 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/with-acl-meta.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/middlewares/with-acl-meta.ts @@ -8,7 +8,7 @@ */ import { NoPermissionError } from '@nocobase/acl'; -import { snakeCase } from '@nocobase/database'; +import { filterIncludes, mergeIncludes, snakeCase } from '@nocobase/database'; import lodash from 'lodash'; function createWithACLMetaMiddleware() { @@ -170,6 +170,10 @@ function createWithACLMetaMiddleware() { context: actionCtx, }); + const include = filterIncludes(queryParams.where, queryParams.include || [], { + underscored: db.options.underscored, + }); + const actionSql = ctx.db.sequelize.queryInterface.queryGenerator.selectQuery( Model.getTableName(), { @@ -246,7 +250,7 @@ function createWithACLMetaMiddleware() { conditions.push({ whereCase: '1=1', action, - include: queryParams.include, + include: [], }); } else { const whereCase = actionSql.match(/WHERE (.*?);/)[1]; @@ -254,10 +258,11 @@ function createWithACLMetaMiddleware() { conditions.push({ whereCase, action, - include: queryParams.include, + include: include, }); } } + const finalIncludes = mergeIncludes(conditions.map((condition) => condition.include || []).flat()); const results = await collection.model.findAll({ where: { @@ -269,7 +274,7 @@ function createWithACLMetaMiddleware() { return [ctx.db.sequelize.literal(`CASE WHEN ${condition.whereCase} THEN 1 ELSE 0 END`), condition.action]; }), ], - include: conditions.map((condition) => condition.include).flat(), + include: finalIncludes, raw: true, }); diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/adaptor-of-desktop/ActionDrawer.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/adaptor-of-desktop/ActionDrawer.tsx index 2f7458ddae1..aa449d6cf1d 100644 --- a/packages/plugins/@nocobase/plugin-mobile/src/client/adaptor-of-desktop/ActionDrawer.tsx +++ b/packages/plugins/@nocobase/plugin-mobile/src/client/adaptor-of-desktop/ActionDrawer.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { ISchema, observer, useField, useFieldSchema } from '@formily/react'; +import { ISchema, observer, Schema, useField, useFieldSchema } from '@formily/react'; import { Action, FlagProvider, @@ -111,6 +111,12 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?: const { visible, setVisible } = useActionContext(); const { visiblePopup } = usePopupContainer(visible); + // 克隆的目的是为了把底部按钮的 schema 去掉,避免重复渲染。 + // 不使用 filterProperties 的原因是防止 Iphone 中出现卡死的问题,具体原因未知。 + const clonedFieldSchema = useMemo(() => { + return new Schema(fieldSchema.toJSON()); + }, []); // 不需要依赖 fieldSchema,不然会导致在弹窗中添加区块时不刷新 UI + // this schema need to add padding in the content area of the popup const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema); @@ -118,19 +124,15 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?: const specialStyle = isSpecialSchema ? { backgroundColor: 'white' } : {}; - const footerSchema = fieldSchema.reduceProperties((buf, s) => { - if (s['x-component'] === footerNodeName) { - return s; - } - return buf; - }); - - const filterProperties = useCallback( - (s) => { - return s['x-component'] !== footerNodeName; - }, - [footerNodeName], - ); + const footerSchema = useMemo(() => { + return clonedFieldSchema.reduceProperties((buf, s) => { + if (s['x-component'] === footerNodeName) { + s.parent.removeProperty(s.name); // 移除掉底部按钮区域的 schema + return s; + } + return buf; + }) as Schema; + }, [clonedFieldSchema, footerNodeName]); const title = field.title || ''; @@ -140,27 +142,15 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?: const popupContent = isSpecialSchema ? (
- +
) : ( - + ); const footerContent = footerSchema ? (
- { - return s['x-component'] === footerNodeName; - }} - /> +
) : null; diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobileDatePicker.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobileDatePicker.tsx index bcc1467ab30..b9537c2d346 100644 --- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobileDatePicker.tsx +++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobileDatePicker.tsx @@ -7,25 +7,25 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; -import { DatePicker, Picker } from 'antd-mobile'; -import { Space, Select } from 'antd'; -import { - mapDatePicker, - DatePicker as NBDatePicker, - useDatePickerContext, - useCompile, - inferPickerType, - TimePicker as NBTimePicker, - mapTimeFormat, - useVariables, - isVariable, - useLocalVariables, -} from '@nocobase/client'; -import { autorun } from '@formily/reactive'; -import dayjs from 'dayjs'; import { connect, mapProps, mapReadPretty, useField, useFieldSchema } from '@formily/react'; +import { autorun } from '@formily/reactive'; +import { + inferPickerType, + isVariable, + mapDatePicker, + mapTimeFormat, + DatePicker as NBDatePicker, + TimePicker as NBTimePicker, + useCompile, + useDatePickerContext, + useLocalVariables, + useVariables, +} from '@nocobase/client'; import { getPickerFormat } from '@nocobase/utils/client'; +import { Select, Space } from 'antd'; +import { DatePicker, Picker } from 'antd-mobile'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; function getPrecision(timeFormat: string): 'hour' | 'minute' | 'second' { @@ -133,7 +133,7 @@ const MobileDateTimePicker = connect( }, []); return ( <> -
!disabled && setVisible(true)}> +
!disabled && setVisible(true)}> setVisible(true)} value={value} @@ -326,7 +326,7 @@ const MobileTimePicker: ComposedMobileTimePicker = connect( }; return ( -
setVisible(true)}> +
setVisible(true)}>
@@ -342,4 +342,4 @@ MobileDateFilterWithPicker.displayName = 'MobileDateFilterWithPicker'; MobileTimePicker.displayName = 'MobileTimePicker'; MobileTimePicker.RangePicker = NBTimePicker.RangePicker; -export { MobileDateTimePicker, MobileRangePicker, MobileDateFilterWithPicker, MobileTimePicker }; +export { MobileDateFilterWithPicker, MobileDateTimePicker, MobileRangePicker, MobileTimePicker }; diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobilePicker.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobilePicker.tsx index 5dbbe58d7f3..4bb68a33041 100644 --- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobilePicker.tsx +++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/components/MobilePicker.tsx @@ -37,7 +37,7 @@ const MobilePicker = connect( return ( <> -
!disabled && setVisible(true)}> +
!disabled && setVisible(true)}>