Compare commits

...

23 Commits

Author SHA1 Message Date
chenos
3b39df9147 feat: improve openStepSettingsDialog 2025-07-01 09:30:47 +08:00
gchust
7561db0fa0 test: unit tests for flowSettings 2025-07-01 08:38:39 +08:00
chenos
407408b36b
Merge branch 'develop' into 2.0 2025-07-01 07:16:28 +08:00
gchust
318145dac9 fix: bug 2025-07-01 07:13:50 +08:00
Zeke Zhang
750468bb74 feat: add DragHandler to extraToolbarItems in TableColumnModel and TableModel 2025-07-01 06:50:11 +08:00
Zeke Zhang
df7434c970 fix: use React.Fragment for rendering items in Grid component 2025-07-01 06:50:11 +08:00
gchust
c2d77907ae fix: add fields btn style issue 2025-07-01 06:24:09 +08:00
nocobase[bot]
bf1886cff5 Merge branch 'next' into develop 2025-06-30 11:42:10 +00:00
nocobase[bot]
90fe98b4e5 Merge branch 'main' into next 2025-06-30 11:41:45 +00:00
Katherine
026baab241
fix: filtering error on DateOnly or Datetime (without time zone) using Exact day variable (#7113)
* fix: filtering error on DateOnly or Datetime (without time zone) fields using Exact day variable

* fix: bug

* fix: bug

* fix: bug
2025-06-30 22:41:20 +11:00
Jiann
0278e09fef
fix: adjust the license copy when installing the plugin (#7135)
* fix: license text

* fix: prompt when the plugin version cannot be downloaded
2025-06-30 19:04:03 +08:00
nocobase[bot]
54d3867551 Merge branch 'next' into develop 2025-06-30 09:35:01 +00:00
nocobase[bot]
469bd5acee Merge branch 'main' into next 2025-06-30 09:34:41 +00:00
ajie
d1ff280d9f
fix: setting field displayName in connected view does not take effect (#7130) 2025-06-30 17:34:17 +08:00
nocobase[bot]
2424e93369 Merge branch 'next' into develop 2025-06-30 07:26:39 +00:00
nocobase[bot]
5c4619f712 Merge branch 'main' into next 2025-06-30 07:26:19 +00:00
Junyi
88591454ec
fix(plugin-workflow): fix cycling import (#7134) 2025-06-30 15:25:57 +08:00
nocobase[bot]
a4ca48a1f7 Merge branch 'next' into develop 2025-06-30 03:46:22 +00:00
nocobase[bot]
c2176861d8 Merge branch 'main' into next 2025-06-30 03:46:01 +00:00
Junyi
3c555e9fc0
refactor(plugin-workflow): add log for node testing (#7129) 2025-06-30 11:45:39 +08:00
nocobase[bot]
0a801f655e Merge branch 'next' into develop 2025-06-29 01:35:05 +00:00
nocobase[bot]
0dcfc2ec27 Merge branch 'main' into next 2025-06-29 01:34:44 +00:00
Junyi
6afcbffdec
fix(client): fault tolerance for settings based on x-acl-action (#7128) 2025-06-29 09:34:21 +08:00
33 changed files with 935 additions and 452 deletions

View File

@ -143,6 +143,17 @@ class Package {
});
console.log(chalk.greenBright(`Downloaded: ${this.packageName}@${version}`));
} catch (error) {
if (error.response.data && typeof error.response.data.pipe === 'function') {
let errorMessageBuffer = '';
error.response.data.on('data', (chunk) => {
errorMessageBuffer += chunk.toString('utf8'); // 收集错误信息
});
error.response.data.on('end', () => {
if (error.response.status === 403) {
console.error(chalk.redBright('You do not have permission to download this package version.'));
}
});
}
console.log(chalk.redBright(`Download failed: ${this.packageName}`));
}
}
@ -252,7 +263,13 @@ module.exports = (cli) => {
NOCOBASE_PKG_USERNAME,
NOCOBASE_PKG_PASSWORD,
} = process.env;
const { accessKeyId, accessKeySecret } = await getAccessKeyPair();
let accessKeyId;
let accessKeySecret;
try {
({ accessKeyId, accessKeySecret } = await getAccessKeyPair());
} catch (e) {
return;
}
if (!(NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD) && !(accessKeyId && accessKeySecret)) {
return;
}

View File

@ -19,7 +19,7 @@ const fs = require('fs-extra');
const os = require('os');
const moment = require('moment-timezone');
const { keyDecrypt, getEnvAsync } = require('@nocobase/license-kit');
const _ = require('lodash');
const omit = require('lodash/omit');
exports.isPackageValid = (pkg) => {
try {
@ -491,10 +491,20 @@ exports.generatePlugins = function () {
}
};
async function isEnvMatch(keyData) {
const env = await getEnvAsync();
if (env?.container?.id && keyData?.instanceData?.container?.id) {
return (
JSON.stringify(omit(env, ['timestamp', 'container', 'hostname'])) ===
JSON.stringify(omit(keyData?.instanceData, ['timestamp', 'container', 'hostname']))
);
}
return JSON.stringify(omit(env, ['timestamp'])) === JSON.stringify(omit(keyData?.instanceData, ['timestamp']));
}
exports.getAccessKeyPair = async function () {
const keyFile = resolve(process.cwd(), 'storage/.license/license-key');
if (!fs.existsSync(keyFile)) {
// showLicenseInfo(LicenseKeyError.notExist);
return {};
}
@ -505,13 +515,13 @@ exports.getAccessKeyPair = async function () {
keyData = JSON.parse(keyDataStr);
} catch (error) {
showLicenseInfo(LicenseKeyError.parseFailed);
return {};
throw new Error(LicenseKeyError.parseFailed.title);
}
const currentEnv = await getEnvAsync();
if (!_.isEqual(_.omit(keyData?.instanceData, ['timestamp']), _.omit(currentEnv, ['timestamp']))) {
const isEnvMatched = await isEnvMatch(keyData);
if (!isEnvMatched) {
showLicenseInfo(LicenseKeyError.notMatch);
return {};
throw new Error(LicenseKeyError.notMatch.title);
}
const { accessKeyId, accessKeySecret } = keyData;
@ -520,21 +530,21 @@ exports.getAccessKeyPair = async function () {
const LicenseKeyError = {
notExist: {
title: 'License key not exist',
title: 'License key not found',
content:
'Please go to the license settings page to obtain the Instance ID for the current environment, and then generate the license key on the service platform.',
},
parseFailed: {
title: 'License key parse failed',
title: 'Invalid license key format',
content: 'Please check your license key, or regenerate the license key on the service platform.',
},
notMatch: {
title: 'License key not matched',
title: 'License key mismatch',
content:
'Please go to the license settings page to obtain the Instance ID for the current environment, and then regenerate the license key on the service platform.',
},
notValid: {
title: 'License key not valid',
title: 'Invalid license key',
content:
'Please go to the license settings page to obtain the Instance ID for the current environment, and then regenerate the license key on the service platform.',
},

View File

@ -278,6 +278,7 @@ export class Application {
api: this.apiClient,
i18n: this.i18n,
router: this.router.router,
flowEngine: this.flowEngine,
});
this.use(FlowEngineProvider, { engine: this.flowEngine });
this.use(FlowEngineGlobalsContextProvider);

View File

@ -24,6 +24,7 @@ export class DateFieldInterface extends CollectionFieldInterface {
'x-component': 'DatePicker',
'x-component-props': {
dateOnly: true,
showTime: false,
},
},
};

View File

@ -9,7 +9,7 @@
import { useField, useForm } from '@formily/react';
import { Cascader, Input, Select, Spin, Table, Tag } from 'antd';
import { last, omit } from 'lodash';
import _, { last, omit } from 'lodash';
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ResourceActionContext, useCompile } from '../../../';
@ -120,9 +120,10 @@ const PreviewCom = (props) => {
}, [databaseView]);
const handleFieldChange = (record, index) => {
dataSource.splice(index, 1, record);
setDataSource(dataSource);
field.value = dataSource.map((v) => {
const newDataSource = _.cloneDeep(dataSource);
newDataSource[index] = record;
setDataSource(newDataSource);
field.value = newDataSource.map((v) => {
const source = typeof v.source === 'string' ? v.source : v.source?.filter?.(Boolean)?.join('.');
return {
...v,
@ -198,8 +199,7 @@ const PreviewCom = (props) => {
style={{ width: '100%' }}
popupMatchSelectWidth={false}
onChange={(value) => {
const interfaceConfig = getInterface(value);
handleFieldChange({ ...item, interface: value, uiSchema: interfaceConfig?.default?.uiSchema }, index);
handleFieldChange({ ...item, interface: value }, index);
}}
>
{data.map((group) => (

View File

@ -47,7 +47,7 @@ export function Grid(props: {
<Col key={cellIdx} span={spans[cellIdx]}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{cell.map((uid) => (
<div key={uid}>{renderItem(uid)}</div>
<React.Fragment key={uid}>{renderItem(uid)}</React.Fragment>
))}
</div>
</Col>

View File

@ -39,7 +39,9 @@ export const BlockItemCard = (props) => {
<Card
title={title}
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
styles={{
body: { flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' },
}}
>
{children}
</Card>

View File

@ -9,7 +9,7 @@
import { QuestionCircleOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { FlowsFloatContextMenu } from '@nocobase/flow-engine';
import { DragHandler, FlowsFloatContextMenu } from '@nocobase/flow-engine';
import { tval } from '@nocobase/utils/client';
import { TableColumnProps, Tooltip } from 'antd';
import React from 'react';
@ -24,6 +24,13 @@ export class TableColumnModel extends FieldModel {
containerStyle={{ display: 'block', padding: '11px 8px', margin: '-11px -8px' }}
showBorder={false}
settingsMenuLevel={2}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
>
<div
className={css`

View File

@ -15,6 +15,7 @@ import {
AddActionButton,
AddFieldButton,
DndProvider,
DragHandler,
FlowModelRenderer,
MultiRecordResource,
useFlowEngine,
@ -103,10 +104,21 @@ export class TableModel extends DataBlockModel<TableModelStructure> {
appendItems={[
{
key: 'actions',
label: tval('Actions column'),
label: this.translate('Actions column'),
createModelOptions: {
use: 'TableActionsColumnModel',
},
toggleDetector: (ctx) => {
// 检测是否已存在操作列
const subModels = ctx.model.subModels.columns;
const modelClass = ctx.model.flowEngine.getModelClass('TableActionsColumnModel');
if (Array.isArray(subModels)) {
return subModels.some((subModel) => {
return subModel instanceof modelClass;
});
}
return false;
},
},
]}
onModelCreated={async (model: TableColumnModel) => {
@ -246,7 +258,17 @@ export class TableModel extends DataBlockModel<TableModelStructure> {
// @ts-ignore
if (action.props.position !== 'left') {
return (
<FlowModelRenderer model={action} showFlowSettings={{ showBackground: false, showBorder: false }} />
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false }}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
/>
);
}

View File

@ -203,6 +203,18 @@ export class TabulatorModel extends DataBlockModel<S> {
createModelOptions: {
use: 'TabulatorTableActionsColumnModel',
},
toggleDetector: (ctx) => {
// 检测是否已存在操作列
const subModels = ctx.model.subModels.columns;
const modelClass = ctx.model.flowEngine.getModelClass('TabulatorTableActionsColumnModel');
if (Array.isArray(subModels)) {
return subModels.some((subModel) => {
// 使用 flowEngine 的模型注册信息进行检测
return subModel instanceof modelClass;
});
}
return false;
},
},
]}
onModelCreated={async (model: TabulatorColumnModel) => {

View File

@ -66,7 +66,7 @@ export const createFormBlockSettings = new SchemaSettings({
useVisible() {
const { action } = useFormBlockContext();
const schema = useFieldSchema();
return !action && schema?.['x-acl-action'].includes('create');
return !action && schema?.['x-acl-action']?.includes('create');
},
useComponentProps() {
const { name } = useCollection_deprecated();

View File

@ -10,6 +10,7 @@
import { getDefaultFormat, str2moment, toGmt, toLocal, getPickerFormat } from '@nocobase/utils/client';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { dayjsable, formatDayjsValue } from '@formily/antd-v5/esm/__builtins__';
const toStringByPicker = (value, picker = 'date', timezone: 'gmt' | 'local') => {
if (!dayjs.isDayjs(value)) return value;
@ -89,7 +90,7 @@ export const handleDateChangeOnForm = (value, dateOnly, utc, picker, showTime, g
return value;
}
if (dateOnly) {
return dayjs(value).startOf(picker).format('YYYY-MM-DD');
return formatDayjsValue(value, 'YYYY-MM-DD');
}
if (utc) {
if (gmt) {
@ -114,6 +115,7 @@ export const mapDatePicker = function () {
const { dateOnly, showTime, picker = 'date', utc, gmt, underFilter } = props;
const format = getDefaultFormat(props);
const onChange = props.onChange;
return {
...props,
inputReadOnly: isMobileMedia,

View File

@ -33,7 +33,7 @@ const toDate = (date, options: any = {}) => {
}
if (field.constructor.name === 'DateOnlyField') {
val = moment(val).format('YYYY-MM-DD HH:mm:ss');
val = moment.utc(val).format('YYYY-MM-DD HH:mm:ss');
}
const eventObj = {
@ -69,7 +69,6 @@ export default {
const r = parseDate(value, {
timezone: parseDateTimezone(ctx),
});
if (typeof r === 'string') {
return {
[Op.eq]: toDate(r, { ctx }),
@ -77,6 +76,9 @@ export default {
}
if (Array.isArray(r)) {
console.log(11111111, {
[Op.and]: [{ [Op.gte]: toDate(r[0], { ctx }) }, { [Op.lt]: toDate(r[1], { ctx }) }],
});
return {
[Op.and]: [{ [Op.gte]: toDate(r[0], { ctx }) }, { [Op.lt]: toDate(r[1], { ctx }) }],
};

View File

@ -0,0 +1,536 @@
/**
* 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, afterEach, vi } from 'vitest';
import { FlowSettings } from '../flowSettings';
import { DefaultSettingsIcon } from '../components/settings/wrappers/contextual/DefaultSettingsIcon';
import { DragHandler } from '../components/dnd';
import { FlowModel } from '../models';
import { FlowEngine } from '../flowEngine';
// Mock external dependencies
vi.mock('../components/settings/wrappers/contextual/StepSettingsDialog', () => ({
openStepSettingsDialog: vi.fn().mockResolvedValue({ success: true, data: 'test-result' }),
}));
vi.mock('../components/settings/wrappers/contextual/DefaultSettingsIcon', () => ({
DefaultSettingsIcon: vi.fn(() => 'DefaultSettingsIcon'),
}));
vi.mock('../components/dnd', () => ({
DragHandler: vi.fn(() => 'DragHandler'),
}));
// Mock dynamic imports
vi.mock('@formily/antd-v5', () => ({
ArrayBase: vi.fn(() => 'ArrayBase'),
ArrayCards: vi.fn(() => 'ArrayCards'),
ArrayCollapse: vi.fn(() => 'ArrayCollapse'),
ArrayItems: vi.fn(() => 'ArrayItems'),
ArrayTable: vi.fn(() => 'ArrayTable'),
ArrayTabs: vi.fn(() => 'ArrayTabs'),
Cascader: vi.fn(() => 'Cascader'),
Checkbox: vi.fn(() => 'Checkbox'),
DatePicker: vi.fn(() => 'DatePicker'),
Editable: vi.fn(() => 'Editable'),
Form: vi.fn(() => 'Form'),
FormDialog: vi.fn(() => 'FormDialog'),
FormDrawer: vi.fn(() => 'FormDrawer'),
FormButtonGroup: vi.fn(() => 'FormButtonGroup'),
FormCollapse: vi.fn(() => 'FormCollapse'),
FormGrid: vi.fn(() => 'FormGrid'),
FormItem: vi.fn(() => 'FormItem'),
FormLayout: vi.fn(() => 'FormLayout'),
FormStep: vi.fn(() => 'FormStep'),
FormTab: vi.fn(() => 'FormTab'),
Input: vi.fn(() => 'Input'),
NumberPicker: vi.fn(() => 'NumberPicker'),
Password: vi.fn(() => 'Password'),
PreviewText: vi.fn(() => 'PreviewText'),
Radio: vi.fn(() => 'Radio'),
Reset: vi.fn(() => 'Reset'),
Select: vi.fn(() => 'Select'),
SelectTable: vi.fn(() => 'SelectTable'),
Space: vi.fn(() => 'Space'),
Submit: vi.fn(() => 'Submit'),
Switch: vi.fn(() => 'Switch'),
TimePicker: vi.fn(() => 'TimePicker'),
Transfer: vi.fn(() => 'Transfer'),
TreeSelect: vi.fn(() => 'TreeSelect'),
Upload: vi.fn(() => 'Upload'),
}));
vi.mock('antd', () => ({
Button: vi.fn(() => 'Button'),
}));
describe('FlowSettings', () => {
let flowSettings: FlowSettings;
let consoleSpy: any;
beforeEach(() => {
flowSettings = new FlowSettings();
consoleSpy = {
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
};
});
afterEach(() => {
vi.clearAllMocks();
consoleSpy.log.mockRestore();
consoleSpy.warn.mockRestore();
consoleSpy.error.mockRestore();
});
describe('Constructor & Initialization', () => {
test('should initialize with default values', () => {
expect(flowSettings.enabled).toBe(false);
expect(flowSettings.components).toEqual({});
expect(flowSettings.scopes).toEqual({});
expect(flowSettings.toolbarItems).toHaveLength(1);
});
test('should add default toolbar items during construction', () => {
const toolbarItems = flowSettings.getToolbarItems();
expect(toolbarItems).toHaveLength(1);
const settingsItem = toolbarItems.find((item) => item.key === 'settings-menu');
expect(settingsItem).toBeDefined();
expect(settingsItem?.component).toBe(DefaultSettingsIcon);
expect(settingsItem?.sort).toBe(0);
});
test('should set up observable properties', () => {
// Test that enabled property is reactive
const initialEnabled = flowSettings.enabled;
flowSettings.enable();
expect(flowSettings.enabled).not.toBe(initialEnabled);
expect(flowSettings.enabled).toBe(true);
});
});
describe('Component Registration', () => {
test('should register single component', () => {
const TestComponent = () => 'TestComponent';
flowSettings.registerComponents({ TestComponent });
expect(flowSettings.components.TestComponent).toBe(TestComponent);
});
test('should register multiple components', () => {
const Component1 = () => 'Component1';
const Component2 = () => 'Component2';
flowSettings.registerComponents({ Component1, Component2 });
expect(flowSettings.components.Component1).toBe(Component1);
expect(flowSettings.components.Component2).toBe(Component2);
});
test('should warn when overwriting existing component', () => {
const Component1 = () => 'Component1';
const Component1Updated = () => 'Component1Updated';
flowSettings.registerComponents({ Component1 });
flowSettings.registerComponents({ Component1: Component1Updated });
expect(consoleSpy.warn).toHaveBeenCalledWith(
"FlowSettings: Component with name 'Component1' is already registered and will be overwritten.",
);
expect(flowSettings.components.Component1).toBe(Component1Updated);
});
test('should handle empty components object', () => {
flowSettings.registerComponents({});
expect(Object.keys(flowSettings.components)).toHaveLength(0);
});
});
describe('Scope Registration', () => {
test('should register single scope', () => {
const testHook = () => 'testHook';
flowSettings.registerScopes({ testHook });
expect(flowSettings.scopes.testHook).toBe(testHook);
});
test('should register multiple scopes', () => {
const hook1 = () => 'hook1';
const variable1 = 'variable1';
const function1 = () => 'function1';
flowSettings.registerScopes({ hook1, variable1, function1 });
expect(flowSettings.scopes.hook1).toBe(hook1);
expect(flowSettings.scopes.variable1).toBe(variable1);
expect(flowSettings.scopes.function1).toBe(function1);
});
test('should warn when overwriting existing scope', () => {
const scope1 = () => 'scope1';
const scope1Updated = () => 'scope1Updated';
flowSettings.registerScopes({ scope1 });
flowSettings.registerScopes({ scope1: scope1Updated });
expect(consoleSpy.warn).toHaveBeenCalledWith(
"FlowSettings: Scope with name 'scope1' is already registered and will be overwritten.",
);
expect(flowSettings.scopes.scope1).toBe(scope1Updated);
});
test('should handle empty scopes object', () => {
flowSettings.registerScopes({});
expect(Object.keys(flowSettings.scopes)).toHaveLength(0);
});
});
describe('Enable/Disable Functionality', () => {
test('should enable flow settings', () => {
expect(flowSettings.enabled).toBe(false);
flowSettings.enable();
expect(flowSettings.enabled).toBe(true);
});
test('should disable flow settings', () => {
flowSettings.enable();
expect(flowSettings.enabled).toBe(true);
flowSettings.disable();
expect(flowSettings.enabled).toBe(false);
});
test('should handle multiple enable/disable calls', () => {
flowSettings.enable();
flowSettings.enable();
expect(flowSettings.enabled).toBe(true);
flowSettings.disable();
flowSettings.disable();
expect(flowSettings.enabled).toBe(false);
});
});
describe('Resource Loading', () => {
test('should load Antd components successfully', async () => {
await flowSettings.load();
// Verify all expected components are registered
const expectedComponents = [
'Form',
'FormDialog',
'FormDrawer',
'FormItem',
'FormLayout',
'FormGrid',
'FormStep',
'FormTab',
'FormCollapse',
'FormButtonGroup',
'Input',
'NumberPicker',
'Password',
'Select',
'SelectTable',
'Cascader',
'TreeSelect',
'Transfer',
'DatePicker',
'TimePicker',
'Checkbox',
'Radio',
'Switch',
'ArrayBase',
'ArrayCards',
'ArrayCollapse',
'ArrayItems',
'ArrayTable',
'ArrayTabs',
'Upload',
'Space',
'Editable',
'PreviewText',
'Button',
'Submit',
'Reset',
];
expectedComponents.forEach((componentName) => {
expect(flowSettings.components[componentName]).toBeDefined();
});
});
});
describe('Toolbar Item Management', () => {
describe('addToolbarItem', () => {
test('should add new toolbar item', () => {
const TestIcon = () => 'TestIcon';
const config = {
key: 'test-item',
component: TestIcon,
sort: 10,
};
flowSettings.addToolbarItem(config);
const items = flowSettings.getToolbarItems();
const testItem = items.find((item) => item.key === 'test-item');
expect(testItem).toBeDefined();
expect(testItem?.component).toBe(TestIcon);
expect(testItem?.sort).toBe(10);
});
test('should replace existing toolbar item with same key', () => {
const TestIcon1 = () => 'TestIcon1';
const TestIcon2 = () => 'TestIcon2';
flowSettings.addToolbarItem({
key: 'duplicate-key',
component: TestIcon1,
sort: 10,
});
flowSettings.addToolbarItem({
key: 'duplicate-key',
component: TestIcon2,
sort: 20,
});
expect(consoleSpy.warn).toHaveBeenCalledWith(
"FlowSettings: Toolbar item with key 'duplicate-key' already exists and will be replaced.",
);
const items = flowSettings.getToolbarItems();
const duplicateItems = items.filter((item) => item.key === 'duplicate-key');
expect(duplicateItems).toHaveLength(1);
expect(duplicateItems[0].component).toBe(TestIcon2);
expect(duplicateItems[0].sort).toBe(20);
});
test('should sort toolbar items by sort field (descending)', () => {
const Icon1 = () => 'Icon1';
const Icon2 = () => 'Icon2';
const Icon3 = () => 'Icon3';
flowSettings.addToolbarItem({ key: 'item1', component: Icon1, sort: 10 });
flowSettings.addToolbarItem({ key: 'item2', component: Icon2, sort: 30 });
flowSettings.addToolbarItem({ key: 'item3', component: Icon3, sort: 20 });
const items = flowSettings.getToolbarItems();
const customItems = items.filter((item) => ['item1', 'item2', 'item3'].includes(item.key));
expect(customItems[0].key).toBe('item2'); // sort: 30
expect(customItems[1].key).toBe('item3'); // sort: 20
expect(customItems[2].key).toBe('item1'); // sort: 10
});
test('should handle items with undefined sort', () => {
const TestIcon = () => 'TestIcon';
flowSettings.addToolbarItem({
key: 'no-sort-item',
component: TestIcon,
});
const items = flowSettings.getToolbarItems();
const item = items.find((item) => item.key === 'no-sort-item');
expect(item).toBeDefined();
expect(item?.sort).toBeUndefined();
});
});
describe('addToolbarItems', () => {
test('should add multiple toolbar items', () => {
const Icon1 = () => 'Icon1';
const Icon2 = () => 'Icon2';
const configs = [
{ key: 'multi-item1', component: Icon1, sort: 10 },
{ key: 'multi-item2', component: Icon2, sort: 20 },
];
flowSettings.addToolbarItems(configs);
const items = flowSettings.getToolbarItems();
expect(items.find((item) => item.key === 'multi-item1')).toBeDefined();
expect(items.find((item) => item.key === 'multi-item2')).toBeDefined();
});
test('should handle empty array', () => {
const initialLength = flowSettings.getToolbarItems().length;
flowSettings.addToolbarItems([]);
expect(flowSettings.getToolbarItems()).toHaveLength(initialLength);
});
});
describe('removeToolbarItem', () => {
test('should remove existing toolbar item', () => {
const TestIcon = () => 'TestIcon';
flowSettings.addToolbarItem({
key: 'remove-test',
component: TestIcon,
sort: 10,
});
let items = flowSettings.getToolbarItems();
expect(items.find((item) => item.key === 'remove-test')).toBeDefined();
flowSettings.removeToolbarItem('remove-test');
items = flowSettings.getToolbarItems();
expect(items.find((item) => item.key === 'remove-test')).toBeUndefined();
});
test('should handle removal of non-existent item', () => {
const initialLength = flowSettings.getToolbarItems().length;
flowSettings.removeToolbarItem('non-existent-key');
expect(flowSettings.getToolbarItems()).toHaveLength(initialLength);
});
});
describe('getToolbarItems', () => {
test('should return copy of toolbar items', () => {
const items1 = flowSettings.getToolbarItems();
const items2 = flowSettings.getToolbarItems();
expect(items1).not.toBe(items2); // Different references
expect(items1).toEqual(items2); // Same content
});
});
describe('clearToolbarItems', () => {
test('should clear all toolbar items', () => {
const TestIcon = () => 'TestIcon';
flowSettings.addToolbarItem({
key: 'clear-test',
component: TestIcon,
sort: 10,
});
expect(flowSettings.getToolbarItems().length).toBeGreaterThan(0);
flowSettings.clearToolbarItems();
expect(flowSettings.getToolbarItems()).toHaveLength(0);
});
test('should handle clearing empty toolbar items', () => {
flowSettings.clearToolbarItems();
flowSettings.clearToolbarItems();
expect(flowSettings.getToolbarItems()).toHaveLength(0);
});
});
});
describe('Step Settings Dialog', () => {
test('should call openStepSettingsDialog with correct parameters', async () => {
const { openStepSettingsDialog } = await import('../components/settings/wrappers/contextual/StepSettingsDialog');
const props = {
model: new FlowModel({ uid: 'test-model', flowEngine: new FlowEngine() }),
flowKey: 'test-flow',
stepKey: 'test-step',
dialogWidth: 800,
dialogTitle: 'Test Dialog',
};
const result = await flowSettings.openStepSettingsDialog(props);
expect(openStepSettingsDialog).toHaveBeenCalledWith(props);
expect(result).toEqual({ success: true, data: 'test-result' });
});
test('should handle dialog errors', async () => {
const { openStepSettingsDialog } = await import('../components/settings/wrappers/contextual/StepSettingsDialog');
(openStepSettingsDialog as any).mockRejectedValueOnce(new Error('Dialog error'));
const props = {
model: new FlowModel({ uid: 'test-model', flowEngine: new FlowEngine() }),
flowKey: 'test-flow',
stepKey: 'test-step',
};
await expect(flowSettings.openStepSettingsDialog(props)).rejects.toThrow('Dialog error');
});
});
describe('Complex Integration Scenarios', () => {
test('should maintain state consistency during multiple operations', () => {
// Initialize with components and scopes
const TestComponent = () => 'TestComponent';
const testScope = () => 'testScope';
flowSettings.registerComponents({ TestComponent });
flowSettings.registerScopes({ testScope });
// Add toolbar items
flowSettings.addToolbarItem({
key: 'integration-test',
component: TestComponent,
sort: 15,
});
// Enable/disable
flowSettings.enable();
expect(flowSettings.enabled).toBe(true);
// Verify all state is maintained
expect(flowSettings.components.TestComponent).toBe(TestComponent);
expect(flowSettings.scopes.testScope).toBe(testScope);
expect(flowSettings.getToolbarItems().find((item) => item.key === 'integration-test')).toBeDefined();
flowSettings.disable();
expect(flowSettings.enabled).toBe(false);
// State should still be maintained after disable
expect(flowSettings.components.TestComponent).toBe(TestComponent);
expect(flowSettings.scopes.testScope).toBe(testScope);
});
test('should handle complex toolbar sorting scenarios', () => {
// Clear default items for this test
flowSettings.clearToolbarItems();
const items = [
{ key: 'item-a', component: () => 'A', sort: 10 },
{ key: 'item-b', component: () => 'B' }, // no sort (should be 0)
{ key: 'item-c', component: () => 'C', sort: 30 },
{ key: 'item-d', component: () => 'D', sort: 10 }, // duplicate sort
{ key: 'item-e', component: () => 'E', sort: 5 },
];
items.forEach((item) => flowSettings.addToolbarItem(item));
const sortedItems = flowSettings.getToolbarItems();
const keys = sortedItems.map((item) => item.key);
// Expected order: item-c (30), then items with sort 10 (order of insertion: item-a, item-d), item-e (5), item-b (0)
expect(keys).toEqual(['item-c', 'item-a', 'item-d', 'item-e', 'item-b']);
});
});
});

View File

@ -20,6 +20,7 @@ export const FlowSettingsButton: FC<ButtonProps> = (props) => {
...props.style,
borderColor: 'var(--colorSettings)',
color: 'var(--colorSettings)',
alignSelf: 'flex-start',
}}
/>
);

View File

@ -7,13 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { createSchemaField, ISchema } from '@formily/react';
import { message } from 'antd';
import { FormButtonGroup } from '@formily/antd-v5';
import { createForm } from '@formily/core';
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
import { toJS } from '@formily/reactive';
import { Button, message, Space } from 'antd';
import React from 'react';
import { StepSettingsDialogProps } from '../../../../types';
import { resolveDefaultParams, resolveUiSchema, compileUiSchema, getT } from '../../../../utils';
import { compileUiSchema, getT, resolveDefaultParams, resolveUiSchema } from '../../../../utils';
import { StepSettingContextProvider, StepSettingContextType, useStepSettingContext } from './StepSettingContext';
import { toJS } from '@formily/reactive';
const SchemaField = createSchemaField();
@ -32,8 +34,10 @@ const openStepSettingsDialog = async ({
stepKey,
dialogWidth = 600,
dialogTitle,
mode = 'dialog',
}: StepSettingsDialogProps): Promise<any> => {
const t = getT(model);
const message = model.flowEngine.context.message;
if (!model) {
message.error(t('Invalid model provided'));
@ -127,25 +131,47 @@ const openStepSettingsDialog = async ({
},
};
// 动态导入FormDialog
let FormDialog;
try {
({ FormDialog } = await import('@formily/antd-v5'));
} catch (error) {
throw new Error(`${t('Failed to import FormDialog')}: ${error.message}`);
}
const view = model.flowEngine.context[mode];
// 创建FormDialog
const formDialog = FormDialog(
{
title: dialogTitle || `${t(title)} - ${t('Configuration')}`,
width: dialogWidth,
okText: t('OK'),
cancelText: t('Cancel'),
destroyOnClose: true,
},
(form) => {
// 创建上下文值
const form = createForm({
initialValues: compileUiSchema(scopes, initialValues),
});
const currentDialog = view.open({
title: dialogTitle || t(title),
width: dialogWidth,
destroyOnClose: true,
footer: (
<Space align="end">
<Button
type="default"
onClick={() => {
currentDialog.close();
}}
>
{t('Cancel')}
</Button>
<Button
type="primary"
onClick={async () => {
try {
await form.submit();
const currentValues = form.values;
model.setStepParams(flowKey, stepKey, currentValues);
await model.save();
message.success(t('Configuration saved'));
currentDialog.close();
} catch (error) {
console.error(t('Error saving configuration'), ':', error);
message.error(t('Error saving configuration, please check console'));
}
}}
>
{t('OK')}
</Button>
</Space>
),
content: (currentDialog) => {
const contextValue: StepSettingContextType = {
model,
globals: model.flowEngine?.context || {},
@ -155,45 +181,22 @@ const openStepSettingsDialog = async ({
flowKey,
stepKey,
};
// 编译 formSchema 中的表达式
const compiledFormSchema = compileUiSchema(scopes, formSchema);
return (
<StepSettingContextProvider value={contextValue}>
<SchemaField
schema={compiledFormSchema}
components={{
...flowEngine.flowSettings?.components,
}}
scope={scopes}
/>
</StepSettingContextProvider>
<FormProvider form={form}>
<StepSettingContextProvider value={contextValue}>
<SchemaField
schema={compiledFormSchema}
components={{
...flowEngine.flowSettings?.components,
}}
scope={scopes}
/>
</StepSettingContextProvider>
</FormProvider>
);
},
);
// 设置保存回调
formDialog.forConfirm(async (payload, next) => {
try {
// 获取表单当前值
const currentValues = payload.values;
model.setStepParams(flowKey, stepKey, currentValues);
await model.save();
message.success(t('Configuration saved'));
next(payload);
} catch (error) {
console.error(t('Error saving configuration'), ':', error);
message.error(t('Error saving configuration, please check console'));
throw error;
}
});
formDialog.forCancel(async (payload, next) => next(payload));
// 打开对话框
return formDialog.open({
initialValues: compileUiSchema(scopes, initialValues),
});
};

View File

@ -6,17 +6,8 @@
* 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 { createSchemaField, ISchema } from '@formily/react';
import { message, Button, Space } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StepSettingsDrawerProps } from '../../../../types';
import { resolveDefaultParams, resolveUiSchema, compileUiSchema, getT } from '../../../../utils';
import { StepSettingContextProvider, StepSettingContextType, useStepSettingContext } from './StepSettingContext';
import { toJS } from '@formily/reactive';
const SchemaField = createSchemaField();
import { openStepSettingsDialog } from './StepSettingsDialog';
/**
* StepSettingsDrawer组件 - 使 Drawer
@ -27,230 +18,8 @@ const SchemaField = createSchemaField();
* @param props.drawerTitle 使step的title
* @returns Promise<any>
*/
const openStepSettingsDrawer = async ({
model,
flowKey,
stepKey,
drawerWidth = 600,
drawerTitle,
}: StepSettingsDrawerProps): Promise<any> => {
const t = getT(model);
if (!model) {
message.error(t('Invalid model provided'));
throw new Error(t('Invalid model provided'));
}
// 获取流程和步骤信息
const flow = model.getFlow(flowKey);
const step = flow?.steps?.[stepKey];
if (!flow) {
message.error(t('Flow with key {{flowKey}} not found', { flowKey }));
throw new Error(t('Flow with key {{flowKey}} not found', { flowKey }));
}
if (!step) {
message.error(t('Step with key {{stepKey}} not found', { stepKey }));
throw new Error(t('Step with key {{stepKey}} not found', { stepKey }));
}
let title = step.title;
// 创建参数解析上下文
const paramsContext = {
model,
globals: model.flowEngine?.context || {},
app: model.flowEngine?.context?.app,
};
const stepUiSchema = step.uiSchema || {};
let actionDefaultParams = {};
// 如果step使用了action也获取action的uiSchema
let actionUiSchema = {};
if (step.use) {
const action = model.flowEngine?.getAction?.(step.use);
if (action && action.uiSchema) {
actionUiSchema = action.uiSchema;
}
actionDefaultParams = action.defaultParams || {};
title = title || action.title;
}
// 解析动态 uiSchema
const resolvedActionUiSchema = await resolveUiSchema(actionUiSchema, paramsContext);
const resolvedStepUiSchema = await resolveUiSchema(stepUiSchema, paramsContext);
// 合并uiSchema确保step的uiSchema优先级更高
const mergedUiSchema = { ...toJS(resolvedActionUiSchema) };
Object.entries(toJS(resolvedStepUiSchema)).forEach(([fieldKey, schema]) => {
if (mergedUiSchema[fieldKey]) {
mergedUiSchema[fieldKey] = { ...mergedUiSchema[fieldKey], ...schema };
} else {
mergedUiSchema[fieldKey] = schema;
}
});
// 如果没有可配置的UI Schema显示提示
if (Object.keys(mergedUiSchema).length === 0) {
message.info(t('This step has no configurable parameters'));
return {};
}
// 获取初始值
const stepParams = model.getStepParams(flowKey, stepKey) || {};
const flowEngine = model.flowEngine;
const scopes = {
useStepSettingContext,
...flowEngine.flowSettings?.scopes,
};
// 解析 defaultParams
const resolvedDefaultParams = await resolveDefaultParams(step.defaultParams, paramsContext);
const resolveActionDefaultParams = await resolveDefaultParams(actionDefaultParams, paramsContext);
const initialValues = { ...toJS(resolveActionDefaultParams), ...toJS(resolvedDefaultParams), ...toJS(stepParams) };
// 构建表单Schema
const formSchema: ISchema = {
type: 'object',
properties: {
layout: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
layout: 'vertical',
},
properties: mergedUiSchema,
},
},
};
// 动态导入Formily组件
let Form, createForm;
try {
({ Form } = await import('@formily/antd-v5'));
({ createForm } = await import('@formily/core'));
} catch (error) {
throw new Error(`${t('Failed to import Formily components')}: ${error.message}`);
}
// 获取drawer API
const drawer = model.flowEngine?.context?.drawer;
if (!drawer) {
throw new Error(t('Drawer API is not available, please ensure it is used within FlowEngineGlobalsContextProvider'));
}
return new Promise((resolve) => {
// 用于跟踪Promise状态避免重复调用resolve
let isResolved = false;
// 创建表单实例
const form = createForm({
initialValues: compileUiSchema(scopes, initialValues),
});
// 创建抽屉内容组件
const DrawerContent: React.FC = () => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
try {
setLoading(true);
// 先获取表单当前值,然后验证
const currentValues = form.values;
await form.validate();
// 保存配置
model.setStepParams(flowKey, stepKey, currentValues);
await model.save();
message.success(t('Configuration saved'));
isResolved = true;
drawerRef.destroy();
resolve(currentValues);
} catch (error) {
console.error(t('Error saving configuration'), ':', error);
message.error(t('Error saving configuration, please check console'));
} finally {
setLoading(false);
}
};
const handleCancel = () => {
if (!isResolved) {
isResolved = true;
resolve(null);
}
drawerRef.destroy();
};
// 创建上下文值
const contextValue: StepSettingContextType = {
model,
globals: model.flowEngine?.context || {},
app: model.flowEngine?.context?.app,
step,
flow,
flowKey,
stepKey,
};
// 编译 formSchema 中的表达式
const compiledFormSchema = compileUiSchema(scopes, formSchema);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto', padding: '16px' }}>
<Form form={form} layout="vertical">
<StepSettingContextProvider value={contextValue}>
<SchemaField
schema={compiledFormSchema}
components={{
...flowEngine.flowSettings?.components,
}}
scope={scopes}
/>
</StepSettingContextProvider>
</Form>
</div>
<div
style={{
padding: '16px',
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
}}
>
<Space>
<Button onClick={handleCancel}>{t('Cancel')}</Button>
<Button type="primary" loading={loading} onClick={handleSubmit}>
{t('OK')}
</Button>
</Space>
</div>
</div>
);
};
// 打开抽屉
const drawerRef = drawer.open({
title: drawerTitle || `${t(title)} - ${t('Configuration')}`,
width: drawerWidth,
content: <DrawerContent />,
onClose: () => {
// 只有在Promise还未被处理时才reject
if (!isResolved) {
isResolved = true;
resolve(null);
}
},
});
});
const openStepSettingsDrawer = async (options: StepSettingsDrawerProps): Promise<any> => {
return openStepSettingsDialog({ ...options, mode: 'drawer' });
};
export { openStepSettingsDrawer };

View File

@ -129,7 +129,6 @@ const AddFieldButtonCore: React.FC<AddFieldButtonProps> = ({
key: field.name,
label: field.title,
icon: fieldClass.meta?.icon,
unique: true,
createModelOptions: buildCreateModelOptions({
defaultOptions,
collectionField: field,

View File

@ -40,7 +40,6 @@ export interface SubModelItem {
searchable?: boolean;
searchPlaceholder?: string;
keepDropdownOpen?: boolean;
unique?: boolean;
toggleDetector?: (ctx: AddSubModelContext) => boolean | Promise<boolean>;
removeModelOptions?: {
customRemove?: (ctx: AddSubModelContext, item: SubModelItem) => Promise<void>;
@ -182,10 +181,10 @@ const createSwitchLabel = (originalLabel: string, isToggled: boolean) => (
);
/**
* unique
*
*/
const hasUniqueItems = (items: SubModelItem[]): boolean => {
return items.some((item) => item.unique && item.toggleDetector && !item.children);
const hasToggleItems = (items: SubModelItem[]): boolean => {
return items.some((item) => item.toggleDetector && !item.children);
};
/**
@ -194,20 +193,20 @@ const hasUniqueItems = (items: SubModelItem[]): boolean => {
const transformSubModelItems = async (items: SubModelItem[], context: AddSubModelContext): Promise<Item[]> => {
if (items.length === 0) return [];
// 批量收集需要异步检测的 unique
const uniqueItems: Array<{ item: SubModelItem; index: number }> = [];
// 批量收集需要异步检测的可切换
const toggleItems: 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 });
if (item.toggleDetector && !item.children) {
toggleItems.push({ item, index: i });
}
}
// 批量执行 toggleDetector
const toggleResults = await Promise.allSettled(uniqueItems.map(({ item }) => item.toggleDetector!(context)));
const toggleResults = await Promise.allSettled(toggleItems.map(({ item }) => item.toggleDetector!(context)));
const toggleMap = new Map<number, boolean>();
uniqueItems.forEach(({ index }, i) => {
toggleItems.forEach(({ index }, i) => {
const result = toggleResults[i];
toggleMap.set(index, result.status === 'fulfilled' ? result.value : false);
});
@ -240,12 +239,11 @@ const transformSubModelItems = async (items: SubModelItem[], context: AddSubMode
}
// 处理开关式菜单项
if (item.unique && item.toggleDetector && !item.children) {
if (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;
@ -265,8 +263,8 @@ const transformItems = (items: SubModelItemsType, context: AddSubModelContext):
};
}
const hasUnique = hasUniqueItems(items as SubModelItem[]);
if (hasUnique) {
const hasToggle = hasToggleItems(items as SubModelItem[]);
if (hasToggle) {
return () => transformSubModelItems(items as SubModelItem[], context);
} else {
let cachedResult: Item[] | null = null;
@ -321,13 +319,23 @@ const createDefaultRemoveHandler = (config: {
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 (createOpts?.use) {
try {
const modelClass = config.model.flowEngine.getModelClass(createOpts.use);
if (modelClass && subModel instanceof modelClass) {
return true;
}
} catch (error) {
// 如果获取模型类失败,继续使用 uid 匹配
}
return (subModel as any).uid.includes(createOpts.use);
}
return false;
});
if (targetModel) {
targetModel.remove();
await targetModel.destroy();
const index = subModels.indexOf(targetModel);
if (index > -1) subModels.splice(index, 1);
}
@ -335,7 +343,7 @@ const createDefaultRemoveHandler = (config: {
} else {
const subModel = (model.subModels as any)[subModelKey] as FlowModel;
if (subModel) {
subModel.remove();
await subModel.destroy();
(model.subModels as any)[subModelKey] = undefined;
}
}
@ -388,10 +396,9 @@ const AddSubModelButtonCore = function AddSubModelButton({
const clickedItem = info.originalItem || info;
const item = clickedItem.originalItem || (clickedItem as SubModelItem);
const isToggled = clickedItem.isToggled;
const isUnique = clickedItem.unique || item.unique;
// 处理 unique 菜单项的开关操作
if (isUnique && item.toggleDetector && isToggled) {
// 处理可切换菜单项的开关操作
if (item.toggleDetector && isToggled) {
try {
if (item.removeModelOptions?.customRemove) {
await item.removeModelOptions.customRemove(buildContext, item);

View File

@ -11,7 +11,6 @@ import { define, observable } from '@formily/reactive';
import { openStepSettingsDialog } from './components/settings/wrappers/contextual/StepSettingsDialog';
import { StepSettingsDialogProps, ToolbarItemConfig } from './types';
import { DefaultSettingsIcon } from './components/settings/wrappers/contextual/DefaultSettingsIcon';
import { DragHandler } from './components/dnd';
export class FlowSettings {
public components: Record<string, any> = {};
@ -38,18 +37,11 @@ export class FlowSettings {
*/
private addDefaultToolbarItems(): void {
// 添加基础的配置菜单项目(原有的菜单功能)
this.toolbarItems.push(
{
key: 'settings-menu',
component: DefaultSettingsIcon,
sort: 0, // 默认为0作为第一个添加的项目
},
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
);
this.toolbarItems.push({
key: 'settings-menu',
component: DefaultSettingsIcon,
sort: 0, // 默认为0作为第一个添加的项目
});
}
/**

View File

@ -263,6 +263,7 @@ export interface StepSettingsDialogProps {
stepKey: string;
dialogWidth?: number | string;
dialogTitle?: string;
mode?: 'dialog' | 'drawer'; // 设置模式,默认为'dialog'
}
/**

View File

@ -34,6 +34,13 @@ const DrawerComponent = forwardRef<unknown, DrawerComponentProps>(({ afterClose,
setVisible(false);
config.onClose?.(e);
}}
styles={{
...config?.styles,
footer: {
textAlign: 'end',
...config?.styles?.footer,
},
}}
afterOpenChange={(open) => {
if (!open) {
afterClose?.();

View File

@ -15,6 +15,7 @@ export interface Str2momentOptions {
picker?: 'year' | 'month' | 'week' | 'quarter';
utcOffset?: number;
utc?: boolean;
dateOnly?: boolean;
}
export type Str2momentValue = string | string[] | dayjs.Dayjs | dayjs.Dayjs[];
@ -83,10 +84,13 @@ const toMoment = (val: any, options?: Str2momentOptions) => {
return;
}
const offset = options.utcOffset;
const { gmt, picker, utc = true } = options;
const { gmt, picker, utc = true, dateOnly } = options;
if (dayjs(val).isValid()) {
if (dateOnly) {
return dayjs.utc(val, 'YYYY-MM-DD');
}
if (!utc) {
console.log(888);
return dayjs(val);
}

View File

@ -188,9 +188,11 @@ export const parseFilter = async (filter: any, opts: ParseFilterOptions = {}) =>
const field = getField?.(path);
if (field?.constructor.name === 'DateOnlyField' || field?.constructor.name === 'DatetimeNoTzField') {
if (value.type) {
return getDayRangeByParams({ ...value, timezone: field?.timezone || timezone });
}
return value;
}
return dateValueWrapper(value, field?.timezone || timezone);
}
return value;

View File

@ -63,7 +63,7 @@ const useSubmitProps = () => {
},
});
setLoading(false);
message.success(t('License key saved successfully, please restart the server'));
message.success(t('License key saved successfully, please re-run the plugin installation.'));
} catch (e) {
setLoading(false);
}

View File

@ -1,5 +1,5 @@
{
"License key saved successfully, please restart the server": "License key saved successfully, please restart the server",
"License key saved successfully, please re-run the plugin installation.": "License key saved successfully, please re-run the plugin installation.",
"License settings": "License settings",
"Instance ID": "Instance ID",
"License key": "License key",

View File

@ -1,5 +1,5 @@
{
"License key saved successfully, please restart the server": "授权密钥保存成功,请重启服务器",
"License key saved successfully, please re-run the plugin installation.": "授权密钥保存成功,请重新执行插件安装操作",
"License settings": "授权设置",
"Instance ID": "实例 ID",
"License key": "授权密钥",

View File

@ -30,13 +30,15 @@ import { WorkflowLink } from './WorkflowLink';
import OpenDrawer from './components/OpenDrawer';
import { workflowSchema } from './schemas/workflows';
import { ExecutionStatusSelect, ExecutionStatusColumn } from './components/ExecutionStatus';
import WorkflowPlugin, { ExecutionStatusOptions, RadioWithTooltip } from '.';
import WorkflowPlugin from '.';
import { RadioWithTooltip } from './components';
import { useRefreshActionProps } from './hooks/useRefreshActionProps';
import { useTranslation } from 'react-i18next';
import { TriggerOptionRender } from './components/TriggerOptionRender';
import { CategoryTabs } from './WorkflowCategoryTabs';
import { EnumerationField } from './components/EmunerationField';
import { useWorkflowFilterActionProps } from './hooks/useWorkflowFilterActionProps';
import { ExecutionStatusOptions } from './constants';
function SyncOptionSelect(props) {
const field = useField<any>();

View File

@ -100,4 +100,5 @@ export default class extends Instruction {
resultTitle: lang('Calculation result'),
};
}
testable = true;
}

View File

@ -194,4 +194,5 @@ export default class extends Instruction {
</NodeDefaultView>
);
}
testable = true;
}

View File

@ -7,11 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CloseOutlined, DeleteOutlined } from '@ant-design/icons';
import { CaretRightOutlined, CloseOutlined, DeleteOutlined } from '@ant-design/icons';
import { createForm, Field } from '@formily/core';
import { toJS } from '@formily/reactive';
import { ISchema, observer, useField, useForm } from '@formily/react';
import { Alert, App, Button, Dropdown, Empty, Input, Space, Tag, Tooltip, message } from 'antd';
import { Alert, App, Button, Collapse, Dropdown, Empty, Input, Space, Tag, Tooltip, message } from 'antd';
import { cloneDeep, get, set } from 'lodash';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -26,7 +26,6 @@ import {
cx,
useAPIClient,
useActionContext,
useCancelAction,
useCompile,
usePlugin,
useResourceActionContext,
@ -330,6 +329,7 @@ const useRunAction = () => {
async run() {
const template = parse(node.config);
const config = template(toJS(values.config));
const logField = query('log').take() as Field;
const resultField = query('result').take() as Field;
resultField.setValue(null);
resultField.setFeedback({});
@ -352,6 +352,7 @@ const useRunAction = () => {
messages: data.status > 0 ? [lang('Resolved')] : [lang('Failed')],
});
resultField.setValue(data.result);
logField.setValue(data.log || '');
} catch (err) {
resultField.setFeedback({
type: 'error',
@ -359,7 +360,6 @@ const useRunAction = () => {
});
}
field.data.loading = false;
ctx.setFormValueChanged(false);
},
};
};
@ -397,113 +397,163 @@ function TestFormFieldset({ value, onChange }) {
);
}
function LogCollapse({ value }) {
return value ? (
<Collapse
ghost
items={[
{
key: 'log',
label: lang('Log'),
children: (
<Input.TextArea
value={value}
autoSize={{ minRows: 5, maxRows: 20 }}
style={{ whiteSpace: 'pre', cursor: 'text', fontFamily: 'monospace', fontSize: '80%' }}
disabled
/>
),
},
]}
className={css`
.ant-collapse-item > .ant-collapse-header {
padding: 0;
}
.ant-collapse-content > .ant-collapse-content-box {
padding: 0;
}
`}
/>
) : null;
}
function useCancelAction() {
const form = useForm();
const ctx = useActionContext();
return {
async run() {
const resultField = form.query('result').take() as Field;
resultField.setFeedback();
form.setValues({ result: null, log: null });
form.clearFormGraph('*');
form.reset();
ctx.setVisible(false);
},
};
}
function TestButton() {
const node = useNodeContext();
const { values } = useForm();
const [visible, setVisible] = useState(false);
const template = parse(values);
const keys = template.parameters.map((item) => item.key);
const form = useMemo(() => createForm(), []);
const onOpen = useCallback(() => {
setVisible(true);
}, []);
const setModalVisible = useCallback(
(v: boolean) => {
if (v) {
setVisible(true);
return;
}
const resultField = form.query('result').take() as Field;
resultField?.setFeedback();
form.setValues({ result: null, log: null });
form.clearFormGraph('*');
form.reset();
setVisible(false);
},
[form],
);
return (
<NodeContext.Provider value={{ ...node, config: values }}>
<VariableKeysContext.Provider value={keys}>
<SchemaComponent
components={{
Alert,
TestFormFieldset,
}}
scope={{
useCancelAction,
useRunAction,
}}
schema={{
type: 'void',
name: 'testButton',
title: '{{t("Test run")}}',
'x-component': 'Action',
'x-component-props': {
icon: 'CaretRightOutlined',
// openSize: 'small',
},
properties: {
modal: {
type: 'void',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
<ActionContextProvider value={{ visible, setVisible: setModalVisible }}>
<Button icon={<CaretRightOutlined />} onClick={onOpen}>
{lang('Test run')}
</Button>
<SchemaComponent
components={{
Alert,
TestFormFieldset,
LogCollapse,
}}
scope={{
useCancelAction,
useRunAction,
}}
schema={{
type: 'void',
name: 'modal',
'x-decorator': 'FormV2',
'x-decorator-props': {
form,
},
'x-component': 'Action.Modal',
title: `{{t("Test run", { ns: "workflow" })}}`,
properties: {
alert: {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: `{{t("Test run will do the actual data manipulating or API calling, please use with caution.", { ns: "workflow" })}}`,
type: 'warning',
showIcon: true,
className: css`
margin-bottom: 1em;
`,
},
},
'x-component': 'Action.Modal',
title: `{{t("Test run", { ns: "workflow" })}}`,
properties: {
alert: {
type: 'void',
'x-component': 'Alert',
'x-component-props': {
message: `{{t("Test run will do the actual data manipulating or API calling, please use with caution.", { ns: "workflow" })}}`,
type: 'warning',
showIcon: true,
className: css`
margin-bottom: 1em;
`,
},
},
config: {
type: 'object',
title: '{{t("Replace variables", { ns: "workflow" })}}',
'x-decorator': 'FormItem',
'x-component': 'TestFormFieldset',
},
actions: {
type: 'void',
'x-component': 'ActionBar',
properties: {
submit: {
type: 'void',
title: '{{t("Run")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useRunAction }}',
},
config: {
type: 'object',
title: '{{t("Replace variables", { ns: "workflow" })}}',
'x-decorator': 'FormItem',
'x-component': 'TestFormFieldset',
},
actions: {
type: 'void',
'x-component': 'ActionBar',
properties: {
submit: {
type: 'void',
title: '{{t("Run")}}',
'x-component': 'Action',
'x-component-props': {
type: 'primary',
useAction: '{{ useRunAction }}',
},
},
},
result: {
type: 'string',
title: `{{t("Result", { ns: "workflow" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Input.JSON',
'x-component-props': {
autoSize: {
minRows: 5,
maxRows: 20,
},
style: {
whiteSpace: 'pre',
cursor: 'text',
},
},
result: {
type: 'string',
title: `{{t("Result", { ns: "workflow" })}}`,
'x-decorator': 'FormItem',
'x-component': 'Input.JSON',
'x-component-props': {
autoSize: {
minRows: 5,
maxRows: 20,
},
'x-pattern': 'disabled',
},
footer: {
type: 'void',
'x-component': 'Action.Modal.Footer',
properties: {
cancel: {
type: 'void',
title: '{{t("Close")}}',
'x-component': 'Action',
'x-component-props': {
useAction: '{{ useCancelAction }}',
},
},
style: {
whiteSpace: 'pre',
cursor: 'text',
},
},
'x-pattern': 'disabled',
},
log: {
type: 'string',
'x-component': 'LogCollapse',
},
},
},
}}
/>
}}
/>
</ActionContextProvider>
</VariableKeysContext.Provider>
</NodeContext.Provider>
);

View File

@ -39,6 +39,22 @@ export class CalculationInstruction extends Instruction {
};
}
}
async test({ engine = 'math.js', expression = '' }) {
const evaluator = <Evaluator | undefined>evaluators.get(engine);
try {
const result = evaluator && expression ? evaluator(expression) : null;
return {
result,
status: JOB_STATUS.RESOLVED,
};
} catch (e) {
return {
result: e.toString(),
status: JOB_STATUS.ERROR,
};
}
}
}
export default CalculationInstruction;

View File

@ -7,7 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { evaluators } from '@nocobase/evaluators';
import { Evaluator, evaluators } from '@nocobase/evaluators';
import { Instruction } from '.';
import type Processor from '../Processor';
import { JOB_STATUS } from '../constants';
@ -80,6 +80,22 @@ export class ConditionInstruction extends Instruction {
// pass control to upper scope by ending current scope
return processor.exit(branchJob.status);
}
async test({ engine, calculation, expression = '' }) {
const evaluator = <Evaluator | undefined>evaluators.get(engine);
try {
const result = evaluator ? evaluator(expression) : logicCalculate(calculation);
return {
result,
status: JOB_STATUS.RESOLVED,
};
} catch (e) {
return {
result: e.toString(),
status: JOB_STATUS.ERROR,
};
}
}
}
export default ConditionInstruction;