mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
Merge branch 'next' into develop
This commit is contained in:
+19
-11
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+19
-29
@@ -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;
|
||||
|
||||
|
||||
+20
-20
@@ -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 };
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ const MobileDateTimePicker = connect(
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<div contentEditable="false" onClick={() => setVisible(true)}>
|
||||
<div onClick={() => setVisible(true)}>
|
||||
<NBDatePicker
|
||||
onClick={() => setVisible(true)}
|
||||
value={value}
|
||||
|
||||
+3
-3
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user