mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-07-01 18:52:20 +08:00
Compare commits
16 Commits
3286db836f
...
dec669a94a
Author | SHA1 | Date | |
---|---|---|---|
|
dec669a94a | ||
|
3dbd75b9e4 | ||
|
cec9b5ccf4 | ||
|
e22e9e425a | ||
|
a06623ba9d | ||
|
bb3d9a78ec | ||
|
090ee6df4c | ||
|
151bc938a3 | ||
|
8c37c88b53 | ||
|
40b386eb7c | ||
|
95f3ba188c | ||
|
a37828c3cd | ||
|
582107f0c6 | ||
|
aaf09d3f81 | ||
|
28baf20755 | ||
|
a33a91a091 |
@ -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();
|
||||
},
|
||||
});
|
||||
|
@ -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({
|
||||
|
@ -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} >
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}}
|
||||
>
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
@ -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;
|
||||
|
@ -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 });
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -19,3 +19,4 @@ export * from './ColorReadPrettyFieldModel';
|
||||
export * from './IconReadPrettyFieldModel';
|
||||
export * from './JsonReadPrettyFieldModel';
|
||||
export * from './AssociationFieldModel';
|
||||
export * from './MarkdownReadPrettyFieldModel';
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
983
packages/core/flow-engine/src/models/__tests__/utils.test.ts
Normal file
983
packages/core/flow-engine/src/models/__tests__/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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) {
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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<{
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
@ -120,7 +120,7 @@ export type ActionOptions = {
|
||||
|
||||
export type WorkContextOptions = {
|
||||
name?: string;
|
||||
menu: {
|
||||
menu?: {
|
||||
icon?: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
Component?: ComponentType<{
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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();
|
||||
};
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user