Compare commits

...

16 Commits

Author SHA1 Message Date
gchust
dec669a94a feat: support switch menu when add sub models 2025-06-30 23:21:01 +08:00
katherinehhh
3dbd75b9e4 fix: bug 2025-06-30 23:11:55 +08:00
chenos
cec9b5ccf4 Merge branch '2.0' of github.com:nocobase/nocobase into 2.0 2025-06-30 22:26:54 +08:00
chenos
e22e9e425a fix: currentView.close 2025-06-30 22:26:19 +08:00
Katherine
a06623ba9d
Refactor/block model (#7137)
* refactor: block model flow

* fix: bug

* refactor: block title

* fix: bug

* refactor: code improve

* fix: bug
2025-07-01 01:25:01 +11:00
chenos
bb3d9a78ec feat: resource setItem 2025-06-30 22:11:15 +08:00
chenos
090ee6df4c fix: quick edit support onSuccess 2025-06-30 22:06:59 +08:00
gchust
151bc938a3 Merge branch '2.0' of github.com:nocobase/nocobase into 2.0 2025-06-30 22:01:43 +08:00
gchust
8c37c88b53 test: add unit tests for utils 2025-06-30 21:58:50 +08:00
xilesun
40b386eb7c Merge branch '2.0-ai' into 2.0 2025-06-30 21:54:36 +08:00
xilesun
95f3ba188c fix: types 2025-06-30 21:53:41 +08:00
gchust
a37828c3cd fix: title translate 2025-06-30 21:15:25 +08:00
gchust
582107f0c6 feat: allow keep open after clicking menu item 2025-06-30 21:00:14 +08:00
katherinehhh
aaf09d3f81 fix: bug 2025-06-30 20:14:55 +08:00
Katherine
28baf20755
Refactor/2.0 association (#7136)
* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve

* refactor: code improve
2025-06-30 23:03:12 +11:00
gchust
a33a91a091 chore: remove props from model options 2025-06-30 18:35:32 +08:00
41 changed files with 2048 additions and 729 deletions

View File

@ -46,24 +46,36 @@ export const titleField = defineAction({
},
},
defaultParams: (ctx: any) => {
const { target } = ctx.model.collectionField.options;
const collectionManager = ctx.model.collectionField.collection.collectionManager;
const targetCollection = collectionManager.getCollection(target);
const targetCollection = ctx.model.collectionField.targetCollection;
const filterKey = getUniqueKeyFromCollection(targetCollection.options as any);
return {
label: ctx.model.props.fieldNames?.label || targetCollection.options.titleField || filterKey,
};
},
handler(ctx: any, params) {
const { target } = ctx.model.collectionField.options;
const collectionManager = ctx.model.collectionField.collection.collectionManager;
ctx.model.setStepParams;
const targetCollection = collectionManager.getCollection(target);
async handler(ctx: any, params) {
const target = ctx.model.collectionField.target;
const targetCollection = ctx.model.collectionField.targetCollection;
const filterKey = getUniqueKeyFromCollection(targetCollection.options as any);
const label = params.label || targetCollection.options.titleField || filterKey;
const newFieldNames = {
value: filterKey,
label: params.label || targetCollection.options.titleField || filterKey,
label,
};
ctx.model.setComponentProps({ fieldNames: newFieldNames });
const targetCollectionField = targetCollection.getField(label);
const use = targetCollectionField.getFirstSubclassNameOf('ReadPrettyFieldModel') || 'ReadPrettyFieldModel';
const model = ctx.model.setSubModel('field', {
use,
stepParams: {
default: {
step1: {
dataSourceKey: ctx.model.collectionField.dataSourceKey,
collectionName: target,
fieldPath: newFieldNames.label,
},
},
},
});
await model.applyAutoFlows();
},
});

View File

@ -7,10 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import type { ButtonProps } from 'antd';
import { tval } from '@nocobase/utils/client';
import { GlobalActionModel } from '../base/ActionModel';
import type { ButtonProps } from 'antd';
import { openLinkAction } from '../../actions/openLinkAction';
import { GlobalActionModel } from '../base/ActionModel';
export class LinkGlobalActionModel extends GlobalActionModel {
defaultProps: ButtonProps = {
@ -20,6 +20,7 @@ export class LinkGlobalActionModel extends GlobalActionModel {
LinkGlobalActionModel.define({
title: tval('Link'),
hide: true,
});
LinkGlobalActionModel.registerFlow({

View File

@ -6,13 +6,16 @@
* 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 { tval } from '@nocobase/utils/client';
import { APIResource, BaseRecordResource, Collection, DefaultStructure, FlowModel } from '@nocobase/flow-engine';
import { Card } from 'antd';
import React from 'react';
import { BlockItemCard } from '../common/BlockItemCard';
export class BlockModel<T = DefaultStructure> extends FlowModel<T> {
decoratorProps: Record<string, any> = {};
setDecoratorProps(props) {
this.decoratorProps = { ...this.decoratorProps, ...props };
}
renderComponent() {
throw new Error('renderComponent method must be implemented in subclasses of BlockModel');
@ -20,10 +23,92 @@ export class BlockModel<T = DefaultStructure> extends FlowModel<T> {
}
render() {
return <Card {...this.decoratorProps}>{this.renderComponent()}</Card>;
return <BlockItemCard {...this.decoratorProps}>{this.renderComponent()}</BlockItemCard>;
}
}
export const HeightMode = {
DEFAULT: 'defaultHeight',
SPECIFY_VALUE: 'specifyValue',
FULL_HEIGHT: 'fullHeight',
};
BlockModel.registerFlow({
key: 'blockProps',
title: tval('Basic configuration'),
auto: true,
steps: {
editBlockTitleAndDescription: {
title: tval('Edit block title & description'),
uiSchema: {
title: {
'x-component': 'Input',
'x-decorator': 'FormItem',
title: tval('Title'),
},
description: {
'x-component': 'Input.TextArea',
'x-decorator': 'FormItem',
title: tval('Description'),
},
},
handler(ctx, params) {
const title = ctx.globals.flowEngine.translate(params.title);
const description = ctx.globals.flowEngine.translate(params.description);
ctx.model.setDecoratorProps({ title: title, description: description });
},
},
setBlockHeight: {
title: tval('Set block height'),
uiSchema: {
heightMode: {
type: 'string',
enum: [
{ label: tval('Default'), value: HeightMode.DEFAULT },
{ label: tval('Specify height'), value: HeightMode.SPECIFY_VALUE },
{ label: tval('Full height'), value: HeightMode.FULL_HEIGHT },
],
required: true,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
height: {
title: tval('Height'),
type: 'string',
required: true,
'x-decorator': 'FormItem',
'x-component': 'NumberPicker',
'x-component-props': {
addonAfter: 'px',
},
'x-validator': [
{
minimum: 40,
},
],
'x-reactions': {
dependencies: ['heightMode'],
fulfill: {
state: {
hidden: '{{ $deps[0]==="fullHeight"||$deps[0]==="defaultHeight"}}',
value: '{{$deps[0]!=="specifyValue"?null:$self.value}}',
},
},
},
},
},
defaultParams: () => {
return {
heightMode: HeightMode.DEFAULT,
};
},
handler(ctx, params) {
ctx.model.setProps('heightMode', params.heightMode);
ctx.model.setProps('height', params.height);
},
},
},
});
export class DataBlockModel<T = DefaultStructure> extends BlockModel<T> {
resource: APIResource;
collection: Collection;
@ -36,7 +121,7 @@ export class DataBlockModel<T = DefaultStructure> extends BlockModel<T> {
get title() {
return (
this._title ||
this.translate(this._title) ||
`
${this.collection.title} >
${this.collection.dataSource.displayName} >

View File

@ -8,20 +8,12 @@
*/
import { PlusOutlined } from '@ant-design/icons';
import { Input } from '@formily/antd-v5';
import { uid } from '@formily/shared';
import {
AddBlockButton,
FlowModel,
FlowModelRenderer,
FlowSettingsButton,
FlowsFloatContextMenu,
useStepSettingContext,
} from '@nocobase/flow-engine';
import { AddBlockButton, FlowModel, FlowModelRenderer, FlowSettingsButton } from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { Alert, Space } from 'antd';
import { Space } from 'antd';
import _ from 'lodash';
import React, { useState } from 'react';
import React from 'react';
import { Grid } from '../../components/Grid';
import JsonEditor from '../../components/JsonEditor';
import { SkeletonFallback } from '../../components/SkeletonFallback';

View File

@ -0,0 +1,47 @@
/**
* 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 { theme, Card } from 'antd';
import _ from 'lodash';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NAMESPACE_UI_SCHEMA } from '../../../i18n/constant';
import { MarkdownReadPretty } from '../fields/EditableField/MarkdownEditableFieldModel';
export const BlockItemCard = (props) => {
const { t } = useTranslation();
const { token } = theme.useToken();
const { title: blockTitle, description, children } = props;
const title = (blockTitle || description) && (
<div style={{ padding: '8px 0px 8px' }}>
<span> {t(blockTitle, { ns: NAMESPACE_UI_SCHEMA })}</span>
{description && (
<MarkdownReadPretty
value={t(description, { ns: NAMESPACE_UI_SCHEMA })}
style={{
overflowWrap: 'break-word',
whiteSpace: 'normal',
fontWeight: 400,
color: token.colorTextDescription,
borderRadius: '4px',
}}
/>
)}
</div>
);
return (
<Card
title={title}
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
>
{children}
</Card>
);
};

View File

@ -7,8 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ButtonProps } from 'antd';
import { tval } from '@nocobase/utils/client';
import { ButtonProps } from 'antd';
import { ActionModel } from '../../base/ActionModel';
import { DataBlockModel } from '../../base/BlockModel';
import { FormModel } from './FormModel';
@ -49,7 +49,7 @@ FormSubmitActionModel.registerFlow({
parentBlockModel.resource.refresh();
}
if (ctx.shared.currentView) {
ctx.shared.currentView.destroy();
ctx.shared.currentView.close();
}
},
},

View File

@ -12,7 +12,6 @@ import { createForm, Form } from '@formily/core';
import { FormProvider } from '@formily/react';
import { AddActionButton, AddFieldButton, FlowModelRenderer, SingleRecordResource } from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { Card } from 'antd';
import React from 'react';
import { DataBlockModel } from '../../base/BlockModel';
import { EditableFieldModel } from '../../fields/EditableField/EditableFieldModel';
@ -21,53 +20,51 @@ export class FormModel extends DataBlockModel {
form: Form;
declare resource: SingleRecordResource;
render() {
renderComponent() {
return (
<Card>
<FormProvider form={this.form}>
<FormLayout layout={'vertical'}>
{this.mapSubModels('fields', (field) => (
<FlowModelRenderer
model={field}
showFlowSettings={{ showBorder: false }}
sharedContext={{ currentRecord: this.resource.getData() }}
/>
))}
</FormLayout>
<AddFieldButton
buildCreateModelOptions={({ defaultOptions, fieldPath }) => ({
use: defaultOptions.use,
stepParams: {
default: {
step1: {
dataSourceKey: this.collection.dataSourceKey,
collectionName: this.collection.name,
fieldPath,
},
<FormProvider form={this.form}>
<FormLayout layout={'vertical'}>
{this.mapSubModels('fields', (field) => (
<FlowModelRenderer
model={field}
showFlowSettings={{ showBorder: false }}
sharedContext={{ currentRecord: this.resource.getData() }}
/>
))}
</FormLayout>
<AddFieldButton
buildCreateModelOptions={({ defaultOptions, fieldPath }) => ({
use: defaultOptions.use,
stepParams: {
default: {
step1: {
dataSourceKey: this.collection.dataSourceKey,
collectionName: this.collection.name,
fieldPath,
},
},
})}
subModelKey="fields"
model={this}
collection={this.collection}
subModelBaseClass="EditableFieldModel"
onSubModelAdded={async (model: EditableFieldModel) => {
const params = model.getStepParams('default', 'step1');
this.addAppends(params?.fieldPath, true);
}}
/>
<FormButtonGroup style={{ marginTop: 16 }}>
{this.mapSubModels('actions', (action) => (
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false }}
sharedContext={{ currentRecord: this.resource.getData() }}
/>
))}
<AddActionButton model={this} subModelBaseClass="FormActionModel" />
</FormButtonGroup>
</FormProvider>
</Card>
},
})}
subModelKey="fields"
model={this}
collection={this.collection}
subModelBaseClass="EditableFieldModel"
onSubModelAdded={async (model: EditableFieldModel) => {
const params = model.getStepParams('default', 'step1');
this.addAppends(params?.fieldPath, !!this.ctx.shared?.currentFlow?.extra?.filterByTk);
}}
/>
<FormButtonGroup style={{ marginTop: 16 }}>
{this.mapSubModels('actions', (action) => (
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false }}
sharedContext={{ currentRecord: this.resource.getData() }}
/>
))}
<AddActionButton model={this} subModelBaseClass="FormActionModel" />
</FormButtonGroup>
</FormProvider>
);
}
}

View File

@ -40,8 +40,9 @@ export class QuickEditForm extends DataBlockModel {
collectionName: string;
fieldPath: string;
filterByTk: string;
onSuccess?: (values: any) => void;
}) {
const { flowEngine, target, dataSourceKey, collectionName, fieldPath, filterByTk } = options;
const { flowEngine, target, dataSourceKey, collectionName, fieldPath, filterByTk, onSuccess } = options;
const model = flowEngine.createModel({
use: 'QuickEditForm',
stepParams: {
@ -63,7 +64,7 @@ export class QuickEditForm extends DataBlockModel {
<FlowModelRenderer
fallback={<Skeleton.Input size="small" />}
model={model}
sharedContext={{ currentView: popover }}
sharedContext={{ currentView: popover, __onSubmitSuccess: onSuccess }}
extraContext={{ filterByTk }}
/>
);
@ -85,6 +86,9 @@ export class QuickEditForm extends DataBlockModel {
},
{ refresh: false },
);
this.ctx.shared.__onSubmitSuccess?.({
[this.fieldPath]: this.form.values[this.fieldPath],
});
this.ctx.shared.currentView.close();
}}
>

View File

@ -37,7 +37,6 @@ export class TableColumnModel extends FieldModel {
</div>
</FlowsFloatContextMenu>
);
console.log('TableColumnModel props:', this.props);
return {
...this.props,
ellipsis: true,
@ -51,8 +50,9 @@ export class TableColumnModel extends FieldModel {
) : (
titleContent
),
onCell: (record) => ({
onCell: (record, recordIndex) => ({
record,
recordIndex,
width: this.props.width,
editable: this.props.editable,
dataIndex: this.props.dataIndex,

View File

@ -20,11 +20,11 @@ import {
useFlowEngine,
} from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { Card, Space, Spin, Table } from 'antd';
import { Space, Spin, Table } from 'antd';
import classNames from 'classnames';
import { t } from 'i18next';
import _ from 'lodash';
import React, { useRef } from 'react';
import { BlockItemCard } from '../../common/BlockItemCard';
import { ActionModel } from '../../base/ActionModel';
import { DataBlockModel } from '../../base/BlockModel';
import { QuickEditForm } from '../form/QuickEditForm';
@ -125,7 +125,7 @@ export class TableModel extends DataBlockModel<TableModelStructure> {
};
EditableCell = observer<any>((props) => {
const { className, title, editable, width, record, dataIndex, children, ...restProps } = props;
const { className, title, editable, width, record, recordIndex, dataIndex, children, ...restProps } = props;
const ref = useRef(null);
if (editable) {
return (
@ -169,8 +169,14 @@ export class TableModel extends DataBlockModel<TableModelStructure> {
collectionName: this.collection.name,
fieldPath: dataIndex,
filterByTk: record.id,
onSuccess: (values) => {
this.resource.setItem(recordIndex, {
...record,
...values,
});
},
});
await this.resource.refresh();
// await this.resource.refresh();
} catch (error) {
// console.error('Error stopping event propagation:', error);
}

View File

@ -9,8 +9,8 @@
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { Select } from 'antd';
import React from 'react';
import { FlowModelRenderer, useFlowEngine, useFlowModel, reactive } from '@nocobase/flow-engine';
import { useCompile } from '../../../../../schema-component';
import { castArray } from 'lodash';
import { useFlowModel, FlowModel } from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { AssociationFieldEditableFieldModel } from './AssociationFieldEditableFieldModel';
@ -34,63 +34,35 @@ function toValue(record: any | any[], fieldNames, multiple = false) {
return convert(record);
}
const modelCache = new Map<string, any>();
function LabelByField(props) {
const { option, fieldNames } = props;
const cacheKey = option[fieldNames.value] + option[fieldNames.label];
const currentModel: any = useFlowModel();
const flowEngine = useFlowEngine();
if (modelCache.has(cacheKey)) {
return option[fieldNames.label] ? <FlowModelRenderer model={modelCache.get(cacheKey)} /> : tval('N/A');
}
const collectionManager = currentModel.collectionField.collection.collectionManager;
const target = currentModel.collectionField?.options?.target;
const targetCollection = collectionManager.getCollection(target);
const targetLabelField = targetCollection.getField(fieldNames.label);
const fieldClasses = Array.from(flowEngine.filterModelClassByParent('ReadPrettyFieldModel').values()).sort(
(a, b) => (a.meta?.sort || 0) - (b.meta?.sort || 0),
);
const fieldClass = fieldClasses.find(
(cls) => cls.supportedFieldInterfaces?.includes(targetLabelField?.options?.interface),
);
const model = flowEngine.createModel({
use: fieldClass?.name || 'ReadPrettyFieldModel',
stepParams: {
default: {
step1: {
dataSourceKey: currentModel.collectionField.collection.dataSourceKey,
collectionName: target,
fieldPath: fieldNames.label,
},
},
},
});
model.setSharedContext({
...currentModel.getSharedContext(),
value: option[fieldNames.label],
const currentModel = useFlowModel();
const field = currentModel.subModels.field as FlowModel;
const key = option[fieldNames.value];
const fieldModel = field.createFork({}, key);
fieldModel.setSharedContext({
value: option?.[fieldNames.label],
currentRecord: option,
});
model.setParent(currentModel.parent);
modelCache.set(cacheKey, model);
return (
<span key={option[fieldNames.value]}>
{option[fieldNames.label] ? <FlowModelRenderer model={model} uid={option[fieldNames.value]} /> : tval('N/A')}
</span>
);
return <span key={option[fieldNames.value]}>{option[fieldNames.label] ? fieldModel.render() : tval('N/A')}</span>;
}
function LazySelect(props) {
const { fieldNames, value, multiple } = props;
const { fieldNames, value, multiple, options, ...others } = props;
const realOptions =
options && options.length ? options : multiple ? (Array.isArray(value) ? value : []) : value ? [value] : [];
return (
<Select
{...others}
showSearch
labelInValue
{...props}
fieldNames={fieldNames}
options={realOptions}
value={toValue(value, fieldNames, multiple)}
mode={multiple && 'multiple'}
mode={multiple ? 'multiple' : undefined}
onChange={(value, option) => {
props.onChange(option);
}}
@ -116,7 +88,42 @@ const AssociationSelect = connect(
},
),
mapReadPretty((props) => {
return props.value;
const currentModel: any = useFlowModel();
const { fieldNames, value } = props;
if (!value) {
return;
}
const field = currentModel.subModels.field as FlowModel;
const key = value?.[fieldNames.value];
const fieldModel = field.createFork({}, key);
fieldModel.setSharedContext({
value: value?.[fieldNames.label],
currentRecord: value,
});
const arrayValue = castArray(value);
return (
<>
{arrayValue.map((v, index) => {
const key = `${index}`;
const fieldModel = field.createFork({}, key);
fieldModel.setSharedContext({
index,
value: v?.[fieldNames.label],
currentRecord: v,
});
const content = v?.[fieldNames.label] ? fieldModel.render() : tval('N/A');
return (
<React.Fragment key={index}>
{index > 0 && ', '}
{content}
</React.Fragment>
);
})}
</>
);
}),
);
@ -194,7 +201,7 @@ AssociationSelectEditableFieldModel.registerFlow({
steps: {
step1: {
async handler(ctx, params) {
const { target } = ctx.model.collectionField.options;
const { target } = ctx.model.collectionField;
const apiClient = ctx.app.apiClient;
const response = await apiClient.request({
url: `/${target}:list`,
@ -278,9 +285,7 @@ AssociationSelectEditableFieldModel.registerFlow({
async handler(ctx, params) {
try {
const collectionField = ctx.model.collectionField;
const collectionManager = collectionField.collection.collectionManager;
const targetCollection = collectionManager.getCollection(collectionField.options.target);
const targetCollection = ctx.model.collectionField.targetCollection;
const labelFieldName = ctx.model.field.componentProps.fieldNames.label;
const targetLabelField = targetCollection.getField(labelFieldName);

View File

@ -119,7 +119,7 @@ EditableFieldModel.registerFlow({
if (collectionField.enum.length) {
ctx.model.setDataSource(collectionField.enum);
}
const validator = collectionField.options.uiSchema?.['x-validator'];
const validator = collectionField.uiSchema?.['x-validator'];
if (validator) {
ctx.model.setValidator(validator);
}

View File

@ -11,21 +11,23 @@ import { Input } from '@formily/antd-v5';
import React from 'react';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { EditableFieldModel } from '../EditableFieldModel';
import { useParseMarkdown } from './util';
import { useParseMarkdown, convertToText } from './util';
import { useMarkdownStyles } from './style';
const MarkdownReadPretty = (props) => {
export const MarkdownReadPretty = (props) => {
const { textOnly } = props;
const markdownClass = useMarkdownStyles();
const { html = '' } = useParseMarkdown(props.value);
const text = convertToText(html);
const value = (
<div
className={` ${markdownClass} nb-markdown nb-markdown-default nb-markdown-table`}
dangerouslySetInnerHTML={{ __html: html }}
style={props.style}
/>
);
return value;
return <>{textOnly ? text : value}</>;
};
const Markdown: any = connect(

View File

@ -12,7 +12,7 @@ import { customAlphabet as Alphabet } from 'nanoid';
import { EditableFieldModel } from './EditableFieldModel';
export class NanoIDEditableFieldModel extends EditableFieldModel {
static supportedFieldInterfaces = ['nanoID'];
static supportedFieldInterfaces = ['nanoid'];
get component() {
return [Input, {}];
@ -22,7 +22,7 @@ export class NanoIDEditableFieldModel extends EditableFieldModel {
NanoIDEditableFieldModel.registerFlow({
key: 'initialValue',
auto: true,
sort: 5,
sort: 1000,
steps: {
initialValue: {
handler(ctx, params) {

View File

@ -13,20 +13,3 @@ import { ReadPrettyFieldModel } from '../ReadPrettyFieldModel';
export class AssociationReadPrettyFieldModel extends ReadPrettyFieldModel {
targetCollection;
}
AssociationReadPrettyFieldModel.registerFlow({
key: 'AssociationReadPrettyFieldDefault',
auto: true,
sort: 150,
steps: {
step1: {
handler(ctx, params) {
const { collectionField } = ctx.model;
const { target } = collectionField?.options || {};
const collectionManager = collectionField.collection.collectionManager;
const targetCollection = collectionManager.getCollection(target);
ctx.model.targetCollection = targetCollection;
},
},
},
});

View File

@ -8,10 +8,11 @@
*/
import React from 'react';
import { castArray } from 'lodash';
import { Button } from 'antd';
import { tval } from '@nocobase/utils/client';
import { AssociationReadPrettyFieldModel } from './AssociationReadPrettyFieldModel';
import { FlowEngineProvider, reactive } from '@nocobase/flow-engine';
import { reactive, FlowModel } from '@nocobase/flow-engine';
import { getUniqueKeyFromCollection } from '../../../../../collection-manager/interfaces/utils';
const LinkToggleWrapper = ({ enableLink, children, currentRecord, ...props }) => {
@ -46,70 +47,43 @@ export class AssociationSelectReadPrettyFieldModel extends AssociationReadPretty
set onClick(fn) {
this.setProps({ ...this.props, onClick: fn });
}
private fieldModelCache: Record<string, FlowModel> = {};
@reactive
public render() {
const { fieldNames, enableLink = true } = this.props;
const value = this.getValue();
if (!this.collectionField || !value) {
return;
}
const { target } = this.collectionField?.options || {};
const collectionManager = this.collectionField.collection.collectionManager;
const targetCollection = collectionManager.getCollection(target);
const targetLabelField = targetCollection.getField(fieldNames.label);
const fieldClasses = Array.from(this.flowEngine.filterModelClassByParent('ReadPrettyFieldModel').values())?.sort(
(a, b) => (a.meta?.sort || 0) - (b.meta?.sort || 0),
);
const fieldInterfaceName = targetLabelField?.options?.interface;
const fieldClass = fieldClasses.find((fieldClass) => {
return fieldClass.supportedFieldInterfaces?.includes(fieldInterfaceName);
});
const model = this.flowEngine.createModel({
use: fieldClass?.name || 'ReadPrettyFieldModel',
stepParams: {
default: {
step1: {
dataSourceKey: this.collectionField.collection.dataSourceKey,
collectionName: target,
fieldPath: fieldNames.label,
},
},
},
props: {
dataSource: targetLabelField.enum,
...targetLabelField.getComponentProps(),
},
});
model.setSharedContext({
...this.ctx.shared,
value: value?.[fieldNames.label],
currentRecord: value,
});
model.setParent(this.parent);
if (Array.isArray(value)) {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{value.map((v, idx) => {
const mol = model.createFork({}, `${idx}`);
mol.setSharedContext({ ...this.ctx.shared, index: idx, value: v?.[fieldNames.label], currentRecord: v });
return (
<React.Fragment key={idx}>
{idx > 0 && <span style={{ color: 'rgb(170, 170, 170)' }}>,</span>}
<LinkToggleWrapper enableLink={enableLink} {...this.props} currentRecord={v}>
<FlowEngineProvider engine={this.flowEngine}>
{v?.[fieldNames.label] ? mol.render() : this.flowEngine.translate('N/A')}
</FlowEngineProvider>
</LinkToggleWrapper>
</React.Fragment>
);
})}
</div>
);
}
if (!value) return null;
const arrayValue = castArray(value);
const field = this.subModels.field as FlowModel;
return (
<LinkToggleWrapper enableLink={enableLink} {...this.props} currentRecord={value}>
<FlowEngineProvider engine={this.flowEngine}>{model.render()}</FlowEngineProvider>
</LinkToggleWrapper>
<>
{arrayValue.map((v, index) => {
const key = `${index}`;
let fieldModel = this.fieldModelCache[v?.[fieldNames.label]];
if (!fieldModel) {
fieldModel = field.createFork({}, key);
fieldModel.setSharedContext({
index,
value: v?.[fieldNames.label],
currentRecord: v,
});
this.fieldModelCache[v?.[fieldNames.label]] = fieldModel;
}
const content = v?.[fieldNames.label] ? fieldModel.render() : this.flowEngine.translate('N/A');
return (
<React.Fragment key={index}>
{index > 0 && ', '}
<LinkToggleWrapper enableLink={enableLink} {...this.props} currentRecord={v}>
{content}
</LinkToggleWrapper>
</React.Fragment>
);
})}
</>
);
}
}
@ -123,16 +97,32 @@ AssociationSelectReadPrettyFieldModel.registerFlow({
fieldNames: {
use: 'titleField',
title: tval('Title field'),
handler(ctx, params) {
const { target } = ctx.model.collectionField.options;
async handler(ctx, params) {
const { target } = ctx.model.collectionField;
const collectionManager = ctx.model.collectionField.collection.collectionManager;
const targetCollection = collectionManager.getCollection(target);
const filterKey = getUniqueKeyFromCollection(targetCollection.options as any);
const label = params.label || targetCollection.options.titleField || filterKey;
const newFieldNames = {
value: filterKey,
label: params.label || targetCollection.options.titleField || filterKey,
label,
};
const targetCollectionField = targetCollection.getField(label);
const use = targetCollectionField.getFirstSubclassNameOf('ReadPrettyFieldModel') || 'ReadPrettyFieldModel';
ctx.model.setProps({ fieldNames: newFieldNames });
const model = ctx.model.setSubModel('field', {
use,
stepParams: {
default: {
step1: {
dataSourceKey: ctx.model.collectionField.dataSourceKey,
collectionName: target,
fieldPath: newFieldNames.label,
},
},
},
});
await model.applyAutoFlows();
},
},
enableLink: {

View File

@ -1,10 +1,21 @@
/**
* 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 React from 'react';
import { reactive } from '@nocobase/flow-engine';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
import { Checkbox } from 'antd';
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
export class CheckboxReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['checkbox'];
@reactive
public render() {
const value = this.getValue();
const { prefix = '', suffix = '', showUnchecked } = this.props;

View File

@ -10,10 +10,12 @@
import React from 'react';
import { ColorPicker } from 'antd';
import { css } from '@emotion/css';
import { reactive } from '@nocobase/flow-engine';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
export class ColorReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['color'];
@reactive
public render() {
const value = this.getValue();

View File

@ -8,11 +8,13 @@
*/
import React from 'react';
import { reactive } from '@nocobase/flow-engine';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
import { Icon } from '../../../../icon';
export class IconReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['icon'];
@reactive
public render() {
const value = this.getValue();

View File

@ -7,7 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { useMemo } from 'react';
import React from 'react';
import { reactive } from '@nocobase/flow-engine';
import { cx, css } from '@emotion/css';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
@ -19,6 +20,7 @@ const JSONClassName = css`
export class JsonReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['json'];
@reactive
public render() {
const value = this.getValue();
const { space, style, className } = this.props;

View File

@ -0,0 +1,53 @@
/**
* 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 React from 'react';
import { reactive } from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
import { MarkdownReadPretty } from '../EditableField/MarkdownEditableFieldModel/index';
export class MarkdownReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['markdown'];
@reactive
public render() {
const { textOnly = true } = this.props;
const value = this.getValue();
return <MarkdownReadPretty textOnly={textOnly} value={value} />;
}
}
MarkdownReadPrettyFieldModel.registerFlow({
key: 'displayMode',
title: tval('Specific properties'),
auto: true,
sort: 200,
steps: {
displayMode: {
uiSchema: {
textOnly: {
type: 'string',
enum: [
{ label: tval('Text only'), value: true },
{ label: tval('Html'), value: false },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
},
title: tval('Display mode'),
defaultParams: {
textOnly: true,
},
handler(ctx, params) {
ctx.model.setProps({ textOnly: params.textOnly });
},
},
},
});

View File

@ -1,5 +1,15 @@
/**
* 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 React from 'react';
import * as math from 'mathjs';
import { reactive } from '@nocobase/flow-engine';
import { isNum } from '@formily/shared';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
@ -13,6 +23,7 @@ const toValue = (value: any, callback: (v: number) => number) => {
};
export class PercentReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['percent'];
@reactive
public render() {
const value = this.getValue();
const { prefix = '', suffix = '' } = this.props;

View File

@ -8,23 +8,46 @@
*/
import React from 'react';
import { tval } from '@nocobase/utils/client';
import { reactive } from '@nocobase/flow-engine';
import { ReadPrettyFieldModel } from './ReadPrettyFieldModel';
const lineHeight142 = { lineHeight: '1.42' };
import { MarkdownReadPretty } from '../EditableField/MarkdownEditableFieldModel/index';
export class RichTextReadPrettyFieldModel extends ReadPrettyFieldModel {
public static readonly supportedFieldInterfaces = ['richText'];
@reactive
public render() {
const { textOnly = true } = this.props;
const value = this.getValue();
const html = (
<div
style={lineHeight142}
dangerouslySetInnerHTML={{
__html: value,
}}
/>
);
return <div>{html}</div>;
return <MarkdownReadPretty textOnly={textOnly} value={value} />;
}
}
RichTextReadPrettyFieldModel.registerFlow({
key: 'displayMode',
title: tval('Specific properties'),
auto: true,
sort: 200,
steps: {
displayMode: {
uiSchema: {
textOnly: {
type: 'string',
enum: [
{ label: tval('Text only'), value: true },
{ label: tval('Html'), value: false },
],
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
},
title: tval('Display mode'),
defaultParams: {
textOnly: true,
},
handler(ctx, params) {
ctx.model.setProps({ textOnly: params.textOnly });
},
},
},
});

View File

@ -19,3 +19,4 @@ export * from './ColorReadPrettyFieldModel';
export * from './IconReadPrettyFieldModel';
export * from './JsonReadPrettyFieldModel';
export * from './AssociationFieldModel';
export * from './MarkdownReadPrettyFieldModel';

View File

@ -14,7 +14,7 @@ import { FlowModel } from '../../models';
import { FlowModelOptions, ModelConstructor } from '../../types';
import { FlowSettingsButton } from '../common/FlowSettingsButton';
import { withFlowDesignMode } from '../common/withFlowDesignMode';
import { AddSubModelButton, SubModelItemsType, mergeSubModelItems } from './AddSubModelButton';
import { AddSubModelButton, SubModelItemsType, mergeSubModelItems, AddSubModelContext } from './AddSubModelButton';
export type BuildCreateModelOptionsType = {
defaultOptions: FlowModelOptions;
@ -129,12 +129,47 @@ const AddFieldButtonCore: React.FC<AddFieldButtonProps> = ({
key: field.name,
label: field.title,
icon: fieldClass.meta?.icon,
unique: true,
createModelOptions: buildCreateModelOptions({
defaultOptions,
collectionField: field,
fieldPath: field.name,
fieldModelClass: fieldClass,
}),
toggleDetector: (ctx: AddSubModelContext) => {
// 检测是否已存在该字段的子模型
const subModels = ctx.model.subModels[subModelKey];
const checkFieldInStepParams = (subModel: FlowModel): boolean => {
const stepParams = subModel.stepParams;
// 快速检查:如果 stepParams 为空,直接返回 false
if (!stepParams || Object.keys(stepParams).length === 0) {
return false;
}
// 遍历所有 flow 和 step 来查找 fieldPath 或 field 参数
for (const flowKey in stepParams) {
const flowSteps = stepParams[flowKey];
if (!flowSteps) continue;
for (const stepKey in flowSteps) {
const stepData = flowSteps[stepKey];
if (stepData?.fieldPath === field.name || stepData?.field === field.name) {
return true; // 找到匹配,立即返回
}
}
}
return false;
};
if (Array.isArray(subModels)) {
return subModels.some(checkFieldInStepParams);
} else if (subModels) {
return checkFieldInStepParams(subModels);
}
return false;
},
};
allFields.push(fieldItem);
}
@ -151,7 +186,7 @@ const AddFieldButtonCore: React.FC<AddFieldButtonProps> = ({
},
];
};
}, [model, subModelBaseClass, fields, buildCreateModelOptions]);
}, [model, subModelBaseClass, fields, buildCreateModelOptions, subModelKey]);
const fieldItems = useMemo(() => {
return mergeSubModelItems([buildFieldItems, appendItems], { addDividers: true });
@ -165,6 +200,7 @@ const AddFieldButtonCore: React.FC<AddFieldButtonProps> = ({
items={items ?? fieldItems}
onModelCreated={onModelCreated}
onSubModelAdded={onSubModelAdded}
keepDropdownOpen
>
{children || defaultChildren}
</AddSubModelButton>

View File

@ -8,12 +8,17 @@
*/
import React, { useMemo } from 'react';
import { Switch } from 'antd';
import { FlowModel } from '../../models';
import { ModelConstructor } from '../../types';
import { withFlowDesignMode } from '../common/withFlowDesignMode';
import LazyDropdown, { Item, ItemsType } from './LazyDropdown';
import _ from 'lodash';
// ============================================================================
// 类型定义
// ============================================================================
export interface AddSubModelContext {
model: FlowModel;
globals: Record<string, any>;
@ -22,85 +27,6 @@ export interface AddSubModelContext {
subModelBaseClass?: ModelConstructor;
}
export type SubModelItemsType =
| SubModelItem[]
| ((ctx: AddSubModelContext) => SubModelItem[] | Promise<SubModelItem[]>);
/**
* SubModelItemsType
*/
export interface MergeSubModelItemsOptions {
/**
* 线
*/
addDividers?: boolean;
}
/**
* SubModelItemsType
*
*
*
* @param sources - SubModelItemsType undefined null
* @param options -
* @returns SubModelItemsType
*
* @example
* ```typescript
* const mergedItems = mergeSubModelItems([
* fieldItems, // 字段 items静态数组
* customItems, // 自定义 items静态数组
* async (ctx) => [...], // 动态 items异步函数
* condition ? extraItems : null, // 条件性 items
* ], { addDividers: true });
* ```
*/
export function mergeSubModelItems(
sources: (SubModelItemsType | undefined | null)[],
options: MergeSubModelItemsOptions = {},
): SubModelItemsType {
const { addDividers = false } = options;
// 过滤掉空值
const validSources = sources.filter((source): source is SubModelItemsType => source !== undefined && source !== null);
if (validSources.length === 0) {
return [];
}
if (validSources.length === 1) {
return validSources[0];
}
// 统一返回异步函数处理所有情况
return async (ctx: AddSubModelContext) => {
const result: SubModelItem[] = [];
for (let i = 0; i < validSources.length; i++) {
const source = validSources[i];
let items: SubModelItem[] = [];
if (Array.isArray(source)) {
items = source;
} else {
items = await source(ctx);
}
// 添加分割线(除了第一个来源)
if (i > 0 && addDividers && items.length > 0) {
result.push({
key: `divider-${i}`,
type: 'divider',
} as SubModelItem);
}
result.push(...items);
}
return result;
};
}
export interface SubModelItem {
key?: string;
label?: string;
@ -111,57 +37,53 @@ export interface SubModelItem {
createModelOptions?:
| { use: string; stepParams?: Record<string, any> }
| ((item: SubModelItem) => { use: string; stepParams?: Record<string, any> });
/**
* group group
*/
searchable?: boolean;
/**
* group
*/
searchPlaceholder?: string;
keepDropdownOpen?: boolean;
unique?: boolean;
toggleDetector?: (ctx: AddSubModelContext) => boolean | Promise<boolean>;
removeModelOptions?: {
customRemove?: (ctx: AddSubModelContext, item: SubModelItem) => Promise<void>;
};
}
export type SubModelItemsType =
| SubModelItem[]
| ((ctx: AddSubModelContext) => SubModelItem[] | Promise<SubModelItem[]>);
export interface MergeSubModelItemsOptions {
addDividers?: boolean;
}
interface AddSubModelButtonProps {
/**
*
*/
model: FlowModel;
/**
*
*/
items: SubModelItemsType;
/**
* context items 使
*/
subModelBaseClass?: string | ModelConstructor;
/**
* 'object' 'array'
*/
subModelType?: 'object' | 'array';
/**
*
*/
subModelKey: string;
/**
*
*/
onModelCreated?: (subModel: FlowModel) => Promise<void>;
/**
*
*/
onSubModelAdded?: (subModel: FlowModel) => Promise<void>;
/**
* "Add"
*/
children?: React.ReactNode;
keepDropdownOpen?: boolean;
}
// ============================================================================
// 工具函数
// ============================================================================
// 预定义样式对象,避免重复创建
const SWITCH_CONTAINER_STYLE = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
padding: '0 4px',
} as const;
const SWITCH_STYLE = {
pointerEvents: 'none' as const,
};
/**
* createModelOptions
*/
@ -172,12 +94,10 @@ const validateCreateModelOptions = (
console.warn('No createModelOptions found for item');
return false;
}
if (!createOpts.use) {
console.warn('createModelOptions must specify "use" property:', createOpts);
return false;
}
return true;
};
@ -195,22 +115,143 @@ const handleModelCreationError = async (error: any, addedModel?: FlowModel) => {
}
};
/**
*
*/
const getCreateModelOptions = (item: SubModelItem) => {
let createOpts = item.createModelOptions;
if (typeof createOpts === 'function') {
createOpts = createOpts(item);
}
return createOpts;
};
/**
*
*/
const createBuildContext = (model: FlowModel, subModelBaseClass?: string | ModelConstructor): AddSubModelContext => {
const globalContext = model.flowEngine.getContext();
return {
model,
globals: globalContext,
subModelBaseClass:
typeof subModelBaseClass === 'string' ? model.flowEngine.getModelClass(subModelBaseClass) : subModelBaseClass,
};
};
/**
* SubModelItemsType
*/
export function mergeSubModelItems(
sources: (SubModelItemsType | undefined | null)[],
options: MergeSubModelItemsOptions = {},
): SubModelItemsType {
const { addDividers = false } = options;
const validSources = sources.filter((source): source is SubModelItemsType => source !== undefined && source !== null);
if (validSources.length === 0) return [];
if (validSources.length === 1) return validSources[0];
return async (ctx: AddSubModelContext) => {
const result: SubModelItem[] = [];
for (let i = 0; i < validSources.length; i++) {
const source = validSources[i];
const items: SubModelItem[] = Array.isArray(source) ? source : await source(ctx);
if (i > 0 && addDividers && items.length > 0) {
result.push({ key: `divider-${i}`, type: 'divider' } as SubModelItem);
}
result.push(...items);
}
return result;
};
}
// ============================================================================
// 转换器函数
// ============================================================================
/**
* Switch
*/
const createSwitchLabel = (originalLabel: string, isToggled: boolean) => (
<div style={SWITCH_CONTAINER_STYLE}>
<span>{originalLabel}</span>
<Switch size="small" checked={isToggled} style={SWITCH_STYLE} />
</div>
);
/**
* unique
*/
const hasUniqueItems = (items: SubModelItem[]): boolean => {
return items.some((item) => item.unique && item.toggleDetector && !item.children);
};
/**
* SubModelItem LazyDropdown Item
*/
const transformSubModelItems = (items: SubModelItem[], context: AddSubModelContext): Item[] => {
return items.map((item) => ({
...item,
children: item.children
? typeof item.children === 'function'
? async () => {
const childrenFn = item.children as (ctx: AddSubModelContext) => SubModelItem[] | Promise<SubModelItem[]>;
const result = await childrenFn(context);
return transformSubModelItems(result, context);
}
: transformSubModelItems(item.children as SubModelItem[], context)
: undefined,
}));
const transformSubModelItems = async (items: SubModelItem[], context: AddSubModelContext): Promise<Item[]> => {
if (items.length === 0) return [];
// 批量收集需要异步检测的 unique 项
const uniqueItems: Array<{ item: SubModelItem; index: number }> = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.unique && item.toggleDetector && !item.children) {
uniqueItems.push({ item, index: i });
}
}
// 批量执行 toggleDetector
const toggleResults = await Promise.allSettled(uniqueItems.map(({ item }) => item.toggleDetector!(context)));
const toggleMap = new Map<number, boolean>();
uniqueItems.forEach(({ index }, i) => {
const result = toggleResults[i];
toggleMap.set(index, result.status === 'fulfilled' ? result.value : false);
});
// 并发转换所有项目
const transformPromises = items.map(async (item, index) => {
const transformedItem: Item = {
key: item.key,
label: item.label,
type: item.type,
disabled: item.disabled,
icon: item.icon,
searchable: item.searchable,
searchPlaceholder: item.searchPlaceholder,
keepDropdownOpen: item.keepDropdownOpen,
originalItem: item,
};
// 处理 children
if (item.children) {
if (typeof item.children === 'function') {
transformedItem.children = async () => {
const childrenFn = item.children as (ctx: AddSubModelContext) => SubModelItem[] | Promise<SubModelItem[]>;
const childrenResult = await childrenFn(context);
return transformSubModelItems(childrenResult, context);
};
} else {
transformedItem.children = await transformSubModelItems(item.children as SubModelItem[], context);
}
}
// 处理开关式菜单项
if (item.unique && item.toggleDetector && !item.children) {
const isToggled = toggleMap.get(index) || false;
const originalLabel = item.label || '';
transformedItem.label = createSwitchLabel(originalLabel, isToggled);
transformedItem.isToggled = isToggled;
transformedItem.unique = true;
}
return transformedItem;
});
return Promise.all(transformPromises);
};
/**
@ -223,9 +264,88 @@ const transformItems = (items: SubModelItemsType, context: AddSubModelContext):
return transformSubModelItems(result, context);
};
}
return transformSubModelItems(items, context);
const hasUnique = hasUniqueItems(items as SubModelItem[]);
if (hasUnique) {
return () => transformSubModelItems(items as SubModelItem[], context);
} else {
let cachedResult: Item[] | null = null;
return async () => {
if (!cachedResult) {
cachedResult = await transformSubModelItems(items as SubModelItem[], context);
}
return cachedResult;
};
}
};
// ============================================================================
// 删除处理器
// ============================================================================
/**
* stepParams
*/
const findFieldInStepParams = (subModel: FlowModel, fieldKey: string): boolean => {
const stepParams = subModel.stepParams;
if (!stepParams || Object.keys(stepParams).length === 0) return false;
for (const flowKey in stepParams) {
const flowSteps = stepParams[flowKey];
if (!flowSteps) continue;
for (const stepKey in flowSteps) {
const stepData = flowSteps[stepKey];
if (stepData?.fieldPath === fieldKey || stepData?.field === fieldKey) {
return true;
}
}
}
return false;
};
/**
*
*/
const createDefaultRemoveHandler = (config: {
model: FlowModel;
subModelKey: string;
subModelType: 'object' | 'array';
}) => {
return async (item: SubModelItem, _context: AddSubModelContext): Promise<void> => {
const { model, subModelKey, subModelType } = config;
if (subModelType === 'array') {
const subModels = (model.subModels as any)[subModelKey] as FlowModel[];
if (Array.isArray(subModels)) {
const createOpts = getCreateModelOptions(item);
const targetModel = subModels.find((subModel) => {
if (item.key && findFieldInStepParams(subModel, item.key)) return true;
return (
(subModel as any).constructor.name === createOpts?.use || (subModel as any).uid.includes(createOpts?.use)
);
});
if (targetModel) {
targetModel.remove();
const index = subModels.indexOf(targetModel);
if (index > -1) subModels.splice(index, 1);
}
}
} else {
const subModel = (model.subModels as any)[subModelKey] as FlowModel;
if (subModel) {
subModel.remove();
(model.subModels as any)[subModelKey] = undefined;
}
}
};
};
// ============================================================================
// 主组件
// ============================================================================
/**
* FlowModel
*
@ -233,7 +353,7 @@ const transformItems = (items: SubModelItemsType, context: AddSubModelContext):
* - items
* -
* - flowEngine
*
* - unique
*/
const AddSubModelButtonCore = function AddSubModelButton({
model,
@ -244,28 +364,49 @@ const AddSubModelButtonCore = function AddSubModelButton({
onModelCreated,
onSubModelAdded,
children = 'Add',
keepDropdownOpen = false,
}: AddSubModelButtonProps) {
// 构建上下文对象,从 flowEngine 的全局上下文中获取服务
const buildContext = useMemo((): AddSubModelContext => {
const globalContext = model.flowEngine.getContext();
return {
model,
globals: globalContext,
subModelBaseClass:
typeof subModelBaseClass === 'string' ? model.flowEngine.getModelClass(subModelBaseClass) : subModelBaseClass,
};
}, [model, model.flowEngine, subModelBaseClass]);
// 构建上下文对象
const buildContext = useMemo(
() => createBuildContext(model, subModelBaseClass),
[model, model.flowEngine, subModelBaseClass],
);
// 创建删除处理器
const removeHandler = useMemo(
() =>
createDefaultRemoveHandler({
model,
subModelKey,
subModelType,
}),
[model, subModelKey, subModelType],
);
// 点击处理逻辑
const onClick = async (info: any) => {
const item = info.originalItem as SubModelItem;
let createOpts = item.createModelOptions;
const clickedItem = info.originalItem || info;
const item = clickedItem.originalItem || (clickedItem as SubModelItem);
const isToggled = clickedItem.isToggled;
const isUnique = clickedItem.unique || item.unique;
// 如果 createModelOptions 是函数,则调用它获取实际的选项
if (typeof createOpts === 'function') {
createOpts = createOpts(item);
// 处理 unique 菜单项的开关操作
if (isUnique && item.toggleDetector && isToggled) {
try {
if (item.removeModelOptions?.customRemove) {
await item.removeModelOptions.customRemove(buildContext, item);
} else {
await removeHandler(item, buildContext);
}
} catch (error) {
console.error('Failed to remove sub model:', error);
}
return;
}
// 验证 createModelOptions 的有效性
// 处理添加操作
const createOpts = getCreateModelOptions(item);
if (!validateCreateModelOptions(createOpts)) {
return;
}
@ -281,7 +422,6 @@ const AddSubModelButtonCore = function AddSubModelButton({
});
addedModel.setParent(model);
await addedModel.configureRequiredSteps();
if (onModelCreated) {
@ -304,7 +444,17 @@ const AddSubModelButtonCore = function AddSubModelButton({
}
};
return <LazyDropdown menu={{ items: transformItems(items, buildContext), onClick }}>{children}</LazyDropdown>;
return (
<LazyDropdown
menu={{
items: transformItems(items, buildContext),
onClick,
keepDropdownOpen,
}}
>
{children}
</LazyDropdown>
);
};
export const AddSubModelButton = withFlowDesignMode(AddSubModelButtonCore);

View File

@ -9,8 +9,7 @@
import { css } from '@emotion/css';
import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
import React, { FC, useEffect, useMemo, useRef, useState } from 'react';
import { useFlowModel } from '../../hooks';
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFlowEngine } from '../../provider';
// ==================== Types ====================
@ -22,6 +21,19 @@ export type Item = {
children?: Item[] | (() => Item[] | Promise<Item[]>);
searchable?: boolean;
searchPlaceholder?: string;
keepDropdownOpen?: boolean;
/**
* 使
*/
isToggled?: boolean;
/**
* 使
*/
originalItem?: any;
/**
* 使
*/
unique?: boolean;
[key: string]: any;
};
@ -29,6 +41,16 @@ export type ItemsType = Item[] | (() => Item[] | Promise<Item[]>);
interface LazyDropdownMenuProps extends Omit<DropdownProps['menu'], 'items'> {
items: ItemsType;
keepDropdownOpen?: boolean;
}
interface ExtendedMenuInfo {
key: string;
keyPath: string[];
item: any;
domEvent: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>;
originalItem: Item;
keepDropdownOpen: boolean;
}
// ==================== Custom Hooks ====================
@ -102,6 +124,34 @@ const useAsyncMenuItems = (menuVisible: boolean, rootItems: Item[]) => {
};
};
/**
*
*/
const useKeepDropdownOpen = () => {
const shouldKeepOpenRef = useRef(false);
const [forceKeepOpen, setForceKeepOpen] = useState(false);
const requestKeepOpen = useCallback(() => {
shouldKeepOpenRef.current = true;
setForceKeepOpen(true);
// 使用 requestAnimationFrame 来确保在下一个渲染周期后重置
requestAnimationFrame(() => {
shouldKeepOpenRef.current = false;
setForceKeepOpen(false);
});
}, []);
const shouldPreventClose = useCallback(() => {
return shouldKeepOpenRef.current || forceKeepOpen;
}, [forceKeepOpen]);
return {
requestKeepOpen,
shouldPreventClose,
};
};
/**
*
*/
@ -269,6 +319,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
// 使用自定义 hooks
const { loadedChildren, loadingKeys, handleLoadChildren } = useAsyncMenuItems(menuVisible, rootItems);
const { searchValues, isSearching, updateSearchValue } = useMenuSearch();
const { requestKeepOpen, shouldPreventClose } = useKeepDropdownOpen();
useSubmenuStyles(menuVisible, dropdownMaxHeight);
// 加载根 items支持同步/异步函数
@ -376,10 +427,23 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
if (children) {
return;
}
menu.onClick?.({
// 检查是否应该保持下拉菜单打开
const itemShouldKeepOpen = item.keepDropdownOpen ?? menu.keepDropdownOpen ?? false;
// 如果需要保持菜单打开,请求保持打开状态
if (itemShouldKeepOpen) {
requestKeepOpen();
}
const extendedInfo: ExtendedMenuInfo = {
...info,
item: info.item || item,
originalItem: item,
} as any);
keepDropdownOpen: itemShouldKeepOpen,
};
menu.onClick?.(extendedInfo);
},
onMouseEnter: () => {
setOpenKeys((prev) => {
@ -461,9 +525,16 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
},
}}
onOpenChange={(visible) => {
// 阻止在搜索时关闭菜单
if (!visible && isSearching) {
return;
}
// 阻止在需要保持打开时关闭菜单
if (!visible && shouldPreventClose()) {
return;
}
setMenuVisible(visible);
}}
>

View File

@ -135,7 +135,6 @@ beforeEach(() => {
modelOptions = {
uid: 'test-model-uid',
flowEngine,
props: { testProp: 'value' },
stepParams: { testFlow: { step1: { param1: 'value1' } } },
sortIndex: 0,
subModels: {},
@ -150,7 +149,6 @@ describe('FlowModel', () => {
const model = new FlowModel(modelOptions);
expect(model.uid).toBe(modelOptions.uid);
expect(model.props).toEqual(expect.objectContaining(modelOptions.props));
expect(model.stepParams).toEqual(expect.objectContaining(modelOptions.stepParams));
expect(model.flowEngine).toBe(modelOptions.flowEngine);
expect(model.sortIndex).toBe(modelOptions.sortIndex);
@ -682,7 +680,6 @@ describe('FlowModel', () => {
const childModel = new FlowModel({
uid: 'child-model-uid',
flowEngine,
props: { childProp: 'childValue' },
stepParams: { childFlow: { childStep: { childParam: 'childValue' } } },
});
@ -692,12 +689,11 @@ describe('FlowModel', () => {
expect(result.parent).toBe(parentModel);
expect((parentModel.subModels.testChild as FlowModel).uid).toBe(result.uid);
expect(result.uid).toBe('child-model-uid');
expect(result.props).toEqual(expect.objectContaining({ childProp: 'childValue' }));
});
test('should replace existing subModel', () => {
const firstChild = new FlowModel({ uid: 'first-child', flowEngine });
const secondChild = new FlowModel({ uid: 'second-child', flowEngine, props: { newProp: 'newValue' } });
const secondChild = new FlowModel({ uid: 'second-child', flowEngine });
parentModel.setSubModel('testChild', firstChild);
const result = parentModel.setSubModel('testChild', secondChild);
@ -705,7 +701,6 @@ describe('FlowModel', () => {
expect(result.uid).toBe(secondChild.uid);
expect((parentModel.subModels.testChild as FlowModel).uid).toBe(result.uid);
expect(result.uid).toBe('second-child');
expect(result.props).toEqual(expect.objectContaining({ newProp: 'newValue' }));
});
test('should throw error when setting model with existing parent', () => {
@ -734,7 +729,6 @@ describe('FlowModel', () => {
const childModel = new FlowModel({
uid: 'child-model-uid',
flowEngine,
props: { childProp: 'childValue' },
});
const result = parentModel.addSubModel('testChildren', childModel);
@ -830,53 +824,35 @@ describe('FlowModel', () => {
expect(results).toEqual([]);
});
test('should handle complex mapping operations', () => {
const item1 = new FlowModel({ uid: 'item1', flowEngine, props: { value: 10 } });
const item2 = new FlowModel({ uid: 'item2', flowEngine, props: { value: 20 } });
parentModel.addSubModel('items', item1);
parentModel.addSubModel('items', item2);
const totalValue = parentModel
.mapSubModels('items', (model) => model.props.value)
.reduce((sum, value) => sum + value, 0);
expect(totalValue).toBe(30);
});
});
describe('findSubModel', () => {
test('should find subModel by condition in array', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine, props: { name: 'first' } });
const child2 = new FlowModel({ uid: 'child2', flowEngine, props: { name: 'second' } });
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
parentModel.addSubModel('testChildren', child1);
parentModel.addSubModel('testChildren', child2);
const found = parentModel.findSubModel('testChildren', (model) => model.props.name === 'second');
const found = parentModel.findSubModel('testChildren', (model) => model.uid === 'child2');
expect(found).toBeDefined();
expect(found?.uid).toBe('child2');
expect(found?.props.name).toBe('second');
});
test('should find single subModel by condition', () => {
const child = new FlowModel({ uid: 'target-child', flowEngine, props: { name: 'target' } });
const child = new FlowModel({ uid: 'target-child', flowEngine });
parentModel.setSubModel('testChild', child);
const found = parentModel.findSubModel('testChild', (model) => model.props.name === 'target');
const found = parentModel.findSubModel('testChild', (model) => model.uid === 'target-child');
expect(found).toBeDefined();
expect(found?.uid).toBe('target-child');
expect(found?.props.name).toBe('target');
});
test('should return null when no match found', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine, props: { name: 'first' } });
const child1 = new FlowModel({ uid: 'child1', flowEngine });
parentModel.addSubModel('testChildren', child1);
const found = parentModel.findSubModel('testChildren', (model) => model.props.name === 'nonexistent');
const found = parentModel.findSubModel('testChildren', (model) => model.uid === 'nonexistent');
expect(found).toBeNull();
});
@ -886,20 +862,6 @@ describe('FlowModel', () => {
expect(found).toBeNull();
});
test('should find first matching model in array', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine, props: { type: 'match' } });
const child2 = new FlowModel({ uid: 'child2', flowEngine, props: { type: 'match' } });
parentModel.addSubModel('testChildren', child1);
parentModel.addSubModel('testChildren', child2);
const found = parentModel.findSubModel('testChildren', (model) => model.props.type === 'match');
expect(found).toBeDefined();
expect(found?.uid).toBe('child1'); // Should return first match
expect(found?.props.type).toBe('match');
});
});
describe('applySubModelsAutoFlows', () => {
@ -950,8 +912,8 @@ describe('FlowModel', () => {
describe('subModels serialization', () => {
test('should serialize subModels in model data', () => {
const child1 = new FlowModel({ uid: 'child1', flowEngine, props: { name: 'first' } });
const child2 = new FlowModel({ uid: 'child2', flowEngine, props: { name: 'second' } });
const child1 = new FlowModel({ uid: 'child1', flowEngine });
const child2 = new FlowModel({ uid: 'child2', flowEngine });
parentModel.setSubModel('singleChild', child1);
parentModel.addSubModel('multipleChildren', child2);
@ -1245,7 +1207,6 @@ describe('FlowModel', () => {
const emptyModel = new FlowModel({
uid: 'empty-model',
flowEngine,
props: {},
stepParams: {},
subModels: {},
});
@ -1298,7 +1259,9 @@ describe('FlowModel', () => {
group: 'test',
});
const mockTranslate = vi.fn().mockReturnValue('Translated Title');
const mockTranslate = vi.fn((v) => {
if (v) return 'Translated Title';
});
const mockFlowEngine = {
...flowEngine,
translate: mockTranslate,
@ -1310,7 +1273,7 @@ describe('FlowModel', () => {
const title = modelWithTranslate.title;
expect(mockTranslate).toHaveBeenCalledWith('model.title.key');
expect(mockTranslate).toHaveBeenLastCalledWith('model.title.key');
expect(title).toBe('Translated Title');
});
@ -1482,7 +1445,9 @@ describe('FlowModel', () => {
describe('title with translation', () => {
test('should call translate method for meta title', () => {
const mockTranslate = vi.fn().mockReturnValue('Translated Meta Title');
const mockTranslate = vi.fn((v) => {
if (v) return 'Translated Meta Title';
});
TestFlowModel.define({
title: 'meta.title.key',
@ -1500,7 +1465,7 @@ describe('FlowModel', () => {
const title = modelWithTranslate.title;
expect(mockTranslate).toHaveBeenCalledWith('meta.title.key');
expect(mockTranslate).toHaveBeenLastCalledWith('meta.title.key');
expect(title).toBe('Translated Meta Title');
});
});

View File

@ -0,0 +1,983 @@
/**
* 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 { describe, test, expect, beforeEach, vi } from 'vitest';
import {
getT,
generateUid,
mergeFlowDefinitions,
isInheritedFrom,
resolveDefaultParams,
resolveUiSchema,
FlowExitException,
defineAction,
compileUiSchema,
FLOW_ENGINE_NAMESPACE,
} from '../../utils';
import { FlowModel } from '../flowModel';
import { FlowEngine } from '../../flowEngine';
import type {
FlowDefinition,
ParamsContext,
ActionDefinition,
DeepPartial,
ModelConstructor,
StepParams,
} from '../../types';
import type { ISchema } from '@formily/json-schema';
import type { APIClient } from '@nocobase/sdk';
// Helper functions
const createMockFlowEngine = (): FlowEngine => {
const mockEngine = {
translate: vi.fn((key: string, options?: Record<string, unknown>) => {
if (options?.returnOriginal) return key;
return `translated_${key}`;
}),
getContext: vi.fn(() => ({ app: {}, api: {} as APIClient, flowEngine: mockEngine as FlowEngine })),
createModel: vi.fn(),
getModel: vi.fn(),
applyFlowCache: new Map(),
} as Partial<FlowEngine>;
return mockEngine as FlowEngine;
};
interface MockFlowModelOptions {
uid?: string;
flowEngine?: FlowEngine;
stepParams?: StepParams;
sortIndex?: number;
subModels?: Record<string, FlowModel | FlowModel[]>;
}
const createMockFlowModel = (overrides: MockFlowModelOptions = {}): FlowModel => {
const flowEngine = createMockFlowEngine();
const options = {
uid: 'test-model-uid',
flowEngine,
stepParams: {},
sortIndex: 0,
subModels: {},
...overrides,
};
const model = new FlowModel(options);
// Ensure the flowEngine is properly set
Object.defineProperty(model, 'flowEngine', {
value: flowEngine,
writable: true,
configurable: true,
});
return model;
};
const createBasicFlowDefinition = (overrides: Partial<FlowDefinition> = {}): FlowDefinition => ({
key: 'testFlow',
steps: {
step1: {
handler: vi.fn().mockResolvedValue('step1-result'),
},
step2: {
handler: vi.fn().mockResolvedValue('step2-result'),
},
},
...overrides,
});
const createPatchFlowDefinition = (
overrides: Partial<DeepPartial<FlowDefinition>> = {},
): DeepPartial<FlowDefinition> => ({
title: 'Patched Flow',
steps: {
step1: {
handler: vi.fn().mockResolvedValue('patched-step1-result'),
},
},
...overrides,
});
// Test setup
let mockModel: FlowModel;
let mockFlowEngine: FlowEngine;
beforeEach(() => {
mockFlowEngine = createMockFlowEngine();
mockModel = createMockFlowModel();
vi.clearAllMocks();
});
describe('Utils', () => {
// ==================== getT() FUNCTION ====================
describe('getT()', () => {
describe('basic translation functionality', () => {
test('should return translation function when flowEngine.translate exists', () => {
const translateFn = getT(mockModel);
expect(typeof translateFn).toBe('function');
});
test('should call flowEngine.translate with correct parameters', () => {
const translateFn = getT(mockModel);
translateFn('test.key', { custom: 'option' });
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('test.key', {
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
nsMode: 'fallback',
custom: 'option',
});
});
test('should return translated result from flowEngine', () => {
const translateFn = getT(mockModel);
const result = translateFn('hello.world');
expect(result).toBe('translated_hello.world');
});
});
describe('namespace handling', () => {
test('should add flow-engine namespace by default', () => {
const translateFn = getT(mockModel);
translateFn('key');
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
nsMode: 'fallback',
});
});
test('should merge with existing options', () => {
const translateFn = getT(mockModel);
translateFn('key', { ns: ['custom'], extraOption: 'value' });
// The implementation spreads options after defaults, so options override defaults
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
ns: ['custom'], // options.ns overrides default ns
nsMode: 'fallback',
extraOption: 'value',
});
});
test('should allow nsMode override', () => {
const translateFn = getT(mockModel);
translateFn('key', { nsMode: 'strict' });
// The implementation spreads options after defaults, so options override defaults
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
nsMode: 'strict', // options.nsMode overrides default nsMode
});
});
});
describe('fallback mechanism', () => {
test('should return fallback function when no flowEngine', () => {
const modelWithoutEngine = { flowEngine: null } as unknown as FlowModel;
const translateFn = getT(modelWithoutEngine);
expect(typeof translateFn).toBe('function');
expect(translateFn('test.key')).toBe('test.key');
});
test('should return fallback function when no translate method', () => {
const modelWithoutTranslate = {
flowEngine: { translate: null },
} as unknown as FlowModel;
const translateFn = getT(modelWithoutTranslate);
expect(translateFn('test.key')).toBe('test.key');
});
});
describe('error handling', () => {
test('should handle translate method throwing errors', () => {
mockModel.flowEngine.translate = vi.fn(() => {
throw new Error('Translation error');
});
expect(() => {
const translateFn = getT(mockModel);
translateFn('test.key');
}).toThrow('Translation error');
});
test('should handle null options parameter', () => {
const translateFn = getT(mockModel);
translateFn('key', null);
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
nsMode: 'fallback',
});
});
test('should handle undefined options parameter', () => {
const translateFn = getT(mockModel);
translateFn('key');
expect(mockModel.flowEngine.translate).toHaveBeenCalledWith('key', {
ns: [FLOW_ENGINE_NAMESPACE, 'client'],
nsMode: 'fallback',
});
});
});
});
// ==================== generateUid() FUNCTION ====================
describe('generateUid()', () => {
describe('basic generation functionality', () => {
test('should generate a string', () => {
const uid = generateUid();
expect(typeof uid).toBe('string');
expect(uid).toBeDefined();
expect(uid.length).toBeGreaterThan(10); // Should be reasonably long
});
});
describe('uniqueness validation', () => {
test('should generate different UIDs on consecutive calls', () => {
const uid1 = generateUid();
const uid2 = generateUid();
expect(uid1).not.toBe(uid2);
});
test('should generate unique UIDs in bulk', () => {
const uids = Array.from({ length: 100 }, () => generateUid());
const uniqueUids = new Set(uids);
expect(uniqueUids.size).toBe(100); // All should be unique
});
test('should maintain uniqueness across multiple executions', () => {
const batch1 = Array.from({ length: 10 }, () => generateUid());
const batch2 = Array.from({ length: 10 }, () => generateUid());
const allUids = [...batch1, ...batch2];
const uniqueUids = new Set(allUids);
expect(uniqueUids.size).toBe(20);
});
});
describe('format validation', () => {
test('should contain only alphanumeric characters', () => {
const uid = generateUid();
expect(/^[a-z0-9]+$/.test(uid)).toBe(true);
});
test('should have consistent length range', () => {
const uids = Array.from({ length: 50 }, () => generateUid());
uids.forEach((uid) => {
expect(uid.length).toBeGreaterThan(15);
expect(uid.length).toBeLessThan(30);
});
});
test('should not contain special characters', () => {
const uid = generateUid();
expect(uid).not.toMatch(/[^a-z0-9]/);
});
});
});
// ==================== mergeFlowDefinitions() FUNCTION ====================
describe('mergeFlowDefinitions()', () => {
let originalFlow: FlowDefinition;
let patchFlow: DeepPartial<FlowDefinition>;
beforeEach(() => {
originalFlow = createBasicFlowDefinition({
title: 'Original Flow',
on: { eventName: 'originalEvent' },
});
patchFlow = createPatchFlowDefinition();
});
describe('basic merging functionality', () => {
test('should merge flow definitions correctly', () => {
const merged = mergeFlowDefinitions(originalFlow, patchFlow);
expect(merged.title).toBe('Patched Flow');
expect(merged.key).toBe(originalFlow.key);
expect(merged.steps).toBeDefined();
});
test('should preserve original flow when patch is empty', () => {
const merged = mergeFlowDefinitions(originalFlow, {});
expect(merged.title).toBe(originalFlow.title);
expect(merged.key).toBe(originalFlow.key);
expect(merged.steps).toEqual(originalFlow.steps);
});
test('should handle undefined patch properties', () => {
const merged = mergeFlowDefinitions(originalFlow, {
title: undefined,
steps: undefined,
});
expect(merged.title).toBe(originalFlow.title);
expect(merged.steps).toEqual(originalFlow.steps);
});
});
describe('property override', () => {
test('should override title when provided in patch', () => {
const merged = mergeFlowDefinitions(originalFlow, {
title: 'Overridden Title',
});
expect(merged.title).toBe('Overridden Title');
});
test('should override event configuration when provided', () => {
const merged = mergeFlowDefinitions(originalFlow, {
on: { eventName: 'newEvent' },
});
expect(merged.on).toEqual({ eventName: 'newEvent' });
});
test('should preserve original properties when not in patch', () => {
const merged = mergeFlowDefinitions(originalFlow, {
title: 'New Title',
});
expect(merged.key).toBe(originalFlow.key);
expect(merged.on).toEqual(originalFlow.on);
});
test('should handle null values in patch', () => {
const merged = mergeFlowDefinitions(originalFlow, {
title: null,
});
expect(merged.title).toBe(originalFlow.title);
});
});
describe('steps merging', () => {
test('should merge step definitions correctly', () => {
const patchWithSteps = {
steps: {
step1: { newProperty: 'value', use: 'confirm' },
step3: { handler: vi.fn() },
},
};
const merged = mergeFlowDefinitions(originalFlow, patchWithSteps);
expect(merged.steps.step1).toEqual(
expect.objectContaining({
handler: originalFlow.steps.step1.handler,
newProperty: 'value',
}),
);
expect(merged.steps.step2).toEqual(originalFlow.steps.step2);
expect(merged.steps.step3).toEqual(patchWithSteps.steps.step3);
});
test('should create new steps when they do not exist in original', () => {
const patchWithNewStep = {
steps: {
newStep: { handler: vi.fn().mockReturnValue('new-result') },
},
};
const merged = mergeFlowDefinitions(originalFlow, patchWithNewStep);
expect(merged.steps.newStep).toEqual(patchWithNewStep.steps.newStep);
expect(merged.steps.step1).toEqual(originalFlow.steps.step1);
});
test('should handle empty steps in patch', () => {
const merged = mergeFlowDefinitions(originalFlow, { steps: {} });
expect(merged.steps).toEqual(originalFlow.steps);
});
});
});
// ==================== isInheritedFrom() FUNCTION ====================
describe('isInheritedFrom()', () => {
let BaseClass: ModelConstructor;
let MiddleClass: ModelConstructor;
let DerivedClass: ModelConstructor;
let UnrelatedClass: ModelConstructor;
beforeEach(() => {
BaseClass = class extends FlowModel {} as ModelConstructor;
MiddleClass = class MiddleClass extends BaseClass {} as ModelConstructor;
DerivedClass = class DerivedClass extends MiddleClass {} as ModelConstructor;
UnrelatedClass = class extends FlowModel {} as ModelConstructor;
});
describe('basic inheritance checking', () => {
test('should return true for direct inheritance', () => {
const result = isInheritedFrom(MiddleClass, BaseClass);
expect(result).toBe(true);
});
test('should return false for same class', () => {
const result = isInheritedFrom(BaseClass, BaseClass);
expect(result).toBe(false);
});
test('should return false for unrelated classes', () => {
const result = isInheritedFrom(UnrelatedClass, BaseClass);
expect(result).toBe(false);
});
});
describe('multi-level inheritance', () => {
test('should return true for multi-level inheritance', () => {
const result = isInheritedFrom(DerivedClass, BaseClass);
expect(result).toBe(true);
});
test('should return true for immediate parent', () => {
const result = isInheritedFrom(DerivedClass, MiddleClass);
expect(result).toBe(true);
});
test('should handle deep inheritance chains', () => {
class Level1 extends DerivedClass {}
class Level2 extends Level1 {}
class Level3 extends Level2 {}
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, BaseClass)).toBe(true);
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, MiddleClass)).toBe(true);
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, DerivedClass)).toBe(true);
expect(isInheritedFrom(Level3 as unknown as ModelConstructor, Level2 as unknown as ModelConstructor)).toBe(
true,
);
});
});
describe('prototype chain validation', () => {
test('should traverse prototype chain correctly', () => {
// Create a complex inheritance chain
const A = class extends FlowModel {} as ModelConstructor;
const B = class B extends A {} as ModelConstructor;
const C = class C extends B {} as ModelConstructor;
const D = class D extends C {} as ModelConstructor;
expect(isInheritedFrom(D, C)).toBe(true);
expect(isInheritedFrom(D, B)).toBe(true);
expect(isInheritedFrom(D, A)).toBe(true);
expect(isInheritedFrom(C, A)).toBe(true);
expect(isInheritedFrom(B, A)).toBe(true);
});
test('should handle null prototype correctly', () => {
const NullProtoClass = function () {} as unknown as ModelConstructor;
Object.setPrototypeOf((NullProtoClass as unknown as { prototype: unknown }).prototype, null);
expect(() => {
isInheritedFrom(NullProtoClass, BaseClass);
}).not.toThrow();
});
});
});
// ==================== resolveDefaultParams() FUNCTION ====================
describe('resolveDefaultParams()', () => {
let mockContext: ParamsContext<FlowModel>;
beforeEach(() => {
mockContext = {
model: mockModel,
globals: {
flowEngine: mockFlowEngine,
app: {},
},
app: {} as any,
extra: { testExtra: 'value' },
};
});
describe('static parameter resolution', () => {
test('should return static object directly', async () => {
const staticParams = { param1: 'value1', param2: 'value2' };
const result = await resolveDefaultParams(staticParams, mockContext);
expect(result).toEqual(staticParams);
});
test('should return empty object for undefined params', async () => {
const result = await resolveDefaultParams(undefined, mockContext);
expect(result).toEqual({});
});
test('should return empty object for null params', async () => {
const result = await resolveDefaultParams(null, mockContext);
expect(result).toEqual({});
});
test('should handle complex static objects', async () => {
const complexParams = {
user: { name: 'John', age: 30 },
settings: { theme: 'dark', notifications: true },
array: [1, 2, 3],
};
const result = await resolveDefaultParams(complexParams, mockContext);
expect(result).toEqual(complexParams);
});
});
describe('function parameter resolution', () => {
test('should call function with context and return result', async () => {
const paramsFn = vi.fn().mockReturnValue({ dynamic: 'value' });
const result = await resolveDefaultParams(paramsFn, mockContext);
expect(paramsFn).toHaveBeenCalledWith(mockContext);
expect(result).toEqual({ dynamic: 'value' });
});
test('should handle function accessing context properties', async () => {
const paramsFn = vi.fn((ctx: ParamsContext<FlowModel>) => ({
modelUid: ctx.model.uid,
extraData: ctx.extra.testExtra,
}));
const result = await resolveDefaultParams(paramsFn, mockContext);
expect(result).toEqual({
modelUid: mockModel.uid,
extraData: 'value',
});
});
});
describe('async processing', () => {
test('should handle async function correctly', async () => {
const asyncParamsFn = vi.fn().mockResolvedValue({ async: 'result' });
const result = await resolveDefaultParams(asyncParamsFn, mockContext);
expect(asyncParamsFn).toHaveBeenCalledWith(mockContext);
expect(result).toEqual({ async: 'result' });
});
test('should handle async function with delay', async () => {
const asyncParamsFn = vi.fn(
() => new Promise((resolve) => setTimeout(() => resolve({ delayed: 'value' }), 10)),
);
const result = await resolveDefaultParams(asyncParamsFn, mockContext);
expect(result).toEqual({ delayed: 'value' });
});
});
});
// ==================== resolveUiSchema() FUNCTION ====================
describe('resolveUiSchema()', () => {
let mockContext: ParamsContext<FlowModel>;
beforeEach(() => {
mockContext = {
model: mockModel,
globals: {
flowEngine: mockFlowEngine,
app: {},
},
app: {} as any,
extra: { testExtra: 'value' },
};
});
describe('static schema resolution', () => {
test('should return static schema object directly', async () => {
const staticSchema: Record<string, ISchema> = {
field1: { type: 'string', title: 'Field 1' },
field2: { type: 'number', title: 'Field 2' },
};
const result = await resolveUiSchema(staticSchema, mockContext);
expect(result).toEqual(staticSchema);
});
test('should return empty object for undefined schema', async () => {
const result = await resolveUiSchema(undefined, mockContext);
expect(result).toEqual({});
});
test('should return empty object for null schema', async () => {
const result = await resolveUiSchema(null, mockContext);
expect(result).toEqual({});
});
test('should handle complex static schema', async () => {
const complexSchema: Record<string, ISchema> = {
user: {
type: 'object',
properties: {
name: { type: 'string', title: 'Name' },
age: { type: 'number', title: 'Age' },
},
},
settings: {
type: 'object',
'x-component': 'FormLayout',
properties: {
theme: { type: 'string', enum: ['light', 'dark'] },
},
},
};
const result = await resolveUiSchema(complexSchema, mockContext);
expect(result).toEqual(complexSchema);
});
});
describe('function schema resolution', () => {
test('should call function with context and return result', async () => {
const schemaFn = vi.fn().mockReturnValue({
dynamicField: { type: 'string', title: 'Dynamic Field' },
});
const result = await resolveUiSchema(schemaFn, mockContext);
expect(schemaFn).toHaveBeenCalledWith(mockContext);
expect(result).toEqual({
dynamicField: { type: 'string', title: 'Dynamic Field' },
});
});
test('should handle function accessing context properties', async () => {
const schemaFn = vi.fn((ctx: ParamsContext<FlowModel>) => ({
modelInfo: {
type: 'string',
title: 'Model UID',
default: ctx.model.uid,
},
extraInfo: {
type: 'string',
title: 'Extra Data',
default: ctx.extra.testExtra,
},
}));
const result = await resolveUiSchema(schemaFn, mockContext);
expect(result.modelInfo.default).toBe(mockModel.uid);
expect(result.extraInfo.default).toBe('value');
});
});
describe('async processing', () => {
test('should handle async function correctly', async () => {
const asyncSchemaFn = vi.fn().mockResolvedValue({
asyncField: { type: 'string', title: 'Async Field' },
});
const result = await resolveUiSchema(asyncSchemaFn, mockContext);
expect(asyncSchemaFn).toHaveBeenCalledWith(mockContext);
expect(result).toEqual({
asyncField: { type: 'string', title: 'Async Field' },
});
});
test('should handle async function with delay', async () => {
const asyncSchemaFn = vi.fn(
() =>
new Promise<any>((resolve) =>
setTimeout(
() =>
resolve({
delayedField: { type: 'number', title: 'Delayed Field' },
}),
10,
),
),
);
const result = await resolveUiSchema(asyncSchemaFn, mockContext);
expect(result).toEqual({
delayedField: { type: 'number', title: 'Delayed Field' },
});
});
});
});
// ==================== FlowExitException CLASS ====================
describe('FlowExitException', () => {
describe('constructor', () => {
test('should create exception with all parameters', () => {
const exception = new FlowExitException('testFlow', 'model-123', 'Custom exit message');
expect(exception.flowKey).toBe('testFlow');
expect(exception.modelUid).toBe('model-123');
expect(exception.message).toBe('Custom exit message');
expect(exception.name).toBe('FlowExitException');
});
test('should create exception with default message', () => {
const exception = new FlowExitException('testFlow', 'model-123');
expect(exception.flowKey).toBe('testFlow');
expect(exception.modelUid).toBe('model-123');
expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit().");
expect(exception.name).toBe('FlowExitException');
});
test('should create exception with empty string message', () => {
const exception = new FlowExitException('testFlow', 'model-123', '');
// Empty string is falsy, so the default message is used in the constructor
expect(exception.message).toBe("Flow 'testFlow' on model 'model-123' exited via ctx.exit().");
});
});
describe('property access', () => {
test('should have readonly properties accessible', () => {
const exception = new FlowExitException('flowKey', 'modelUid');
expect(exception.flowKey).toBe('flowKey');
expect(exception.modelUid).toBe('modelUid');
});
});
});
// ==================== defineAction() FUNCTION ====================
describe('defineAction()', () => {
describe('basic functionality', () => {
test('should return action definition unchanged', () => {
const actionDef: ActionDefinition = {
name: 'testAction',
handler: vi.fn(),
};
const result = defineAction(actionDef);
expect(result).toBe(actionDef);
expect(result).toEqual(actionDef);
});
test('should handle complex action definition', () => {
const complexAction: ActionDefinition = {
name: 'complexAction',
handler: vi.fn().mockResolvedValue('result'),
defaultParams: { param1: 'value1' },
uiSchema: {
field1: { type: 'string', title: 'Field 1' },
},
};
const result = defineAction(complexAction);
expect(result).toBe(complexAction);
expect(result.name).toBe('complexAction');
expect(result.defaultParams).toEqual({ param1: 'value1' });
expect(result.uiSchema).toEqual({
field1: { type: 'string', title: 'Field 1' },
});
});
});
});
// ==================== compileUiSchema() FUNCTION ====================
describe('compileUiSchema()', () => {
let mockScope: Record<string, unknown>;
beforeEach(() => {
mockScope = {
t: vi.fn((key: string) => `translated_${key}`),
randomString: vi.fn(() => 'random123'),
user: { name: 'John', role: 'admin' },
};
});
describe('expression compilation', () => {
test('should compile simple expressions', () => {
const result = compileUiSchema(mockScope, "{{ t('Hello World') }}");
expect(mockScope.t).toHaveBeenCalledWith('Hello World');
expect(typeof result).toBe('string');
});
test('should compile expressions with variables', () => {
const result = compileUiSchema(mockScope, '{{ user.name }}');
expect(result).toBe('John');
});
test('should compile complex expressions', () => {
const result = compileUiSchema(mockScope, "{{ user.role === 'admin' ? 'Administrator' : 'User' }}");
expect(result).toBe('Administrator');
});
test('should handle non-expression strings', () => {
const result = compileUiSchema(mockScope, 'Plain string without expressions');
expect(result).toBe('Plain string without expressions');
});
});
describe('caching mechanism', () => {
test('should cache compiled results', () => {
const schema = "{{ t('Cached Test') }}";
const result1 = compileUiSchema(mockScope, schema);
const result2 = compileUiSchema(mockScope, schema);
expect(result1).toBe(result2);
// Schema.compile should be called once and then cached
expect(mockScope.t).toHaveBeenCalledTimes(1);
});
test('should bypass cache when noCache option is true', () => {
const schema = "{{ t('No Cache Test') }}";
compileUiSchema(mockScope, schema, { noCache: false });
compileUiSchema(mockScope, schema, { noCache: true });
// t function should be called twice when bypassing cache
expect(mockScope.t).toHaveBeenCalledTimes(2);
});
test('should cache object compilations', () => {
const schema = {
title: "{{ t('Object Title') }}",
description: 'Static description',
};
const result1 = compileUiSchema(mockScope, schema);
const result2 = compileUiSchema(mockScope, schema);
expect(result1).toBe(result2);
expect(result1.title).toBeDefined();
expect(result1.description).toBe('Static description');
});
test('should cache array compilations', () => {
const schema = [{ title: "{{ t('Item 1') }}" }, { title: "{{ t('Item 2') }}" }];
const result1 = compileUiSchema(mockScope, schema);
const result2 = compileUiSchema(mockScope, schema);
expect(result1).toBe(result2);
expect(Array.isArray(result1)).toBe(true);
expect(result1).toHaveLength(2);
});
});
describe('object compilation', () => {
test('should compile objects with template strings', () => {
const schema = {
title: "{{ t('Form Title') }}",
description: 'Static description',
user: '{{ user.name }}',
role: '{{ user.role }}',
};
const result = compileUiSchema(mockScope, schema);
expect(typeof result.title).toBe('string');
expect(result.description).toBe('Static description');
expect(result.user).toBe('John');
expect(result.role).toBe('admin');
});
test('should handle nested objects', () => {
const schema = {
form: {
title: "{{ t('Nested Form') }}",
fields: {
username: {
label: "{{ t('Username') }}",
placeholder: "{{ t('Enter username') }}",
},
},
},
};
const result = compileUiSchema(mockScope, schema);
expect(typeof result.form.title).toBe('string');
expect(typeof result.form.fields.username.label).toBe('string');
expect(typeof result.form.fields.username.placeholder).toBe('string');
});
test('should handle arrays within objects', () => {
const schema = {
items: [
{ title: "{{ t('Item 1') }}", value: 1 },
{ title: "{{ t('Item 2') }}", value: 2 },
],
metadata: {
count: "{{ user.role === 'admin' ? 'unlimited' : '10' }}",
},
};
const result = compileUiSchema(mockScope, schema);
expect(Array.isArray(result.items)).toBe(true);
expect(result.items).toHaveLength(2);
expect(result.items[0].value).toBe(1);
expect(result.items[1].value).toBe(2);
expect(result.metadata.count).toBe('unlimited');
});
test('should preserve non-template properties', () => {
const schema = {
type: 'object',
properties: {
name: {
type: 'string',
title: "{{ t('Name Field') }}",
maxLength: 100,
},
},
};
const result = compileUiSchema(mockScope, schema);
expect(result.type).toBe('object');
expect(result.properties.name.type).toBe('string');
expect(typeof result.properties.name.title).toBe('string');
expect(result.properties.name.maxLength).toBe(100);
});
});
});
});

View File

@ -87,7 +87,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
}
this.uid = options.uid;
this.props = options.props || {};
this.props = {};
this.stepParams = options.stepParams || {};
this.subModels = {};
this.sortIndex = options.sortIndex || 0;
@ -138,7 +138,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
get title() {
// model 可以通过 setTitle 来自定义title 具有更高的优先级
return this._title || this.translate(this.constructor['meta']?.title);
return this.translate(this._title) || this.translate(this.constructor['meta']?.title);
}
setTitle(value: string) {

View File

@ -92,12 +92,23 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
await this.refresh();
}
async get(filterByTk: any): Promise<TDataItem | undefined> {
const options = {
params: {
filterByTk,
},
};
const { data } = await this.runAction<TDataItem, any>('get', {
...options,
});
return data;
}
async update(filterByTk: string | number, data: Partial<TDataItem>): Promise<void> {
const options = {
params: {
filterByTk,
},
headers: this.request.headers,
};
await this.runAction('update', {
...options,
@ -121,7 +132,6 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
return typeof item === 'object' ? item['id'] : item; // TODO: ID 字段还需要根据实际情况更改
}),
},
headers: this.request.headers,
};
await this.runAction('destroy', {
...options,
@ -129,6 +139,13 @@ export class MultiRecordResource<TDataItem = any> extends BaseRecordResource<TDa
await this.refresh();
}
setItem(index: number, newDataItem: TDataItem) {
const oldData = this.getData();
const newData = oldData.slice(); // 浅拷贝
newData[index] = { ...newDataItem };
this.setData(newData);
}
async refresh(): Promise<void> {
const { data, meta } = await this.runAction<TDataItem[], any>('list', {
method: 'get',

View File

@ -315,7 +315,6 @@ export interface FlowModelOptions<Structure extends { parent?: FlowModel; subMod
uid?: string;
use?: string;
async?: boolean; // 是否异步加载模型
props?: IModelComponentProps;
stepParams?: StepParams;
subModels?: Structure['subModels'];
flowEngine?: FlowEngine;

View File

@ -14,15 +14,15 @@
},
"devDependencies": {
"@ant-design/x": "^1.1.0",
"@langchain/core": "^0.3.55",
"@langchain/deepseek": "^0.0.1",
"@langchain/openai": "^0.4.3",
"@langchain/core": "^0.3.61",
"@langchain/deepseek": "^0.0.2",
"@langchain/openai": "^0.5.16",
"nodejs-snowflake": "^2.0.1",
"use-context-selector": "^2.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
"@langchain/google-genai": "^0.2.5",
"@langchain/anthropic": "^0.3.20",
"@langchain/google-genai": "^0.2.14",
"@langchain/anthropic": "^0.3.23",
"echarts": "^5.5.0",
"echarts-for-react": "3.0.2",
"rehype-raw": "^7.0.0",

View File

@ -7,13 +7,14 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// @ts-nocheck
import React, { useMemo } from 'react';
import { Button, Dropdown } from 'antd';
import { useT } from '../locale';
import { AppstoreAddOutlined } from '@ant-design/icons';
import { Schema } from '@formily/react';
import { usePlugin } from '@nocobase/client';
import PluginAIClient from '../';
import PluginAIClient from '..';
import { ContextItem } from './types';
export const AddContextButton: React.FC<{

View File

@ -17,7 +17,6 @@ import { UserAddOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { AIEmployeeListItem } from '../AIEmployeeListItem';
import { avatars } from '../avatars';
import { ProfileCard } from '../ProfileCard';
import { AddContextButton } from './AddContextButton';
import { AttachmentsHeader } from './AttachmentsHeader';
import { ContextItemsHeader } from './ContextItemsHeader';

View File

@ -55,19 +55,22 @@ const layoutElements = async (nodes: Node[], edges: Edge[]) => {
edges: edges,
};
return elk
.layout(graph)
.then((layoutedGraph) => ({
nodes: layoutedGraph.children.map((node) => ({
...node,
// React Flow expects a position property on the node instead of `x`
// and `y` fields.
position: { x: node.x, y: node.y },
})),
return (
elk
// @ts-ignore
.layout(graph)
.then((layoutedGraph) => ({
nodes: layoutedGraph.children.map((node) => ({
...node,
// React Flow expects a position property on the node instead of `x`
// and `y` fields.
position: { x: node.x, y: node.y },
})),
edges: layoutedGraph.edges,
}))
.catch(console.error);
edges: layoutedGraph.edges,
}))
.catch(console.error)
);
};
const getDiagramData = (collections: any[]) => {
@ -226,6 +229,7 @@ export const Diagram: React.FC<{
useEffect(() => {
getDiagramData(collections)
// @ts-ignore
.then(({ nodes, edges }) => {
setNodes(nodes);
setEdges(edges);

View File

@ -1,259 +0,0 @@
/**
* 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.
*/
export interface CollectionOptions extends Omit<SequelizeModelOptions, 'name' | 'hooks'> {
/** The unique identifier of the collection, must be unique across the database */
name: string;
/** The display title of the collection, used for UI presentation */
title?: string;
/** The description of the collection */
description?: string;
/** Whether this collection is a through table for many-to-many relationships */
isThrough?: boolean;
/** The target key(s) used for filtering operations, can be a single key or array of keys */
filterTargetKey?: string | string[];
/** Array of field definitions for the collection */
fields?: FieldOptions[];
/**
* Whether to automatically generate an 'id' field
* @default true
*/
autoGenId?: boolean;
/**
* Whether to automatically generate a 'createdAt' timestamp field
* @default true
*/
createdAt?: boolean;
/** Whether to automatically generate an 'updatedAt' timestamp field
* @default true
*/
updatedAt?: boolean;
/**
* Whether to automatically generate a 'createdById' field for record ownership
* @default false
*/
createdBy?: boolean;
/**
* Whether to automatically generate an 'updatedById' field for tracking updates
* @default false
*/
updatedBy?: boolean;
/** The template identifier used to create this collection */
template: 'general' | 'tree' | 'file' | 'calendar' | 'expression';
/** The field name used for tree structure functionality */
tree?: 'adjacencyList';
}
export type FieldOptions =
| BaseFieldOptions
| StringFieldOptions
| IntegerFieldOptions
| FloatFieldOptions
| DecimalFieldOptions
| DoubleFieldOptions
| JsonFieldOptions
| JsonbFieldOptions
| BooleanFieldOptions
| RadioFieldOptions
| TextFieldOptions
| TimeFieldOptions
| DateFieldOptions
| DatetimeTzFieldOptions
| DatetimeNoTzFieldOptions
| DateOnlyFieldOptions
| UnixTimestampFieldOptions
| UidFieldOptions
| UUIDFieldOptions
| NanoidFieldOptions
| PasswordFieldOptions
| BelongsToFieldOptions
| HasOneFieldOptions
| HasManyFieldOptions
| BelongsToManyFieldOptions;
/**
* Base options for all field types
* Provides common properties that are available to all field configurations
*/
export interface BaseFieldOptions {
/** The name of the field, used as the column name in the database */
name: string;
/** The title of the field, used for display in the UI */
title: string;
/** The description of the field */
description?: string;
/** Whether the field should be hidden from API responses and UI */
hidden?: boolean;
/** Required. The user interface component type for this field */
interface:
| 'id'
| 'input'
| 'integer'
| 'checkbox'
| 'checkboxGroup'
| 'color'
| 'createdAt'
| 'updatedAt'
| 'createdBy'
| 'updatedBy'
| 'date'
| 'datetime'
| 'datetimeNoTz'
| 'email'
| 'icon'
| 'json'
| 'markdown'
| 'multipleSelect'
| 'nanoid'
| 'number'
| 'password'
| 'percent'
| 'phone'
| 'radioGroup'
| 'richText'
| 'select'
| 'textarea'
| 'time'
| 'unixTimestamp'
| 'url'
| 'uuid'
| 'm2m'
| 'm2o'
| 'o2m'
| 'o2o';
/** enumeration options for the field, used for select/radio/checkbox interfaces */
enum?: {
label: string;
value: string | number | boolean;
}[];
/** Additional properties for extensibility */
[key: string]: any;
}
/**
* Base options for column-based field types
* Extends BaseFieldOptions and includes Sequelize column-specific options
* Excludes the 'type' property as it's handled by the specific field implementations
*/
export interface BaseColumnFieldOptions extends BaseFieldOptions, Omit<ModelAttributeColumnOptions, 'type'> {
/** The Sequelize data type for the column */
dataType?: DataType;
/** Index configuration for the column, can be boolean or detailed index options */
index?: boolean | ModelIndexesOptions;
}
export interface StringFieldOptions extends BaseColumnFieldOptions {
type: 'string';
length?: number;
trim?: boolean;
}
export interface IntegerFieldOptions extends BaseColumnFieldOptions {
type: 'integer';
}
export interface FloatFieldOptions extends BaseColumnFieldOptions {
type: 'float';
}
export interface DecimalFieldOptions extends BaseColumnFieldOptions {
type: 'decimal';
precision: number;
scale: number;
}
export interface DoubleFieldOptions extends BaseColumnFieldOptions {
type: 'double';
}
export interface JsonFieldOptions extends BaseColumnFieldOptions {
type: 'json';
}
export interface JsonbFieldOptions extends BaseColumnFieldOptions {
type: 'jsonb';
}
export interface BooleanFieldOptions extends BaseColumnFieldOptions {
type: 'boolean';
}
export interface RadioFieldOptions extends BaseColumnFieldOptions {
type: 'radio';
}
export interface TextFieldOptions extends BaseColumnFieldOptions {
type: 'text';
length?: 'tiny' | 'medium' | 'long';
trim?: boolean;
}
export interface TimeFieldOptions extends BaseColumnFieldOptions {
type: 'time';
}
export interface DateFieldOptions extends BaseColumnFieldOptions {
type: 'date';
}
export interface DatetimeTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeTz';
}
export interface DatetimeNoTzFieldOptions extends BaseColumnFieldOptions {
type: 'datetimeNoTz';
}
export interface DateOnlyFieldOptions extends BaseColumnFieldOptions {
type: 'dateOnly';
}
export interface UnixTimestampFieldOptions extends BaseColumnFieldOptions {
type: 'unixTimestamp';
}
export interface UidFieldOptions extends BaseColumnFieldOptions {
type: 'uid';
prefix?: string;
pattern?: string;
}
export interface UUIDFieldOptions extends BaseColumnFieldOptions {
type: 'uuid';
autoFill?: boolean;
}
export interface NanoidFieldOptions extends BaseColumnFieldOptions {
type: 'nanoid';
size?: number;
customAlphabet?: string;
autoFill?: boolean;
}
export interface PasswordFieldOptions extends BaseColumnFieldOptions {
type: 'password';
/**
* @default 64
*/
length?: number;
/**
* @default 8
*/
randomBytesSize?: number;
}
export interface BelongsToFieldOptions extends BaseRelationFieldOptions, SequelizeBelongsToOptions {
type: 'belongsTo';
foreignKey: string;
target: string;
targetKey: string;
}
export interface HasOneFieldOptions extends BaseRelationFieldOptions, SequelizeHasOneOptions {
type: 'hasOne';
sourceKey: string;
target: string;
foreignKey: string;
}
export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions {
type: 'hasMany';
sourceKey: string;
target: string;
foreignKey: string;
}
export interface BelongsToManyFieldOptions
extends MultipleRelationFieldOptions,
Omit<SequelizeBelongsToManyOptions, 'through'> {
type: 'belongsToMany';
target: string;
through: string;
sourceKey: string;
foreignKey: string;
otherKey: string;
targetKey: string;
}

View File

@ -120,7 +120,7 @@ export type ActionOptions = {
export type WorkContextOptions = {
name?: string;
menu: {
menu?: {
icon?: React.ReactNode;
label?: React.ReactNode;
Component?: ComponentType<{

View File

@ -8,7 +8,7 @@
*/
import React from 'react';
import { Markdown } from '../../ai-employees/chatbox/Markdown';
import { Markdown } from '../../ai-employees/chatbox/markdown/Markdown';
import { ToolCard } from '../../ai-employees/chatbox/ToolCard';
export const MessageRenderer: React.FC<{
@ -41,7 +41,15 @@ export const MessageRenderer: React.FC<{
gap: 16,
}}
>
{typeof content === 'string' && <Markdown markdown={content} />}
{typeof content === 'string' && (
<Markdown
message={{
...msg,
// @ts-ignore
content,
}}
/>
)}
{msg.tool_calls?.length && <ToolCard tools={msg.tool_calls} messageId={msg.messageId} />}
</div>
);

View File

@ -7,4 +7,114 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const defineCollections = () => {};
import { Context, Next } from '@nocobase/actions';
import _ from 'lodash';
export const defineCollections = async (ctx: Context, next: Next) => {
const { collections } = ctx.action.params.values || {};
const id = {
name: 'id',
type: 'bigInt',
autoIncrement: true,
primaryKey: true,
allowNull: false,
uiSchema: {
type: 'number',
title: '{{t("ID")}}',
'x-component': 'InputNumber',
'x-read-pretty': true,
},
interface: 'integer',
};
const createdAt = {
name: 'createdAt',
interface: 'createdAt',
type: 'date',
field: 'createdAt',
uiSchema: {
type: 'datetime',
title: '{{t("Created at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
};
const updatedAt = {
type: 'date',
field: 'updatedAt',
name: 'updatedAt',
interface: 'updatedAt',
uiSchema: {
type: 'datetime',
title: '{{t("Last updated at")}}',
'x-component': 'DatePicker',
'x-component-props': {},
'x-read-pretty': true,
},
};
for (const options of collections) {
if (options.name === 'users') {
continue;
}
if (options.autoGenId !== false && !options.isThrough) {
options.autoGenId = false;
options.fields.unshift(id);
}
if (options.createdAt !== false) {
options.fields.push(createdAt);
}
if (options.updatedAt !== false) {
options.fields.push(updatedAt);
}
const primaryKey = options.fields.find((field: any) => field.primaryKey);
if (!options.filterTargetKey) {
options.filterTargetKey = primaryKey?.name || 'id';
}
// omit defaultValue
options.fields = options.fields.map((field: any) => {
return _.omit(field, ['defaultValue']);
});
const transaction = await ctx.app.db.sequelize.transaction();
try {
const repo = ctx.db.getRepository('collections');
const collection = await repo.findOne({
filter: {
name: options.name,
},
transaction,
});
if (!collection) {
const collection = await repo.create({
values: options,
transaction,
context: ctx,
});
// await collection.load({ transaction });
await transaction.commit();
continue;
}
await repo.update({
filterByTk: collection.name,
values: {
...options,
fields: options.fields?.map((f: any) => {
delete f.key;
return f;
}),
},
updateAssociationValues: ['fields'],
transaction,
});
await collection.loadFields({
transaction,
});
await collection.load({ transaction, resetFields: true });
await transaction.commit();
} catch (e) {
await transaction.rollback();
throw e;
}
}
await next();
};

View File

@ -81,7 +81,13 @@ export class AIManager {
const processSchema = (schema: any) => {
if (!schema) return undefined;
return schema instanceof ZodObject && raw ? zodToJsonSchema(schema) : schema;
try {
// Use type assertion to break the recursive type checking
return (schema as any) instanceof ZodObject && raw ? zodToJsonSchema(schema as any) : schema;
} catch (error) {
// Fallback if zodToJsonSchema fails
return schema;
}
};
if (tool.type === 'group' && child) {