Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2025-08-21 07:36:47 +00:00
9 changed files with 265 additions and 69 deletions
@@ -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 (
<CollectionProvider_deprecated name={collectionField?.target}>
<CreateAction {...props} onClick={(arg) => addbuttonClick(arg)} />
@@ -51,14 +66,7 @@ export const CreateRecordAction = observer(
>
<CollectionProvider_deprecated name={currentCollection} dataSource={currentDataSource}>
<TabsContextProvider>
<NocoBaseRecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.AddNewer';
}}
/>
<NocoBaseRecursionField basePath={field.address} schema={addNewerSchema} />
</TabsContextProvider>
</CollectionProvider_deprecated>
</ActionContextProvider>
+1
View File
@@ -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';
@@ -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<string>();
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<string, unknown>)) {
// 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<string, any>();
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;
};
@@ -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,
});
@@ -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 ? (
<div style={{ padding: 12, ...specialStyle }}>
<SchemaComponent basePath={field.address} schema={fieldSchema} filterProperties={filterProperties} />
<SchemaComponent basePath={field.address} schema={clonedFieldSchema} />
</div>
) : (
<SchemaComponent
basePath={field.address}
schema={fieldSchema}
onlyRenderProperties
filterProperties={filterProperties}
/>
<SchemaComponent basePath={field.address} schema={clonedFieldSchema} />
);
const footerContent = footerSchema ? (
<div className="nb-mobile-action-drawer-footer" style={isSpecialSchema ? specialStyle : null}>
<NocoBaseRecursionField
basePath={field.address}
schema={fieldSchema}
onlyRenderProperties
filterProperties={(s) => {
return s['x-component'] === footerNodeName;
}}
/>
<NocoBaseRecursionField basePath={field.address} schema={footerSchema} />
</div>
) : null;
@@ -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 (
<>
<div contentEditable="false" onClick={() => !disabled && setVisible(true)}>
<div onClick={() => !disabled && setVisible(true)}>
<NBDatePicker
onClick={() => setVisible(true)}
value={value}
@@ -326,7 +326,7 @@ const MobileTimePicker: ComposedMobileTimePicker = connect(
};
return (
<div contentEditable="false" onClick={() => setVisible(true)}>
<div onClick={() => setVisible(true)}>
<NBTimePicker {...props} style={{ pointerEvents: 'none' }} />
<Picker onConfirm={handleTimeChange} columns={timeData} visible={visible} />
</div>
@@ -342,4 +342,4 @@ MobileDateFilterWithPicker.displayName = 'MobileDateFilterWithPicker';
MobileTimePicker.displayName = 'MobileTimePicker';
MobileTimePicker.RangePicker = NBTimePicker.RangePicker;
export { MobileDateTimePicker, MobileRangePicker, MobileDateFilterWithPicker, MobileTimePicker };
export { MobileDateFilterWithPicker, MobileDateTimePicker, MobileRangePicker, MobileTimePicker };
@@ -37,7 +37,7 @@ const MobilePicker = connect(
return (
<>
<div contentEditable="false" onClick={() => !disabled && setVisible(true)}>
<div onClick={() => !disabled && setVisible(true)}>
<Select
disabled={disabled}
value={value}
@@ -73,7 +73,7 @@ const MobileDateTimePicker = connect(
}, []);
return (
<>
<div contentEditable="false" onClick={() => setVisible(true)}>
<div onClick={() => setVisible(true)}>
<NBDatePicker
onClick={() => setVisible(true)}
value={value}
@@ -7,10 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
import { connect, mapProps } from '@formily/react';
import { Select } from '@nocobase/client';
import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
const MobilePicker = connect(
@@ -38,7 +38,7 @@ const MobilePicker = connect(
return (
<>
<div contentEditable="false" onClick={() => setVisible(true)}>
<div onClick={() => setVisible(true)}>
<Select
placeholder={t('Select')}
value={value}