Merge branch 'next' into develop

This commit is contained in:
nocobase[bot] 2025-04-08 09:25:39 +00:00
commit 102f916354
18 changed files with 104 additions and 49 deletions

View File

@ -11,10 +11,11 @@ import React, { FC } from 'react';
import { MainComponent } from './MainComponent'; import { MainComponent } from './MainComponent';
const Loading: FC = () => <div>Loading...</div>; const Loading: FC = () => <div>Loading...</div>;
const AppError: FC<{ error: Error }> = ({ error }) => { const AppError: FC<{ error: Error & { title?: string } }> = ({ error }) => {
const title = error?.title || 'App Error';
return ( return (
<div> <div>
<div>App Error</div> <div>{title}</div>
{error?.message} {error?.message}
{process.env.__TEST__ && error?.stack} {process.env.__TEST__ && error?.stack}
</div> </div>

View File

@ -129,12 +129,12 @@ export const enumType = [
label: '{{t("is")}}', label: '{{t("is")}}',
value: '$eq', value: '$eq',
selected: true, selected: true,
schema: { 'x-component': 'Select' }, schema: { 'x-component': 'Select', 'x-component-props': { mode: null } },
}, },
{ {
label: '{{t("is not")}}', label: '{{t("is not")}}',
value: '$ne', value: '$ne',
schema: { 'x-component': 'Select' }, schema: { 'x-component': 'Select', 'x-component-props': { mode: null } },
}, },
{ {
label: '{{t("is any of")}}', label: '{{t("is any of")}}',

View File

@ -96,7 +96,7 @@ export const FilterCollectionFieldInternalField: React.FC = (props: Props) => {
const originalProps = const originalProps =
compile({ ...(operator?.schema?.['x-component-props'] || {}), ...(uiSchema['x-component-props'] || {}) }) || {}; compile({ ...(operator?.schema?.['x-component-props'] || {}), ...(uiSchema['x-component-props'] || {}) }) || {};
field.componentProps = merge(originalProps, field.componentProps || {}, dynamicProps || {}); field.componentProps = merge(field.componentProps || {}, originalProps, dynamicProps || {});
}, [uiSchemaOrigin]); }, [uiSchemaOrigin]);
if (!uiSchemaOrigin) return null; if (!uiSchemaOrigin) return null;

View File

@ -74,7 +74,7 @@ const useErrorProps = (app: Application, error: any) => {
} }
}; };
const AppError: FC<{ error: Error; app: Application }> = observer( const AppError: FC<{ error: Error & { title?: string }; app: Application }> = observer(
({ app, error }) => { ({ app, error }) => {
const props = getProps(app); const props = getProps(app);
return ( return (
@ -87,7 +87,7 @@ const AppError: FC<{ error: Error; app: Application }> = observer(
transform: translate(0, -50%); transform: translate(0, -50%);
`} `}
status="error" status="error"
title={app.i18n.t('App error')} title={error?.title || app.i18n.t('App error', { ns: 'client' })}
subTitle={app.i18n.t(error?.message)} subTitle={app.i18n.t(error?.message)}
{...props} {...props}
extra={[ extra={[

View File

@ -14,6 +14,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useAPIClient, useRequest } from '../../../api-client'; import { useAPIClient, useRequest } from '../../../api-client';
import { useCollectionManager } from '../../../data-source/collection'; import { useCollectionManager } from '../../../data-source/collection';
import { markRecordAsNew } from '../../../data-source/collection-record/isNewRecord'; import { markRecordAsNew } from '../../../data-source/collection-record/isNewRecord';
import { getDataSourceHeaders } from '../../../data-source/utils';
import { useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive'; import { useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive';
import { useSchemaComponentContext } from '../../hooks'; import { useSchemaComponentContext } from '../../hooks';
import { AssociationFieldContext } from './context'; import { AssociationFieldContext } from './context';
@ -67,9 +68,11 @@ export const AssociationFieldProvider = observer(
if (_.isUndefined(ids) || _.isNil(ids) || _.isNaN(ids)) { if (_.isUndefined(ids) || _.isNil(ids) || _.isNaN(ids)) {
return Promise.reject(null); return Promise.reject(null);
} }
return api.request({ return api.request({
resource: collectionField.target, resource: collectionField.target,
action: Array.isArray(ids) ? 'list' : 'get', action: Array.isArray(ids) ? 'list' : 'get',
headers: getDataSourceHeaders(cm?.dataSource?.key),
params: { params: {
filter: { filter: {
[targetKey]: ids, [targetKey]: ids,

View File

@ -65,7 +65,6 @@ export const DynamicComponent = (props: Props) => {
...props.style, ...props.style,
}, },
utc: false, utc: false,
underFilter: true,
}), }),
name: 'value', name: 'value',
'x-read-pretty': false, 'x-read-pretty': false,

View File

@ -92,7 +92,6 @@ export const FormItem: any = withDynamicSchemaProps(
[formItemLabelCss]: showTitle === false, [formItemLabelCss]: showTitle === false,
}); });
}, [showTitle]); }, [showTitle]);
console.log(className);
// 联动规则中的“隐藏保留值”的效果 // 联动规则中的“隐藏保留值”的效果
if (field.data?.hidden) { if (field.data?.hidden) {
return null; return null;

View File

@ -11,22 +11,40 @@ import { LoadingOutlined } from '@ant-design/icons';
import { connect, mapProps, mapReadPretty } from '@formily/react'; import { connect, mapProps, mapReadPretty } from '@formily/react';
import { Input as AntdInput } from 'antd'; import { Input as AntdInput } from 'antd';
import { InputProps, TextAreaProps } from 'antd/es/input'; import { InputProps, TextAreaProps } from 'antd/es/input';
import React from 'react'; import React, { useCallback } from 'react';
import { JSONTextAreaProps, Json } from './Json'; import { JSONTextAreaProps, Json } from './Json';
import { InputReadPrettyComposed, ReadPretty } from './ReadPretty'; import { InputReadPrettyComposed, ReadPretty } from './ReadPretty';
export { ReadPretty as InputReadPretty } from './ReadPretty'; export { ReadPretty as InputReadPretty } from './ReadPretty';
type ComposedInput = React.FC<InputProps> & { type ComposedInput = React.FC<NocoBaseInputProps> & {
ReadPretty: InputReadPrettyComposed['Input']; ReadPretty: InputReadPrettyComposed['Input'];
TextArea: React.FC<TextAreaProps> & { ReadPretty: InputReadPrettyComposed['TextArea'] }; TextArea: React.FC<TextAreaProps> & { ReadPretty: InputReadPrettyComposed['TextArea'] };
URL: React.FC<InputProps> & { ReadPretty: InputReadPrettyComposed['URL'] }; URL: React.FC<InputProps> & { ReadPretty: InputReadPrettyComposed['URL'] };
JSON: React.FC<JSONTextAreaProps> & { ReadPretty: InputReadPrettyComposed['JSON'] }; JSON: React.FC<JSONTextAreaProps> & { ReadPretty: InputReadPrettyComposed['JSON'] };
}; };
export type NocoBaseInputProps = InputProps & {
trim?: boolean;
};
function InputInner(props: NocoBaseInputProps) {
const { onChange, trim, ...others } = props;
const handleChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
if (trim) {
ev.target.value = ev.target.value.trim();
}
onChange?.(ev);
},
[onChange, trim],
);
return <AntdInput {...others} onChange={handleChange} />;
}
export const Input: ComposedInput = Object.assign( export const Input: ComposedInput = Object.assign(
connect( connect(
AntdInput, InputInner,
mapProps((props, field) => { mapProps((props, field) => {
return { return {
...props, ...props,

View File

@ -1,4 +1,3 @@
import React from 'react'; import React from 'react';
import { mockApp } from '@nocobase/client/demo-utils'; import { mockApp } from '@nocobase/client/demo-utils';
import { SchemaComponent, Plugin, ISchema } from '@nocobase/client'; import { SchemaComponent, Plugin, ISchema } from '@nocobase/client';
@ -15,15 +14,25 @@ const schema: ISchema = {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': 'Input', 'x-component': 'Input',
}, },
trim: {
type: 'string',
title: `Trim heading and tailing spaces`,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
trim: true,
},
},
}, },
} };
const Demo = () => { const Demo = () => {
return <SchemaComponent schema={schema} />; return <SchemaComponent schema={schema} />;
}; };
class DemoPlugin extends Plugin { class DemoPlugin extends Plugin {
async load() { async load() {
this.app.router.add('root', { path: '/', Component: Demo }) this.app.router.add('root', { path: '/', Component: Demo });
} }
} }

View File

@ -10,6 +10,7 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useForm } from '@formily/react'; import { useForm } from '@formily/react';
import { Space, theme } from 'antd'; import { Space, theme } from 'antd';
import type { CascaderProps, DefaultOptionType } from 'antd/lib/cascader';
import useInputStyle from 'antd/es/input/style'; import useInputStyle from 'antd/es/input/style';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { renderToString } from 'react-dom/server'; import { renderToString } from 'react-dom/server';
@ -110,7 +111,7 @@ function renderHTML(exp: string, keyLabelMap, delimiters: [string, string] = ['{
}); });
} }
function createOptionsValueLabelMap(options: any[], fieldNames = { value: 'value', label: 'label' }) { function createOptionsValueLabelMap(options: any[], fieldNames: CascaderProps['fieldNames'] = defaultFieldNames) {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
for (const option of options) { for (const option of options) {
map.set(option[fieldNames.value], [option[fieldNames.label]]); map.set(option[fieldNames.value], [option[fieldNames.label]]);
@ -220,10 +221,24 @@ function useVariablesFromValue(value: string, delimiters: [string, string] = ['{
}, [value, delimitersString]); }, [value, delimitersString]);
} }
export function TextArea(props) { export type TextAreaProps = {
value?: string;
scope?: Partial<DefaultOptionType>[] | (() => Partial<DefaultOptionType>[]);
onChange?(value: string): void;
disabled?: boolean;
changeOnSelect?: CascaderProps['changeOnSelect'];
style?: React.CSSProperties;
fieldNames?: CascaderProps['fieldNames'];
trim?: boolean;
delimiters?: [string, string];
addonBefore?: React.ReactNode;
};
export function TextArea(props: TextAreaProps) {
const { wrapSSR, hashId, componentCls } = useStyles(); const { wrapSSR, hashId, componentCls } = useStyles();
const { scope, onChange, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'], addonBefore } = props; const { scope, changeOnSelect, style, fieldNames, delimiters = ['{{', '}}'], addonBefore, trim = true } = props;
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : props.value.toString(); const value =
typeof props.value === 'string' ? props.value : props.value == null ? '' : (props.value as any).toString();
const variables = useVariablesFromValue(value, delimiters); const variables = useVariablesFromValue(value, delimiters);
const inputRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLDivElement>(null);
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
@ -241,6 +256,14 @@ export function TextArea(props) {
const { token } = theme.useToken(); const { token } = theme.useToken();
const delimitersString = delimiters.join(' '); const delimitersString = delimiters.join(' ');
const onChange = useCallback(
(target: HTMLDivElement) => {
const v = getValue(target, delimiters);
props.onChange?.(trim ? v.trim() : v);
},
[delimitersString, props.onChange, trim],
);
useEffect(() => { useEffect(() => {
preloadOptions(scope, variables) preloadOptions(scope, variables)
.then((preloaded) => { .then((preloaded) => {
@ -324,9 +347,9 @@ export function TextArea(props) {
setChanged(true); setChanged(true);
setRange(getCurrentRange(current)); setRange(getCurrentRange(current));
onChange(getValue(current, delimiters)); onChange(current);
}, },
[keyLabelMap, onChange, range, delimitersString], [keyLabelMap, onChange, range],
); );
const onInput = useCallback( const onInput = useCallback(
@ -336,9 +359,9 @@ export function TextArea(props) {
} }
setChanged(true); setChanged(true);
setRange(getCurrentRange(currentTarget)); setRange(getCurrentRange(currentTarget));
onChange(getValue(currentTarget, delimiters)); onChange(currentTarget);
}, },
[ime, onChange, delimitersString], [ime, onChange],
); );
const onBlur = useCallback(function ({ currentTarget }) { const onBlur = useCallback(function ({ currentTarget }) {
@ -360,9 +383,9 @@ export function TextArea(props) {
setIME(false); setIME(false);
setChanged(true); setChanged(true);
setRange(getCurrentRange(currentTarget)); setRange(getCurrentRange(currentTarget));
onChange(getValue(currentTarget, delimiters)); onChange(currentTarget);
}, },
[onChange, delimitersString], [onChange],
); );
const onPaste = useCallback( const onPaste = useCallback(
@ -393,9 +416,9 @@ export function TextArea(props) {
setChanged(true); setChanged(true);
pasteHTML(ev.currentTarget, sanitizedHTML); pasteHTML(ev.currentTarget, sanitizedHTML);
setRange(getCurrentRange(ev.currentTarget)); setRange(getCurrentRange(ev.currentTarget));
onChange(getValue(ev.currentTarget, delimiters)); onChange(ev.currentTarget);
}, },
[onChange, delimitersString], [onChange],
); );
const disabled = props.disabled || form.disabled; const disabled = props.disabled || form.disabled;
return wrapSSR( return wrapSSR(

View File

@ -96,7 +96,6 @@ export const conditionAnalyses = async (
) => { ) => {
const type = Object.keys(ruleGroup)[0] || '$and'; const type = Object.keys(ruleGroup)[0] || '$and';
const conditions = ruleGroup[type]; const conditions = ruleGroup[type];
let results = conditions.map(async (condition) => { let results = conditions.map(async (condition) => {
if ('$and' in condition || '$or' in condition) { if ('$and' in condition || '$or' in condition) {
return await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic); return await conditionAnalyses({ ruleGroup: condition, variables, localVariables }, jsonLogic);
@ -147,7 +146,10 @@ export const conditionAnalyses = async (
if (type === '$and') { if (type === '$and') {
return every(results, (v) => v); return every(results, (v) => v);
} else { } else {
return some(results, (v) => v); if (results.length) {
return some(results, (v) => v);
}
return true;
} }
}; };

View File

@ -471,7 +471,6 @@ export const useFilterFormItemInitializerFields = (options?: any) => {
'x-collection-field': `${name}.${field.name}`, 'x-collection-field': `${name}.${field.name}`,
'x-component-props': { 'x-component-props': {
utc: false, utc: false,
underFilter: true,
}, },
}; };
if (isAssocField(field)) { if (isAssocField(field)) {
@ -486,7 +485,7 @@ export const useFilterFormItemInitializerFields = (options?: any) => {
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-use-decorator-props': 'useFormItemProps', 'x-use-decorator-props': 'useFormItemProps',
'x-collection-field': `${name}.${field.name}`, 'x-collection-field': `${name}.${field.name}`,
'x-component-props': { ...field.uiSchema?.['x-component-props'], utc: false, underFilter: true }, 'x-component-props': { ...field.uiSchema?.['x-component-props'], utc: false },
}; };
} }
const resultItem = { const resultItem = {
@ -581,7 +580,7 @@ const associationFieldToMenu = (
interface: field.interface, interface: field.interface,
}, },
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-component-props': { utc: false, underFilter: true }, 'x-component-props': { utc: false },
'x-read-pretty': false, 'x-read-pretty': false,
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-collection-field': `${collectionName}.${schemaName}`, 'x-collection-field': `${collectionName}.${schemaName}`,
@ -698,7 +697,7 @@ export const useFilterInheritsFormItemInitializerFields = (options?) => {
'x-component': 'CollectionField', 'x-component': 'CollectionField',
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-collection-field': `${name}.${field.name}`, 'x-collection-field': `${name}.${field.name}`,
'x-component-props': { utc: false, underFilter: true }, 'x-component-props': { utc: false },
'x-read-pretty': field?.uiSchema?.['x-read-pretty'], 'x-read-pretty': field?.uiSchema?.['x-read-pretty'],
}; };
return { return {

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
export const REGEX_OF_VARIABLE = /^\s*\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}\s*$/g; export const REGEX_OF_VARIABLE = /^\s*\{\{\s*([\p{L}0-9_$-.]+?)\s*\}\}\s*$/u;
export const REGEX_OF_VARIABLE_IN_EXPRESSION = /\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g; export const REGEX_OF_VARIABLE_IN_EXPRESSION = /\{\{\s*([a-zA-Z0-9_$-.]+?)\s*\}\}/g;
export const isVariable = (str: unknown) => { export const isVariable = (str: unknown) => {

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, RawAxiosRequestHeaders } from 'axios';
import qs from 'qs'; import qs from 'qs';
export interface ActionParams { export interface ActionParams {

View File

@ -10,6 +10,7 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat'; import advancedFormat from 'dayjs/plugin/advancedFormat';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import duration from 'dayjs/plugin/duration';
import IsBetween from 'dayjs/plugin/isBetween'; import IsBetween from 'dayjs/plugin/isBetween';
import IsSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import IsSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
@ -35,5 +36,6 @@ dayjs.extend(weekOfYear);
dayjs.extend(weekYear); dayjs.extend(weekYear);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
dayjs.extend(advancedFormat); dayjs.extend(advancedFormat);
dayjs.extend(duration);
export { dayjs }; export { dayjs };

View File

@ -26,13 +26,16 @@ export class ErrorHandler {
message += `: ${err.cause.message}`; message += `: ${err.cause.message}`;
} }
const errorData: { message: string; code: string; title?: string } = {
message,
code: err.code,
};
if (err?.title) {
errorData.title = err.title;
}
ctx.body = { ctx.body = {
errors: [ errors: [errorData],
{
message,
code: err.code,
},
],
}; };
} }

View File

@ -104,10 +104,6 @@ export const GanttBlockProvider = (props) => {
const collection = cm.getCollection(props.collection, props.dataSource); const collection = cm.getCollection(props.collection, props.dataSource);
const params = { filter: props.params?.filter, paginate: false, sort: [collection?.primaryKey || 'id'] }; const params = { filter: props.params?.filter, paginate: false, sort: [collection?.primaryKey || 'id'] };
if (collection?.tree) {
params['tree'] = true;
}
return ( return (
<div aria-label="block-item-gantt" role="button"> <div aria-label="block-item-gantt" role="button">
<TableBlockProvider {...props} params={params}> <TableBlockProvider {...props} params={params}>

View File

@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
const MobilePicker = connect( const MobilePicker = connect(
(props) => { (props) => {
const { value, onChange, disabled, options = [], mode } = props; const { value, onChange, disabled, options = [], mode } = props;
console.log(props);
const { t } = useTranslation(); const { t } = useTranslation();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [selected, setSelected] = useState(value || []); const [selected, setSelected] = useState(value || []);
@ -42,7 +43,7 @@ const MobilePicker = connect(
disabled={disabled} disabled={disabled}
value={value} value={value}
dropdownStyle={{ display: 'none' }} dropdownStyle={{ display: 'none' }}
multiple={mode === 'multiple'} multiple={['multiple', 'tags'].includes(mode)}
onClear={() => { onClear={() => {
setVisible(false); setVisible(false);
onChange(null); onChange(null);
@ -77,10 +78,10 @@ const MobilePicker = connect(
}} }}
> >
<CheckList <CheckList
multiple={mode === 'multiple'} multiple={['multiple', 'tags'].includes(mode)}
value={Array.isArray(selected) ? selected : [selected] || []} value={Array.isArray(selected) ? selected : [selected] || []}
onChange={(val) => { onChange={(val) => {
if (mode === 'multiple') { if (['multiple', 'tags'].includes(mode)) {
setSelected(val); setSelected(val);
} else { } else {
setSelected(val[0]); setSelected(val[0]);
@ -96,7 +97,7 @@ const MobilePicker = connect(
))} ))}
</CheckList> </CheckList>
</div> </div>
{mode === 'multiple' && ( {['multiple', 'tags'].includes(mode) && (
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}> <Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
{t('Confirm')} {t('Confirm')}
</Button> </Button>