diff --git a/.github/workflows/nocobase-test-backend.yml b/.github/workflows/nocobase-test-backend.yml index 5be681806..0224ac2dd 100644 --- a/.github/workflows/nocobase-test-backend.yml +++ b/.github/workflows/nocobase-test-backend.yml @@ -13,6 +13,8 @@ on: - 'packages/core/acl/**' - 'packages/core/actions/**' - 'packages/core/database/**' + - 'packages/core/resourcer/**' + - 'packages/core/data-source-manager/**' - 'packages/core/server/**' - 'packages/core/utils/**' - 'packages/plugins/**/src/server/**' @@ -23,6 +25,8 @@ on: - 'packages/core/acl/**' - 'packages/core/actions/**' - 'packages/core/database/**' + - 'packages/core/resourcer/**' + - 'packages/core/data-source-manager/**' - 'packages/core/server/**' - 'packages/core/utils/**' - 'packages/plugins/**/src/server/**' diff --git a/CHANGELOG.md b/CHANGELOG.md index a72a034c8..779e8ea09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +## [v0.20.0-alpha.17](https://github.com/nocobase/nocobase/compare/v0.20.0-alpha.16...v0.20.0-alpha.17) - 2024-03-26 + +### Merged + +- feat: read pretty input number field support display format config [`#3815`](https://github.com/nocobase/nocobase/pull/3815) +- fix(Table): fix invalid pagination [`#3821`](https://github.com/nocobase/nocobase/pull/3821) +- chore: add tsdoc [`#3788`](https://github.com/nocobase/nocobase/pull/3788) +- chore(test): fix agent type [`#3819`](https://github.com/nocobase/nocobase/pull/3819) +- fix: embed plugin need hooks and e2e change [`#3727`](https://github.com/nocobase/nocobase/pull/3727) +- fix(associationBlock): fix association blocks for parent collection f… [`#3813`](https://github.com/nocobase/nocobase/pull/3813) +- fix(plugin-workflow-manual): fix schema migration [`#3814`](https://github.com/nocobase/nocobase/pull/3814) +- refactor(DataBlock): table block [`#3748`](https://github.com/nocobase/nocobase/pull/3748) +- fix(Details): block template [`#3807`](https://github.com/nocobase/nocobase/pull/3807) +- chore: cascade can replace set null action [`#3812`](https://github.com/nocobase/nocobase/pull/3812) +- feat(data-vi): support multiple data sources [`#3743`](https://github.com/nocobase/nocobase/pull/3743) +- feat(plugin-workflow): support multiple data source in workflow [`#3739`](https://github.com/nocobase/nocobase/pull/3739) +- chore: add options for matching and ignoring test files in e2e and p-test commands [`#3811`](https://github.com/nocobase/nocobase/pull/3811) +- chore: file collection template preset fields should be disabled [`#3810`](https://github.com/nocobase/nocobase/pull/3810) +- fix(plugin-workflow): remove string template in condition calculation [`#3688`](https://github.com/nocobase/nocobase/pull/3688) +- fix: refresh collection name when update [`#3797`](https://github.com/nocobase/nocobase/pull/3797) +- fix: reload when data source click refresh [`#3804`](https://github.com/nocobase/nocobase/pull/3804) +- fix: plugin manager keywords [`#3809`](https://github.com/nocobase/nocobase/pull/3809) +- fix: expand action and add new action should support drag & sort [`#3808`](https://github.com/nocobase/nocobase/pull/3808) +- fix: create attachments middleware [`#3794`](https://github.com/nocobase/nocobase/pull/3794) +- fix: useExpressionScope [`#3805`](https://github.com/nocobase/nocobase/pull/3805) +- chore: set default association reference on delete action to no action [`#3722`](https://github.com/nocobase/nocobase/pull/3722) +- fix: field permission all fields should be displayed [`#3799`](https://github.com/nocobase/nocobase/pull/3799) + +### Commits + +- chore(versions): 😊 publish v0.20.0-alpha.17 [`3398222`](https://github.com/nocobase/nocobase/commit/339822241f2f641656f64107318b793d63d0b2c9) +- fix: description [`0dc0d32`](https://github.com/nocobase/nocobase/commit/0dc0d329f80c268672bd80fc6cb0190c3cef964d) +- chore: update changelog [`35a6514`](https://github.com/nocobase/nocobase/commit/35a6514993bede12b952ce13641f7258fe6c76d2) + ## [v0.20.0-alpha.16](https://github.com/nocobase/nocobase/compare/v0.20.0-alpha.15...v0.20.0-alpha.16) - 2024-03-23 ### Merged diff --git a/lerna.json b/lerna.json index ee82671c1..57a40e21d 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "npmClient": "yarn", "useWorkspaces": true, "npmClientArgs": ["--ignore-engines"], diff --git a/packages/core/acl/package.json b/packages/core/acl/package.json index 7bf58c829..ea10f05c7 100644 --- a/packages/core/acl/package.json +++ b/packages/core/acl/package.json @@ -1,13 +1,13 @@ { "name": "@nocobase/acl", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/resourcer": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/resourcer": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "minimatch": "^5.1.1" }, "repository": { diff --git a/packages/core/acl/src/acl.ts b/packages/core/acl/src/acl.ts index e750f663f..db52b9a72 100644 --- a/packages/core/acl/src/acl.ts +++ b/packages/core/acl/src/acl.ts @@ -45,14 +45,40 @@ interface CanArgs { } export class ACL extends EventEmitter { + /** + * @internal + */ public availableStrategy = new Map(); + + /** + * @internal + */ public allowManager = new AllowManager(this); + + /** + * @internal + */ public snippetManager = new SnippetManager(); + + /** + * @internal + */ roles = new Map(); + + /** + * @internal + */ actionAlias = new Map(); + + /** + * @internal + */ configResources: string[] = []; + protected availableActions = new Map(); + protected fixedParamsManager = new FixedParamsManager(); + protected middlewares: Toposort; constructor() { @@ -114,14 +140,23 @@ export class ACL extends EventEmitter { return this.roles.delete(name); } + /** + * @internal + */ registerConfigResources(names: string[]) { names.forEach((name) => this.registerConfigResource(name)); } + /** + * @internal + */ registerConfigResource(name: string) { this.configResources.push(name); } + /** + * @internal + */ isConfigResource(name: string) { return this.configResources.includes(name); } @@ -227,6 +262,9 @@ export class ACL extends EventEmitter { return null; } + /** + * @internal + */ public resolveActionAlias(action: string) { return this.actionAlias.get(action) ? this.actionAlias.get(action) : action; } @@ -242,6 +280,9 @@ export class ACL extends EventEmitter { return this.skip(resourceName, actionNames, condition); } + /** + * @deprecated + */ skip(resourceName: string, actionNames: string[] | string, condition?: string | ConditionFunc) { if (!Array.isArray(actionNames)) { actionNames = [actionNames]; @@ -252,6 +293,9 @@ export class ACL extends EventEmitter { } } + /** + * @internal + */ async parseJsonTemplate(json: any, ctx: any) { if (json.filter) { ctx.logger?.info?.('parseJsonTemplate.raw', JSON.parse(JSON.stringify(json.filter))); @@ -295,6 +339,9 @@ export class ACL extends EventEmitter { }; } + /** + * @internal + */ async getActionParams(ctx) { const roleName = ctx.state.currentRole || 'anonymous'; const { resourceName, actionName } = ctx.action; @@ -322,6 +369,9 @@ export class ACL extends EventEmitter { this.snippetManager.register(snippet); } + /** + * @internal + */ filterParams(ctx, resourceName, params) { if (params?.filter?.createdById) { const collection = ctx.db.getCollection(resourceName); diff --git a/packages/core/actions/package.json b/packages/core/actions/package.json index 0acaa14c2..6db4dba4d 100644 --- a/packages/core/actions/package.json +++ b/packages/core/actions/package.json @@ -1,14 +1,14 @@ { "name": "@nocobase/actions", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/cache": "0.20.0-alpha.16", - "@nocobase/database": "0.20.0-alpha.16", - "@nocobase/resourcer": "0.20.0-alpha.16" + "@nocobase/cache": "0.20.0-alpha.17", + "@nocobase/database": "0.20.0-alpha.17", + "@nocobase/resourcer": "0.20.0-alpha.17" }, "repository": { "type": "git", diff --git a/packages/core/app/package.json b/packages/core/app/package.json index 8f26b0ce8..8b2572dfd 100644 --- a/packages/core/app/package.json +++ b/packages/core/app/package.json @@ -1,17 +1,17 @@ { "name": "@nocobase/app", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/database": "0.20.0-alpha.16", - "@nocobase/preset-nocobase": "0.20.0-alpha.16", - "@nocobase/server": "0.20.0-alpha.16" + "@nocobase/database": "0.20.0-alpha.17", + "@nocobase/preset-nocobase": "0.20.0-alpha.17", + "@nocobase/server": "0.20.0-alpha.17" }, "devDependencies": { - "@nocobase/client": "0.20.0-alpha.16" + "@nocobase/client": "0.20.0-alpha.17" }, "repository": { "type": "git", diff --git a/packages/core/auth/package.json b/packages/core/auth/package.json index 923c88627..9e2da52a3 100644 --- a/packages/core/auth/package.json +++ b/packages/core/auth/package.json @@ -1,16 +1,16 @@ { "name": "@nocobase/auth", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/actions": "0.20.0-alpha.16", - "@nocobase/cache": "0.20.0-alpha.16", - "@nocobase/database": "0.20.0-alpha.16", - "@nocobase/resourcer": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/actions": "0.20.0-alpha.17", + "@nocobase/cache": "0.20.0-alpha.17", + "@nocobase/database": "0.20.0-alpha.17", + "@nocobase/resourcer": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "@types/jsonwebtoken": "^8.5.8", "jsonwebtoken": "^8.5.1" }, diff --git a/packages/core/build/package.json b/packages/core/build/package.json index 93bf79c7c..8bee12eb9 100755 --- a/packages/core/build/package.json +++ b/packages/core/build/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/build", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "Library build tool based on rollup.", "main": "lib/index.js", "types": "./lib/index.d.ts", diff --git a/packages/core/build/src/build.ts b/packages/core/build/src/build.ts index b8f625c95..46be1144b 100755 --- a/packages/core/build/src/build.ts +++ b/packages/core/build/src/build.ts @@ -14,16 +14,22 @@ import { buildClient } from './buildClient'; import { buildCjs } from './buildCjs'; import { buildPlugin } from './buildPlugin'; import { buildDeclaration } from './buildDeclaration'; -import { PkgLog, getPkgLog, toUnixPath, getPackageJson, getUserConfig, UserConfig } from './utils'; +import { PkgLog, getPkgLog, toUnixPath, getPackageJson, getUserConfig, UserConfig, writeToCache, readFromCache } from './utils'; import { getPackages } from './utils/getPackages'; import { Package } from '@lerna/package'; import { tarPlugin } from './tarPlugin' +const BUILD_ERROR = 'build-error'; + export async function build(pkgs: string[]) { const isDev = process.argv.includes('--development'); process.env.NODE_ENV = isDev ? 'development' : 'production'; - const packages = getPackages(pkgs); + let packages = getPackages(pkgs); + const cachePkg = readFromCache(BUILD_ERROR); + if (process.argv.includes('--retry') && cachePkg?.pkg) { + packages = packages.slice(packages.findIndex((item) => item.name === cachePkg.pkg)); + } if (packages.length === 0) { let msg = ''; if (pkgs.length) { @@ -59,6 +65,7 @@ export async function build(pkgs: string[]) { APP_ROOT: path.join(CORE_APP, 'client'), }); } + writeToCache(BUILD_ERROR, {}); } export async function buildPackages( @@ -67,6 +74,7 @@ export async function buildPackages( doBuildPackage: (cwd: string, userConfig: UserConfig, sourcemap: boolean, log?: PkgLog) => Promise, ) { for await (const pkg of packages) { + writeToCache(BUILD_ERROR, { pkg: pkg.name }) await buildPackage(pkg, targetDir, doBuildPackage); } } diff --git a/packages/core/build/src/constant.ts b/packages/core/build/src/constant.ts index 9b113f84d..f92072aff 100644 --- a/packages/core/build/src/constant.ts +++ b/packages/core/build/src/constant.ts @@ -26,6 +26,7 @@ export const EsbuildSupportExts = [ '.data', ]; export const ROOT_PATH = path.join(__dirname, '../../../../'); +export const NODE_MODULES = path.join(ROOT_PATH, 'node_modules'); export const PACKAGES_PATH = path.join(ROOT_PATH, 'packages'); export const PLUGINS_DIR = ['plugins', 'samples', 'pro-plugins'] .concat((process.env.PLUGINS_DIRS || '').split(',')) diff --git a/packages/core/build/src/utils/getPackages.ts b/packages/core/build/src/utils/getPackages.ts index 5bcd45f44..75231dfa1 100644 --- a/packages/core/build/src/utils/getPackages.ts +++ b/packages/core/build/src/utils/getPackages.ts @@ -10,6 +10,7 @@ import { toUnixPath } from './utils'; * 获取构建包的绝对路径,支持项目路径和 npm 两种形式 * @example * yarn build packages/core/client @nocobase/acl => ['/home/xx/packages/core/client', '/home/xx/packages/core/acl'] + * yarn build packages/plugins/* => ['/home/xx/packages/plugins/a', '/home/xx/packages/plugins/b'] * yarn build => all packages */ function getPackagesPath(pkgs: string[]) { @@ -24,7 +25,6 @@ function getPackagesPath(pkgs: string[]) { return allPackageJson .map(toUnixPath).map(item => path.dirname(item)); } - const allPackageInfo = allPackageJson .map(packageJsonPath => ({ name: require(packageJsonPath).name, path: path.dirname(toUnixPath(packageJsonPath)) })) .reduce((acc, cur) => { @@ -37,7 +37,9 @@ function getPackagesPath(pkgs: string[]) { const relativePaths = pkgNames.length ? pkgs.filter(item => !pkgNames.includes(item)) : pkgs; const pkgPaths = pkgs.map(item => allPackageInfo[item]) const absPaths = allPackagePaths.filter(absPath => relativePaths.some((relativePath) => absPath.endsWith(relativePath))); - return [...pkgPaths, ...absPaths]; + const dirPaths = fg.sync(pkgs, { onlyDirectories: true, absolute: true, cwd: ROOT_PATH }); + const dirMatchPaths = allPackagePaths.filter(pkgPath => dirPaths.some(dirPath => pkgPath.startsWith(dirPath))); + return [...new Set([...pkgPaths, ...absPaths, ...dirMatchPaths])]; } export function getPackages(pkgs: string[]) { diff --git a/packages/core/build/src/utils/utils.ts b/packages/core/build/src/utils/utils.ts index aa616dda9..2af074868 100644 --- a/packages/core/build/src/utils/utils.ts +++ b/packages/core/build/src/utils/utils.ts @@ -1,10 +1,11 @@ import chalk from 'chalk'; import path from 'path'; -import fs from 'fs-extra'; import fg from 'fast-glob'; +import fs from 'fs-extra'; import { Options as TsupConfig } from 'tsup' import { InlineConfig as ViteConfig } from 'vite' import { register } from 'esbuild-register/dist/node'; +import { NODE_MODULES } from '../constant'; let previousColor = ''; function randomColor() { @@ -79,3 +80,18 @@ export function getUserConfig(cwd: string) { } return config; } + +const CACHE_DIR = path.join(NODE_MODULES, '.cache', 'nocobase'); +export function writeToCache(key: string, data: Record) { + const cachePath = path.join(CACHE_DIR, `${key}.json`); + fs.ensureDirSync(path.dirname(cachePath)); + fs.writeJsonSync(cachePath, data, { spaces: 2 }); +} + +export function readFromCache(key: string) { + const cachePath = path.join(CACHE_DIR, `${key}.json`); + if (fs.existsSync(cachePath)) { + return fs.readJsonSync(cachePath); + } + return {}; +} diff --git a/packages/core/cache/package.json b/packages/core/cache/package.json index c78c8c30a..7c911ae76 100644 --- a/packages/core/cache/package.json +++ b/packages/core/cache/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/cache", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./lib/index.js", diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index 0436cd394..b3af7a018 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/cli", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./src/index.js", @@ -8,7 +8,7 @@ "nocobase": "./bin/index.js" }, "dependencies": { - "@nocobase/app": "0.20.0-alpha.16", + "@nocobase/app": "0.20.0-alpha.17", "@types/fs-extra": "^11.0.1", "@umijs/utils": "3.5.20", "chalk": "^4.1.1", @@ -25,7 +25,7 @@ "tsx": "^4.6.2" }, "devDependencies": { - "@nocobase/devtools": "0.20.0-alpha.16" + "@nocobase/devtools": "0.20.0-alpha.17" }, "repository": { "type": "git", diff --git a/packages/core/cli/src/commands/build.js b/packages/core/cli/src/commands/build.js index d08bd5d7c..267abd837 100644 --- a/packages/core/cli/src/commands/build.js +++ b/packages/core/cli/src/commands/build.js @@ -13,6 +13,7 @@ module.exports = (cli) => { .argument('[packages...]') .option('-v, --version', 'print version') .option('-c, --compile', 'compile the @nocobase/build package') + .option('-r, --retry', 'retry the last failed package') .option('-w, --watch', 'watch compile the @nocobase/build package') .option('-s, --sourcemap', 'generate sourcemap') .option('--no-dts', 'not generate dts') @@ -24,12 +25,14 @@ module.exports = (cli) => { }); if (options.watch) return; } + process.env['VITE_CJS_IGNORE_WARNING'] = 'true'; await run('nocobase-build', [ ...pkgs, options.version ? '--version' : '', !options.dts ? '--no-dts' : '', options.sourcemap ? '--sourcemap' : '', + options.retry ? '--retry' : '', ]); buildIndexHtml(true); }); diff --git a/packages/core/cli/src/commands/e2e.js b/packages/core/cli/src/commands/e2e.js index 361687b9b..8d70f553a 100644 --- a/packages/core/cli/src/commands/e2e.js +++ b/packages/core/cli/src/commands/e2e.js @@ -243,6 +243,12 @@ module.exports = (cli) => { .option('--stop-on-error') .option('--build') .option('--concurrency [concurrency]', '', os.cpus().length) + .option( + '--match [match]', + 'Only the files matching one of these patterns are executed as test files. Matching is performed against the absolute file path. Strings are treated as glob patterns.', + 'packages/**/__e2e__/**/*.test.ts', + ) + .option('--ignore [ignore]', 'Skip tests that match the pattern. Strings are treated as glob patterns.', undefined) .action(async (options) => { process.env.__E2E__ = true; if (options.build) { diff --git a/packages/core/cli/src/commands/p-test.js b/packages/core/cli/src/commands/p-test.js index 05bcaa06e..fb5b8a828 100644 --- a/packages/core/cli/src/commands/p-test.js +++ b/packages/core/cli/src/commands/p-test.js @@ -60,7 +60,8 @@ exports.pTest = async (options) => { fs.mkdirSync(dir, { recursive: true }); } - const files = glob.sync('packages/**/__e2e__/**/*.test.ts', { + const files = glob.sync(options.match, { + ignore: options.ignore, root: process.cwd(), }); diff --git a/packages/core/cli/src/commands/test.js b/packages/core/cli/src/commands/test.js index e592ee5b7..071e45d39 100644 --- a/packages/core/cli/src/commands/test.js +++ b/packages/core/cli/src/commands/test.js @@ -19,6 +19,7 @@ function addTestCommand(name, cli) { .arguments('[paths...]') .allowUnknownOption() .action(async (paths, opts) => { + process.argv.push('--disable-console-intercept'); if (name === 'test:server') { process.env.TEST_ENV = 'server-side'; } else if (name === 'test:client') { diff --git a/packages/core/client/package.json b/packages/core/client/package.json index 219455de2..cd4247f48 100644 --- a/packages/core/client/package.json +++ b/packages/core/client/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/client", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "Apache-2.0", "main": "lib/index.js", "module": "es/index.mjs", @@ -26,9 +26,9 @@ "@formily/reactive-react": "^2.2.27", "@formily/shared": "^2.2.27", "@formily/validator": "^2.2.27", - "@nocobase/evaluators": "0.20.0-alpha.16", - "@nocobase/sdk": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/evaluators": "0.20.0-alpha.17", + "@nocobase/sdk": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "ahooks": "^3.7.2", "antd": "^5.12.8", "antd-style": "3.4.5", diff --git a/packages/core/client/src/application/PluginSettingsManager.ts b/packages/core/client/src/application/PluginSettingsManager.ts index 3a5a12a8a..1b08efe79 100644 --- a/packages/core/client/src/application/PluginSettingsManager.ts +++ b/packages/core/client/src/application/PluginSettingsManager.ts @@ -11,7 +11,7 @@ export const ADMIN_SETTINGS_PATH = '/admin/settings/'; export const SNIPPET_PREFIX = 'pm.'; export interface PluginSettingOptions { - title: string | React.ReactElement; + title: any; /** * @default Outlet */ diff --git a/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx b/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx index 6f4f538ee..374beae63 100644 --- a/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx +++ b/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { SchemaComponent, SchemaComponentProvider } from '../../../schema-component'; -import { render, screen, sleep, userEvent, waitFor } from '@nocobase/test/client'; +import { render } from '@nocobase/test/client'; import { withDynamicSchemaProps } from '../../hoc'; const HelloComponent = withDynamicSchemaProps((props: any) => ( @@ -66,7 +66,7 @@ describe('withDynamicSchemaProps', () => { const Demo = withTestDemo(schema, scopes); const { getByTestId } = render(); - expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ a: 'a', b: 'b' })); + expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ b: 'b', a: 'a' })); }); test('x-use-decorator-props', () => { @@ -101,7 +101,7 @@ describe('withDynamicSchemaProps', () => { const Demo = withTestDemo(schema, scopes); const { getByTestId } = render(); - expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ a: 'a', b: 'b' })); + expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ b: 'b', a: 'a' })); }); test('x-use-component-props and x-use-decorator-props exist simultaneously', () => { @@ -130,8 +130,8 @@ describe('withDynamicSchemaProps', () => { const Demo = withTestDemo(schema, scopes); const { getByTestId } = render(); - expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ a: 'a', b: 'b' })); - expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ c: 'c', d: 'd' })); + expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ b: 'b', a: 'a' })); + expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ d: 'd', c: 'c' })); }); test('no register scope', () => { @@ -142,4 +142,23 @@ describe('withDynamicSchemaProps', () => { const { getByTestId } = render(); expect(getByTestId('component')).toHaveTextContent(JSON.stringify({})); }); + + test('x-use-component-props should override x-component-props', () => { + function useComponentProps() { + return { + a: 'a', + }; + } + const schema = { + 'x-use-component-props': 'useComponentProps', + 'x-component-props': { + a: 'b', + }, + }; + const scopes = { useComponentProps }; + + const Demo = withTestDemo(schema, scopes); + const { getByTestId } = render(); + expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ a: 'a' })); + }); }); diff --git a/packages/core/client/src/application/hoc/withDynamicSchemaProps.tsx b/packages/core/client/src/application/hoc/withDynamicSchemaProps.tsx index fc3d918ad..4d5daf178 100644 --- a/packages/core/client/src/application/hoc/withDynamicSchemaProps.tsx +++ b/packages/core/client/src/application/hoc/withDynamicSchemaProps.tsx @@ -1,6 +1,7 @@ +import { useExpressionScope } from '@formily/react'; import { merge, omit } from 'lodash'; import React, { ComponentType, useMemo } from 'react'; -import { useDesignable, useSchemaComponentContext } from '../../schema-component'; +import { useDesignable } from '../../schema-component'; const useDefaultSchemaProps = () => undefined; @@ -8,11 +9,11 @@ interface WithSchemaHookOptions { displayName?: string; } -export function withDynamicSchemaProps(Component: ComponentType, options: WithSchemaHookOptions = {}) { +export function withDynamicSchemaProps(Component: any, options: WithSchemaHookOptions = {}) { const displayName = options.displayName || Component.displayName || Component.name; const ComponentWithProps: ComponentType = (props) => { const { dn, findComponent } = useDesignable(); - const { scope } = useSchemaComponentContext(); + const scope = useExpressionScope(); const useComponentPropsStr = useMemo(() => { const xComponent = dn.getSchemaAttribute('x-component'); const xDecorator = dn.getSchemaAttribute('x-decorator'); @@ -40,7 +41,7 @@ export function withDynamicSchemaProps(Component: ComponentType, opt const schemaProps = useSchemaProps(props); const memoProps = useMemo(() => { - return merge(omit(schemaProps, 'children'), omit(props, 'children')); + return merge(omit(props, 'children'), omit(schemaProps, 'children')); }, [schemaProps, props]); return {props.children}; diff --git a/packages/core/client/src/block-provider/BlockProvider.tsx b/packages/core/client/src/block-provider/BlockProvider.tsx index da97a5c09..11040c5f0 100644 --- a/packages/core/client/src/block-provider/BlockProvider.tsx +++ b/packages/core/client/src/block-provider/BlockProvider.tsx @@ -59,31 +59,6 @@ export const useBlockResource = () => { return useContext(BlockResourceContext) || resource; }; -interface UseResourceProps { - resource: any; - association?: any; - useSourceId?: any; - collection?: any; - dataSource?: any; - block?: any; -} - -const useAssociation = (props) => { - const { association } = props; - const { getCollectionField } = useCollectionManager_deprecated(); - if (typeof association === 'string') { - return getCollectionField(association); - } else if (association?.collectionName && association?.name) { - return getCollectionField(`${association?.collectionName}.${association?.name}`); - } -}; - -const useActionParams = (props) => { - const { useParams } = props; - const params = useParams?.() || {}; - return { ...props.params, ...params }; -}; - export const MaybeCollectionProvider = (props) => { const { collection } = props; return collection ? ( @@ -218,6 +193,22 @@ export const useBlockContext = () => { return useContext(BlockContext); }; +/** + * 用于兼容旧版本 Schema + */ +const useCompatDataBlockSourceId = (props) => { + const fieldSchema = useFieldSchema(); + + // 如果存在 x-use-decorator-props,说明是新版 Schema + if (fieldSchema['x-use-decorator-props']) { + return props.sourceId; + } else { + // 是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks + // eslint-disable-next-line react-hooks/rules-of-hooks + return useDataBlockSourceId(props); + } +}; + /** * @deprecated use `DataBlockProvider` instead */ @@ -236,8 +227,11 @@ export const BlockProvider = (props: { useParams?: any; }) => { const { name, dataSource, association, useParams, parentRecord } = props; - const sourceId = useDataBlockSourceId({ association }); + const sourceId = useCompatDataBlockSourceId(props); + + // 新版(1.0)已弃用 useParams,这里之所以继续保留是为了兼容旧版的 UISchema const paramsFromHook = useParams?.(); + const { getAssociationAppends } = useAssociationNames(dataSource); const { appends, updateAssociationValues } = getAssociationAppends(); const params = useMemo(() => { @@ -330,7 +324,9 @@ export const useParamsFromRecord = () => { const { fields } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); const { getCollectionJoinField } = useCollectionManager_deprecated(); - const collectionField = getCollectionJoinField(fieldSchema?.['x-decorator-props']?.resource); + const collectionField = getCollectionJoinField( + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association, + ); const filterFields = fields .filter((v) => { return ['boolean', 'date', 'integer', 'radio', 'sort', 'string', 'time', 'uid', 'uuid'].includes(v.type); diff --git a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx index fc08fc1e1..8e3c7c1eb 100644 --- a/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx +++ b/packages/core/client/src/block-provider/BlockSchemaComponentProvider.tsx @@ -10,11 +10,13 @@ import { DetailsBlockProvider, useDetailsBlockProps } from './DetailsBlockProvid import { FilterFormBlockProvider } from './FilterFormBlockProvider'; import { FormBlockProvider, useFormBlockProps } from './FormBlockProvider'; import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider'; -import { TableBlockProvider, useTableBlockProps } from './TableBlockProvider'; +import { TableBlockProvider } from './TableBlockProvider'; +import { useTableBlockProps } from '../modules/blocks/data-blocks/table/hooks/useTableBlockProps'; import { TableFieldProvider, useTableFieldProps } from './TableFieldProvider'; import { TableSelectorProvider, useTableSelectorProps } from './TableSelectorProvider'; import * as bp from './hooks'; import { BlockSchemaToolbar } from '../modules/blocks/BlockSchemaToolbar'; +import { useTableBlockDecoratorProps } from '../modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps'; // TODO: delete this, replaced by `BlockSchemaComponentPlugin` export const BlockSchemaComponentProvider: React.FC = (props) => { @@ -41,6 +43,7 @@ export const BlockSchemaComponentProvider: React.FC = (props) => { useTableFieldProps, useTableBlockProps, useTableSelectorProps, + useTableBlockDecoratorProps, }} > {props.children} @@ -84,6 +87,7 @@ export class BlockSchemaComponentPlugin extends Plugin { useTableFieldProps, useTableBlockProps, useTableSelectorProps, + useTableBlockDecoratorProps, }); } } diff --git a/packages/core/client/src/block-provider/TableBlockProvider.tsx b/packages/core/client/src/block-provider/TableBlockProvider.tsx index 1963b5225..252e396a1 100644 --- a/packages/core/client/src/block-provider/TableBlockProvider.tsx +++ b/packages/core/client/src/block-provider/TableBlockProvider.tsx @@ -1,12 +1,12 @@ -import { ArrayField, createForm } from '@formily/core'; +import { createForm } from '@formily/core'; import { FormContext, useField, useFieldSchema } from '@formily/react'; -import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; import { useCollectionManager_deprecated } from '../collection-manager'; -import { useFilterBlock } from '../filter-provider/FilterProvider'; -import { mergeFilter } from '../filter-provider/utils'; -import { FixedBlockWrapper, SchemaComponentOptions, removeNullCondition } from '../schema-component'; +import { FixedBlockWrapper, SchemaComponentOptions } from '../schema-component'; import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider'; -import { findFilterTargets, useParsedFilter } from './hooks'; +import { useParsedFilter } from './hooks'; +import { useTableBlockParams } from '../modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps'; +import { withDynamicSchemaProps } from '../application/hoc/withDynamicSchemaProps'; export const TableBlockContext = createContext({}); TableBlockContext.displayName = 'TableBlockContext'; @@ -74,16 +74,37 @@ const InternalTableBlockProvider = (props: Props) => { ); }; -export const TableBlockProvider = (props) => { - const resourceName = props.resource; - const params = useMemo(() => ({ ...props.params }), [props.params]); +/** + * 用于兼容旧版本的 schema,当不需要兼容时可直接移除该方法 + * @param props + * @returns + */ +const useTableBlockParamsCompat = (props) => { + const fieldSchema = useFieldSchema(); + + let params; + // 1. 新版本的 schema 存在 x-use-decorator-props 属性 + if (fieldSchema['x-use-decorator-props']) { + params = props.params; + } else { + // 2. 旧版本的 schema 不存在 x-use-decorator-props 属性 + // 因为 schema 中是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks + // eslint-disable-next-line react-hooks/rules-of-hooks + params = useTableBlockParams(props); + } + + return params; +}; + +export const TableBlockProvider = withDynamicSchemaProps((props) => { + const resourceName = props.resource || props.association; + const fieldSchema = useFieldSchema(); const { getCollection, getCollectionField } = useCollectionManager_deprecated(props.dataSource); const collection = getCollection(props.collection, props.dataSource); - const { treeTable, dragSortBy } = fieldSchema?.['x-decorator-props'] || {}; - if (props.dragSort && dragSortBy) { - params['sort'] = dragSortBy; - } + const { treeTable } = fieldSchema?.['x-decorator-props'] || {}; + const params = useTableBlockParamsCompat(props); + let childrenColumnName = 'children'; if (collection?.tree && treeTable !== false) { if (resourceName?.includes('.')) { @@ -101,140 +122,18 @@ export const TableBlockProvider = (props) => { } } const form = useMemo(() => createForm(), [treeTable]); - const { filter: parsedFilter } = useParsedFilter({ - filterOption: params?.filter, - }); - const paramsWithFilter = useMemo(() => { - return { - ...params, - filter: parsedFilter, - }; - }, [parsedFilter, params]); return ( - - + + ); -}; +}); export const useTableBlockContext = () => { return useContext(TableBlockContext); }; - -export const useTableBlockProps = () => { - const field = useField(); - const fieldSchema = useFieldSchema(); - const ctx = useTableBlockContext(); - const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort']; - const { getDataBlocks } = useFilterBlock(); - - useEffect(() => { - if (!ctx?.service?.loading) { - field.value = []; - field.value = ctx?.service?.data?.data; - field?.setInitialValue(ctx?.service?.data?.data); - field.data = field.data || {}; - field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys; - field.componentProps.pagination = field.componentProps.pagination || {}; - field.componentProps.pagination.pageSize = ctx?.service?.data?.meta?.pageSize; - field.componentProps.pagination.total = ctx?.service?.data?.meta?.count; - field.componentProps.pagination.current = ctx?.service?.data?.meta?.page; - } - }, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题: - return { - childrenColumnName: ctx.childrenColumnName, - loading: ctx?.service?.loading, - showIndex: ctx.showIndex, - dragSort: ctx.dragSort && ctx.dragSortBy, - rowKey: ctx.rowKey || 'id', - pagination: - ctx?.params?.paginate !== false - ? { - defaultCurrent: ctx?.params?.page || 1, - defaultPageSize: ctx?.params?.pageSize, - } - : false, - onRowSelectionChange(selectedRowKeys) { - ctx.field.data = ctx?.field?.data || {}; - ctx.field.data.selectedRowKeys = selectedRowKeys; - ctx?.field?.onRowSelect?.(selectedRowKeys); - }, - async onRowDragEnd({ from, to }) { - await ctx.resource.move({ - sourceId: from[ctx.rowKey || 'id'], - targetId: to[ctx.rowKey || 'id'], - sortField: ctx.dragSort && ctx.dragSortBy, - }); - ctx.service.refresh(); - }, - onChange({ current, pageSize }, filters, sorter) { - const sort = sorter.order ? (sorter.order === `ascend` ? [sorter.field] : [`-${sorter.field}`]) : globalSort; - ctx.service.run({ ...ctx.service.params?.[0], page: current, pageSize, sort }); - }, - onClickRow(record, setSelectedRow, selectedRow) { - const { targets, uid } = findFilterTargets(fieldSchema); - const dataBlocks = getDataBlocks(); - - // 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错 - if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) { - // 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。 - // 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。 - setSelectedRow((prev) => (prev.length ? [] : prev)); - return; - } - - const value = [record[ctx.rowKey]]; - - dataBlocks.forEach((block) => { - const target = targets.find((target) => target.uid === block.uid); - if (!target) return; - - const param = block.service.params?.[0] || {}; - // 保留原有的 filter - const storedFilter = block.service.params?.[1]?.filters || {}; - - if (selectedRow.includes(record[ctx.rowKey])) { - if (block.dataLoadingMode === 'manual') { - return block.clearData(); - } - delete storedFilter[uid]; - } else { - storedFilter[uid] = { - $and: [ - { - [target.field || ctx.rowKey]: { - [target.field ? '$in' : '$eq']: value, - }, - }, - ], - }; - } - - const mergedFilter = mergeFilter([ - ...Object.values(storedFilter).map((filter) => removeNullCondition(filter)), - block.defaultFilter, - ]); - - return block.doFilter( - { - ...param, - page: 1, - filter: mergedFilter, - }, - { filters: storedFilter }, - ); - }); - - // 更新表格的选中状态 - setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value])); - }, - onExpand(expanded, record) { - ctx?.field.onExpandClick?.(expanded, record); - }, - }; -}; diff --git a/packages/core/client/src/block-provider/hooks/useDataBlockSourceId.tsx b/packages/core/client/src/block-provider/hooks/useDataBlockSourceId.tsx index 5089b848b..b7efefeff 100644 --- a/packages/core/client/src/block-provider/hooks/useDataBlockSourceId.tsx +++ b/packages/core/client/src/block-provider/hooks/useDataBlockSourceId.tsx @@ -1,5 +1,10 @@ -import { useFieldSchema } from '@formily/react'; -import { useCollection, useCollectionManager, useCollectionParentRecordData, useCollectionRecordData } from '../..'; +import { + InheritanceCollectionMixin, + useCollection, + useCollectionManager, + useCollectionParentRecordData, + useCollectionRecordData, +} from '../..'; /** * 注意:这里有一个需要更改 schema 才能解决的问题,就是在获取 sourceId 的时候无法确定(在关系字段和当前表同表时) @@ -12,14 +17,17 @@ export const useDataBlockSourceId = ({ association }: { association: string }) = const recordData = useCollectionRecordData(); const parentRecordData = useCollectionParentRecordData(); const cm = useCollectionManager(); - const collectionOutsideBlock = useCollection(); + const collectionOutsideBlock = useCollection(); if (!association) return; const associationField = cm.getCollectionField(association); - const associationCollection = cm.getCollection(associationField.collectionName); + const associationCollection = cm.getCollection(associationField.collectionName); - if (collectionOutsideBlock.name === associationCollection.name) { + if ( + collectionOutsideBlock.name === associationCollection.name || + collectionOutsideBlock.getParentCollectionsName?.().includes(associationCollection.name) + ) { return recordData?.[ associationField.sourceKey || associationCollection.filterTargetKey || diff --git a/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx b/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx index 81d6180d3..39b682323 100644 --- a/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx +++ b/packages/core/client/src/collection-manager/templates/components/PresetFields.tsx @@ -193,6 +193,10 @@ export const PresetFields = observer( rowSelection={{ type: 'checkbox', selectedRowKeys, + getCheckboxProps: (record) => ({ + disabled: form.values.template === 'file', // Column configuration not to be checked + name: record.name, + }), onChange: (_, selectedRows) => { const fields = getDefaultCollectionFields(selectedRows, form.values); const config = { diff --git a/packages/core/client/src/data-source/data-source/DataSource.ts b/packages/core/client/src/data-source/data-source/DataSource.ts index d1c5cfa7a..6a991f455 100644 --- a/packages/core/client/src/data-source/data-source/DataSource.ts +++ b/packages/core/client/src/data-source/data-source/DataSource.ts @@ -11,6 +11,7 @@ export interface DataSourceOptions { collections?: CollectionOptions[]; errorMessage?: string; status?: 'loaded' | 'loading-failed' | 'loading' | 'reloading'; + isDBInstance?: boolean; } export type DataSourceFactory = new (options: DataSourceOptions, dataSourceManager: DataSourceManager) => DataSource; diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index e76cbb80f..51a222ac2 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -910,5 +910,14 @@ "Second": "秒", "Unix Timestamp": "Unix 时间戳", "Field value do not meet the requirements": "字符不符合要求", - "Field value size is": "字符长度要求" + "Field value size is": "字符长度要求", + "Style": "风格", + "Unit conversion": "单位换算", + "Separator": "分隔符", + "Prefix": "前缀", + "Suffix": "后缀", + "Multiply by":"乘以", + "Divide by":"除以", + "Scientifix notation":"科学计数法", + "Normal":"常规" } diff --git a/packages/core/client/src/modules/blocks/data-blocks/details-multi/multiDataDetailsBlockSettings.tsx b/packages/core/client/src/modules/blocks/data-blocks/details-multi/multiDataDetailsBlockSettings.tsx index d4a8601ef..8b52f5907 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/details-multi/multiDataDetailsBlockSettings.tsx +++ b/packages/core/client/src/modules/blocks/data-blocks/details-multi/multiDataDetailsBlockSettings.tsx @@ -177,7 +177,8 @@ export const multiDataDetailsBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'Details', collectionName: name, diff --git a/packages/core/client/src/modules/blocks/data-blocks/details-single/singleDataDetailsBlockSettings.ts b/packages/core/client/src/modules/blocks/data-blocks/details-single/singleDataDetailsBlockSettings.ts index cd130c554..c2bfdda96 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/details-single/singleDataDetailsBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/details-single/singleDataDetailsBlockSettings.ts @@ -16,7 +16,8 @@ export const singleDataDetailsBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { insertAdjacentPosition: 'beforeEnd', componentName: 'ReadPrettyFormItem', diff --git a/packages/core/client/src/modules/blocks/data-blocks/form/createFormBlockSettings.tsx b/packages/core/client/src/modules/blocks/data-blocks/form/createFormBlockSettings.tsx index 0e7551ed9..c93d35087 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/form/createFormBlockSettings.tsx +++ b/packages/core/client/src/modules/blocks/data-blocks/form/createFormBlockSettings.tsx @@ -51,7 +51,8 @@ export const creationFormBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FormItem', collectionName: name, @@ -117,7 +118,8 @@ export const createFormBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FormItem', collectionName: name, diff --git a/packages/core/client/src/modules/blocks/data-blocks/form/editFormBlockSettings.ts b/packages/core/client/src/modules/blocks/data-blocks/form/editFormBlockSettings.ts index 134fa5235..f12f6a9a8 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/form/editFormBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/form/editFormBlockSettings.ts @@ -50,7 +50,8 @@ export const editFormBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FormItem', collectionName: name, diff --git a/packages/core/client/src/modules/blocks/data-blocks/grid-card/gridCardBlockSettings.ts b/packages/core/client/src/modules/blocks/data-blocks/grid-card/gridCardBlockSettings.ts index 11938ba5b..ce626c00c 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/grid-card/gridCardBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/grid-card/gridCardBlockSettings.ts @@ -257,7 +257,8 @@ export const gridCardBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'GridCard', diff --git a/packages/core/client/src/modules/blocks/data-blocks/list/listBlockSettings.ts b/packages/core/client/src/modules/blocks/data-blocks/list/listBlockSettings.ts index 32b4b179b..627700a88 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/list/listBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/list/listBlockSettings.ts @@ -196,7 +196,8 @@ export const listBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'List', diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/TableBlockInitializer.tsx b/packages/core/client/src/modules/blocks/data-blocks/table/TableBlockInitializer.tsx index 56d38dfc9..374293f46 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/table/TableBlockInitializer.tsx +++ b/packages/core/client/src/modules/blocks/data-blocks/table/TableBlockInitializer.tsx @@ -2,9 +2,9 @@ import { TableOutlined } from '@ant-design/icons'; import { useSchemaInitializer, useSchemaInitializerItem } from '../../../../application/schema-initializer/context'; import { useCollectionManager_deprecated } from '../../../../collection-manager/hooks/useCollectionManager_deprecated'; import { DataBlockInitializer } from '../../../../schema-initializer/items/DataBlockInitializer'; -import { createTableBlockSchema } from '../../../../schema-initializer/utils'; import React from 'react'; import { Collection, CollectionFieldOptions } from '../../../../data-source/collection/Collection'; +import { createTableBlockUISchema } from './createTableBlockUISchema'; export const TableBlockInitializer = ({ filterCollections, @@ -42,8 +42,8 @@ export const TableBlockInitializer = ({ } const collection = getCollection(item.name, item.dataSource); - const schema = createTableBlockSchema({ - collection: item.name, + const schema = createTableBlockUISchema({ + collectionName: item.name, dataSource: item.dataSource, rowKey: collection.filterTargetKey || 'id', }); diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/schemaInitializer.test.ts b/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/schemaInitializer.test.ts index 6cd112846..157dbdd01 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/schemaInitializer.test.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/schemaInitializer.test.ts @@ -1,4 +1,5 @@ import { createBlockInPage, expect, oneEmptyTable, test } from '@nocobase/test/e2e'; +import { T3686 } from './templatesOfBug'; test.describe('where table block can be added', () => { test('page', async ({ page, mockPage }) => { @@ -9,7 +10,45 @@ test.describe('where table block can be added', () => { await expect(page.getByLabel('block-item-CardItem-users-table')).toBeVisible(); }); - test('popup', async () => {}); + test('popup', async ({ page, mockPage, mockRecord }) => { + await mockPage(T3686).goto(); + await mockRecord('parentCollection'); + const childRecord = await mockRecord('childCollection'); + + // 打开弹窗 + await page.getByLabel('action-Action.Link-View').click(); + + // 添加当前表关系区块 + await page.getByLabel('schema-initializer-Grid-popup').hover(); + await page.getByRole('menuitem', { name: 'table Table right' }).hover(); + await page.getByRole('menuitem', { name: 'childAssociationField ->' }).click(); + await page + .getByTestId('drawer-Action.Container-childCollection-View record') + .getByLabel('schema-initializer-TableV2-') + .hover(); + await page.getByRole('menuitem', { name: 'childTargetText' }).click(); + + // 添加父表关系区块 + await page.getByLabel('schema-initializer-Grid-popup').hover(); + await page.getByRole('menuitem', { name: 'table Table right' }).hover(); + await page.getByRole('menuitem', { name: 'parentAssociationField ->' }).click(); + await page.getByLabel('schema-initializer-TableV2-table:configureColumns-parentTargetCollection').hover(); + await page.getByRole('menuitem', { name: 'parentTargetText' }).click(); + + // 普通关系区块应该显示正常 + await expect( + page + .getByLabel('block-item-CardItem-childTargetCollection-table') + .getByText(childRecord.childAssociationField[0].childTargetText), + ).toBeVisible(); + + // 父表关系区块应该显示正常 + await expect( + page + .getByLabel('block-item-CardItem-parentTargetCollection-table') + .getByText(childRecord.parentAssociationField[0].parentTargetText), + ).toBeVisible(); + }); }); test.describe('configure actions', () => { diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/templatesOfBug.ts b/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/templatesOfBug.ts index 0e544064f..e13a50b0f 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/templatesOfBug.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/table/__e2e__/templatesOfBug.ts @@ -1,3 +1,5 @@ +import { PageConfig } from '@nocobase/test/e2e'; + export const T2183 = { pageSchema: { _isJSONSchemaObject: true, @@ -670,3 +672,260 @@ export const T2187 = { 'x-index': 1, }, }; + +export const T3686: PageConfig = { + collections: [ + { + name: 'parentTargetCollection', + fields: [ + { + name: 'parentTargetText', + interface: 'input', + }, + ], + }, + { + name: 'childTargetCollection', + fields: [ + { + name: 'childTargetText', + interface: 'input', + }, + ], + }, + { + name: 'childCollection', + inherits: ['parentCollection'], + fields: [ + { + name: 'childAssociationField', + interface: 'm2m', + target: 'childTargetCollection', + }, + ], + }, + { + name: 'parentCollection', + fields: [ + { + name: 'parentAssociationField', + interface: 'm2m', + target: 'parentTargetCollection', + }, + ], + }, + ], + pageSchema: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Page', + properties: { + fa2wzem9pud: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'page:addBlock', + properties: { + '14kr5bu1min': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Row', + properties: { + uc0jyubx2p3: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid.Col', + properties: { + k22vt5rvlf8: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': 'TableBlockProvider', + 'x-acl-action': 'childCollection:list', + 'x-use-decorator-props': 'useTableBlockDecoratorProps', + 'x-decorator-props': { + collection: 'childCollection', + dataSource: 'main', + action: 'list', + params: { + pageSize: 20, + }, + rowKey: 'id', + showIndex: true, + dragSort: false, + }, + 'x-toolbar': 'BlockSchemaToolbar', + 'x-settings': 'blockSettings:table', + 'x-component': 'CardItem', + 'x-filter-targets': [], + properties: { + actions: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-initializer': 'table:configureActions', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 'var(--nb-spacing)', + }, + }, + 'x-uid': '42xfns3215b', + 'x-async': false, + 'x-index': 1, + }, + mv0fpgnz9x4: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'array', + 'x-initializer': 'table:configureColumns', + 'x-component': 'TableV2', + 'x-use-component-props': 'useTableBlockProps', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + }, + properties: { + actions: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{ t("Actions") }}', + 'x-action-column': 'actions', + 'x-decorator': 'TableV2.Column.ActionBar', + 'x-component': 'TableV2.Column', + 'x-designer': 'TableV2.ActionColumnDesigner', + 'x-initializer': 'table:configureItemActions', + properties: { + '4pr5w722wko': { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + '0z54f36l29g': { + 'x-uid': '6ga7ofdmqac', + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: 'View record', + 'x-action': 'view', + 'x-toolbar': 'ActionSchemaToolbar', + 'x-settings': 'actionSettings:view', + 'x-component': 'Action.Link', + 'x-component-props': { + openMode: 'drawer', + danger: false, + }, + 'x-decorator': 'ACLActionProvider', + 'x-designer-props': { + linkageAction: true, + }, + properties: { + drawer: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{ t("View record") }}', + 'x-component': 'Action.Container', + 'x-component-props': { + className: 'nb-action-popup', + }, + properties: { + tabs: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + 'x-initializer': 'TabPaneInitializers', + properties: { + tab1: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + title: '{{t("Details")}}', + 'x-component': 'Tabs.TabPane', + 'x-designer': 'Tabs.Designer', + 'x-component-props': {}, + properties: { + grid: { + _isJSONSchemaObject: true, + version: '2.0', + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'popup:common:addBlock', + 'x-uid': '8isg655oydv', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'avctzq7wpne', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '8mimixsn47i', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'l491lw6ud7u', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '0y6h0doaa8s', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'cusyvu100n5', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'kp6yhoecxt4', + 'x-async': false, + 'x-index': 2, + }, + }, + 'x-uid': '6fsjzz00845', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'gbcojp8p120', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '18neqdk2pbg', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': '7przcz662jy', + 'x-async': false, + 'x-index': 1, + }, + }, + 'x-uid': 'teroosp0elp', + 'x-async': true, + 'x-index': 1, + }, +}; diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/__tests__/createTableBLockSchema.test.ts b/packages/core/client/src/modules/blocks/data-blocks/table/__tests__/createTableBLockSchema.test.ts new file mode 100644 index 000000000..7d0cd5d96 --- /dev/null +++ b/packages/core/client/src/modules/blocks/data-blocks/table/__tests__/createTableBLockSchema.test.ts @@ -0,0 +1,85 @@ +import { createTableBlockUISchema } from '../createTableBlockUISchema'; + +vi.mock('@formily/shared', () => { + return { + uid: () => 'mocked-uid', + }; +}); + +describe('createTableBLockSchemaV2', () => { + it('should create a default table block schema with minimum options', () => { + const options = { dataSource: 'abc', collectionName: 'users', association: 'users.roles', rowKey: 'rowKey' }; + const schema = createTableBlockUISchema(options); + + expect(schema).toMatchInlineSnapshot(` + { + "properties": { + "actions": { + "properties": {}, + "type": "void", + "x-component": "ActionBar", + "x-component-props": { + "style": { + "marginBottom": "var(--nb-spacing)", + }, + }, + "x-initializer": "table:configureActions", + }, + "mocked-uid": { + "properties": { + "actions": { + "properties": { + "mocked-uid": { + "type": "void", + "x-component": "Space", + "x-component-props": { + "split": "|", + }, + "x-decorator": "DndContext", + }, + }, + "title": "{{ t("Actions") }}", + "type": "void", + "x-action-column": "actions", + "x-component": "TableV2.Column", + "x-decorator": "TableV2.Column.ActionBar", + "x-designer": "TableV2.ActionColumnDesigner", + "x-initializer": "table:configureItemActions", + }, + }, + "type": "array", + "x-component": "TableV2", + "x-component-props": { + "rowKey": "id", + "rowSelection": { + "type": "checkbox", + }, + }, + "x-initializer": "table:configureColumns", + "x-use-component-props": "useTableBlockProps", + }, + }, + "type": "void", + "x-acl-action": "users:list", + "x-component": "CardItem", + "x-decorator": "TableBlockProvider", + "x-decorator-props": { + "action": "list", + "association": "users.roles", + "collection": "users", + "dataSource": "abc", + "dragSort": false, + "params": { + "pageSize": 20, + }, + "rowKey": "rowKey", + "showIndex": true, + }, + "x-filter-targets": [], + "x-settings": "blockSettings:table", + "x-toolbar": "BlockSchemaToolbar", + "x-use-decorator-props": "useTableBlockDecoratorProps", + } + `); + }); +}); diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/createTableBlockUISchema.ts b/packages/core/client/src/modules/blocks/data-blocks/table/createTableBlockUISchema.ts new file mode 100644 index 000000000..ca5da9dbd --- /dev/null +++ b/packages/core/client/src/modules/blocks/data-blocks/table/createTableBlockUISchema.ts @@ -0,0 +1,84 @@ +import { ISchema } from '@formily/react'; +import { uid } from '@formily/shared'; + +export const createTableBlockUISchema = (options: { + dataSource: string; + collectionName?: string; + rowKey?: string; + association?: string; +}): ISchema => { + const { collectionName, dataSource, rowKey, association } = options; + + if (!dataSource) { + throw new Error('dataSource is required'); + } + + return { + type: 'void', + 'x-decorator': 'TableBlockProvider', + 'x-acl-action': `${collectionName}:list`, + 'x-use-decorator-props': 'useTableBlockDecoratorProps', + 'x-decorator-props': { + collection: collectionName, + association, + dataSource, + action: 'list', + params: { + pageSize: 20, + }, + rowKey, + showIndex: true, + dragSort: false, + }, + 'x-toolbar': 'BlockSchemaToolbar', + 'x-settings': 'blockSettings:table', + 'x-component': 'CardItem', + 'x-filter-targets': [], + properties: { + actions: { + type: 'void', + 'x-initializer': 'table:configureActions', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 'var(--nb-spacing)', + }, + }, + properties: {}, + }, + [uid()]: { + type: 'array', + 'x-initializer': 'table:configureColumns', + 'x-component': 'TableV2', + 'x-use-component-props': 'useTableBlockProps', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + }, + properties: { + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-action-column': 'actions', + 'x-decorator': 'TableV2.Column.ActionBar', + 'x-component': 'TableV2.Column', + 'x-designer': 'TableV2.ActionColumnDesigner', + 'x-initializer': 'table:configureItemActions', + properties: { + [uid()]: { + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + }, + }, + }, + }, + }, + }, + }; +}; diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps.ts b/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps.ts new file mode 100644 index 000000000..55650748a --- /dev/null +++ b/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps.ts @@ -0,0 +1,51 @@ +import { useFieldSchema } from '@formily/react'; +import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFilter'; +import { useMemo } from 'react'; +import { useDataBlockSourceId } from '../../../../../block-provider/hooks/useDataBlockSourceId'; + +export const useTableBlockDecoratorProps = (props) => { + const params = useTableBlockParams(props); + const sourceId = useTableBlockSourceId(props); + + return { + params, + sourceId, + }; +}; + +export function useTableBlockParams(props) { + const fieldSchema = useFieldSchema(); + const { filter: parsedFilter } = useParsedFilter({ + filterOption: props.params?.filter, + }); + + return useMemo(() => { + const params = props.params || {}; + + // 1. sort + const { dragSortBy } = fieldSchema?.['x-decorator-props'] || {}; + if (props.dragSort && dragSortBy) { + params['sort'] = dragSortBy; + } + + // 2. filter + const paramsWithFilter = { + ...params, + filter: parsedFilter, + }; + + return paramsWithFilter; + }, [fieldSchema, parsedFilter, props.dragSort, props.params]); +} + +function useTableBlockSourceId(props) { + let sourceId: string | undefined; + + // 因为 association 是固定不变的,所以在条件中使用 hooks 是安全的 + if (props.association) { + // eslint-disable-next-line react-hooks/rules-of-hooks + sourceId = useDataBlockSourceId({ association: props.association }); + } + + return sourceId; +} diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockProps.tsx b/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockProps.tsx new file mode 100644 index 000000000..d30dfe2f6 --- /dev/null +++ b/packages/core/client/src/modules/blocks/data-blocks/table/hooks/useTableBlockProps.tsx @@ -0,0 +1,121 @@ +import { ArrayField } from '@formily/core'; +import { useField, useFieldSchema } from '@formily/react'; +import { useEffect } from 'react'; +import { useFilterBlock } from '../../../../../filter-provider/FilterProvider'; +import { mergeFilter } from '../../../../../filter-provider/utils'; +import { removeNullCondition } from '../../../../../schema-component'; +import { findFilterTargets } from '../../../../../block-provider/hooks'; +import { useTableBlockContext } from '../../../../../block-provider/TableBlockProvider'; + +export const useTableBlockProps = () => { + const field = useField(); + const fieldSchema = useFieldSchema(); + const ctx = useTableBlockContext(); + const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort']; + const { getDataBlocks } = useFilterBlock(); + + useEffect(() => { + if (!ctx?.service?.loading) { + field.value = []; + field.value = ctx?.service?.data?.data; + field?.setInitialValue(ctx?.service?.data?.data); + field.data = field.data || {}; + field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys; + field.componentProps.pagination = field.componentProps.pagination || {}; + field.componentProps.pagination.pageSize = ctx?.service?.data?.meta?.pageSize; + field.componentProps.pagination.total = ctx?.service?.data?.meta?.count; + field.componentProps.pagination.current = ctx?.service?.data?.meta?.page; + } + }, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题: + return { + childrenColumnName: ctx.childrenColumnName, + loading: ctx?.service?.loading, + showIndex: ctx.showIndex, + dragSort: ctx.dragSort && ctx.dragSortBy, + rowKey: ctx.rowKey || 'id', + pagination: + ctx?.params?.paginate !== false + ? { + defaultCurrent: ctx?.params?.page || 1, + defaultPageSize: ctx?.params?.pageSize, + } + : false, + onRowSelectionChange(selectedRowKeys) { + ctx.field.data = ctx?.field?.data || {}; + ctx.field.data.selectedRowKeys = selectedRowKeys; + ctx?.field?.onRowSelect?.(selectedRowKeys); + }, + async onRowDragEnd({ from, to }) { + await ctx.resource.move({ + sourceId: from[ctx.rowKey || 'id'], + targetId: to[ctx.rowKey || 'id'], + sortField: ctx.dragSort && ctx.dragSortBy, + }); + ctx.service.refresh(); + }, + onChange({ current, pageSize }, filters, sorter) { + const sort = sorter.order ? (sorter.order === `ascend` ? [sorter.field] : [`-${sorter.field}`]) : globalSort; + ctx.service.run({ ...ctx.service.params?.[0], page: current, pageSize, sort }); + }, + onClickRow(record, setSelectedRow, selectedRow) { + const { targets, uid } = findFilterTargets(fieldSchema); + const dataBlocks = getDataBlocks(); + + // 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错 + if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) { + // 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。 + // 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。 + setSelectedRow((prev) => (prev.length ? [] : prev)); + return; + } + + const value = [record[ctx.rowKey]]; + + dataBlocks.forEach((block) => { + const target = targets.find((target) => target.uid === block.uid); + if (!target) return; + + const param = block.service.params?.[0] || {}; + // 保留原有的 filter + const storedFilter = block.service.params?.[1]?.filters || {}; + + if (selectedRow.includes(record[ctx.rowKey])) { + if (block.dataLoadingMode === 'manual') { + return block.clearData(); + } + delete storedFilter[uid]; + } else { + storedFilter[uid] = { + $and: [ + { + [target.field || ctx.rowKey]: { + [target.field ? '$in' : '$eq']: value, + }, + }, + ], + }; + } + + const mergedFilter = mergeFilter([ + ...Object.values(storedFilter).map((filter) => removeNullCondition(filter)), + block.defaultFilter, + ]); + + return block.doFilter( + { + ...param, + page: 1, + filter: mergedFilter, + }, + { filters: storedFilter }, + ); + }); + + // 更新表格的选中状态 + setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value])); + }, + onExpand(expanded, record) { + ctx?.field.onExpandClick?.(expanded, record); + }, + }; +}; diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/index.ts b/packages/core/client/src/modules/blocks/data-blocks/table/index.ts index 643720b49..5b35a76b8 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/table/index.ts +++ b/packages/core/client/src/modules/blocks/data-blocks/table/index.ts @@ -5,3 +5,5 @@ export * from './TableColumnSchemaToolbar'; export * from './tableBlockSettings'; export * from './tableColumnSettings'; export * from './TableColumnInitializers'; +export * from './createTableBlockUISchema'; +export * from './hooks/useTableBlockDecoratorProps'; diff --git a/packages/core/client/src/modules/blocks/data-blocks/table/tableBlockSettings.tsx b/packages/core/client/src/modules/blocks/data-blocks/table/tableBlockSettings.tsx index 3d9cdd7dc..2e8b96699 100644 --- a/packages/core/client/src/modules/blocks/data-blocks/table/tableBlockSettings.tsx +++ b/packages/core/client/src/modules/blocks/data-blocks/table/tableBlockSettings.tsx @@ -359,7 +359,8 @@ export const tableBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'Table', collectionName: name, diff --git a/packages/core/client/src/modules/blocks/filter-blocks/collapse/filterCollapseBlockSettings.ts b/packages/core/client/src/modules/blocks/filter-blocks/collapse/filterCollapseBlockSettings.ts index 68d8b5263..0ad5faaec 100644 --- a/packages/core/client/src/modules/blocks/filter-blocks/collapse/filterCollapseBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/filter-blocks/collapse/filterCollapseBlockSettings.ts @@ -22,7 +22,8 @@ export const filterCollapseBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FilterCollapse', diff --git a/packages/core/client/src/modules/blocks/filter-blocks/form/filterFormBlockSettings.ts b/packages/core/client/src/modules/blocks/filter-blocks/form/filterFormBlockSettings.ts index 707e9aa67..46761790e 100644 --- a/packages/core/client/src/modules/blocks/filter-blocks/form/filterFormBlockSettings.ts +++ b/packages/core/client/src/modules/blocks/filter-blocks/form/filterFormBlockSettings.ts @@ -23,7 +23,8 @@ export const filterFormBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FilterFormItem', collectionName: name, diff --git a/packages/core/client/src/modules/fields/component/InputNumber/inputNumberComponentFieldSettings.tsx b/packages/core/client/src/modules/fields/component/InputNumber/inputNumberComponentFieldSettings.tsx new file mode 100644 index 000000000..0a890813d --- /dev/null +++ b/packages/core/client/src/modules/fields/component/InputNumber/inputNumberComponentFieldSettings.tsx @@ -0,0 +1,26 @@ +import { useField, useFieldSchema, useForm } from '@formily/react'; +import { SchemaSettings } from '../../../../application/schema-settings/SchemaSettings'; +import { SchemaSettingsNumberFormat } from '../../../../schema-settings/SchemaSettingsNumberFormat'; +import { useColumnSchema } from '../../../../schema-component/antd/table-v2/Table.Column.Decorator'; +import { useIsFieldReadPretty } from '../../../../schema-component/antd/form-item/FormItem.Settings'; +export const inputNumberComponentFieldSettings = new SchemaSettings({ + name: 'fieldSettings:component:InputNumber', + items: [ + { + name: 'displayFormat', + Component: SchemaSettingsNumberFormat as any, + useComponentProps() { + const schema = useFieldSchema(); + const { fieldSchema: tableColumnSchema } = useColumnSchema(); + const fieldSchema = tableColumnSchema || schema; + return { + fieldSchema, + }; + }, + useVisible() { + const isFieldReadPretty = useIsFieldReadPretty(); + return isFieldReadPretty; + }, + }, + ], +}); diff --git a/packages/core/client/src/pm/PluginManager.tsx b/packages/core/client/src/pm/PluginManager.tsx index 0250e3f91..e097a62c8 100644 --- a/packages/core/client/src/pm/PluginManager.tsx +++ b/packages/core/client/src/pm/PluginManager.tsx @@ -92,7 +92,7 @@ const LocalPlugins = () => { const keyWordsfilterList = useMemo(() => { const list = keyWordlists.map((i) => { if (i === 'Others') { - const result = data?.data.filter((v) => !v.keywords); + const result = data?.data.filter((v) => !v.keywords || !v.keywords.every((k) => keyWordlists.includes(k))); return { key: i, list: result, diff --git a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx index 68697e73e..1de66c99e 100644 --- a/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/action/Action.Designer.tsx @@ -11,7 +11,11 @@ import { usePlugin } from '../../../application/hooks'; import { SchemaSettingOptions, SchemaSettings } from '../../../application/schema-settings'; import { useSchemaToolbar } from '../../../application/schema-toolbar'; import { useFormBlockContext } from '../../../block-provider'; -import { useCollectionManager_deprecated, useCollection_deprecated } from '../../../collection-manager'; +import { + joinCollectionName, + useCollectionManager_deprecated, + useCollection_deprecated, +} from '../../../collection-manager'; import { FlagProvider } from '../../../flag-provider'; import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings'; import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items'; @@ -28,6 +32,7 @@ import { import { DefaultValueProvider } from '../../../schema-settings/hooks/useIsAllowToSetDefaultValue'; import { useLinkageAction } from './hooks'; import { requestSettingsSchema } from './utils'; +import { DataSourceProvider, useDataSourceKey } from '../../../data-source'; const MenuGroup = (props) => { return props.children; @@ -327,7 +332,8 @@ function WorkflowSelect({ actionType, direct = false, ...props }) { const { setValuesIn } = useForm(); const baseCollection = useCollection_deprecated(); const { getCollection } = useCollectionManager_deprecated(); - const [workflowCollection, setWorkflowCollection] = useState(baseCollection.name); + const dataSourceKey = useDataSourceKey(); + const [workflowCollection, setWorkflowCollection] = useState(joinCollectionName(dataSourceKey, baseCollection.name)); const compile = useCompile(); const workflowPlugin = usePlugin('workflow') as any; @@ -351,11 +357,11 @@ function WorkflowSelect({ actionType, direct = false, ...props }) { const path = paths[i]; const associationField = collection.fields.find((f) => f.name === path); if (associationField) { - collection = getCollection(associationField.target); + collection = getCollection(associationField.target, dataSourceKey); } } } - setWorkflowCollection(collection.name); + setWorkflowCollection(joinCollectionName(dataSourceKey, collection.name)); setValuesIn(`group[${index}].workflowKey`, null); }); }); @@ -375,38 +381,40 @@ function WorkflowSelect({ actionType, direct = false, ...props }) { ); return ( - + { - const typeOption = workflowPlugin.getTriggersOptions().find((item) => item.value === data.type); - return typeOption ? ( - - {label} - {compile(typeOption.label)} - - ) : ( - label - ); - }} - {...props} - /> + }} + optionFilter={optionFilter} + optionRender={({ label, data }) => { + const typeOption = workflowPlugin.getTriggersOptions().find((item) => item.value === data.type); + return typeOption ? ( + + {label} + {compile(typeOption.label)} + + ) : ( + label + ); + }} + {...props} + /> + ); } @@ -414,7 +422,7 @@ export function WorkflowConfig() { const { dn } = useDesignable(); const { t } = useTranslation(); const fieldSchema = useFieldSchema(); - const { name: collection } = useCollection_deprecated(); + const collection = useCollection_deprecated(); // TODO(refactor): should refactor for getting certain action type, better from 'x-action'. const formBlock = useFormBlockContext(); const actionType = formBlock?.type || fieldSchema['x-action']; @@ -483,7 +491,9 @@ export function WorkflowConfig() { 'x-component-props': { placeholder: t('Select context', { ns: 'workflow' }), popupMatchSelectWidth: false, - collection, + collection: `${ + collection.dataSource && collection.dataSource !== 'main' ? `${collection.dataSource}:` : '' + }${collection.name}`, filter: '{{ fieldFilter }}', rootOption: { label: t('Full form data', { ns: 'workflow' }), diff --git a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx index 812cf48de..4cef09c37 100644 --- a/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx +++ b/packages/core/client/src/schema-component/antd/appends-tree-select/AppendsTreeSelect.tsx @@ -3,7 +3,12 @@ import { Tag, TreeSelect } from 'antd'; import type { DefaultOptionType, TreeSelectProps } from 'rc-tree-select/es/TreeSelect'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CollectionFieldOptions_deprecated, useCollectionManager_deprecated, useCompile } from '../../..'; +import { + CollectionFieldOptions_deprecated, + parseCollectionName, + useCollectionManager_deprecated, + useCompile, +} from '../../..'; export type AppendsTreeSelectProps = { value: string[] | string; @@ -27,7 +32,7 @@ function usePropsCollection({ collection }) { type CallScope = { compile?(value: string): string; - getCollectionFields?(name: any): CollectionFieldOptions_deprecated[]; + getCollectionFields?(name: any, dataSource?: string): CollectionFieldOptions_deprecated[]; filter(field): boolean; }; @@ -52,7 +57,8 @@ function trueFilter(field) { } function getCollectionFieldOptions(this: CallScope, collection, parentNode?): TreeOptionType[] { - const fields = this.getCollectionFields(collection).filter(isAssociation); + const [dataSourceName, collectionName] = parseCollectionName(collection); + const fields = this.getCollectionFields(collectionName, dataSourceName).filter(isAssociation); const boundLoadChildren = loadChildren.bind(this); return fields.filter(this.filter).map((field) => { const key = parentNode ? `${parentNode.value ? `${parentNode.value}.` : ''}${field.name}` : field.name; @@ -84,11 +90,12 @@ export const AppendsTreeSelect: React.FC { if (props.multiple) { @@ -111,7 +118,7 @@ export const AppendsTreeSelect: React.FC { const parentNode = rootOption ? { @@ -123,17 +130,19 @@ export const AppendsTreeSelect: React.FC Object.assign(result, { [item.value]: item }), {}); + : getCollectionFieldOptions.call({ compile, getCollectionFields, filter }, collectionString, parentNode); + + const map = tData.reduce((result, item) => Object.assign(result, { [item.value]: item }), {}); if (parentNode) { map[parentNode.value] = parentNode; } setOptionsMap(map); - }, [collection, baseCollection, rootOption, filter, propsLoadData]); + }, [collectionString, rootOption, filter, propsLoadData]); + // NOTE: preload options in value useEffect(() => { const arr = (props.multiple ? propsValue : propsValue ? [propsValue] : []) as string[]; if (!arr?.length || arr.every((v) => Boolean(optionsMap[v]))) { diff --git a/packages/core/client/src/schema-component/antd/association-filter/AssociationFilter.BlockDesigner.tsx b/packages/core/client/src/schema-component/antd/association-filter/AssociationFilter.BlockDesigner.tsx index 387d31e92..2bf44589e 100644 --- a/packages/core/client/src/schema-component/antd/association-filter/AssociationFilter.BlockDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/association-filter/AssociationFilter.BlockDesigner.tsx @@ -18,7 +18,8 @@ export const AssociationFilterBlockDesigner = () => { const template = useSchemaTemplate(); const fieldSchema = useFieldSchema(); const { t } = useTranslation(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return ( diff --git a/packages/core/client/src/schema-component/antd/expand-action/Expand.Action.tsx b/packages/core/client/src/schema-component/antd/expand-action/Expand.Action.tsx index 19a1a3aa5..2744ba40a 100644 --- a/packages/core/client/src/schema-component/antd/expand-action/Expand.Action.tsx +++ b/packages/core/client/src/schema-component/antd/expand-action/Expand.Action.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; import { useFieldSchema } from '@formily/react'; import { Button } from 'antd'; -import React from 'react'; +import React, { forwardRef, createRef } from 'react'; +import { composeRef } from 'rc-util/lib/ref'; import { useCompile } from '../../hooks'; import { useTableBlockContext, useTableSelectorContext } from '../../../block-provider'; import { Icon } from '../../../icon'; @@ -46,7 +47,7 @@ const actionDesignerCss = css` } `; -export const ExpandAction = (props) => { +const InternalExpandAction = (props, ref) => { const schema = useFieldSchema(); const ctxSelector = useTableSelectorContext(); const ctxBlock = useTableBlockContext(); @@ -54,8 +55,11 @@ export const ExpandAction = (props) => { const ctx = isTableSelector ? ctxSelector : ctxBlock; const { titleExpand, titleCollapse, iconExpand, iconCollapse } = schema['x-component-props'] || {}; const compile = useCompile(); + const internalRef = createRef(); + const buttonRef = composeRef(ref, internalRef); return ( -
+ //@ts-ignore +
}> {ctx?.params['tree'] && (
); }; + +export const ExpandAction = forwardRef(InternalExpandAction); diff --git a/packages/core/client/src/schema-component/antd/form-v2/Form.Settings.tsx b/packages/core/client/src/schema-component/antd/form-v2/Form.Settings.tsx index 55c57c97f..d6e033988 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/Form.Settings.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/Form.Settings.tsx @@ -62,7 +62,8 @@ export const formSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'FormItem', collectionName: name, @@ -103,7 +104,8 @@ export const readPrettyFormSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { insertAdjacentPosition: 'beforeEnd', componentName: 'ReadPrettyFormItem', @@ -294,7 +296,8 @@ export const formDetailsSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'Details', collectionName: name, diff --git a/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx b/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx index ce216ba07..d48bc8a01 100644 --- a/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/grid-card/GridCard.Designer.tsx @@ -39,7 +39,8 @@ export const GridCardDesigner = () => { const sortFields = useSortFields(name); const record = useRecord(); const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; const columnCount = field.decoratorProps.columnCount || defaultColumnCount; const columnCountSchema = useMemo(() => { diff --git a/packages/core/client/src/schema-component/antd/input-number/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/input-number/ReadPretty.tsx index b7ed4371e..0f5debc66 100644 --- a/packages/core/client/src/schema-component/antd/input-number/ReadPretty.tsx +++ b/packages/core/client/src/schema-component/antd/input-number/ReadPretty.tsx @@ -2,21 +2,125 @@ import { isValid } from '@formily/shared'; import { toFixedByStep } from '@nocobase/utils/client'; import type { InputProps } from 'antd/es/input'; import type { InputNumberProps } from 'antd/es/input-number'; -import React from 'react'; +import { format } from 'd3-format'; +import * as math from 'mathjs'; +import React, { useMemo } from 'react'; + +function countDecimalPlaces(value) { + const number = Number(value); + if (!Number.isFinite(number)) return 0; + + const decimalPart = String(number).split('.')[1]; + return decimalPart ? decimalPart.length : 0; +} +const separators = { + '0,0.00': { thousands: ',', decimal: '.' }, + '0.0,00': { thousands: '.', decimal: ',' }, + '0 0,00': { thousands: ' ', decimal: '.' }, + '0.00': { thousands: '', decimal: '.' }, // 没有千位分隔符 +}; +//分隔符换算 +export function formatNumberWithSeparator(number, format = '0.00', step = 1) { + let formattedNumber = ''; + + if (separators[format]) { + const { thousands, decimal } = separators[format]; + formattedNumber = number + .toLocaleString('en-US', { + style: 'decimal', + minimumFractionDigits: step, + maximumFractionDigits: step, + }) + .replace(/,/g, 'comma_placeholder') + .replace(/\./g, 'dot_placeholder') + .replace(/comma_placeholder/g, thousands) + .replace(/dot_placeholder/g, decimal); + } else { + formattedNumber = number.toString(); + } + + return formattedNumber; +} + +//单位换算 +export function formatUnitConversion(value, operator = '*', multiplier) { + if (!multiplier) { + return value; + } + let result; + + if (operator === '*') { + result = value * multiplier; + } else if (operator === '/') { + if (multiplier !== 0) { + result = value / multiplier; + } else { + console.error('Error: Division by zero.'); + return null; + } + } else { + console.error("Error: Invalid operator. Use '*' for multiplication or '/' for division."); + return null; + } + + return math.round(result, 9); +} + +//科学计数法显示 +export function scientificNotation(number, decimalPlaces, separator = '.') { + const formatter = format(`.${decimalPlaces}e`); + const formattedNumber = formatter(number).replace('.', separator); + + // 匹配科学计数法中的指数部分,判断正负情况 + const result = formattedNumber.replace(/e([+-]?\d+)/, (match, exponent) => { + if (exponent.startsWith('+')) { + // 正数指数,不显示符号 + return `×10${exponent.slice(1)}`; + } else { + // 负数指数,显示 "-" 符号 + return `×10-${exponent.slice(1)}`; + } + }); + + return result; +} + +export function formatNumber(props) { + const { step, formatStyle, value, unitConversion, unitConversionType, separator = '0.00' } = props; + + if (!isValid(value)) { + return null; + } + + //单位换算 + const unitData = formatUnitConversion(value, unitConversionType, unitConversion); + //精度换算 + const preciationData = toFixedByStep(unitData, step); + let result; + //分隔符换算 + result = formatNumberWithSeparator(Number(preciationData), separator, countDecimalPlaces(step)); + if (formatStyle === 'scientifix') { + //科学计数显示 + result = scientificNotation(Number(unitData), countDecimalPlaces(step), separators?.[separator]?.['decimal']); + } + return result; +} export const ReadPretty: React.FC = (props: any) => { - const { step, value, addonBefore, addonAfter } = props; - if (!isValid(props.value)) { - return null; - } - const result = toFixedByStep(value, step); - if (isNaN(result)) { + const { step, formatStyle, value, addonBefore, addonAfter, unitConversion, unitConversionType, separator } = props; + + const result = useMemo(() => { + return formatNumber({ step, formatStyle, value, unitConversion, unitConversionType, separator }); + }, [step, formatStyle, value, unitConversion, unitConversionType, separator]); + + if (!isValid(result)) { return null; } + return (
{addonBefore} - {result} + {addonAfter}
); diff --git a/packages/core/client/src/schema-component/antd/input-number/__tests__/input-number.test.tsx b/packages/core/client/src/schema-component/antd/input-number/__tests__/input-number.test.tsx index 678e4707e..8793b2232 100644 --- a/packages/core/client/src/schema-component/antd/input-number/__tests__/input-number.test.tsx +++ b/packages/core/client/src/schema-component/antd/input-number/__tests__/input-number.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@nocobase/test/client'; import React from 'react'; +import { formatNumberWithSeparator, formatUnitConversion, scientificNotation } from '../ReadPretty'; import App2 from '../demos/addonBefore&addonAfter'; import App3 from '../demos/highPrecisionDecimals'; import App1 from '../demos/inputNumber'; @@ -43,7 +44,7 @@ describe('InputNumber: addonBefore/addonAfter', () => { fireEvent.change(input, { target: { value: 1 } }); expect(input.value).toBe('1'); // @ts-ignore - expect(screen.getByText('¥1万元')).toBeInTheDocument(); + expect(screen.getByText('¥')).toBeInTheDocument(); // empty value fireEvent.change(input, { target: { value: '' } }); @@ -67,7 +68,7 @@ describe('InputNumber: High precision decimals', () => { fireEvent.change(input, { target: { value: 1 } }); expect(input.value).toBe('1.00'); // @ts-ignore - expect(screen.getByText('1.00%')).toBeInTheDocument(); + expect(screen.getByText('1.00')).toBeInTheDocument(); // empty value fireEvent.change(input, { target: { value: '' } }); @@ -75,3 +76,48 @@ describe('InputNumber: High precision decimals', () => { expect(screen.queryByText('NaN')).toBeNull(); }); }); + +describe('ReadPretty:formatNumberWithSeparator', () => { + // Test case 1: Format a number with default format '0,0.00' + test('Format number with default separator', () => { + const formatted = formatNumberWithSeparator(1234567.89); + expect(formatted).toBe('1234567.9'); + }); + + // Test case 2: Format a number with custom format '0.00' + test('Format number with custom separator', () => { + const formatted = formatNumberWithSeparator(1234567.89, '0,0.00', 1); + expect(formatted).toBe('1,234,567.9'); + }); +}); +describe('ReadPretty:formatUnitConversion', () => { + // Test case 1: Multiply a value by 2 + test('Multiply value by 2', () => { + const result = formatUnitConversion(10, '*', 2); + expect(result).toBe(20); + }); + // Test case 2: Divide a value by 0 (error case) + test('Divide value by zero', () => { + const result = formatUnitConversion(10, '/', 0); + expect(result).toBe(10); + }); + + test('0.1*0.2', () => { + const result = formatUnitConversion(0.1, '*', 0.2); + expect(result).toBe(0.02); + }); +}); + +describe('ReadPretty:scientificNotation', () => { + // Test case 1: Format a number into scientific notation with 2 decimal places + test('Format number into scientific notation', () => { + const formatted = scientificNotation(1234567.89, 2); + expect(formatted).toBe('1.23×106'); + }); + + // Test case 2: Format a number into scientific notation with custom separator '.' + test('Format number into scientific notation with custom separator', () => { + const formatted = scientificNotation(1234567.89, 2, '.'); + expect(formatted).toBe('1.23×106'); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/list/List.Designer.tsx b/packages/core/client/src/schema-component/antd/list/List.Designer.tsx index 65f4859dd..ed8de4bcf 100644 --- a/packages/core/client/src/schema-component/antd/list/List.Designer.tsx +++ b/packages/core/client/src/schema-component/antd/list/List.Designer.tsx @@ -35,7 +35,8 @@ export const ListDesigner = () => { const sortFields = useSortFields(name); const record = useRecord(); const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; const sort = defaultSort?.map((item: string) => { return item.startsWith('-') ? { diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx index 1be57dbfa..29d577330 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.tsx @@ -25,6 +25,7 @@ import { useTableBlockContext, useTableSelectorContext, } from '../../../'; +import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps'; import { useACLFieldWhitelist } from '../../../acl/ACLProvider'; import { useToken } from '../__builtins__'; import { SubFormProvider } from '../association-field/hooks'; @@ -242,195 +243,200 @@ const usePaginationProps = (pagination1, pagination2) => { return result.total <= result.pageSize ? false : result; }; -export const Table: any = observer( - (props: { - useProps?: () => any; - onChange?: (pagination, filters, sorter, extra) => void; - onRowSelectionChange?: (selectedRowKeys: any[], selectedRows: any[]) => void; - onRowDragEnd?: (e: { from: any; to: any }) => void; - onClickRow?: (record: any, setSelectedRow: (selectedRow: any[]) => void, selectedRow: any[]) => void; - pagination?: any; - showIndex?: boolean; - dragSort?: boolean; - rowKey?: string | ((record: any) => string); - rowSelection?: any; - required?: boolean; - onExpand?: (flag: boolean, record: any) => void; - isSubTable?: boolean; - }) => { - const { token } = useToken(); - const { pagination: pagination1, useProps, onChange, ...others1 } = props; - const { pagination: pagination2, onClickRow, ...others2 } = useProps?.() || {}; - const { - dragSort = false, - showIndex = true, - onRowSelectionChange, - onChange: onTableChange, - rowSelection, - rowKey, - required, - onExpand, - ...others - } = { ...others1, ...others2 } as any; - const field = useArrayField(others); - const columns = useTableColumns(others); - const schema = useFieldSchema(); - const collection = useCollection_deprecated(); - const isTableSelector = schema?.parent?.['x-decorator'] === 'TableSelectorProvider'; - const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext(); - const { expandFlag, allIncludesChildren } = ctx; - const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => {})); - const paginationProps = usePaginationProps(pagination1, pagination2); - const [expandedKeys, setExpandesKeys] = useState([]); - const [selectedRowKeys, setSelectedRowKeys] = useState(field?.data?.selectedRowKeys || []); - const [selectedRow, setSelectedRow] = useState([]); - const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || []; - const isRowSelect = rowSelection?.type !== 'none'; - const defaultRowKeyMap = useRef(new Map()); - let onRow = null, - highlightRow = ''; +export const Table: any = withDynamicSchemaProps( + observer( + (props: { + useProps?: () => any; + onChange?: (pagination, filters, sorter, extra) => void; + onRowSelectionChange?: (selectedRowKeys: any[], selectedRows: any[]) => void; + onRowDragEnd?: (e: { from: any; to: any }) => void; + onClickRow?: (record: any, setSelectedRow: (selectedRow: any[]) => void, selectedRow: any[]) => void; + pagination?: any; + showIndex?: boolean; + dragSort?: boolean; + rowKey?: string | ((record: any) => string); + rowSelection?: any; + required?: boolean; + onExpand?: (flag: boolean, record: any) => void; + isSubTable?: boolean; + }) => { + const { token } = useToken(); + const { pagination: pagination1, useProps, ...others1 } = props; - if (onClickRow) { - onRow = (record) => { - return { - onClick: (e) => { - if (isPortalInBody(e.target)) { - return; - } - onClickRow(record, setSelectedRow, selectedRow); - }, + // 新版 UISchema(1.0 之后)中已经废弃了 useProps,这里之所以继续保留是为了兼容旧版的 UISchema + const { pagination: pagination2, ...others2 } = useProps?.() || {}; + + const { + dragSort = false, + showIndex = true, + onRowSelectionChange, + onChange: onTableChange, + rowSelection, + rowKey, + required, + onExpand, + onClickRow, + ...others + } = { ...others1, ...others2 } as any; + const field = useArrayField(others); + const columns = useTableColumns(others); + const schema = useFieldSchema(); + const collection = useCollection_deprecated(); + const isTableSelector = schema?.parent?.['x-decorator'] === 'TableSelectorProvider'; + const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext(); + const { expandFlag, allIncludesChildren } = ctx; + const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => { })); + const paginationProps = usePaginationProps(pagination1, pagination2); + const [expandedKeys, setExpandesKeys] = useState([]); + const [selectedRowKeys, setSelectedRowKeys] = useState(field?.data?.selectedRowKeys || []); + const [selectedRow, setSelectedRow] = useState([]); + const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || []; + const isRowSelect = rowSelection?.type !== 'none'; + const defaultRowKeyMap = useRef(new Map()); + let onRow = null, + highlightRow = ''; + + if (onClickRow) { + onRow = (record) => { + return { + onClick: (e) => { + if (isPortalInBody(e.target)) { + return; + } + onClickRow(record, setSelectedRow, selectedRow); + }, + }; }; - }; - highlightRow = css` - & > td { - background-color: ${token.controlItemBgActiveHover} !important; - } - &:hover > td { - background-color: ${token.controlItemBgActiveHover} !important; - } - `; - } - - useEffect(() => { - if (expandFlag) { - setExpandesKeys(allIncludesChildren); - } else { - setExpandesKeys([]); + highlightRow = css` + & > td { + background-color: ${token.controlItemBgActiveHover} !important; + } + &:hover > td { + background-color: ${token.controlItemBgActiveHover} !important; + } + `; } - }, [expandFlag, allIncludesChildren]); - const components = useMemo(() => { - return { - header: { - wrapper: (props) => { - return ( - - - - ); + useEffect(() => { + if (expandFlag) { + setExpandesKeys(allIncludesChildren); + } else { + setExpandesKeys([]); + } + }, [expandFlag, allIncludesChildren]); + + const components = useMemo(() => { + return { + header: { + wrapper: (props) => { + return ( + + + + ); + }, + cell: (props) => { + return ( + + ); + }, }, - cell: (props) => { - return ( - { + return ( + { + if (!e.active || !e.over) { + console.warn('move cancel'); + return; + } + const fromIndex = e.active?.data.current?.sortable?.index; + const toIndex = e.over?.data.current?.sortable?.index; + const from = field.value[fromIndex] || e.active; + const to = field.value[toIndex] || e.over; + void field.move(fromIndex, toIndex); + onRowDragEnd({ from, to }); + }} + > + + + ); + }, + row: (props) => { + return ; + }, + cell: (props) => ( + - ); + ), }, - }, - body: { - wrapper: (props) => { - return ( - { - if (!e.active || !e.over) { - console.warn('move cancel'); - return; - } - const fromIndex = e.active?.data.current?.sortable?.index; - const toIndex = e.over?.data.current?.sortable?.index; - const from = field.value[fromIndex] || e.active; - const to = field.value[toIndex] || e.over; - void field.move(fromIndex, toIndex); - onRowDragEnd({ from, to }); - }} - > - - - ); - }, - row: (props) => { - return ; - }, - cell: (props) => ( - - ), - }, + }; + }, [field, onRowDragEnd, dragSort]); + + /** + * 为没有设置 key 属性的表格行生成一个唯一的 key + * 1. rowKey 的默认值是 “key”,所以先判断有没有 record.key; + * 2. 如果没有就生成一个唯一的 key,并以 record 的值作为索引; + * 3. 这样下次就能取到对应的 key 的值; + * + * 这里有效的前提是:数组中对应的 record 的引用不会发生改变。 + * + * @param record + * @returns + */ + const defaultRowKey = (record: any) => { + if (record.key) { + return record.key; + } + + if (defaultRowKeyMap.current.has(record)) { + return defaultRowKeyMap.current.get(record); + } + + const key = uid(); + defaultRowKeyMap.current.set(record, key); + return key; }; - }, [field, onRowDragEnd, dragSort]); - /** - * 为没有设置 key 属性的表格行生成一个唯一的 key - * 1. rowKey 的默认值是 “key”,所以先判断有没有 record.key; - * 2. 如果没有就生成一个唯一的 key,并以 record 的值作为索引; - * 3. 这样下次就能取到对应的 key 的值; - * - * 这里有效的前提是:数组中对应的 record 的引用不会发生改变。 - * - * @param record - * @returns - */ - const defaultRowKey = (record: any) => { - if (record.key) { - return record.key; - } + const getRowKey = (record: any) => { + if (typeof rowKey === 'string') { + return record[rowKey]?.toString(); + } else { + return (rowKey ?? defaultRowKey)(record)?.toString(); + } + }; - if (defaultRowKeyMap.current.has(record)) { - return defaultRowKeyMap.current.get(record); - } - - const key = uid(); - defaultRowKeyMap.current.set(record, key); - return key; - }; - - const getRowKey = (record: any) => { - if (typeof rowKey === 'string') { - return record[rowKey]?.toString(); - } else { - return (rowKey ?? defaultRowKey)(record)?.toString(); - } - }; - - const restProps = { - rowSelection: rowSelection - ? { + const restProps = { + rowSelection: rowSelection + ? { type: 'checkbox', selectedRowKeys: selectedRowKeys, onChange(selectedRowKeys: any[], selectedRows: any[]) { @@ -465,32 +471,32 @@ export const Table: any = observer( className={classNames( checked ? 'checked' : null, css` - position: relative; - display: flex; - float: left; - align-items: center; - justify-content: space-evenly; - padding-right: 8px; - .nb-table-index { - opacity: 0; - } - &:not(.checked) { + position: relative; + display: flex; + float: left; + align-items: center; + justify-content: space-evenly; + padding-right: 8px; .nb-table-index { - opacity: 1; + opacity: 0; } - } - `, + &:not(.checked) { + .nb-table-index { + opacity: 1; + } + } + `, { [css` - &:hover { - .nb-table-index { - opacity: 0; + &:hover { + .nb-table-index { + opacity: 0; + } + .nb-origin-node { + display: block; + } } - .nb-origin-node { - display: block; - } - } - `]: isRowSelect, + `]: isRowSelect, }, )} > @@ -498,11 +504,11 @@ export const Table: any = observer( className={classNames( checked ? 'checked' : null, css` - position: relative; - display: flex; - align-items: center; - justify-content: space-evenly; - `, + position: relative; + display: flex; + align-items: center; + justify-content: space-evenly; + `, )} > {dragSort && } @@ -514,13 +520,13 @@ export const Table: any = observer( 'nb-origin-node', checked ? 'checked' : null, css` - position: absolute; - right: 50%; - transform: translateX(50%); - &:not(.checked) { - display: none; - } - `, + position: absolute; + right: 50%; + transform: translateX(50%); + &:not(.checked) { + display: none; + } + `, )} > {originNode} @@ -531,101 +537,101 @@ export const Table: any = observer( }, ...rowSelection, } - : undefined, - }; - const SortableWrapper = useCallback( - ({ children }) => { - return dragSort - ? React.createElement>( + : undefined, + }; + const SortableWrapper = useCallback( + ({ children }) => { + return dragSort + ? React.createElement>( SortableContext, { items: field.value?.map?.(getRowKey) || [], }, children, ) - : React.createElement(React.Fragment, {}, children); - }, - [field, dragSort], - ); - const fieldSchema = useFieldSchema(); - const fixedBlock = fieldSchema?.parent?.['x-decorator-props']?.fixedBlock; + : React.createElement(React.Fragment, {}, children); + }, + [field, dragSort], + ); + const fieldSchema = useFieldSchema(); + const fixedBlock = fieldSchema?.parent?.['x-decorator-props']?.fixedBlock; - const { height: tableHeight, tableSizeRefCallback } = useTableSize(); - const scroll = useMemo(() => { - return fixedBlock - ? { + const { height: tableHeight, tableSizeRefCallback } = useTableSize(); + const scroll = useMemo(() => { + return fixedBlock + ? { x: 'max-content', y: tableHeight, } - : { + : { x: 'max-content', }; - }, [fixedBlock, tableHeight]); - return ( -
- - { - onTableChange?.(pagination, filters, sorter, extra); - }} - onRow={onRow} - rowClassName={(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : '')} - scroll={scroll} - columns={columns} - expandable={{ - onExpand: (flag, record) => { - const newKeys = flag - ? [...expandedKeys, record[collection.getPrimaryKey()]] - : expandedKeys.filter((i) => record[collection.getPrimaryKey()] !== i); - setExpandesKeys(newKeys); - onExpand?.(flag, record); - }, - expandedRowKeys: expandedKeys, - }} - /> - - {field.errors.length > 0 && ( -
- {field.errors.map((error) => { - return error.messages.map((message) =>
{message}
); - })} -
- )} -
- ); - }, + .ant-table { + overflow-x: auto; + overflow-y: hidden; + } + `} + > + + { + onTableChange?.(pagination, filters, sorter, extra); + }} + onRow={onRow} + rowClassName={(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : '')} + scroll={scroll} + columns={columns} + expandable={{ + onExpand: (flag, record) => { + const newKeys = flag + ? [...expandedKeys, record[collection.getPrimaryKey()]] + : expandedKeys.filter((i) => record[collection.getPrimaryKey()] !== i); + setExpandesKeys(newKeys); + onExpand?.(flag, record); + }, + expandedRowKeys: expandedKeys, + }} + /> + + {field.errors.length > 0 && ( +
+ {field.errors.map((error) => { + return error.messages.map((message) =>
{message}
); + })} +
+ )} +
+ ); + }, + ), { displayName: 'Table' }, ); diff --git a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx index 947440c26..c823057da 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/TableBlockDesigner.tsx @@ -78,7 +78,8 @@ export const TableBlockDesigner = () => { const { dn } = useDesignable(); const defaultSort = fieldSchema?.['x-decorator-props']?.params?.sort || []; - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; const supportTemplate = !fieldSchema?.['x-decorator-props']?.disableTemplate; const sort = defaultSort?.map((item: string) => { return item?.startsWith('-') diff --git a/packages/core/client/src/schema-initializer/components/CreateRecordAction.tsx b/packages/core/client/src/schema-initializer/components/CreateRecordAction.tsx index 70387ce7c..097945644 100644 --- a/packages/core/client/src/schema-initializer/components/CreateRecordAction.tsx +++ b/packages/core/client/src/schema-initializer/components/CreateRecordAction.tsx @@ -2,7 +2,8 @@ import { DownOutlined } from '@ant-design/icons'; import { css } from '@emotion/css'; import { observer, RecursionField, useField, useFieldSchema, useForm } from '@formily/react'; import { Button, Dropdown, MenuProps } from 'antd'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState, forwardRef, createRef } from 'react'; +import { composeRef } from 'rc-util/lib/ref'; import { useDesignable } from '../../'; import { useACLRolesCheck, useRecordPkValue } from '../../acl/ACLProvider'; import { @@ -96,55 +97,55 @@ function useAclCheckFn() { return actionAclCheck; } -export const CreateRecordAction = observer( - (props: any) => { - const [visible, setVisible] = useState(false); - const collection = useCollection_deprecated(); - const fieldSchema = useFieldSchema(); - const field: any = useField(); - const [currentCollection, setCurrentCollection] = useState(collection.name); - const [currentCollectionDataSource, setCurrentCollectionDataSource] = useState(collection.dataSource); - const linkageRules: any[] = fieldSchema?.['x-linkage-rules'] || []; - const values = useRecord(); - const ctx = useActionContext(); - const variables = useVariables(); - const localVariables = useLocalVariables({ currentForm: { values } as any }); - useEffect(() => { - field.stateOfLinkageRules = {}; - linkageRules - .filter((k) => !k.disabled) - .forEach((v) => { - v.actions?.forEach((h) => { - linkageAction({ - operator: h.operator, - field, - condition: v.condition, - variables, - localVariables, - }); +const InternalCreateRecordAction = (props: any, ref) => { + const [visible, setVisible] = useState(false); + const collection = useCollection_deprecated(); + const fieldSchema = useFieldSchema(); + const field: any = useField(); + const [currentCollection, setCurrentCollection] = useState(collection.name); + const [currentCollectionDataSource, setCurrentCollectionDataSource] = useState(collection.dataSource); + const linkageRules: any[] = fieldSchema?.['x-linkage-rules'] || []; + const values = useRecord(); + const ctx = useActionContext(); + const variables = useVariables(); + const localVariables = useLocalVariables({ currentForm: { values } as any }); + useEffect(() => { + field.stateOfLinkageRules = {}; + linkageRules + .filter((k) => !k.disabled) + .forEach((v) => { + v.actions?.forEach((h) => { + linkageAction({ + operator: h.operator, + field, + condition: v.condition, + variables, + localVariables, }); }); - }, [field, linkageRules, localVariables, variables]); - return ( -
- - { - setVisible(true); - setCurrentCollection(collectionData.name); - setCurrentCollectionDataSource(collectionData.dataSource); - }} - /> - - - - -
- ); - }, - { displayName: 'CreateRecordAction' }, -); + }); + }, [field, linkageRules, localVariables, variables]); + const internalRef = createRef(); + const buttonRef = composeRef(ref, internalRef); + return ( + //@ts-ignore +
}> + + { + setVisible(true); + setCurrentCollection(collectionData.name); + setCurrentCollectionDataSource(collectionData.dataSource); + }} + /> + + + + +
+ ); +}; function getLinkageCollection(str, form, field) { const variablesCtx = { $form: form.values, $iteration: form.values }; @@ -283,6 +284,7 @@ function FinallyButton({ designable: boolean; }) { const { getCollection } = useCollectionManager_deprecated(); + if (inheritsCollections?.length > 0) { if (!linkageFromForm) { return allowAddToCurrent === undefined || allowAddToCurrent ? ( @@ -360,6 +362,7 @@ function FinallyButton({ onClick?.(collection); }} style={{ + ...props?.style, display: !designable && field?.data?.hidden && 'none', opacity: designable && field?.data?.hidden && 0.1, }} @@ -368,3 +371,5 @@ function FinallyButton({ ); } + +export const CreateRecordAction = forwardRef(InternalCreateRecordAction); diff --git a/packages/core/client/src/schema-initializer/items/CreateFilterActionInitializer.tsx b/packages/core/client/src/schema-initializer/items/CreateFilterActionInitializer.tsx index 5e622745f..dbea92159 100644 --- a/packages/core/client/src/schema-initializer/items/CreateFilterActionInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/CreateFilterActionInitializer.tsx @@ -10,6 +10,7 @@ export const CreateFilterActionInitializer = (props) => { 'x-designer': 'Action.Designer', 'x-component-props': { type: 'primary', + htmlType: 'submit', useProps: '{{ useFilterBlockActionProps }}', }, }; diff --git a/packages/core/client/src/schema-initializer/items/RecordAssociationBlockInitializer.tsx b/packages/core/client/src/schema-initializer/items/RecordAssociationBlockInitializer.tsx index 2d6e0d5ff..7f3525453 100644 --- a/packages/core/client/src/schema-initializer/items/RecordAssociationBlockInitializer.tsx +++ b/packages/core/client/src/schema-initializer/items/RecordAssociationBlockInitializer.tsx @@ -3,8 +3,9 @@ import { TableOutlined } from '@ant-design/icons'; import { useCollectionManager_deprecated } from '../../collection-manager'; import { useSchemaTemplateManager } from '../../schema-templates'; -import { createTableBlockSchema, useRecordCollectionDataSourceItems } from '../utils'; +import { useRecordCollectionDataSourceItems } from '../utils'; import { SchemaInitializerItem, useSchemaInitializer, useSchemaInitializerItem } from '../../application'; +import { createTableBlockUISchema } from '../../modules/blocks/data-blocks/table/createTableBlockUISchema'; /** * @deprecated @@ -17,7 +18,7 @@ export const RecordAssociationBlockInitializer = () => { const { getCollection } = useCollectionManager_deprecated(); const field = itemConfig.field; const collection = getCollection(field.target); - const resource = `${field.collectionName}.${field.name}`; + const association = `${field.collectionName}.${field.name}`; return ( } @@ -28,17 +29,15 @@ export const RecordAssociationBlockInitializer = () => { insert(s); } else { insert( - createTableBlockSchema({ + createTableBlockUISchema({ rowKey: collection.filterTargetKey, - collection: field.target, dataSource: collection.dataSource, - resource, - association: resource, + association: association, }), ); } }} - items={useRecordCollectionDataSourceItems('Table', itemConfig, field.target, resource)} + items={useRecordCollectionDataSourceItems('Table', itemConfig, field.target, association)} /> ); }; @@ -53,9 +52,8 @@ export function useCreateAssociationTableBlock() { const collection = getCollection(field.target); insert( - createTableBlockSchema({ + createTableBlockUISchema({ rowKey: collection.filterTargetKey, - collection: field.target, dataSource: collection.dataSource, association: `${field.collectionName}.${field.name}`, }), diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index 08e4353d4..6626f2c36 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -1181,8 +1181,6 @@ export const createFormBlockSchema = (options) => { resource: resourceName, collection, association, - // action: 'get', - // useParams: '{{ useParamsFromRecord }}', }, 'x-toolbar': 'BlockSchemaToolbar', ...(settings ? { 'x-settings': settings } : { 'x-designer': designer }), @@ -1357,6 +1355,12 @@ export const createReadPrettyFormBlockSchema = (options) => { return schema; }; +/** + * @deprecated + * 已弃用,可以使用 createTableBlockUISchema 替换 + * @param options + * @returns + */ export const createTableBlockSchema = (options) => { const { collection, @@ -1688,6 +1692,15 @@ function useAssociationFields({ const targetCollection = cm.getCollection(field.target); const title = `${compile(field.uiSchema.title || field.name)} -> ${compile(targetCollection.title)}`; const templates = getTemplatesByCollection(dataSource, field.target).filter((template) => { + // 针对弹窗中的详情区块 + if (componentName === 'ReadPrettyFormItem') { + if (['hasOne', 'belongsTo'].includes(field.type)) { + return template.componentName === 'ReadPrettyFormItem'; + } else { + return template.componentName === 'Details'; + } + } + return ( componentName && template.componentName === componentName && diff --git a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx index 0b3b963d0..70400da02 100644 --- a/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx +++ b/packages/core/client/src/schema-settings/GeneralSchemaDesigner.tsx @@ -56,13 +56,22 @@ export interface GeneralSchemaDesignerProps { * @default true */ draggable?: boolean; + showDataSource?: boolean; } /** * @deprecated use `SchemaToolbar` instead */ export const GeneralSchemaDesigner: FC = (props: any) => { - const { disableInitializer, title, template, schemaSettings, contextValue, draggable = true } = props; + const { + disableInitializer, + title, + template, + schemaSettings, + contextValue, + draggable = true, + showDataSource = true, + } = props; const { dn, designable } = useDesignable(); const field = useField(); const { t } = useTranslation(); @@ -112,7 +121,9 @@ export const GeneralSchemaDesigner: FC = (props: any
- {dataSource ? `${compile(dataSource?.displayName)} > ${compile(title)}` : compile(title)} + {showDataSource && dataSource + ? `${compile(dataSource?.displayName)} > ${compile(title)}` + : compile(title)} {template && ( diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index 457237373..9e1a227af 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -293,7 +293,7 @@ export const SchemaSettingsTemplate = function Template(props) { const findGridSchema = (fieldSchema) => { return fieldSchema.reduceProperties((buf, s) => { - if (s['x-component'] === 'FormV2') { + if (s['x-component'] === 'FormV2' || s['x-component'] === 'Details') { const f = s.reduceProperties((buf, s) => { if (s['x-component'] === 'Grid' || s['x-component'] === 'BlockTemplate') { return s; @@ -310,7 +310,7 @@ const findGridSchema = (fieldSchema) => { const findBlockTemplateSchema = (fieldSchema) => { return fieldSchema.reduceProperties((buf, s) => { - if (s['x-component'] === 'FormV2') { + if (s['x-component'] === 'FormV2' || s['x-component'] === 'Details') { const f = s.reduceProperties((buf, s) => { if (s['x-component'] === 'BlockTemplate') { return s; diff --git a/packages/core/client/src/schema-settings/SchemaSettingsNumberFormat.tsx b/packages/core/client/src/schema-settings/SchemaSettingsNumberFormat.tsx new file mode 100644 index 000000000..2f578b0d3 --- /dev/null +++ b/packages/core/client/src/schema-settings/SchemaSettingsNumberFormat.tsx @@ -0,0 +1,156 @@ +import { css } from '@emotion/css'; +import { ISchema, Schema, useField, useForm } from '@formily/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Select } from 'antd'; +import { useCollectionManager_deprecated, useDesignable } from '..'; +import { SchemaSettingsModalItem } from './SchemaSettings'; + +const UnitConversion = ({ unitConversionType }) => { + const form = useForm(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const SchemaSettingsNumberFormat = function NumberFormatConfig(props: { fieldSchema: Schema }) { + const { fieldSchema } = props; + const field = useField(); + const { dn } = useDesignable(); + const { t } = useTranslation(); + const { getCollectionJoinField } = useCollectionManager_deprecated(); + const collectionField = getCollectionJoinField(fieldSchema?.['x-collection-field']) || {}; + const { formatStyle, unitConversion, unitConversionType, separator, step, addonBefore, addonAfter } = + fieldSchema['x-component-props'] || {}; + const { step: prescition } = collectionField?.uiSchema['x-component-props'] || {}; + + return ( + , + }, + }, + separator: { + type: 'string', + default: separator || '0,0.00', + enum: [ + { + value: '0,0.00', + label: t('100,000.00'), + }, + { + value: '0.0,00', + label: t('100.000,00'), + }, + { + value: '0 0,00', + label: t('100 000.00'), + }, + { + value: '0.00', + label: t('100000.00'), + }, + ], + 'x-decorator': 'FormItem', + 'x-component': 'Select', + title: "{{t('Separator')}}", + }, + step: { + type: 'string', + title: '{{t("Precision")}}', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + default: step || prescition || '1', + enum: [ + { value: '1', label: '1' }, + { value: '0.1', label: '1.0' }, + { value: '0.01', label: '1.00' }, + { value: '0.001', label: '1.000' }, + { value: '0.0001', label: '1.0000' }, + { value: '0.00001', label: '1.00000' }, + ], + }, + addonBefore: { + type: 'string', + title: '{{t("Prefix")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + default: addonBefore, + }, + addonAfter: { + type: 'string', + title: '{{t("Suffix")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + default: addonAfter, + }, + }, + } as ISchema + } + onSubmit={(data) => { + const schema = { + ['x-uid']: fieldSchema['x-uid'], + }; + schema['x-component-props'] = fieldSchema['x-component-props'] || {}; + fieldSchema['x-component-props'] = { + ...(fieldSchema['x-component-props'] || {}), + ...data, + }; + schema['x-component-props'] = fieldSchema['x-component-props']; + field.componentProps = fieldSchema['x-component-props']; + //子表格/表格区块 + const parts = (field.path.entire as string).split('.'); + parts.pop(); + const modifiedString = parts.join('.'); + field.query(`${modifiedString}.*[0:].${fieldSchema.name}`).forEach((f) => { + if (f.props.name === fieldSchema.name) { + f.setComponentProps({ ...data }); + } + }); + dn.emit('patch', { + schema, + }); + dn.refresh(); + }} + /> + ); +}; diff --git a/packages/core/client/src/schema-settings/SchemaSettingsPlugin.ts b/packages/core/client/src/schema-settings/SchemaSettingsPlugin.ts index b88ec2cef..9272b28e1 100644 --- a/packages/core/client/src/schema-settings/SchemaSettingsPlugin.ts +++ b/packages/core/client/src/schema-settings/SchemaSettingsPlugin.ts @@ -45,6 +45,7 @@ import { selectComponentFieldSettings } from '../modules/fields/component/Select import { subTablePopoverComponentFieldSettings } from '../modules/fields/component/SubTable/subTablePopoverComponentFieldSettings'; import { tagComponentFieldSettings } from '../modules/fields/component/Tag/tagComponentFieldSettings'; import { unixTimestampComponentFieldSettings } from '../modules/fields/component/UnixTimestamp/unixTimestampComponentFieldSettings'; +import { inputNumberComponentFieldSettings } from '../modules/fields/component/InputNumber/inputNumberComponentFieldSettings'; export class SchemaSettingsPlugin extends Plugin { async load() { @@ -95,6 +96,7 @@ export class SchemaSettingsPlugin extends Plugin { this.schemaSettingsManager.add(subTablePopoverComponentFieldSettings); this.schemaSettingsManager.add(datePickerComponentFieldSettings); this.schemaSettingsManager.add(unixTimestampComponentFieldSettings); + this.schemaSettingsManager.add(inputNumberComponentFieldSettings); this.schemaSettingsManager.add(fileManagerComponentFieldSettings); this.schemaSettingsManager.add(tagComponentFieldSettings); diff --git a/packages/core/create-nocobase-app/package.json b/packages/core/create-nocobase-app/package.json index 8667998c3..0a6fe47a2 100755 --- a/packages/core/create-nocobase-app/package.json +++ b/packages/core/create-nocobase-app/package.json @@ -1,6 +1,6 @@ { "name": "create-nocobase-app", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "src/index.js", "license": "Apache-2.0", "dependencies": { diff --git a/packages/core/data-source-manager/package.json b/packages/core/data-source-manager/package.json index 2468156ea..b8a0ec0ba 100644 --- a/packages/core/data-source-manager/package.json +++ b/packages/core/data-source-manager/package.json @@ -1,16 +1,16 @@ { "name": "@nocobase/data-source-manager", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "dependencies": { - "@nocobase/actions": "0.20.0-alpha.16", - "@nocobase/cache": "0.20.0-alpha.16", - "@nocobase/database": "0.20.0-alpha.16", - "@nocobase/resourcer": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/actions": "0.20.0-alpha.17", + "@nocobase/cache": "0.20.0-alpha.17", + "@nocobase/database": "0.20.0-alpha.17", + "@nocobase/resourcer": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "@types/jsonwebtoken": "^8.5.8", "jsonwebtoken": "^8.5.1" }, diff --git a/packages/core/data-source-manager/src/utils.ts b/packages/core/data-source-manager/src/utils.ts index 79eec06f6..392a01bd2 100644 --- a/packages/core/data-source-manager/src/utils.ts +++ b/packages/core/data-source-manager/src/utils.ts @@ -7,3 +7,10 @@ export function parseCollectionName(collection: string) { const dataSourceName = dataSourceCollection[0] ?? 'main'; return [dataSourceName, collectionName]; } + +export function joinCollectionName(dataSourceName: string, collectionName: string) { + if (!dataSourceName || dataSourceName === 'main') { + return collectionName; + } + return `${dataSourceName}:${collectionName}`; +} diff --git a/packages/core/database/package.json b/packages/core/database/package.json index b2a7bffc8..1fb7420f8 100644 --- a/packages/core/database/package.json +++ b/packages/core/database/package.json @@ -1,13 +1,13 @@ { "name": "@nocobase/database", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "main": "./lib/index.js", "types": "./lib/index.d.ts", "license": "Apache-2.0", "dependencies": { - "@nocobase/logger": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/logger": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "async-mutex": "^0.3.2", "chalk": "^4.1.1", "cron-parser": "4.4.0", diff --git a/packages/core/database/src/__tests__/associations/references.test.ts b/packages/core/database/src/__tests__/associations/references.test.ts new file mode 100644 index 000000000..7fe4baabc --- /dev/null +++ b/packages/core/database/src/__tests__/associations/references.test.ts @@ -0,0 +1,52 @@ +import { Database } from '../../database'; +import { mockDatabase } from '../index'; +describe('association references', () => { + let db: Database; + + beforeEach(async () => { + db = mockDatabase(); + + await db.clean({ drop: true }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('should add reference with default priority', async () => { + const User = db.collection({ + name: 'users', + fields: [{ type: 'hasOne', name: 'profile' }], + }); + + const Profile = db.collection({ + name: 'profiles', + fields: [{ type: 'belongsTo', name: 'user' }], + }); + + await db.sync(); + + const references = db.referenceMap.getReferences('users'); + + expect(references[0].priority).toBe('default'); + }); + + it('should add reference with user defined priority', async () => { + const User = db.collection({ + name: 'users', + fields: [{ type: 'hasOne', name: 'profile', onDelete: 'CASCADE' }], + }); + + const Profile = db.collection({ + name: 'profiles', + fields: [{ type: 'belongsTo', name: 'user' }], + }); + + await db.sync(); + + const references = db.referenceMap.getReferences('users'); + + expect(references.length).toBe(1); + expect(references[0].priority).toBe('user'); + }); +}); diff --git a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts index abb81b887..dedcce013 100644 --- a/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts +++ b/packages/core/database/src/__tests__/fields/belongs-to-field.test.ts @@ -14,6 +14,36 @@ describe('belongs to field', () => { await db.close(); }); + it('should load with no action', async () => { + const User = db.collection({ + name: 'users', + fields: [{ type: 'string', name: 'name', unique: true }], + }); + + const Post = db.collection({ + name: 'posts', + fields: [ + { type: 'string', name: 'title' }, + { type: 'belongsTo', name: 'user', onDelete: 'NO ACTION' }, + ], + }); + + await db.sync(); + + const u1 = await User.repository.create({ values: { name: 'u1' } }); + const p1 = await Post.repository.create({ values: { title: 'p1', user: u1.id } }); + + // delete u1 + await User.repository.destroy({ filterByTk: u1.id }); + + // list posts with user + const post = await Post.repository.findOne({ + appends: ['user'], + }); + + expect(post.user).toBeNull(); + }); + it('should throw error when associated with item that null with target key', async () => { const User = db.collection({ name: 'users', diff --git a/packages/core/database/src/database.ts b/packages/core/database/src/database.ts index e0726dfdd..106bb8828 100644 --- a/packages/core/database/src/database.ts +++ b/packages/core/database/src/database.ts @@ -27,7 +27,7 @@ import { CollectionFactory } from './collection-factory'; import { CollectionGroupManager } from './collection-group-manager'; import { ImporterReader, ImportFileExtension } from './collection-importer'; import DatabaseUtils from './database-utils'; -import ReferencesMap from './features/ReferencesMap'; +import ReferencesMap from './features/references-map'; import { referentialIntegrityCheck } from './features/referential-integrity-check'; import { ArrayFieldRepository } from './field-repository/array-field-repository'; import * as FieldTypes from './fields'; diff --git a/packages/core/database/src/features/ReferencesMap.ts b/packages/core/database/src/features/ReferencesMap.ts deleted file mode 100644 index ca4ff7364..000000000 --- a/packages/core/database/src/features/ReferencesMap.ts +++ /dev/null @@ -1,76 +0,0 @@ -export interface Reference { - sourceCollectionName: string; - sourceField: string; - targetField: string; - targetCollectionName: string; - onDelete: string; -} - -class ReferencesMap { - protected map: Map = new Map(); - - addReference(reference: Reference) { - if (!reference.onDelete) { - reference.onDelete = 'SET NULL'; - } - - reference.onDelete = reference.onDelete.toUpperCase(); - - const existReference = this.existReference(reference); - - if (existReference && existReference.onDelete !== reference.onDelete) { - if (reference.onDelete === 'SET NULL') { - // using existing reference - return; - } else if (existReference.onDelete === 'SET NULL') { - existReference.onDelete = reference.onDelete; - } else { - throw new Error( - `On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify( - reference, - )}`, - ); - } - } - - if (!existReference) { - this.map.set(reference.targetCollectionName, [ - ...(this.map.get(reference.targetCollectionName) || []), - reference, - ]); - } - } - - getReferences(collectionName) { - return this.map.get(collectionName); - } - - existReference(reference: Reference) { - const references = this.map.get(reference.targetCollectionName); - - if (!references) { - return null; - } - - const keys = Object.keys(reference).filter((k) => k !== 'onDelete'); - - return references.find((ref) => keys.every((key) => ref[key] === reference[key])); - } - - removeReference(reference: Reference) { - const references = this.map.get(reference.targetCollectionName); - - if (!references) { - return; - } - - const keys = ['sourceCollectionName', 'sourceField', 'targetField', 'targetCollectionName']; - - this.map.set( - reference.targetCollectionName, - references.filter((ref) => !keys.every((key) => ref[key] === reference[key])), - ); - } -} - -export default ReferencesMap; diff --git a/packages/core/database/src/features/references-map.ts b/packages/core/database/src/features/references-map.ts new file mode 100644 index 000000000..e65bdd6bf --- /dev/null +++ b/packages/core/database/src/features/references-map.ts @@ -0,0 +1,107 @@ +export type ReferencePriority = 'default' | 'user'; + +export interface Reference { + sourceCollectionName: string; + sourceField: string; + targetField: string; + targetCollectionName: string; + onDelete: string; + priority: ReferencePriority; +} + +const DEFAULT_ON_DELETE = 'NO ACTION'; + +export function buildReference(options: Partial): Reference { + const { sourceCollectionName, sourceField, targetField, targetCollectionName, onDelete, priority } = options; + + return { + sourceCollectionName, + sourceField, + targetField, + targetCollectionName, + onDelete: (onDelete || DEFAULT_ON_DELETE).toUpperCase(), + priority: assignPriority(priority, onDelete), + }; +} + +function assignPriority(priority: string | undefined, onDelete: string | undefined): ReferencePriority { + if (priority) { + return priority as ReferencePriority; + } + + return onDelete ? 'user' : 'default'; +} + +const PRIORITY_MAP = { + default: 1, + user: 2, +}; + +class ReferencesMap { + protected map: Map = new Map(); + + addReference(reference: Reference) { + const existReference = this.existReference(reference); + + if (existReference && existReference.onDelete !== reference.onDelete) { + // check two references onDelete priority, using the higher priority, if both are the same, throw error + const existPriority = PRIORITY_MAP[existReference.priority]; + const newPriority = PRIORITY_MAP[reference.priority]; + + if (newPriority > existPriority) { + existReference.onDelete = reference.onDelete; + existReference.priority = reference.priority; + } else if (newPriority === existPriority && newPriority === PRIORITY_MAP['user']) { + if (existReference.onDelete === 'SET NULL' && reference.onDelete === 'CASCADE') { + existReference.onDelete = reference.onDelete; + } else { + console.error(new Error( + `On Delete Conflict, exist reference ${JSON.stringify(existReference)}, new reference ${JSON.stringify( + reference, + )}`, + )); + } + } + } + + if (!existReference) { + this.map.set(reference.targetCollectionName, [ + ...(this.map.get(reference.targetCollectionName) || []), + reference, + ]); + } + } + + getReferences(collectionName) { + return this.map.get(collectionName); + } + + existReference(reference: Reference) { + const references = this.map.get(reference.targetCollectionName); + + if (!references) { + return null; + } + + const keys = Object.keys(reference).filter((k) => k !== 'onDelete' && k !== 'priority'); + + return references.find((ref) => keys.every((key) => ref[key] === reference[key])); + } + + removeReference(reference: Reference) { + const references = this.map.get(reference.targetCollectionName); + + if (!references) { + return; + } + + const keys = ['sourceCollectionName', 'sourceField', 'targetField', 'targetCollectionName']; + + this.map.set( + reference.targetCollectionName, + references.filter((ref) => !keys.every((key) => ref[key] === reference[key])), + ); + } +} + +export default ReferencesMap; diff --git a/packages/core/database/src/features/referential-integrity-check.ts b/packages/core/database/src/features/referential-integrity-check.ts index 3bedb152f..e6269f33c 100644 --- a/packages/core/database/src/features/referential-integrity-check.ts +++ b/packages/core/database/src/features/referential-integrity-check.ts @@ -21,6 +21,11 @@ export async function referentialIntegrityCheck(options: ReferentialIntegrityChe for (const reference of references) { const { sourceCollectionName, sourceField, targetField, onDelete } = reference; + + if (onDelete === 'NO ACTION') { + continue; + } + const sourceCollection = db.collections.get(sourceCollectionName); const sourceRepository = sourceCollection.repository; diff --git a/packages/core/database/src/fields/belongs-to-field.ts b/packages/core/database/src/fields/belongs-to-field.ts index 38eab8ef9..586607340 100644 --- a/packages/core/database/src/fields/belongs-to-field.ts +++ b/packages/core/database/src/fields/belongs-to-field.ts @@ -1,6 +1,6 @@ import lodash, { omit } from 'lodash'; import { BelongsToOptions as SequelizeBelongsToOptions, Utils } from 'sequelize'; -import { Reference } from '../features/ReferencesMap'; +import { buildReference, Reference, ReferencePriority } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; @@ -16,20 +16,26 @@ export class BelongsToField extends RelationField { return target || Utils.pluralize(name); } - static toReference(db, association, onDelete) { + static toReference(db, association, onDelete, priority: ReferencePriority = 'default'): Reference { const targetKey = association.targetKey; - return { + return buildReference({ sourceCollectionName: db.modelCollection.get(association.source).name, sourceField: association.foreignKey, targetField: targetKey, targetCollectionName: db.modelCollection.get(association.target).name, onDelete: onDelete, - }; + priority: priority, + }); } reference(association): Reference { - return BelongsToField.toReference(this.database, association, this.options.onDelete); + return BelongsToField.toReference( + this.database, + association, + this.options.onDelete, + this.options.onDelete ? 'user' : 'default', + ); } checkAssociationKeys() { diff --git a/packages/core/database/src/fields/belongs-to-many-field.ts b/packages/core/database/src/fields/belongs-to-many-field.ts index b69a68e7a..f82d50520 100644 --- a/packages/core/database/src/fields/belongs-to-many-field.ts +++ b/packages/core/database/src/fields/belongs-to-many-field.ts @@ -1,7 +1,7 @@ import { omit } from 'lodash'; import { AssociationScope, BelongsToManyOptions as SequelizeBelongsToManyOptions, Utils } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { BelongsToField } from './belongs-to-field'; import { MultipleRelationFieldOptions, RelationField } from './relation-field'; @@ -32,6 +32,8 @@ export class BelongsToManyField extends RelationField { const onDelete = this.options.onDelete || 'CASCADE'; + const priority = this.options.onDelete ? 'user' : 'default'; + const targetAssociation = association.toTarget; if (association.targetKey) { @@ -45,8 +47,8 @@ export class BelongsToManyField extends RelationField { } return [ - BelongsToField.toReference(db, targetAssociation, onDelete), - BelongsToField.toReference(db, sourceAssociation, onDelete), + BelongsToField.toReference(db, targetAssociation, onDelete, priority), + BelongsToField.toReference(db, sourceAssociation, onDelete, priority), ]; } @@ -149,10 +151,6 @@ export class BelongsToManyField extends RelationField { Object.defineProperty(Through.model, 'isThrough', { value: true }); } - if (!this.options.onDelete) { - this.options.onDelete = 'CASCADE'; - } - const belongsToManyOptions = { constraints: false, ...omit(this.options, ['name', 'type', 'target']), diff --git a/packages/core/database/src/fields/has-many-field.ts b/packages/core/database/src/fields/has-many-field.ts index f266af9f6..28a3f038b 100644 --- a/packages/core/database/src/fields/has-many-field.ts +++ b/packages/core/database/src/fields/has-many-field.ts @@ -8,7 +8,7 @@ import { Utils, } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { buildReference, Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { MultipleRelationFieldOptions, RelationField } from './relation-field'; @@ -89,13 +89,13 @@ export class HasManyField extends RelationField { reference(association): Reference { const sourceKey = association.sourceKey; - return { + return buildReference({ sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceField: association.foreignKey, targetField: sourceKey, targetCollectionName: this.database.modelCollection.get(association.source).name, onDelete: this.options.onDelete, - }; + }); } checkAssociationKeys() { diff --git a/packages/core/database/src/fields/has-one-field.ts b/packages/core/database/src/fields/has-one-field.ts index b6ef32c1f..f2673daca 100644 --- a/packages/core/database/src/fields/has-one-field.ts +++ b/packages/core/database/src/fields/has-one-field.ts @@ -8,7 +8,7 @@ import { Utils, } from 'sequelize'; import { Collection } from '../collection'; -import { Reference } from '../features/ReferencesMap'; +import { buildReference, Reference } from '../features/references-map'; import { checkIdentifier } from '../utils'; import { BaseRelationFieldOptions, RelationField } from './relation-field'; @@ -98,13 +98,13 @@ export class HasOneField extends RelationField { reference(association): Reference { const sourceKey = association.sourceKey; - return { + return buildReference({ sourceCollectionName: this.database.modelCollection.get(association.target).name, sourceField: association.foreignKey, targetField: sourceKey, targetCollectionName: this.database.modelCollection.get(association.source).name, onDelete: this.options.onDelete, - }; + }); } checkAssociationKeys() { diff --git a/packages/core/devtools/package.json b/packages/core/devtools/package.json index 96bf4dc3a..88ceb0513 100644 --- a/packages/core/devtools/package.json +++ b/packages/core/devtools/package.json @@ -1,13 +1,13 @@ { "name": "@nocobase/devtools", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "Apache-2.0", "main": "./src/index.js", "dependencies": { - "@nocobase/build": "0.20.0-alpha.16", - "@nocobase/client": "0.20.0-alpha.16", - "@nocobase/test": "0.20.0-alpha.16", + "@nocobase/build": "0.20.0-alpha.17", + "@nocobase/client": "0.20.0-alpha.17", + "@nocobase/test": "0.20.0-alpha.17", "@types/koa": "^2.13.4", "@types/koa-bodyparser": "^4.3.4", "@types/lodash": "^4.14.177", diff --git a/packages/core/evaluators/package.json b/packages/core/evaluators/package.json index 17a742c85..3e31615fc 100644 --- a/packages/core/evaluators/package.json +++ b/packages/core/evaluators/package.json @@ -1,13 +1,13 @@ { "name": "@nocobase/evaluators", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "main": "./lib/index.js", "types": "./lib/index.d.ts", "license": "Apache-2.0", "dependencies": { "@formulajs/formulajs": "4.2.0", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/utils": "0.20.0-alpha.17", "mathjs": "^10.6.0" }, "repository": { diff --git a/packages/core/logger/package.json b/packages/core/logger/package.json index 2b7569ae8..041756f45 100644 --- a/packages/core/logger/package.json +++ b/packages/core/logger/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/logger", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "nocobase logging library", "license": "Apache-2.0", "main": "./lib/index.js", diff --git a/packages/core/resourcer/package.json b/packages/core/resourcer/package.json index 073199e0a..54abdc1b4 100644 --- a/packages/core/resourcer/package.json +++ b/packages/core/resourcer/package.json @@ -1,12 +1,12 @@ { "name": "@nocobase/resourcer", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "main": "./lib/index.js", "types": "./lib/index.d.ts", "license": "Apache-2.0", "dependencies": { - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/utils": "0.20.0-alpha.17", "deepmerge": "^4.2.2", "koa-compose": "^4.1.0", "lodash": "^4.17.21", diff --git a/packages/core/resourcer/src/action.ts b/packages/core/resourcer/src/action.ts index 6db30eebc..98594d1ce 100644 --- a/packages/core/resourcer/src/action.ts +++ b/packages/core/resourcer/src/action.ts @@ -1,10 +1,9 @@ -import { assign, MergeStrategies, prePerfHooksWrap, requireModule } from '@nocobase/utils'; +import { assign, MergeStrategies, requireModule } from '@nocobase/utils'; import compose from 'koa-compose'; import _ from 'lodash'; import Middleware, { MiddlewareType } from './middleware'; import Resource from './resource'; import { HandlerType } from './resourcer'; -import { RecordableHistogram, performance } from 'perf_hooks'; export type ActionType = string | HandlerType | ActionOptions; @@ -161,27 +160,38 @@ export interface ActionParams { */ values?: any; /** - * 当前资源的主体,对应的表名或 Model 名称 + * This method is deprecated and should not be used. + * Use {@link action.resourceName.split(',')[0]} instead. + * @deprecated */ resourceName?: string; /** - * 资源标识符 + * This method is deprecated and should not be used. + * Use {@link filterByTk} instead. + * @deprecated */ resourceIndex?: string; /** - * 资源的从属关系 + * This method is deprecated and should not be used. + * Use {@link action.resourceName.split(',')[1]} instead. + * @deprecated */ associatedName?: string; /** - * 从属关系的标识符 + * This method is deprecated and should not be used. + * Use {@link action.sourceId} instead. + * @deprecated */ associatedIndex?: string; /** - * 从属关系的当前实例 + * This method is deprecated and should not be used. + * @deprecated */ associated?: any; /** - * 资源提供哪些行为或方法 + * This method is deprecated and should not be used. + * Use {@link action.actionName} instead. + * @deprecated */ actionName?: string; /** @@ -204,11 +214,23 @@ export class Action { public params: ActionParams = {}; public actionName: string; + public resourceName: string; + + /** + * This method is deprecated and should not be used. + * Use {@link this.sourceId} instead. + * @deprecated + */ public resourceOf: any; + public sourceId: any; + public readonly middlewares: Array = []; + /** + * @internal + */ constructor(options: ActionOptions) { options = requireModule(options); if (typeof options === 'function') { @@ -221,15 +243,22 @@ export class Action { this.mergeParams(params); } + /** + * @internal + */ toJSON() { return { actionName: this.actionName, resourceName: this.resourceName, - resourceOf: this.resourceOf, + resourceOf: this.sourceId, + sourceId: this.sourceId, params: this.params, }; } + /** + * @internal + */ clone() { const options = _.cloneDeep(this.options); delete options.middleware; @@ -241,6 +270,9 @@ export class Action { return action; } + /** + * @internal + */ setContext(context: any) { this.context = context; } @@ -266,34 +298,55 @@ export class Action { }); } + /** + * @internal + */ setResource(resource: Resource) { this.resource = resource; return this; } + /** + * @internal + */ getResource() { return this.resource; } + /** + * @internal + */ getOptions(): ActionOptions { return this.options; } + /** + * @internal + */ setName(name: ActionName) { this.name = name; return this; } + /** + * @internal + */ getName() { return this.name; } + /** + * @internal + */ getMiddlewareHandlers() { return this.middlewares .filter((middleware) => middleware.canAccess(this.name)) .map((middleware) => middleware.getHandler()); } + /** + * @internal + */ getHandler() { const handler = requireModule(this.handler || this.resource.resourcer.getRegisteredHandler(this.name)); if (typeof handler !== 'function') { @@ -303,6 +356,9 @@ export class Action { return handler; } + /** + * @internal + */ getHandlers() { const handlers = [ ...this.resource.resourcer.getMiddlewares(), @@ -315,10 +371,16 @@ export class Action { return handlers; } + /** + * @internal + */ async execute(context: any, next?: any) { return await compose(this.getHandlers())(context, next); } + /** + * @internal + */ static toInstanceMap(actions: object, resource?: Resource) { return new Map( Object.entries(actions).map(([key, options]) => { diff --git a/packages/core/resourcer/src/resourcer.ts b/packages/core/resourcer/src/resourcer.ts index c223ffd87..25eb37c04 100644 --- a/packages/core/resourcer/src/resourcer.ts +++ b/packages/core/resourcer/src/resourcer.ts @@ -150,6 +150,9 @@ export interface ImportOptions { } export class Resourcer { + /** + * @internal + */ public readonly options: ResourcerOptions; protected resources = new Map(); /** @@ -173,6 +176,7 @@ export class Resourcer { * @param {object} [options] * @param {string} [options.directory] 指定配置所在路径 * @param {array} [options.extensions = ['js', 'ts', 'json']] 文件后缀 + * */ public async import(options: ImportOptions): Promise> { const { extensions = ['js', 'ts', 'json'], directory } = options; @@ -206,14 +210,27 @@ export class Resourcer { return this.resources.has(name); } + /** + * @internal + */ removeResource(name) { return this.resources.delete(name); } + /** + * This method is deprecated and should not be used. + * Use {@link this.registerActionHandler()} instead. + * @deprecated + */ registerAction(name: ActionName, handler: HandlerType) { this.registerActionHandler(name, handler); } + /** + * This method is deprecated and should not be used. + * Use {@link this.registerActionHandlers()} instead. + * @deprecated + */ registerActions(handlers: Handlers) { this.registerActionHandlers(handlers); } @@ -233,14 +250,23 @@ export class Resourcer { this.actionHandlers.set(name, handler); } + /** + * @internal + */ getRegisteredHandler(name: ActionName) { return this.actionHandlers.get(name); } + /** + * @internal + */ getRegisteredHandlers() { return this.actionHandlers; } + /** + * @internal + */ getResource(name: string): Resource { if (!this.resources.has(name)) { throw new Error(`${name} resource does not exist`); @@ -248,6 +274,9 @@ export class Resourcer { return this.resources.get(name); } + /** + * @internal + */ getAction(name: string, action: ActionName): Action { // 支持注册局部 action if (this.actionHandlers.has(`${name}:${action}`)) { @@ -256,6 +285,9 @@ export class Resourcer { return this.getResource(name).getAction(action); } + /** + * @internal + */ getMiddlewares() { return this.middlewares.nodes; } @@ -264,6 +296,11 @@ export class Resourcer { this.middlewares.add(middlewares, options); } + /** + * This method is deprecated and should not be used. + * Use {@link this.middleware()} instead. + * @deprecated + */ restApiMiddleware({ prefix, accessors, skipIfDataSourceExists = false }: KoaMiddlewareOptions = {}) { return async (ctx: ResourcerContext, next: () => Promise) => { if (skipIfDataSourceExists) { @@ -317,6 +354,7 @@ export class Resourcer { ctx.action.setContext(ctx); ctx.action.actionName = params.actionName; + ctx.action.sourceId = params.associatedIndex; ctx.action.resourceOf = params.associatedIndex; ctx.action.resourceName = params.associatedName ? `${params.associatedName}.${params.resourceName}` @@ -349,11 +387,7 @@ export class Resourcer { } /** - * 实验性 API - * - * @param options - * @param context - * @param next + * @internal */ async execute(options: ExecuteOptions, context: ResourcerContext = {}, next?: any) { const { resource, action } = options; diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 2d05b4b62..7b6dbd326 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/sdk", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "Apache-2.0", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/packages/core/server/package.json b/packages/core/server/package.json index defcb5109..548b5ace3 100644 --- a/packages/core/server/package.json +++ b/packages/core/server/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/server", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "lib/index.js", "types": "./lib/index.d.ts", "license": "Apache-2.0", @@ -10,18 +10,18 @@ "@koa/cors": "^3.1.0", "@koa/multer": "^3.0.2", "@koa/router": "^9.4.0", - "@nocobase/acl": "0.20.0-alpha.16", - "@nocobase/actions": "0.20.0-alpha.16", - "@nocobase/auth": "0.20.0-alpha.16", - "@nocobase/cache": "0.20.0-alpha.16", - "@nocobase/data-source-manager": "0.20.0-alpha.16", - "@nocobase/database": "0.20.0-alpha.16", - "@nocobase/evaluators": "0.20.0-alpha.16", - "@nocobase/logger": "0.20.0-alpha.16", - "@nocobase/resourcer": "0.20.0-alpha.16", - "@nocobase/sdk": "0.20.0-alpha.16", - "@nocobase/telemetry": "0.20.0-alpha.16", - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/acl": "0.20.0-alpha.17", + "@nocobase/actions": "0.20.0-alpha.17", + "@nocobase/auth": "0.20.0-alpha.17", + "@nocobase/cache": "0.20.0-alpha.17", + "@nocobase/data-source-manager": "0.20.0-alpha.17", + "@nocobase/database": "0.20.0-alpha.17", + "@nocobase/evaluators": "0.20.0-alpha.17", + "@nocobase/logger": "0.20.0-alpha.17", + "@nocobase/resourcer": "0.20.0-alpha.17", + "@nocobase/sdk": "0.20.0-alpha.17", + "@nocobase/telemetry": "0.20.0-alpha.17", + "@nocobase/utils": "0.20.0-alpha.17", "@types/decompress": "4.2.4", "@types/ini": "^1.3.31", "@types/koa-send": "^4.1.3", diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index d6b85acf7..3ba4b0049 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -142,19 +142,41 @@ export type MaintainingCommandStatus = { }; export class Application extends Koa implements AsyncEmitter { + /** + * @internal + */ declare middleware: any; + /** + * @internal + */ stopped = false; + /** + * @internal + */ ready = false; declare emitAsync: (event: string | symbol, ...args: any[]) => Promise; + /** + * @internal + */ public rawOptions: ApplicationOptions; + /** + * @internal + */ public activatedCommand: { name: string; } = null; + /** + * @internal + */ public running = false; + /** + * @internal + */ public perfHistograms = new Map(); protected plugins = new Map(); protected _appSupervisor: AppSupervisor = AppSupervisor.getInstance(); protected _started: boolean; + protected _logger: SystemLogger; private _authenticated = false; private _maintaining = false; private _maintainingCommandStatus: MaintainingCommandStatus; @@ -193,12 +215,18 @@ export class Application exten protected _loaded: boolean; + /** + * @internal + */ get loaded() { return this._loaded; } private _maintainingMessage: string; + /** + * @internal + */ get maintainingMessage() { return this._maintainingMessage; } @@ -222,8 +250,6 @@ export class Application exten return this.mainDataSource.collectionManager.db; } - protected _logger: SystemLogger; - get logger() { return this._logger; } @@ -244,6 +270,9 @@ export class Application exten return this._cache; } + /** + * @internal + */ set cache(cache: Cache) { this._cache = cache; } @@ -278,6 +307,11 @@ export class Application exten protected _locales: Locale; + /** + * This method is deprecated and should not be used. + * Use {@link #localeManager} instead. + * @deprecated + */ get locales() { return this._locales; } @@ -312,10 +346,16 @@ export class Application exten return this._dataSourceManager; } + /** + * @internal + */ getMaintaining() { return this._maintainingCommandStatus; } + /** + * @internal + */ setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) { this._maintainingCommandStatus = _maintainingCommandStatus; @@ -329,6 +369,9 @@ export class Application exten this._maintaining = true; } + /** + * @internal + */ setMaintainingMessage(message: string) { this._maintainingMessage = message; @@ -338,11 +381,18 @@ export class Application exten }); } + /** + * This method is deprecated and should not be used. + * Use {@link #this.version.get()} instead. + * @deprecated + */ getVersion() { return packageJson.version; } /** + * This method is deprecated and should not be used. + * Use {@link #this.pm.addPreset()} instead. * @deprecated */ plugin(pluginClass: any, options?: O) { @@ -359,6 +409,9 @@ export class Application exten return this; } + /** + * @internal + */ callback() { const fn = compose(this.middleware.nodes); @@ -372,14 +425,29 @@ export class Application exten }; } + /** + * This method is deprecated and should not be used. + * Use {@link #this.db.collection()} instead. + * @deprecated + */ collection(options: CollectionOptions) { return this.db.collection(options); } + /** + * This method is deprecated and should not be used. + * Use {@link #this.resourcer.define()} instead. + * @deprecated + */ resource(options: ResourceOptions) { return this.resourcer.define(options); } + /** + * This method is deprecated and should not be used. + * Use {@link #this.resourcer.registerActions()} instead. + * @deprecated + */ actions(handlers: any, options?: ActionsOptions) { return this.resourcer.registerActions(handlers); } @@ -392,11 +460,9 @@ export class Application exten return (this.cli as any)._findCommand(name); } - async preload() { - // load core collections - // load plugin commands - } - + /** + * @internal + */ async reInit() { if (!this._loaded) { return; @@ -500,12 +566,19 @@ export class Application exten } /** + * This method is deprecated and should not be used. + * Use {@link this.pm.get()} instead. * @deprecated */ getPlugin

(name: string | typeof Plugin) { return this.pm.get(name) as P; } + /** + * This method is deprecated and should not be used. + * Use {@link this.runAsCLI()} instead. + * @deprecated + */ async parse(argv = process.argv) { return this.runAsCLI(argv); } @@ -528,7 +601,7 @@ export class Application exten return await this.runAsCLI([command, ...args], { from: 'user', throwError: true }); } - createCli() { + protected createCLI() { const command = new AppCommand('nocobase') .usage('[command] [options]') .hook('preAction', async (_, actionCommand) => { @@ -568,6 +641,9 @@ export class Application exten return command; } + /** + * @internal + */ async loadMigrations(options) { const { directory, context, namespace } = options; const migrations = { @@ -594,6 +670,9 @@ export class Application exten return migrations; } + /** + * @internal + */ async loadCoreMigrations() { const migrations = await this.loadMigrations({ directory: resolve(__dirname, 'migrations'), @@ -624,11 +703,17 @@ export class Application exten }; } + /** + * @internal + */ async loadPluginCommands() { this.log.debug('load plugin commands'); await this.pm.loadCommands(); } + /** + * @internal + */ async runAsCLI( argv = process.argv, options?: ParseOptions & { throwError?: boolean; reqId?: string }, @@ -732,6 +817,9 @@ export class Application exten this.stopped = false; } + /** + * @internal + */ async emitStartedEvent(options: StartOptions = {}) { await this.emitAsync('__started', this, { maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus), @@ -743,6 +831,9 @@ export class Application exten return this._started; } + /** + * @internal + */ async tryReloadOrRestart(options: StartOptions = {}) { if (this._started) { await this.restart(options); @@ -769,11 +860,11 @@ export class Application exten const log = options.logging === false ? { - debug() {}, - warn() {}, - info() {}, - error() {}, - } + debug() { }, + warn() { }, + info() { }, + error() { }, + } : this.log; log.debug('stop app...', { method: 'stop' }); this.setMaintainingMessage('stopping app...'); @@ -941,6 +1032,9 @@ export class Application exten }; } + /** + * @internal + */ reInitEvents() { for (const eventName of this.eventNames()) { for (const listener of this.listeners(eventName)) { @@ -986,7 +1080,7 @@ export class Application exten this._cronJobManager = new CronJobManager(this); - this._cli = this.createCli(); + this._cli = this.createCLI(); this._i18n = createI18n(options); this.context.db = this.db; diff --git a/packages/core/server/src/locale/locale.ts b/packages/core/server/src/locale/locale.ts index d605ba5a4..0cf80f285 100644 --- a/packages/core/server/src/locale/locale.ts +++ b/packages/core/server/src/locale/locale.ts @@ -67,6 +67,9 @@ export class Locale { async getCacheResources(lang: string) { this.resourceCached.set(lang, true); + if (process.env.APP_ENV !== 'production') { + await this.cache.reset(); + } return await this.wrapCache(`resources:${lang}`, () => this.getResources(lang)); } diff --git a/packages/core/server/src/locale/resource.ts b/packages/core/server/src/locale/resource.ts index f9d7a56f7..43351be0a 100644 --- a/packages/core/server/src/locale/resource.ts +++ b/packages/core/server/src/locale/resource.ts @@ -25,7 +25,10 @@ export const getResource = (packageName: string, lang: string, isPlugin = true) for (const prefix of prefixes) { try { const file = `${packageName}/${prefix}/locale/${lang}`; - require.resolve(file); + const f = require.resolve(file); + if (process.env.APP_ENV !== 'production') { + delete require.cache[f]; + } const resource = requireModule(file); resources.push(resource); } catch (error) { diff --git a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts index 4fba1d7ff..8cb74b198 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts @@ -3,12 +3,21 @@ import lodash from 'lodash'; import { PluginManager } from './plugin-manager'; export class PluginManagerRepository extends Repository { + /** + * @internal + */ pm: PluginManager; + /** + * @internal + */ setPluginManager(pm: PluginManager) { this.pm = pm; } + /** + * @deprecated + */ async remove(name: string | string[]) { await this.destroy({ filter: { @@ -17,6 +26,9 @@ export class PluginManagerRepository extends Repository { }); } + /** + * @deprecated + */ async enable(name: string | string[]) { const pluginNames = lodash.castArray(name); const plugins = pluginNames.map((name) => this.pm.get(name)); @@ -56,6 +68,9 @@ export class PluginManagerRepository extends Repository { } } + /** + * @deprecated + */ async disable(name: string | string[]) { name = lodash.cloneDeep(name); diff --git a/packages/core/server/src/plugin-manager/plugin-manager.ts b/packages/core/server/src/plugin-manager/plugin-manager.ts index 3a1b72a11..960be122b 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager.ts @@ -45,12 +45,39 @@ export interface InstallOptions { export class AddPresetError extends Error {} export class PluginManager { + /** + * @internal + */ app: Application; + + /** + * @internal + */ collection: Collection; + + /** + * @internal + */ pluginInstances = new Map(); + + /** + * @internal + */ pluginAliases = new Map(); + + /** + * @internal + */ server: net.Server; + /** + * @internal + */ + _repository: PluginManagerRepository; + + /** + * @internal + */ constructor(public options: PluginManagerOptions) { this.app = options.app; this.app.db.registerRepositories({ @@ -76,18 +103,22 @@ export class PluginManager { this.app.resourcer.use(uploadMiddleware); } - _repository: PluginManagerRepository; - get repository() { return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository; } + /** + * @internal + */ static async getPackageJson(packageName: string) { const file = await fs.promises.realpath(resolve(process.env.NODE_MODULES_PATH, packageName, 'package.json')); const data = await fs.promises.readFile(file, { encoding: 'utf-8' }); return JSON.parse(data); } + /** + * @internal + */ static async getPackageName(name: string) { const prefixes = this.getPluginPkgPrefix(); for (const prefix of prefixes) { @@ -100,12 +131,18 @@ export class PluginManager { throw new Error(`${name} plugin does not exist`); } + /** + * @internal + */ static getPluginPkgPrefix() { return (process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-').split( ',', ); } + /** + * @internal + */ static async findPackage(name: string) { try { const packageName = this.getPackageName(name); @@ -130,6 +167,9 @@ export class PluginManager { throw new Error(`No available packages found, ${name} plugin does not exist`); } + /** + * @internal + */ static clearCache(packageName: string) { return; const packageNamePath = packageName.replace('/', sep); @@ -140,6 +180,9 @@ export class PluginManager { }); } + /** + * @internal + */ static async resolvePlugin(pluginName: string | typeof Plugin, isUpgrade = false, isPkg = false) { if (typeof pluginName === 'string') { const packageName = isPkg ? pluginName : await this.getPackageName(pluginName); @@ -296,11 +339,17 @@ export class PluginManager { await instance.afterAdd(); } + /** + * @internal + */ async initPlugins() { await this.initPresetPlugins(); await this.initOtherPlugins(); } + /** + * @internal + */ async loadCommands() { this.app.log.debug('load commands'); const items = await this.repository.find({ @@ -619,6 +668,9 @@ export class PluginManager { await execa('yarn', ['nocobase', 'refresh']); } + /** + * @deprecated + */ async loadOne(plugin: Plugin) { this.app.setMaintainingMessage(`loading plugin ${plugin.name}...`); if (plugin.state.loaded || !plugin.enabled) { @@ -637,6 +689,9 @@ export class PluginManager { this.app.setMaintainingMessage(`loaded plugin ${plugin.name}`); } + /** + * @internal + */ async addViaCLI(urlOrName: string, options?: PluginData) { if (isURL(urlOrName)) { await this.addByCompressedFileUrl({ @@ -679,6 +734,9 @@ export class PluginManager { await execa('yarn', ['nocobase', 'postinstall']); } + /** + * @internal + */ async addByNpm(options: { packageName: string; name?: string; registry: string; authToken?: string }) { let { name = '', registry, packageName, authToken } = options; name = name.trim(); @@ -693,6 +751,9 @@ export class PluginManager { return this.addByCompressedFileUrl({ name, compressedFileUrl, registry, authToken, type: 'npm' }); } + /** + * @internal + */ async addByFile(options: { file: string; registry?: string; authToken?: string; type?: string; name?: string }) { const { file, authToken } = options; @@ -708,6 +769,9 @@ export class PluginManager { return this.add(name, { packageName }, true); } + /** + * @internal + */ async addByCompressedFileUrl(options: { compressedFileUrl: string; registry?: string; @@ -748,6 +812,9 @@ export class PluginManager { await this.app.upgrade(); } + /** + * @internal + */ async upgradeByNpm(values: PluginData) { const name = values.name; const plugin = this.get(name); @@ -769,6 +836,9 @@ export class PluginManager { return this.upgradeByCompressedFileUrl({ compressedFileUrl, name, version, registry, authToken }); } + /** + * @internal + */ async upgradeByCompressedFileUrl(options: PluginData) { const { name, compressedFileUrl, authToken } = options; const data = await this.repository.findOne({ filter: { name } }); @@ -780,6 +850,9 @@ export class PluginManager { await this.add(name, { version, packageName: data.packageName }, true, true); } + /** + * @internal + */ getNameByPackageName(packageName: string) { const prefixes = PluginManager.getPluginPkgPrefix(); const prefix = prefixes.find((prefix) => packageName.startsWith(prefix)); @@ -808,12 +881,18 @@ export class PluginManager { ); } + /** + * @internal + */ async getNpmVersionList(name: string) { const plugin = this.get(name); const npmInfo = await getNpmInfo(plugin.options.packageName, plugin.options.registry, plugin.options.authToken); return Object.keys(npmInfo.versions); } + /** + * @internal + */ async loadPresetMigrations() { const migrations = { beforeLoad: [], @@ -854,6 +933,9 @@ export class PluginManager { }; } + /** + * @internal + */ async loadOtherMigrations() { const migrations = { beforeLoad: [], @@ -897,6 +979,9 @@ export class PluginManager { }; } + /** + * @internal + */ async loadPresetPlugins() { await this.initPresetPlugins(); await this.load(); @@ -931,6 +1016,9 @@ export class PluginManager { }); } + /** + * @internal + */ async initOtherPlugins() { if (this['_initOtherPlugins']) { return; @@ -939,6 +1027,9 @@ export class PluginManager { this['_initOtherPlugins'] = true; } + /** + * @internal + */ async initPresetPlugins() { if (this['_initPresetPlugins']) { return; diff --git a/packages/core/server/src/plugin.ts b/packages/core/server/src/plugin.ts index a97f1caec..5f99eb840 100644 --- a/packages/core/server/src/plugin.ts +++ b/packages/core/server/src/plugin.ts @@ -33,9 +33,22 @@ export interface PluginOptions { export abstract class Plugin implements PluginInterface { options: any; app: Application; + + /** + * @deprecated + */ model: Model; + + /** + * @internal + */ state: any = {}; + /** + * @internal + */ + private _sourceDir: string; + constructor(app: Application, options?: any) { this.app = app; this.setOptions(options); @@ -80,10 +93,6 @@ export abstract class Plugin implements PluginInterface { return this.options.isPreset; } - setOptions(options: any) { - this.options = options || {}; - } - getName() { return (this.options as any).name; } @@ -92,64 +101,6 @@ export abstract class Plugin implements PluginInterface { return this.app.createLogger(options); } - protected _sourceDir: string; - - protected async getSourceDir() { - if (this._sourceDir) { - return this._sourceDir; - } - if (await this.isDev()) { - return (this._sourceDir = 'src'); - } - if (basename(__dirname) === 'src') { - return (this._sourceDir = 'src'); - } - return (this._sourceDir = this.isPreset ? 'lib' : 'dist'); - } - - async loadCommands() { - const extensions = ['js', 'ts']; - const directory = resolve( - process.env.NODE_MODULES_PATH, - this.options.packageName, - await this.getSourceDir(), - 'server/commands', - ); - const patten = `${directory}/*.{${extensions.join(',')}}`; - const files = glob.sync(patten, { - ignore: ['**/*.d.ts'], - }); - for (const file of files) { - let filename = basename(file); - filename = filename.substring(0, filename.lastIndexOf('.')) || filename; - const callback = await importModule(file); - callback(this.app); - } - if (files.length) { - this.app.log.debug(`load commands [${this.name}]`); - } - } - - async loadMigrations() { - this.app.log.debug(`load plugin migrations [${this.name}]`); - if (!this.options.packageName) { - return { beforeLoad: [], afterSync: [], afterLoad: [] }; - } - const directory = resolve( - process.env.NODE_MODULES_PATH, - this.options.packageName, - await this.getSourceDir(), - 'server/migrations', - ); - return await this.app.loadMigrations({ - directory, - namespace: this.options.packageName, - context: { - plugin: this, - }, - }); - } - afterAdd() {} beforeLoad() {} @@ -172,13 +123,86 @@ export abstract class Plugin implements PluginInterface { async afterRemove() {} - async importCollections(collectionsPath: string) { - // await this.db.import({ - // directory: collectionsPath, - // from: `plugin:${this.getName()}`, - // }); + /** + * @deprecated + */ + async importCollections(collectionsPath: string) {} + + /** + * @internal + */ + setOptions(options: any) { + this.options = options || {}; } + /** + * @internal + */ + protected async getSourceDir() { + if (this._sourceDir) { + return this._sourceDir; + } + if (await this.isDev()) { + return (this._sourceDir = 'src'); + } + if (basename(__dirname) === 'src') { + return (this._sourceDir = 'src'); + } + return (this._sourceDir = this.isPreset ? 'lib' : 'dist'); + } + + /** + * @internal + */ + async loadCommands() { + const extensions = ['js', 'ts']; + const directory = resolve( + process.env.NODE_MODULES_PATH, + this.options.packageName, + await this.getSourceDir(), + 'server/commands', + ); + const patten = `${directory}/*.{${extensions.join(',')}}`; + const files = glob.sync(patten, { + ignore: ['**/*.d.ts'], + }); + for (const file of files) { + let filename = basename(file); + filename = filename.substring(0, filename.lastIndexOf('.')) || filename; + const callback = await importModule(file); + callback(this.app); + } + if (files.length) { + this.app.log.debug(`load commands [${this.name}]`); + } + } + + /** + * @internal + */ + async loadMigrations() { + this.app.log.debug(`load plugin migrations [${this.name}]`); + if (!this.options.packageName) { + return { beforeLoad: [], afterSync: [], afterLoad: [] }; + } + const directory = resolve( + process.env.NODE_MODULES_PATH, + this.options.packageName, + await this.getSourceDir(), + 'server/migrations', + ); + return await this.app.loadMigrations({ + directory, + namespace: this.options.packageName, + context: { + plugin: this, + }, + }); + } + + /** + * @internal + */ async loadCollections() { if (!this.options.packageName) { return; @@ -197,6 +221,9 @@ export abstract class Plugin implements PluginInterface { } } + /** + * @deprecated + */ requiredPlugins() { return []; } @@ -205,6 +232,9 @@ export abstract class Plugin implements PluginInterface { return this.app.i18n.t(text, { ns: this.options['packageName'], ...(options as any) }); } + /** + * @internal + */ protected async isDev() { if (!this.options.packageName) { return false; @@ -218,6 +248,9 @@ export abstract class Plugin implements PluginInterface { return false; } + /** + * @experimental + */ async toJSON(options: any = {}) { const { locale = 'en-US' } = options; const { name, packageName, packageJson } = this.options; diff --git a/packages/core/telemetry/package.json b/packages/core/telemetry/package.json index 54adaf084..c205f1b0e 100644 --- a/packages/core/telemetry/package.json +++ b/packages/core/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/telemetry", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "nocobase telemetry library", "license": "Apache-2.0", "main": "./lib/index.js", @@ -11,7 +11,7 @@ "directory": "packages/telemetry" }, "dependencies": { - "@nocobase/utils": "0.20.0-alpha.16", + "@nocobase/utils": "0.20.0-alpha.17", "@opentelemetry/api": "^1.7.0", "@opentelemetry/instrumentation": "^0.46.0", "@opentelemetry/resources": "^1.19.0", diff --git a/packages/core/test/package.json b/packages/core/test/package.json index fc3172483..7871a6401 100644 --- a/packages/core/test/package.json +++ b/packages/core/test/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/test", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "lib/index.js", "module": "./src/index.ts", "types": "./lib/index.d.ts", @@ -40,9 +40,10 @@ }, "dependencies": { "@faker-js/faker": "8.1.0", - "@nocobase/server": "0.20.0-alpha.16", + "@nocobase/server": "0.20.0-alpha.17", "@playwright/test": "^1.42.1", "@testing-library/react": "^14.0.0", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.4.3", "@types/supertest": "^2.0.11", "@vitejs/plugin-react": "^4.0.0", @@ -56,7 +57,7 @@ "sqlite3": "^5.0.8", "supertest": "^6.1.6", "vite": "^5.0.0", - "vitest": "^1.0.0", + "vitest": "^1.4.0", "vitest-dom": "^0.1.1", "ws": "^8.13.0" }, diff --git a/packages/core/test/src/client/index.ts b/packages/core/test/src/client/index.ts index 3cfd0572e..0327ed254 100644 --- a/packages/core/test/src/client/index.ts +++ b/packages/core/test/src/client/index.ts @@ -1,4 +1,5 @@ import { render } from '@testing-library/react'; +export { renderHook } from '@testing-library/react-hooks'; function customRender(ui: React.ReactElement, options = {}) { return render(ui, { diff --git a/packages/core/test/src/e2e/defineConfig.ts b/packages/core/test/src/e2e/defineConfig.ts index e802c573c..441b78044 100644 --- a/packages/core/test/src/e2e/defineConfig.ts +++ b/packages/core/test/src/e2e/defineConfig.ts @@ -21,7 +21,7 @@ export const defineConfig = (config?: PlaywrightTestConfig) => { forbidOnly: !!process.env.CI, // Retry on CI only. - retries: process.env.CI ? 2 : 0, + retries: 2, // Opt out of parallel tests on CI. // workers: process.env.CI ? 1 : undefined, @@ -52,7 +52,14 @@ export const defineConfig = (config?: PlaywrightTestConfig) => { }, { name: 'chromium', - use: { ...devices['Desktop Chrome'], storageState: process.env.PLAYWRIGHT_AUTH_FILE }, + use: { + ...devices['Desktop Chrome'], + storageState: process.env.PLAYWRIGHT_AUTH_FILE, + contextOptions: { + // chromium-specific permissions + permissions: ['clipboard-read', 'clipboard-write'], + }, + }, dependencies: ['authSetup'], }, ], diff --git a/packages/core/test/src/e2e/e2eUtils.ts b/packages/core/test/src/e2e/e2eUtils.ts index 1dc9fe9c0..bf948d5fe 100644 --- a/packages/core/test/src/e2e/e2eUtils.ts +++ b/packages/core/test/src/e2e/e2eUtils.ts @@ -53,6 +53,7 @@ export interface CollectionSetting { * @default false */ inherit?: boolean; + inherits?: string[]; category?: any[]; hidden?: boolean; description?: string; @@ -580,6 +581,10 @@ const deleteCollections = async (collectionNames: string[]) => { const result = await api.post(`/api/collections:destroy?${params}`, { headers, + params: { + // 自动删除依赖于集合的对象(如视图),进而删除依赖于这些对象的所有对象 + cascade: true, + }, }); if (!result.ok()) { diff --git a/packages/core/test/src/server/mockServer.ts b/packages/core/test/src/server/mockServer.ts index a86878a04..005e87bfa 100644 --- a/packages/core/test/src/server/mockServer.ts +++ b/packages/core/test/src/server/mockServer.ts @@ -58,6 +58,12 @@ interface Resource { [name: string]: (params?: ActionParams) => Promise; } +interface ExtendedAgent extends SuperAgentTest { + login: (user: any) => ExtendedAgent; + loginUsingId: (userId: number) => ExtendedAgent; + resource: (name: string, resourceOf?: any) => Resource; +} + export class MockServer extends Application { async loadAndInstall(options: any = {}) { await this.load({ method: 'install' }); @@ -96,11 +102,7 @@ export class MockServer extends Application { await AppSupervisor.getInstance().destroy(); } - agent(): SuperAgentTest & { - login: (user: any) => SuperAgentTest; - loginUsingId: (userId: number) => SuperAgentTest; - resource: (name: string, resourceOf?: any) => Resource; - } { + agent(): ExtendedAgent { const agent = supertest.agent(this.callback()); const prefix = this.resourcer.options.prefix; const proxy = new Proxy(agent, { diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index 7ae13329c..50c910c32 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/utils", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "lib/index.js", "types": "./lib/index.d.ts", "license": "Apache-2.0", diff --git a/packages/core/utils/src/json-templates.ts b/packages/core/utils/src/json-templates.ts index 9a38a29f3..baad0ca02 100644 --- a/packages/core/utils/src/json-templates.ts +++ b/packages/core/utils/src/json-templates.ts @@ -101,18 +101,13 @@ const parseString = (() => { value = value(); } - if (typeof value === 'object' && value !== null) { + // Accommodate numbers as values. + if (str.startsWith('{{') && str.endsWith('}}')) { return value; } - // Accommodate numbers as values. - if (matches.length === 1 && str.startsWith('{{') && str.endsWith('}}')) { - return value; - } - - // Accommodate numbers as values. - if (matches.length === 1 && str.startsWith('{{') && str.endsWith('}}')) { - return value; + if (value instanceof Date) { + value = value.toISOString(); } return result.replace(match, value == null ? '' : value); diff --git a/packages/plugins/@nocobase/plugin-acl/package.json b/packages/plugins/@nocobase/plugin-acl/package.json index 43810e23a..6ac8bc994 100644 --- a/packages/plugins/@nocobase/plugin-acl/package.json +++ b/packages/plugins/@nocobase/plugin-acl/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "权限控制", "description": "Based on roles, resources, and actions, access control can precisely manage interface configuration permissions, data operation permissions, menu access permissions, and plugin permissions.", "description.zh-CN": "基于角色、资源和操作的权限控制,可以精确控制界面配置权限、数据操作权限、菜单访问权限、插件权限。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/acl", diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts index 718dc650c..b84d78a38 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/configure.test.ts @@ -70,7 +70,7 @@ test('allows to confgiure plugins ', async ({ page, mockPage, mockRole, updateRo .locator('span') .nth(1) .click(); - await expect(page.getByRole('tab').getByText('Plugin settings')).toBeVisible(); + await expect(page.getByText('Plugin settings')).toBeVisible(); await updateRole({ name: roleData.name, snippets: ['!pm.*'], diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/menu.test.ts b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/menu.test.ts index 192ab4d78..deb78f6a9 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/menu.test.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/client/__e2e__/menu.test.ts @@ -29,8 +29,10 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => { .nth(1) .click(); await page.getByRole('tab').getByText('Menu').click(); - await page.waitForSelector('.ant-table'); - await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input')).toBeChecked({ checked: true }); + await page.waitForTimeout(1000); + await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({ + checked: true, + }); await expect(page.getByRole('row', { name: 'page2' }).locator('.ant-checkbox-input')).toBeChecked({ checked: false }); //修改菜单权限,page1无权限,page2有权限 await updateRole({ name: roleData.name, menuUiSchemas: [uid2] }); @@ -47,8 +49,10 @@ test('menu permission ', async ({ page, mockPage, mockRole, updateRole }) => { .nth(1) .click(); await page.getByRole('tab').getByText('Menu').click(); - await page.waitForSelector('.ant-table'); - await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input')).toBeChecked({ checked: false }); + await page.waitForTimeout(1000); + await expect(page.getByRole('row', { name: 'page1' }).locator('.ant-checkbox-input').last()).toBeChecked({ + checked: false, + }); await expect(page.getByRole('row', { name: 'page2' }).locator('.ant-checkbox-input')).toBeChecked({ checked: true }); //通过路由访问无权限的菜单,跳到有权限的第一个菜单 await page.goto(`/admin/${uid1}`); diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/index.ts b/packages/plugins/@nocobase/plugin-acl/src/client/index.ts index 172585bc1..cec44c813 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/index.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/client/index.ts @@ -2,12 +2,12 @@ import { Plugin } from '@nocobase/client'; import { RolesManagement } from './RolesManagement'; import { RolesManager } from './roles-manager'; -class ACLPlugin extends Plugin { +export class PluginACLClient extends Plugin { rolesManager = new RolesManager(); async load() { - this.app.pluginSettingsManager.add('users-permissions.roles', { - title: '{{t("Roles & Permissions")}}', + this.pluginSettingsManager.add('users-permissions.roles', { + title: this.t('Roles & Permissions'), icon: 'LockOutlined', Component: RolesManagement, aclSnippet: 'pm.acl.roles', @@ -16,5 +16,5 @@ class ACLPlugin extends Plugin { } } -export default ACLPlugin; export { RolesManagerContext } from './RolesManagerProvider'; +export default PluginACLClient; diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/locale.ts b/packages/plugins/@nocobase/plugin-acl/src/client/locale.ts index e2996e284..d80c57e43 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/locale.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/client/locale.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; export function useACLTranslation() { - return useTranslation(['acl', 'client'], { nsMode: 'fallback' }); + return useTranslation(['@nocobase/plugin-acl', 'client'], { nsMode: 'fallback' }); } diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/ActionPermissions.tsx b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/ActionPermissions.tsx index d174bdf58..e7038f66e 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/ActionPermissions.tsx +++ b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/ActionPermissions.tsx @@ -1,6 +1,6 @@ +import { ISchema, useForm } from '@formily/react'; import { CollectionContext, - CollectionProvider, CollectionProvider_deprecated, ResourceActionContext, SchemaComponent, @@ -8,17 +8,14 @@ import { useActionContext, useFilterFieldOptions, useFilterFieldProps, - useCollectionRecord, useRecord, useRequest, useResourceActionContext, } from '@nocobase/client'; import React, { useContext, useEffect } from 'react'; -import { roleCollectionsSchema } from '../schemas/roles'; import { RolesManagerContext } from '../RolesManagerProvider'; -import { ISchema } from '@formily/react'; +import { roleCollectionsSchema } from '../schemas/roles'; import { RolesResourcesActions } from './RolesResourcesActions'; -import { useForm } from '@formily/react'; const collection = { name: 'collections', diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/GeneralPermissions.tsx b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/GeneralPermissions.tsx index 376262ab1..57ab7b288 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/GeneralPermissions.tsx +++ b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/GeneralPermissions.tsx @@ -5,11 +5,12 @@ import { Checkbox, message } from 'antd'; import uniq from 'lodash/uniq'; import React, { useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { uid } from '@formily/shared'; +import { useMemoizedFn } from 'ahooks'; import { RolesManagerContext } from '../RolesManagerProvider'; import { StrategyActions } from './StrategyActions'; import { useACLTranslation } from '../locale'; -import { uid } from '@formily/shared'; -import { useMemoizedFn } from 'ahooks'; +import { PluginPermissions } from './PluginPermissions'; const SnippetCheckboxGroup = connect((props) => { const { t } = useTranslation(); @@ -61,25 +62,8 @@ export const GeneralPermissions: React.FC<{ const { role, setRole } = useContext(RolesManagerContext); const { t } = useACLTranslation(); const api = useAPIClient(); - const { data } = useRequest( - () => - api - .resource('roles') - .get({ - filterByTk: role?.name, - }) - .then((res) => { - const record = res?.data?.data; - record.snippets?.forEach((key: string) => { - record[key] = true; - }); - return record; - }), - { - ready: active, - refreshDeps: [role?.name], - }, - ); + const pm = role?.snippets?.includes('pm.*'); + const update = useMemoizedFn(async (form: Form) => { await api.resource('roles').update({ filterByTk: role.name, @@ -90,37 +74,48 @@ export const GeneralPermissions: React.FC<{ }); const form = useMemo(() => { return createForm({ - values: data, + values: role, effects() { onFormValuesChange(async (form) => { await update(form); }); }, }); - }, [data, update]); + }, [role, update]); return ( = ({ active }) => { const { styles } = useStyles(); - const { role } = useContext(RolesManagerContext); + const { role, setRole } = useContext(RolesManagerContext); const api = useAPIClient(); const { items } = useMenuItems(); const { t } = useTranslation(); @@ -107,51 +110,89 @@ export const MenuPermissions: React.FC<{ }; }); }; - + const update = useMemoizedFn(async (form: Form) => { + await api.resource('roles').update({ + filterByTk: role.name, + values: form.values, + }); + setRole({ ...role, ...form.values }); + message.success(t('Saved successfully')); + }); + const form = useMemo(() => { + return createForm({ + values: role, + effects() { + onFormValuesChange(async (form) => { + await update(form); + }); + }, + }); + }, [role, update]); return ( - - { - if (allChecked) { - await resource.set({ - values: [], - }); - } else { - await resource.set({ - values: allUids, - }); - } - refresh(); - message.success(t('Saved successfully')); - }} - />{' '} - {t('Accessible')} - - ), - render: (_, schema) => { - const checked = uids.includes(schema.uid); - return handleChange(checked, schema)} />; + <> + + properties: { + allowNewMenu: { + title: t('Menu permissions'), + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + 'x-content': t('New menu items are allowed to be accessed by default.'), + }, + }, + }} + /> + +
+ { + if (allChecked) { + await resource.set({ + values: [], + }); + } else { + await resource.set({ + values: allUids, + }); + } + refresh(); + message.success(t('Saved successfully')); + }} + />{' '} + {t('Accessible')} + + ), + render: (_, schema) => { + const checked = uids.includes(schema.uid); + return handleChange(checked, schema)} />; + }, + }, + ]} + dataSource={translateTitle(items)} + /> + ); }; diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/Permissions.tsx b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/Permissions.tsx index 05ed5a0da..8b28cdbda 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/Permissions.tsx +++ b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/Permissions.tsx @@ -1,4 +1,4 @@ -import { useApp } from '@nocobase/client'; +import { useApp, useRequest, useAPIClient } from '@nocobase/client'; import { Tabs } from 'antd'; import React, { useContext, useEffect, useMemo } from 'react'; import { RolesManagerContext } from '../RolesManagerProvider'; @@ -7,7 +7,6 @@ import { AvailableActionsProvider } from './AvailableActions'; import { GeneralPermissions } from './GeneralPermissions'; import { MenuItemsProvider } from './MenuItemsProvider'; import { MenuPermissions } from './MenuPermissions'; -import { PluginPermissions } from './PluginPermissions'; const TabLayout: React.FC = (props) => { return
{props.children}
; @@ -16,7 +15,7 @@ const TabLayout: React.FC = (props) => { export const Permissions: React.FC<{ active: boolean }> = ({ active }) => { const { t } = useACLTranslation(); const [activeKey, setActiveKey] = React.useState('general'); - const { role } = useContext(RolesManagerContext); + const { role, setRole } = useContext(RolesManagerContext); const pm = role?.snippets?.includes('pm.*'); const app = useApp(); const DataSourcePermissionManager = app.getComponent('DataSourcePermissionManager'); @@ -25,7 +24,7 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => { () => [ { key: 'general', - label: t('General'), + label: t('System'), children: ( @@ -43,19 +42,6 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => { ), }, - ...(pm - ? [ - { - key: 'plugin', - label: t('Plugin settings'), - children: ( - - - - ), - }, - ] - : []), ...(DataSourcePermissionManager ? [ { @@ -74,10 +60,33 @@ export const Permissions: React.FC<{ active: boolean }> = ({ active }) => { ], [pm, activeKey, active, t], ); - + const api = useAPIClient(); + const { data } = useRequest( + () => + api + .resource('roles') + .get({ + filterByTk: role?.name, + }) + .then((res) => { + const record = res?.data?.data; + record.snippets?.forEach((key: string) => { + record[key] = true; + }); + return record; + }), + { + ready: active && !!role, + refreshDeps: [role?.name], + }, + ); useEffect(() => { setActiveKey('general'); }, [role?.name]); + + useEffect(() => { + setRole(data); + }, [data]); return ( setActiveKey(key)} items={items} /> diff --git a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/PluginPermissions.tsx b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/PluginPermissions.tsx index 301d61bdb..f42090efb 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/client/permissions/PluginPermissions.tsx +++ b/packages/plugins/@nocobase/plugin-acl/src/client/permissions/PluginPermissions.tsx @@ -38,7 +38,7 @@ export const PluginPermissions: React.FC<{ const compile = useCompile(); const settings = app.pluginSettingsManager.getList(false); const allAclSnippets = app.pluginSettingsManager.getAclSnippets(); - const [snippets, setSnippets] = useState([]); + const [snippets, setSnippets] = useState(role?.snippets || []); const flatPlugins = useMemo(() => { return flatMap(settings, (item) => { if (item.children) { @@ -52,12 +52,11 @@ export const PluginPermissions: React.FC<{ () => snippets.includes('pm.*') && snippets.every((item) => !item.startsWith('!pm.')), [snippets], ); - const { t } = useTranslation(); const { loading, refresh } = useRequest( { resource: 'roles.snippets', - resourceOf: role.name, + resourceOf: role?.name, action: 'list', params: { paginate: false, @@ -66,6 +65,7 @@ export const PluginPermissions: React.FC<{ { ready: !!role && active, refreshDeps: [role?.name], + manual: true, onSuccess(data) { setSnippets( data?.data.filter((v) => { @@ -75,7 +75,10 @@ export const PluginPermissions: React.FC<{ }, }, ); - const resource = api.resource('roles.snippets', role.name); + if (!role) { + return; + } + const resource = api.resource('roles.snippets', role?.name); const handleChange = async (checked, record) => { const childrenKeys = getChildrenKeys(record?.children, []); const totalKeys = childrenKeys.concat(record.aclSnippet); @@ -129,7 +132,7 @@ export const PluginPermissions: React.FC<{ refresh(); message.success(t('Saved successfully')); }} - />{' '} + /> {t('Accessible')} ), diff --git a/packages/plugins/@nocobase/plugin-acl/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-acl/src/locale/zh-CN.json index 17d953a87..0a13115b3 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-acl/src/locale/zh-CN.json @@ -2,5 +2,10 @@ "The current user has no roles. Please try another account.": "当前用户没有角色,请使用其他账号。", "The user role does not exist. Please try signing in again": "用户角色不存在,请尝试重新登录。", "New role": "新建角色", - "Permissions": "权限" + "Permissions": "权限", + "Roles & Permissions": "角色和权限", + "General": "通用", + "Menu": "菜单", + "Plugin settings": "插件设置", + "Data sources": "数据源" } diff --git a/packages/plugins/@nocobase/plugin-acl/src/server/index.ts b/packages/plugins/@nocobase/plugin-acl/src/server/index.ts index 933c0462e..b35e3bfb9 100644 --- a/packages/plugins/@nocobase/plugin-acl/src/server/index.ts +++ b/packages/plugins/@nocobase/plugin-acl/src/server/index.ts @@ -1,5 +1,6 @@ -export { default } from './server'; +export * from './middlewares/setCurrentRole'; +export * from './middlewares/with-acl-meta'; export { RoleResourceActionModel } from './model/RoleResourceActionModel'; export { RoleResourceModel } from './model/RoleResourceModel'; -export * from './middlewares/with-acl-meta'; -export * from './middlewares/setCurrentRole'; + +export { default } from './server'; diff --git a/packages/plugins/@nocobase/plugin-action-bulk-edit/package.json b/packages/plugins/@nocobase/plugin-action-bulk-edit/package.json index 4f3367102..539686547 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-edit/package.json +++ b/packages/plugins/@nocobase/plugin-action-bulk-edit/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-action-bulk-edit", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-bulk-edit", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-bulk-edit", diff --git a/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/index.tsx b/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/index.tsx index fe337eafc..b64ac562d 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/index.tsx @@ -1,20 +1,20 @@ import { Plugin, useCollection_deprecated } from '@nocobase/client'; import { bulkEditActionSettings, deprecatedBulkEditActionSettings } from './BulkEditAction.Settings'; -import { BulkEditFormItemInitializers_deprecated, bulkEditFormItemInitializers } from './BulkEditFormItemInitializers'; +import { BulkEditActionInitializer } from './BulkEditActionInitializer'; import { - CreateFormBulkEditBlockInitializers, BulkEditBlockInitializers_deprecated, + CreateFormBulkEditBlockInitializers, bulkEditBlockInitializers, } from './BulkEditBlockInitializers'; import { BulkEditFormActionInitializers_deprecated, bulkEditFormActionInitializers, } from './BulkEditFormActionInitializers'; -import { BulkEditActionInitializer } from './BulkEditActionInitializer'; +import { BulkEditFormItemInitializers_deprecated, bulkEditFormItemInitializers } from './BulkEditFormItemInitializers'; import { bulkEditFormItemSettings } from './bulkEditFormItemSettings'; import { BulkEditField } from './component/BulkEditField'; import { useCustomizeBulkEditActionProps } from './utils'; -export class BulkEditPlugin extends Plugin { +export class PluginActionBulkEditClient extends Plugin { async load() { this.app.addComponents({ BulkEditField }); this.app.addScopes({ useCustomizeBulkEditActionProps }); @@ -61,4 +61,4 @@ export class BulkEditPlugin extends Plugin { } } -export default BulkEditPlugin; +export default PluginActionBulkEditClient; diff --git a/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/locale/index.ts b/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/locale/index.ts index 30b82f7d8..da9aa4d92 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/locale/index.ts +++ b/packages/plugins/@nocobase/plugin-action-bulk-edit/src/client/locale/index.ts @@ -1,10 +1,7 @@ import { i18n } from '@nocobase/client'; import { useTranslation } from 'react-i18next'; -export const NAMESPACE = 'bulk-edit'; - -// i18n.addResources('zh-CN', NAMESPACE, zhCN); -// i18n.addResources('en-US', NAMESPACE, enUS); +export const NAMESPACE = '@nocobase/plugin-bulk-edit'; export function lang(key: string) { return i18n.t(key, { ns: NAMESPACE }); diff --git a/packages/plugins/@nocobase/plugin-action-bulk-update/package.json b/packages/plugins/@nocobase/plugin-action-bulk-update/package.json index bc04a194e..631d952ac 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-update/package.json +++ b/packages/plugins/@nocobase/plugin-action-bulk-update/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-action-bulk-update", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-bulk-update", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-bulk-update", diff --git a/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/index.tsx b/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/index.tsx index 360b12649..e8ee9a03e 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/index.tsx @@ -1,9 +1,9 @@ import { Plugin, useCollection_deprecated } from '@nocobase/client'; import { bulkUpdateActionSettings, deprecatedBulkUpdateActionSettings } from './BulkUpdateAction.Settings'; +import { BulkUpdateActionInitializer } from './BulkUpdateActionInitializer'; import { CustomizeActionInitializer } from './CustomizeActionInitializer'; import { useCustomizeBulkUpdateActionProps } from './utils'; -import { BulkUpdateActionInitializer } from './BulkUpdateActionInitializer'; -export class PluginBulkUpdateClient extends Plugin { +export class PluginActionBulkUpdateClient extends Plugin { async load() { this.app.addComponents({ CustomizeActionInitializer }); this.app.addScopes({ useCustomizeBulkUpdateActionProps }); @@ -31,4 +31,4 @@ export class PluginBulkUpdateClient extends Plugin { } } -export default PluginBulkUpdateClient; +export default PluginActionBulkUpdateClient; diff --git a/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/locale/index.ts b/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/locale/index.ts index d65c0dd93..2028aa4a4 100644 --- a/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/locale/index.ts +++ b/packages/plugins/@nocobase/plugin-action-bulk-update/src/client/locale/index.ts @@ -3,9 +3,6 @@ import { useTranslation } from 'react-i18next'; export const NAMESPACE = 'bulk-update'; -// i18n.addResources('zh-CN', NAMESPACE, zhCN); -// i18n.addResources('en-US', NAMESPACE, enUS); - export function lang(key: string) { return i18n.t(key, { ns: NAMESPACE }); } diff --git a/packages/plugins/@nocobase/plugin-action-duplicate/package.json b/packages/plugins/@nocobase/plugin-action-duplicate/package.json index 53711f695..791087636 100644 --- a/packages/plugins/@nocobase/plugin-action-duplicate/package.json +++ b/packages/plugins/@nocobase/plugin-action-duplicate/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-action-duplicate", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-duplicate", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-duplicate", diff --git a/packages/plugins/@nocobase/plugin-action-duplicate/src/client/index.ts b/packages/plugins/@nocobase/plugin-action-duplicate/src/client/index.ts index 072d681d8..264e405ad 100644 --- a/packages/plugins/@nocobase/plugin-action-duplicate/src/client/index.ts +++ b/packages/plugins/@nocobase/plugin-action-duplicate/src/client/index.ts @@ -4,7 +4,7 @@ import { deprecatedDuplicateActionSettings, duplicateActionSettings } from './Du import { DuplicateActionInitializer } from './DuplicateActionInitializer'; import { DuplicatePluginProvider } from './DuplicatePluginProvider'; -export class PluginDuplicateClient extends Plugin { +export class PluginActionDuplicateClient extends Plugin { async load() { this.app.use(DuplicatePluginProvider); this.app.addComponents({ @@ -64,5 +64,5 @@ export class PluginDuplicateClient extends Plugin { } } -export default PluginDuplicateClient; +export default PluginActionDuplicateClient; export * from './DuplicateAction'; diff --git a/packages/plugins/@nocobase/plugin-action-print/package.json b/packages/plugins/@nocobase/plugin-action-print/package.json index 60502edea..3fe51127a 100644 --- a/packages/plugins/@nocobase/plugin-action-print/package.json +++ b/packages/plugins/@nocobase/plugin-action-print/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-action-print", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-print", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-print", diff --git a/packages/plugins/@nocobase/plugin-action-print/src/client/index.tsx b/packages/plugins/@nocobase/plugin-action-print/src/client/index.tsx index 3730092f6..645845abd 100644 --- a/packages/plugins/@nocobase/plugin-action-print/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-action-print/src/client/index.tsx @@ -1,7 +1,7 @@ import { Plugin } from '@nocobase/client'; import { deprecatedPrintActionSettings, printActionSettings } from './PrintAction.Settings'; import { PrintActionPluginProvider } from './PrintActionPluginProvider'; -export class PrintPlugin extends Plugin { +export class PluginActionPrintClient extends Plugin { async load() { this.app.use(PrintActionPluginProvider); this.app.schemaSettingsManager.add(deprecatedPrintActionSettings); @@ -23,4 +23,4 @@ export class PrintPlugin extends Plugin { } } -export default PrintPlugin; +export default PluginActionPrintClient; diff --git a/packages/plugins/@nocobase/plugin-api-doc/package.json b/packages/plugins/@nocobase/plugin-api-doc/package.json index cfe9bf961..fb771dec3 100644 --- a/packages/plugins/@nocobase/plugin-api-doc/package.json +++ b/packages/plugins/@nocobase/plugin-api-doc/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-api-doc", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "API documentation", "displayName.zh-CN": "API 文档", "description": "An OpenAPI documentation generator for NocoBase HTTP API.", diff --git a/packages/plugins/@nocobase/plugin-api-doc/src/client/index.tsx b/packages/plugins/@nocobase/plugin-api-doc/src/client/index.tsx index 92659aa6f..bc8779b50 100644 --- a/packages/plugins/@nocobase/plugin-api-doc/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-api-doc/src/client/index.tsx @@ -37,7 +37,7 @@ const SCDocumentation = () => { ); }; -export class APIDocumentationPlugin extends Plugin { +export class PluginAPIDocClient extends Plugin { async load() { this.app.pluginSettingsManager.add(NAMESPACE, { title: `{{t("API documentation", { ns: "${NAMESPACE}" })}}`, @@ -53,4 +53,4 @@ export class APIDocumentationPlugin extends Plugin { } } -export default APIDocumentationPlugin; +export default PluginAPIDocClient; diff --git a/packages/plugins/@nocobase/plugin-api-doc/src/server/server.ts b/packages/plugins/@nocobase/plugin-api-doc/src/server/server.ts index 8ffad423c..a052885f5 100644 --- a/packages/plugins/@nocobase/plugin-api-doc/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-api-doc/src/server/server.ts @@ -2,7 +2,7 @@ import { Context } from '@nocobase/actions'; import { Plugin } from '@nocobase/server'; import { SwaggerManager } from './swagger'; -export default class APIDoc extends Plugin { +export class PluginAPIDocServer extends Plugin { swagger: SwaggerManager; constructor(app, options) { super(app, options); @@ -10,7 +10,7 @@ export default class APIDoc extends Plugin { } async beforeLoad() {} async load() { - this.app.resource({ + this.app.resourcer.define({ name: 'swagger', type: 'single', actions: { @@ -46,3 +46,5 @@ export default class APIDoc extends Plugin { }); } } + +export default PluginAPIDocServer; diff --git a/packages/plugins/@nocobase/plugin-api-keys/package.json b/packages/plugins/@nocobase/plugin-api-keys/package.json index 135276f4b..182ad552b 100644 --- a/packages/plugins/@nocobase/plugin-api-keys/package.json +++ b/packages/plugins/@nocobase/plugin-api-keys/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "认证:API 密钥", "description": "Allows users to use API key to access application's HTTP API", "description.zh-CN": "允许用户使用 API 密钥访问应用的 HTTP API", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/api-keys", diff --git a/packages/plugins/@nocobase/plugin-api-keys/src/client/Configuration/schema.tsx b/packages/plugins/@nocobase/plugin-api-keys/src/client/Configuration/schema.tsx index b98ab2cb6..aaf6072c4 100644 --- a/packages/plugins/@nocobase/plugin-api-keys/src/client/Configuration/schema.tsx +++ b/packages/plugins/@nocobase/plugin-api-keys/src/client/Configuration/schema.tsx @@ -91,6 +91,7 @@ export const configurationSchema: ISchema = { title: generateNTemplate('Add API key'), 'x-component': 'Action', 'x-component-props': { + icon: 'PlusOutlined', openMode: 'drawer', type: 'primary', }, diff --git a/packages/plugins/@nocobase/plugin-api-keys/src/client/index.tsx b/packages/plugins/@nocobase/plugin-api-keys/src/client/index.tsx index 910dc0db0..c16d4168e 100644 --- a/packages/plugins/@nocobase/plugin-api-keys/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-api-keys/src/client/index.tsx @@ -2,15 +2,15 @@ import { Plugin } from '@nocobase/client'; import { NAMESPACE } from '../constants'; import { Configuration } from './Configuration'; -class APIKeysPlugin extends Plugin { +export class PluginAPIKeysClient extends Plugin { async load() { - this.app.pluginSettingsManager.add(NAMESPACE, { + this.pluginSettingsManager.add(NAMESPACE, { icon: 'KeyOutlined', - title: '{{t("API keys", {"ns": "api-keys"})}}', + title: this.t('API keys'), Component: Configuration, aclSnippet: 'pm.api-keys.configuration', }); } } -export default APIKeysPlugin; +export default PluginAPIKeysClient; diff --git a/packages/plugins/@nocobase/plugin-api-keys/src/constants.ts b/packages/plugins/@nocobase/plugin-api-keys/src/constants.ts index ab5fd31e1..c99c64ea4 100644 --- a/packages/plugins/@nocobase/plugin-api-keys/src/constants.ts +++ b/packages/plugins/@nocobase/plugin-api-keys/src/constants.ts @@ -1 +1 @@ -export const NAMESPACE = 'api-keys'; +export const NAMESPACE = '@nocobase/plugin-api-keys'; diff --git a/packages/plugins/@nocobase/plugin-api-keys/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-api-keys/src/server/plugin.ts index 77f67586b..6314e8579 100644 --- a/packages/plugins/@nocobase/plugin-api-keys/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-api-keys/src/server/plugin.ts @@ -1,24 +1,11 @@ import { Plugin } from '@nocobase/server'; -import { resolve } from 'path'; -import { NAMESPACE } from '../constants'; import { create, destroy } from './actions/api-keys'; -import { enUS, zhCN } from './locale'; -export interface ApiKeysPluginConfig { - name?: string; -} - -export default class ApiKeysPlugin extends Plugin { +export class PluginAPIKeysServer extends Plugin { resourceName = 'apiKeys'; - constructor(app, options) { - super(app, options); - } async beforeLoad() { - this.app.i18n.addResources('zh-CN', NAMESPACE, zhCN); - this.app.i18n.addResources('en-US', NAMESPACE, enUS); - - await this.app.resourcer.define({ + this.app.resourcer.define({ name: this.resourceName, actions: { create, @@ -34,11 +21,9 @@ export default class ApiKeysPlugin extends Plugin { } async load() { - await this.importCollections(resolve(__dirname, '../collections')); - this.app.resourcer.use(async (ctx, next) => { - const { resourceName, actionName } = ctx.action.params; - if (resourceName == this.resourceName && ['list', 'destroy'].includes(actionName)) { + const { resourceName, actionName } = ctx.action; + if (resourceName === this.resourceName && ['list', 'destroy'].includes(actionName)) { ctx.action.mergeParams({ filter: { createdById: ctx.auth.user.id, @@ -49,3 +34,5 @@ export default class ApiKeysPlugin extends Plugin { }); } } + +export default PluginAPIKeysServer; diff --git a/packages/plugins/@nocobase/plugin-audit-logs/package.json b/packages/plugins/@nocobase/plugin-audit-logs/package.json index 0cb2d0f17..ae8af0f71 100644 --- a/packages/plugins/@nocobase/plugin-audit-logs/package.json +++ b/packages/plugins/@nocobase/plugin-audit-logs/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-audit-logs", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "Audit logs (deprecated)", "displayName.zh-CN": "审计日志(废弃)", "description": "This plugin is deprecated. There will be a new audit log plugin in the future.", diff --git a/packages/plugins/@nocobase/plugin-audit-logs/src/client/AuditLogsBlockInitializer.tsx b/packages/plugins/@nocobase/plugin-audit-logs/src/client/AuditLogsBlockInitializer.tsx index 6752dcf10..9607e1581 100644 --- a/packages/plugins/@nocobase/plugin-audit-logs/src/client/AuditLogsBlockInitializer.tsx +++ b/packages/plugins/@nocobase/plugin-audit-logs/src/client/AuditLogsBlockInitializer.tsx @@ -1,28 +1,16 @@ import { TableOutlined } from '@ant-design/icons'; import { ISchema } from '@formily/react'; -import { - createTableBlockSchema, - SchemaInitializerItem, - useSchemaInitializer, - useSchemaInitializerItem, -} from '@nocobase/client'; +import { SchemaInitializerItem, useSchemaInitializer, useSchemaInitializerItem } from '@nocobase/client'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import { createAuditLogsBlockSchema } from './createAuditLogsBlockSchema'; export const AuditLogsBlockInitializer = () => { const { insert } = useSchemaInitializer(); const { t } = useTranslation(); const itemConfig = useSchemaInitializerItem(); - const schema = createTableBlockSchema({ - collection: 'auditLogs', - rowKey: 'id', - tableActionInitializers: 'auditLogsTable:configureActions', - tableColumnInitializers: 'auditLogsTable:configureColumns', - tableActionColumnInitializers: 'auditLogsTable:configureItemActions', - tableBlockProvider: 'AuditLogsBlockProvider', - disableTemplate: true, - }); + const schema = createAuditLogsBlockSchema(); return ( { + let schema: ISchema; + + beforeAll(() => { + schema = createAuditLogsBlockSchema(); + }); + + it('should return a valid schema object', () => { + expect(schema).toBeDefined(); + expect(typeof schema).toBe('object'); + }); + + it('should have specific top-level properties with expected values', () => { + expect(schema).toMatchObject({ + type: 'void', + 'x-decorator': 'AuditLogsBlockProvider', + 'x-acl-action': 'auditLogs:list', + 'x-toolbar': 'BlockSchemaToolbar', + 'x-settings': 'blockSettings:table', + 'x-component': 'CardItem', + 'x-filter-targets': expect.arrayContaining([]), + }); + }); + + describe('x-decorator-props section', () => { + it('should have a collection named "auditLogs"', () => { + expect(schema['x-decorator-props'].collection).toEqual('auditLogs'); + }); + + it('should have a list action', () => { + expect(schema['x-decorator-props'].action).toEqual('list'); + }); + + it('should set pageSize to 20', () => { + expect(schema['x-decorator-props'].params.pageSize).toEqual(20); + }); + + it('contains expected boolean flags', () => { + expect(schema['x-decorator-props'].showIndex).toBeTruthy(); + expect(schema['x-decorator-props'].dragSort).toBeFalsy(); + expect(schema['x-decorator-props'].disableTemplate).toBeTruthy(); + }); + }); + + describe('actions property inside schema.properties', () => { + let actions; + + beforeAll(() => { + // @ts-ignore + actions = schema.properties.actions; + }); + + it('should be defined and have a specific structure', () => { + expect(actions).toBeDefined(); + expect(actions.type).toBe('void'); + expect(actions['x-initializer']).toBe('auditLogsTable:configureActions'); + expect(actions['x-component']).toBe('ActionBar'); + }); + + it('has x-component-props with expected style', () => { + expect(actions['x-component-props'].style.marginBottom).toBe('var(--nb-spacing)'); + }); + }); + + describe('properties should include dynamically keyed objects', () => { + it('keyed objects should match the expected structure for tables', () => { + const propertyKeys = Object.keys(schema.properties).filter((key) => key !== 'actions'); + propertyKeys.forEach((key) => { + expect(schema.properties[key].type).toBe('array'); + expect(schema.properties[key]['x-component']).toBe('TableV2'); + }); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-audit-logs/src/client/createAuditLogsBlockSchema.tsx b/packages/plugins/@nocobase/plugin-audit-logs/src/client/createAuditLogsBlockSchema.tsx new file mode 100644 index 000000000..2f7e1ef6b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-audit-logs/src/client/createAuditLogsBlockSchema.tsx @@ -0,0 +1,71 @@ +import { ISchema } from '@formily/react'; +import { uid } from '@nocobase/utils/client'; + +export function createAuditLogsBlockSchema(): ISchema { + return { + type: 'void', + 'x-decorator': 'AuditLogsBlockProvider', + 'x-acl-action': `auditLogs:list`, + 'x-decorator-props': { + collection: 'auditLogs', + action: 'list', + params: { + pageSize: 20, + }, + rowKey: 'id', + showIndex: true, + dragSort: false, + disableTemplate: true, + }, + 'x-toolbar': 'BlockSchemaToolbar', + 'x-settings': 'blockSettings:table', + 'x-component': 'CardItem', + 'x-filter-targets': [], + properties: { + actions: { + type: 'void', + 'x-initializer': 'auditLogsTable:configureActions', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 'var(--nb-spacing)', + }, + }, + properties: {}, + }, + [uid()]: { + type: 'array', + 'x-initializer': 'auditLogsTable:configureColumns', + 'x-component': 'TableV2', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useProps: '{{ useTableBlockProps }}', + }, + properties: { + actions: { + type: 'void', + title: '{{ t("Actions") }}', + 'x-action-column': 'actions', + 'x-decorator': 'TableV2.Column.ActionBar', + 'x-component': 'TableV2.Column', + 'x-designer': 'TableV2.ActionColumnDesigner', + 'x-initializer': 'auditLogsTable:configureItemActions', + properties: { + [uid()]: { + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + }, + }, + }, + }, + }, + }, + }; +} diff --git a/packages/plugins/@nocobase/plugin-auth/package.json b/packages/plugins/@nocobase/plugin-auth/package.json index d5a768b8a..c52486684 100644 --- a/packages/plugins/@nocobase/plugin-auth/package.json +++ b/packages/plugins/@nocobase/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-auth", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/auth", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/auth", diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx b/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx index 50f5e1203..bf5ec0971 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-auth/src/client/index.tsx @@ -1,13 +1,13 @@ import { Plugin } from '@nocobase/client'; -import { AuthProvider } from './AuthProvider'; -import { NAMESPACE } from './locale'; -import { Authenticator } from './settings/Authenticator'; -import { AuthLayout, SignInPage, SignUpPage } from './pages'; -import { ComponentType } from 'react'; import { Registry } from '@nocobase/utils/client'; +import { ComponentType } from 'react'; import { presetAuthType } from '../preset'; -import { SignInForm, SignUpForm, Options } from './basic'; +import { AuthProvider } from './AuthProvider'; import { Authenticator as AuthenticatorType } from './authenticator'; +import { Options, SignInForm, SignUpForm } from './basic'; +import { NAMESPACE } from './locale'; +import { AuthLayout, SignInPage, SignUpPage } from './pages'; +import { Authenticator } from './settings/Authenticator'; export type AuthOptions = { components: Partial<{ @@ -18,7 +18,7 @@ export type AuthOptions = { }>; }; -export class AuthPlugin extends Plugin { +export class PluginAuthClient extends Plugin { authTypes = new Registry(); registerType(authType: string, options: AuthOptions) { @@ -63,7 +63,8 @@ export class AuthPlugin extends Plugin { } } -export default AuthPlugin; -export { useSignIn } from './basic'; -export { useAuthenticator, AuthenticatorsContext } from './authenticator'; +export { AuthenticatorsContext, useAuthenticator } from './authenticator'; export type { Authenticator } from './authenticator'; +export { useSignIn } from './basic'; + +export default PluginAuthClient; diff --git a/packages/plugins/@nocobase/plugin-auth/src/client/settings/schemas/authenticators.ts b/packages/plugins/@nocobase/plugin-auth/src/client/settings/schemas/authenticators.ts index c2689ba7f..e9225fa5a 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/client/settings/schemas/authenticators.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/client/settings/schemas/authenticators.ts @@ -191,6 +191,7 @@ export const authenticatorsSchema: ISchema = { title: '{{t("Delete")}}', 'x-component': 'Action', 'x-component-props': { + icon: 'DeleteOutlined', useAction: '{{ cm.useBulkDestroyAction }}', confirm: { title: "{{t('Delete')}}", diff --git a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts index 9856744ff..5c42e737e 100644 --- a/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-auth/src/server/plugin.ts @@ -1,17 +1,17 @@ +import { Cache } from '@nocobase/cache'; import { Model } from '@nocobase/database'; import { InstallOptions, Plugin } from '@nocobase/server'; import { resolve } from 'path'; -import { namespace, presetAuthenticator, presetAuthType } from '../preset'; +import { namespace, presetAuthType, presetAuthenticator } from '../preset'; import authActions from './actions/auth'; import authenticatorsActions from './actions/authenticators'; import { BasicAuth } from './basic-auth'; import { enUS, zhCN } from './locale'; import { AuthModel } from './model/authenticator'; -import { TokenBlacklistService } from './token-blacklist'; -import { Cache } from '@nocobase/cache'; import { Storer } from './storer'; +import { TokenBlacklistService } from './token-blacklist'; -export class AuthPlugin extends Plugin { +export class PluginAuthServer extends Plugin { cache: Cache; afterAdd() {} @@ -105,4 +105,4 @@ export class AuthPlugin extends Plugin { async remove() {} } -export default AuthPlugin; +export default PluginAuthServer; diff --git a/packages/plugins/@nocobase/plugin-backup-restore/package.json b/packages/plugins/@nocobase/plugin-backup-restore/package.json index a34a10876..dc3e49088 100644 --- a/packages/plugins/@nocobase/plugin-backup-restore/package.json +++ b/packages/plugins/@nocobase/plugin-backup-restore/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "应用的备份与还原", "description": "Backup and restore applications for scenarios such as application replication, migration, and upgrades.", "description.zh-CN": "备份和还原应用,可用于应用的复制、迁移、升级等场景。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/backup-restore", diff --git a/packages/plugins/@nocobase/plugin-backup-restore/src/client/index.tsx b/packages/plugins/@nocobase/plugin-backup-restore/src/client/index.tsx index 87b73f914..8ea08dbb2 100644 --- a/packages/plugins/@nocobase/plugin-backup-restore/src/client/index.tsx +++ b/packages/plugins/@nocobase/plugin-backup-restore/src/client/index.tsx @@ -2,11 +2,12 @@ import { Plugin } from '@nocobase/client'; import { BackupAndRestoreList } from './Configuration'; import { DuplicatorProvider } from './DuplicatorProvider'; import { NAMESPACE } from './locale'; -export class DuplicatorPlugin extends Plugin { + +export class PluginBackupRestoreClient extends Plugin { async load() { this.app.use(DuplicatorProvider); this.app.pluginSettingsManager.add(NAMESPACE, { - title: `{{t("Backup & Restore", { ns: "${NAMESPACE}" })}}`, + title: this.t('Backup & Restore'), icon: 'CloudServerOutlined', Component: BackupAndRestoreList, aclSnippet: 'pm.backup.restore', @@ -14,4 +15,4 @@ export class DuplicatorPlugin extends Plugin { } } -export default DuplicatorPlugin; +export default PluginBackupRestoreClient; diff --git a/packages/plugins/@nocobase/plugin-backup-restore/src/server/server.ts b/packages/plugins/@nocobase/plugin-backup-restore/src/server/server.ts index d04fbdf6e..4b75c4908 100644 --- a/packages/plugins/@nocobase/plugin-backup-restore/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-backup-restore/src/server/server.ts @@ -1,7 +1,7 @@ import { Plugin } from '@nocobase/server'; import backupFilesResourcer from './resourcers/backup-files'; -export default class Duplicator extends Plugin { +export default class PluginBackupRestoreServer extends Plugin { beforeLoad() { this.app.acl.registerSnippet({ name: `pm.${this.name}`, diff --git a/packages/plugins/@nocobase/plugin-calendar/package.json b/packages/plugins/@nocobase/plugin-calendar/package.json index f659ca4ef..30c9721e7 100644 --- a/packages/plugins/@nocobase/plugin-calendar/package.json +++ b/packages/plugins/@nocobase/plugin-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-calendar", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "Calendar", "displayName.zh-CN": "日历", "description": "Provides callendar collection template and block for managing date data, typically for date/time related information such as events, appointments, tasks, and so on.", diff --git a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calender.Settings.tsx b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calender.Settings.tsx index 0180f9792..2e3260b09 100644 --- a/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calender.Settings.tsx +++ b/packages/plugins/@nocobase/plugin-calendar/src/client/calendar/Calender.Settings.tsx @@ -205,7 +205,8 @@ export const calendarBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'Calendar', collectionName: name, diff --git a/packages/plugins/@nocobase/plugin-cas/package.json b/packages/plugins/@nocobase/plugin-cas/package.json index a70837777..98071299d 100644 --- a/packages/plugins/@nocobase/plugin-cas/package.json +++ b/packages/plugins/@nocobase/plugin-cas/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "认证:CAS", "description": "CAS authentication.", "description.zh-CN": "通过 CAS 协议认证身份。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/auth-cas", diff --git a/packages/plugins/@nocobase/plugin-charts/package.json b/packages/plugins/@nocobase/plugin-charts/package.json index 841f844f9..c03c11de7 100644 --- a/packages/plugins/@nocobase/plugin-charts/package.json +++ b/packages/plugins/@nocobase/plugin-charts/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "图表(废弃)", "description": "The plugin has been deprecated, please use the data visualization plugin instead.", "description.zh-CN": "已废弃插件,请使用数据可视化插件代替。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "license": "AGPL-3.0", "devDependencies": { diff --git a/packages/plugins/@nocobase/plugin-china-region/package.json b/packages/plugins/@nocobase/plugin-china-region/package.json index cbc042c7f..9708a553f 100644 --- a/packages/plugins/@nocobase/plugin-china-region/package.json +++ b/packages/plugins/@nocobase/plugin-china-region/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-china-region", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "Administrative divisions of China", "displayName.zh-CN": "中国行政区划", "description": "Provides data and field type for administrative divisions of China.", diff --git a/packages/plugins/@nocobase/plugin-client/package.json b/packages/plugins/@nocobase/plugin-client/package.json index eb0dcb795..fe35b9fd9 100644 --- a/packages/plugins/@nocobase/plugin-client/package.json +++ b/packages/plugins/@nocobase/plugin-client/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "WEB 客户端", "description": "Provides a client interface for the NocoBase server", "description.zh-CN": "为 NocoBase 服务端提供客户端界面", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "license": "AGPL-3.0", "devDependencies": { diff --git a/packages/plugins/@nocobase/plugin-collection-manager/package.json b/packages/plugins/@nocobase/plugin-collection-manager/package.json index ac89b9d98..9164115ce 100644 --- a/packages/plugins/@nocobase/plugin-collection-manager/package.json +++ b/packages/plugins/@nocobase/plugin-collection-manager/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "数据源:主数据库", "description": "NocoBase main database, supports relational databases such as MySQL, PostgreSQL, SQLite and so on.", "description.zh-CN": "NocoBase 主数据库,支持 MySQL、PostgreSQL、SQLite 等关系型数据库。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/data-source-main", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/data-source-main", diff --git a/packages/plugins/@nocobase/plugin-custom-request/package.json b/packages/plugins/@nocobase/plugin-custom-request/package.json index 619039b34..bdc32ba68 100644 --- a/packages/plugins/@nocobase/plugin-custom-request/package.json +++ b/packages/plugins/@nocobase/plugin-custom-request/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-custom-request", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-custom-request", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/action-custom-request", diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/package.json b/packages/plugins/@nocobase/plugin-data-source-manager/package.json index 686b5a986..0ab3e4806 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/package.json +++ b/packages/plugins/@nocobase/plugin-data-source-manager/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-data-source-manager", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "displayName": "Data source manager", "displayName.zh-CN": "数据源管理", diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx index 92e1c9745..87a07cff5 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/CollectionsManager/EditCollectionAction.tsx @@ -1,26 +1,28 @@ import { ArrayTable } from '@formily/antd-v5'; import { ISchema, useForm } from '@formily/react'; import { uid } from '@formily/shared'; +import { + ActionContextProvider, + IField, + RecordProvider, + SchemaComponent, + tval, + useAPIClient, + useActionContext, + useCollectionManager_deprecated, + useCompile, + useRecord, + useRequest, + useResourceActionContext, + useResourceContext, + useCancelAction, + useDataSourceManager, +} from '@nocobase/client'; import cloneDeep from 'lodash/cloneDeep'; import omit from 'lodash/omit'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; -import { - useAPIClient, - useRequest, - IField, - RecordProvider, - useCollectionManager_deprecated, - useRecord, - ActionContextProvider, - SchemaComponent, - useActionContext, - useCompile, - useResourceActionContext, - useResourceContext, - useCancelAction, -} from '@nocobase/client'; import { NAMESPACE } from '../../locale'; const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => { @@ -60,6 +62,10 @@ const getSchema = (schema: IField, record: any, compile, getContainer): ISchema filterTargetKey: { title: `{{ t("Filter target key",{ ns: "${NAMESPACE}" }) }}`, type: 'single', + description: tval( + 'Filter data based on the specific field, with the requirement that the field value must be unique.', + { ns: NAMESPACE }, + ), 'x-decorator': 'FormItem', 'x-component': 'Select', enum: '{{filterTargetKeyOptions}}', @@ -125,6 +131,7 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => { const { resource, targetKey } = useResourceContext(); const { [targetKey]: filterByTk } = useRecord(); const api = useAPIClient(); + const dm = useDataSourceManager(); return { async run() { await form.submit(); @@ -135,6 +142,7 @@ export const useUpdateCollectionActionAndRefreshCM = (options) => { ...omit(form.values, ['fields', 'autoGenId', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'sortable']), }, }); + await dm.getDataSource(name).reload(); ctx.setVisible(false); await form.reset(); refresh(); diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx index 784459299..f93ae951a 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client/component/DatabaseConnectionManager.tsx @@ -60,6 +60,13 @@ export const DatabaseConnectionManagerPane = () => { const service = useResourceActionContext(); return { async onClick() { + const needReloadDataSources = service?.data?.data.filter((item) => item.status !== 'loaded'); + if (needReloadDataSources?.length) { + const dataSources = dm.getDataSources(); + const needLoadDataSourceKeys = needReloadDataSources.map((item) => item.key); + const needLoadDataSourcesInstance = dataSources.filter((item) => needLoadDataSourceKeys.includes(item.key)); + await Promise.all(needLoadDataSourcesInstance.map((item) => item.reload())); + } service?.refresh?.(); }, }; diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-data-source-manager/src/locale/zh-CN.json index f94de0ba6..d0772ad43 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/locale/zh-CN.json @@ -30,5 +30,6 @@ "Reloading": "加载中", "Data source synchronization in progress": "数据源同步中", "Data source synchronization successful": "数据源同步成功", - "Filter target key":"筛选目标键" + "Filter target key":"筛选目标键", + "Filter data based on the specific field, with the requirement that the field value must be unique.": "根据特定的字段筛选数据,字段值必须具备唯一性。" } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/package.json b/packages/plugins/@nocobase/plugin-data-visualization/package.json index b8c6d9447..8838b7f05 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/package.json +++ b/packages/plugins/@nocobase/plugin-data-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-data-visualization", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "Data visualization", "displayName.zh-CN": "数据可视化", "description": "Provides data visualization feature, including chart block and chart filter block, support line charts, area charts, bar charts and more than a dozen kinds of charts, you can also extend more chart types.", diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/chart-api.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/chart-api.test.ts index 476716103..907ba76ae 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/chart-api.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/chart-api.test.ts @@ -11,38 +11,38 @@ describe('api', () => { }); test('setGroup', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group', charts1); expect(plugin.charts.charts.get('group')).toEqual(charts1); - const charts2 = [new Chart({ name: 'test2', title: 'Test2', component: null })]; + const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })]; plugin.charts.setGroup('group', charts2); expect(plugin.charts.charts.get('group')).toEqual(charts2); }); test('addGroup', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group1', charts1); - const charts2 = [new Chart({ name: 'test2', title: 'Test2', component: null })]; + const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })]; plugin.charts.addGroup('group2', charts2); expect(plugin.charts.charts.get('group1')).toEqual(charts1); expect(plugin.charts.charts.get('group2')).toEqual(charts2); }); test('add', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group', charts1); - const chart = new Chart({ name: 'test2', title: 'Test2', component: null }); + const chart = new Chart({ name: 'test2', title: 'Test2', Component: null }); plugin.charts.add('group', chart); expect(plugin.charts.charts.get('group').length).toEqual(2); expect(plugin.charts.charts.get('group')[1].name).toEqual('test2'); }); test('getChartTypes', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group1', charts1); - const charts2 = [new Chart({ name: 'test2', title: 'Test2', component: null })]; + const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })]; plugin.charts.setGroup('group2', charts2); expect(plugin.charts.getChartTypes()).toEqual([ { @@ -69,9 +69,9 @@ describe('api', () => { }); test('getCharts', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group1', charts1); - const charts2 = [new Chart({ name: 'test2', title: 'Test2', component: null })]; + const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })]; plugin.charts.setGroup('group2', charts2); expect(plugin.charts.getCharts()).toEqual({ 'group1.test1': charts1[0], @@ -80,9 +80,9 @@ describe('api', () => { }); test('getChart', () => { - const charts1 = [new Chart({ name: 'test1', title: 'Test1', component: null })]; + const charts1 = [new Chart({ name: 'test1', title: 'Test1', Component: null })]; plugin.charts.setGroup('group1', charts1); - const charts2 = [new Chart({ name: 'test2', title: 'Test2', component: null })]; + const charts2 = [new Chart({ name: 'test2', title: 'Test2', Component: null })]; plugin.charts.setGroup('group2', charts2); expect(plugin.charts.getChart('group1.test1')).toEqual(charts1[0]); expect(plugin.charts.getChart('group2.test2')).toEqual(charts2[0]); @@ -90,7 +90,7 @@ describe('api', () => { }); describe('auto infer', () => { - const chart = new Chart({ name: 'test', title: 'Test', component: null }); + const chart = new Chart({ name: 'test', title: 'Test', Component: null }); const fields = [ { name: 'price', diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts index 38e0e716f..336f3fd25 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/__tests__/hooks.test.ts @@ -15,66 +15,72 @@ import { describe('hooks', () => { beforeEach(() => { - vi.spyOn(client, 'useCollectionManager_deprecated').mockReturnValue({ - getCollectionFields: (name: string) => - ({ - orders: [ - { - interface: 'string', - name: 'name', - uiSchema: { - title: '{{t("Name")}}', - }, - type: 'string', - }, - { - interface: 'number', - name: 'price', - uiSchema: { - title: '{{t("Price")}}', - }, - type: 'double', - }, - { - interface: 'createdAt', - name: 'createdAt', - uiSchema: { - title: '{{t("Created At")}}', - }, - type: 'date', - }, - { - interface: 'm2o', - name: 'user', - uiSchema: { - title: '{{t("User")}}', - }, - target: 'users', - type: 'belongsTo', - }, - ], - users: [ - { - interface: 'string', - name: 'name', - uiSchema: { - title: '{{t("Name")}}', - }, - type: 'string', - }, - ], - })[name], - getInterface: (i: string) => { - switch (i) { - case 'm2o': - return { - filterable: { - nested: true, - }, - }; - default: - return {}; - } + vi.spyOn(client, 'useDataSourceManager').mockReturnValue({ + getDataSource: () => ({ + collectionManager: { + getCollectionFields: (name: string) => + ({ + orders: [ + { + interface: 'string', + name: 'name', + uiSchema: { + title: '{{t("Name")}}', + }, + type: 'string', + }, + { + interface: 'number', + name: 'price', + uiSchema: { + title: '{{t("Price")}}', + }, + type: 'double', + }, + { + interface: 'createdAt', + name: 'createdAt', + uiSchema: { + title: '{{t("Created At")}}', + }, + type: 'date', + }, + { + interface: 'm2o', + name: 'user', + uiSchema: { + title: '{{t("User")}}', + }, + target: 'users', + type: 'belongsTo', + }, + ], + users: [ + { + interface: 'string', + name: 'name', + uiSchema: { + title: '{{t("Name")}}', + }, + type: 'string', + }, + ], + })[name], + }, + }), + collectionFieldInterfaceManager: { + getFieldInterface: (i: string) => { + switch (i) { + case 'm2o': + return { + filterable: { + nested: true, + }, + }; + default: + return {}; + } + }, }, } as any); }); @@ -84,7 +90,7 @@ describe('hooks', () => { }); test('useFieldsWithAssociation', () => { - const { result } = renderHook(() => useFieldsWithAssociation('orders')); + const { result } = renderHook(() => useFieldsWithAssociation('main', 'orders')); expect(result.current).toMatchObject([ { key: 'name', @@ -118,7 +124,7 @@ describe('hooks', () => { }); test('useChartFields', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useChartFields(fields)); const func = result.current; const field = { @@ -155,7 +161,7 @@ describe('hooks', () => { }); test('useFormatters', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useFormatters(fields)); const func = result.current; const field = { @@ -169,7 +175,7 @@ describe('hooks', () => { }); test('useFieldTypes', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useFieldTypes(fields)); const func = result.current; let state1 = {}; @@ -234,7 +240,7 @@ describe('hooks', () => { }); test('useOrderFieldsOptions', () => { - const fields = renderHook(() => useFieldsWithAssociation('orders')).result.current; + const fields = renderHook(() => useFieldsWithAssociation('main', 'orders')).result.current; const { result } = renderHook(() => useOrderFieldsOptions([], fields)); const func = result.current; const field1 = { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx index 5075f340d..90697dbd4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartBlockDesigner.tsx @@ -10,7 +10,7 @@ import { useChartsTranslation } from '../locale'; export const ChartV2BlockDesigner: React.FC = () => { const { t } = useChartsTranslation(); return ( - + { const { setVisible, setCurrent } = useContext(ChartConfigContext); - const { allowAll, parseAction } = useACLRoleContext(); + const { parseAction } = useACLRoleContext(); const itemConfig = useSchemaInitializerItem(); const filter = useCallback( (item) => { const params = parseAction(`${item.name}:list`); return params; }, - [allowAll, parseAction], + [parseAction], ); return ( { + return ds.key === DEFAULT_DATA_SOURCE_KEY || ds.getOptions().isDBInstance; + }} icon={} componentType={'Chart'} onCreateBlockSchema={async ({ item }) => { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx index 84b6665bc..aba624d9b 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/block/ChartDataProvider.tsx @@ -2,6 +2,7 @@ import React, { createContext, useState } from 'react'; import { useMemoizedFn } from 'ahooks'; type ChartData = { + dataSource: string; collection: string; service: any; query: any; @@ -18,8 +19,8 @@ export const ChartDataProvider: React.FC = (props) => { const [charts, setCharts] = useState<{ [uid: string]: ChartData; }>({}); - const addChart = useMemoizedFn((uid: string, { collection, service, query }: ChartData) => { - setCharts((charts) => ({ ...charts, [uid]: { collection, service, query } })); + const addChart = useMemoizedFn((uid: string, { dataSource, collection, service, query }: ChartData) => { + setCharts((charts) => ({ ...charts, [uid]: { dataSource, collection, service, query } })); }); const removeChart = useMemoizedFn((uid: string) => { setCharts((charts) => ({ ...charts, [uid]: undefined })); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/statistic.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/statistic.ts index 3d7298970..144409a9a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/statistic.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/statistic.ts @@ -8,7 +8,7 @@ export class Statistic extends AntdChart { super({ name: 'statistic', title: 'Statistic', - component: AntdStatistic, + Component: AntdStatistic, config: [ { property: 'field', diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts index 6248bd6e9..f0ad9ff03 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/antd/table.ts @@ -4,7 +4,7 @@ import { Table as AntdTable } from 'antd'; export class Table extends AntdChart { constructor() { - super({ name: 'table', title: 'Table', component: AntdTable }); + super({ name: 'table', title: 'Table', Component: AntdTable }); } getProps({ data, fieldProps, general, advanced }: RenderProps) { @@ -34,14 +34,14 @@ export class Table extends AntdChart { }); const pageSize = advanced?.pagination?.pageSize || 10; return { - bordered: true, + // bordered: true, size: 'middle', - // pagination: - // dataSource.length < pageSize - // ? false - // : { - // pageSize, - // }, + pagination: + dataSource.length < pageSize + ? false + : { + pageSize, + }, dataSource, columns, scroll: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts index 26d6d6539..19f2d8116 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/chart.ts @@ -23,7 +23,7 @@ export type RenderProps = { export interface ChartType { name: string; title: string; - component: React.FC; + Component: React.FC; schema: ISchema; init?: ( fields: FieldOption[], @@ -35,7 +35,7 @@ export interface ChartType { general?: any; advanced?: any; }; - render: (props: RenderProps) => React.FC; + getProps(props: RenderProps): any; getReference?: () => { title: string; link: string; @@ -45,21 +45,21 @@ export interface ChartType { export type ChartProps = { name: string; title: string; - component: React.FC; + Component: React.FC; config?: Config[]; }; export class Chart implements ChartType { name: string; title: string; - component: React.FC; + Component: React.FC; config: Config[]; configs = new Map(); - constructor({ name, title, component, config }: ChartProps) { + constructor({ name, title, Component, config }: ChartProps) { this.name = name; this.title = title; - this.component = component; + this.Component = Component; this.config = config; this.addConfigs(configs); } @@ -171,11 +171,4 @@ export class Chart implements ChartType { getProps(props: RenderProps): any { return props; } - - render({ data, general, advanced, fieldProps, ctx }: RenderProps) { - return () => - React.createElement(this.component, { - ...this.getProps({ data, general, advanced, fieldProps, ctx }), - }); - } } diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/bar.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/bar.ts index 211bb1976..20018cedd 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/bar.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/bar.ts @@ -4,7 +4,7 @@ import { ChartType } from '../chart'; export class Bar extends G2PlotChart { constructor() { - super({ name: 'bar', title: 'Bar Chart', component: G2PlotBar, config: ['isGroup', 'isStack', 'isPercent'] }); + super({ name: 'bar', title: 'Bar Chart', Component: G2PlotBar, config: ['isGroup', 'isStack', 'isPercent'] }); } init: ChartType['init'] = (fields, { measures, dimensions }) => { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/dualAxes.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/dualAxes.ts index 7eb15e5c8..8cd664656 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/dualAxes.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/dualAxes.ts @@ -7,7 +7,7 @@ import { ChartRendererContext } from '../../renderer'; export class DualAxes extends G2PlotChart { constructor() { - super({ name: 'dualAxes', title: 'Dual Axes Chart', component: G2DualAxes }); + super({ name: 'dualAxes', title: 'Dual Axes Chart', Component: G2DualAxes }); this.config = [ 'xField', { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts index d9a041cad..e28dd2231 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/g2plot.ts @@ -2,11 +2,11 @@ import { Chart, ChartProps, ChartType, RenderProps } from '../chart'; import configs from './configs'; export class G2PlotChart extends Chart { - constructor({ name, title, component, config }: ChartProps) { + constructor({ name, title, Component, config }: ChartProps) { super({ name, title, - component, + Component, config: ['xField', 'yField', 'seriesField', ...(config || [])], }); this.addConfigs(configs); @@ -35,11 +35,11 @@ export class G2PlotChart extends Chart { }, }, tooltip: (d, index: number, data, column: any) => { - const field = column.y.field; + const field = column.y?.field; const props = fieldProps[field]; const name = props?.label || field; const transformer = props?.transformer; - const value = column.y.value[index]; + const value = column.y?.value[index]; return { name, value: transformer ? transformer(value) : value }; }, axis: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/index.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/index.ts index d19deda50..653ea9148 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/index.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/index.ts @@ -7,13 +7,13 @@ export default [ new G2PlotChart({ name: 'line', title: 'Line Chart', - component: Line, + Component: Line, config: ['smooth', 'isStack'], }), new G2PlotChart({ name: 'area', title: 'Area Chart', - component: Area, + Component: Area, config: [ 'smooth', { @@ -26,16 +26,16 @@ export default [ new G2PlotChart({ name: 'column', title: 'Column Chart', - component: Column, + Component: Column, config: ['isGroup', 'isStack', 'isPercent'], }), new G2PlotChart({ name: 'bar', title: 'Bar Chart', - component: Bar, + Component: Bar, config: ['isGroup', 'isStack', 'isPercent'], }), new Pie(), new DualAxes(), - new G2PlotChart({ name: 'scatter', title: 'Scatter Chart', component: Scatter }), + new G2PlotChart({ name: 'scatter', title: 'Scatter Chart', Component: Scatter }), ]; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/pie.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/pie.ts index 76f6c6a49..1b1583769 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/pie.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/chart/g2plot/pie.ts @@ -4,7 +4,7 @@ import { ChartType, RenderProps } from '../chart'; export class Pie extends G2PlotChart { constructor() { - super({ name: 'pie', title: 'Pie Chart', component: G2Pie }); + super({ name: 'pie', title: 'Pie Chart', Component: G2Pie }); this.config = [ { property: 'field', diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx index 145718ca4..c8e87fdfb 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/ChartConfigure.tsx @@ -2,21 +2,15 @@ import { RightSquareOutlined } from '@ant-design/icons'; import { ArrayItems, Editable, FormCollapse, FormItem, FormLayout, Switch } from '@formily/antd-v5'; import { Form as FormType, ObjectField, createForm, onFieldChange, onFormInit } from '@formily/core'; import { FormConsumer, ISchema, Schema } from '@formily/react'; -import { - AutoComplete, - FormProvider, - SchemaComponent, - gridRowColWrap, - useCollectionFieldsOptions, - useCollectionFilterOptions, - useDesignable, -} from '@nocobase/client'; +import { AutoComplete, FormProvider, SchemaComponent, gridRowColWrap, useDesignable } from '@nocobase/client'; import { Alert, App, Button, Card, Col, Modal, Row, Space, Table, Tabs, Typography } from 'antd'; import { cloneDeep, isEqual } from 'lodash'; import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useChartFields, useCollectionOptions, + useCollectionFieldsOptions, + useCollectionFilterOptions, useData, useFieldTypes, useFieldsWithAssociation, @@ -58,13 +52,13 @@ export const ChartConfigure: React.FC<{ const { t } = useChartsTranslation(); const { service } = useContext(ChartRendererContext); const { visible, setVisible, current } = useContext(ChartConfigContext); - const { schema, field, collection, initialValues } = current || {}; + const { schema, field, dataSource, collection, initialValues } = current || {}; const { dn } = useDesignable(); const { modal } = App.useApp(); const { insert } = props; const charts = useCharts(); - const fields = useFieldsWithAssociation(collection); + const fields = useFieldsWithAssociation(dataSource, collection); const initChart = (overwrite = false) => { if (!form.modified) { return; @@ -109,15 +103,19 @@ export const ChartConfigure: React.FC<{ const form = useMemo( () => createForm({ - values: { config: { chartType }, ...(initialValues || field?.decoratorProps), collection }, + values: { + config: { chartType }, + ...(initialValues || field?.decoratorProps), + collection: [dataSource, collection], + }, effects: (form) => { onFieldChange('config.chartType', () => initChart(true)); onFormInit(() => queryReact(form)); }, }), - // visible, collection added here to re-initialize form when visible, collection change + // visible, dataSource, collection added here to re-initialize form when visible, dataSource, collection change // eslint-disable-next-line react-hooks/exhaustive-deps - [field, visible, collection], + [field, visible, dataSource, collection], ); const RunButton: React.FC = () => ( @@ -134,7 +132,7 @@ export const ChartConfigure: React.FC<{ } try { - await service.runAsync(collection, form.values.query, true); + await service.runAsync(dataSource, collection, form.values.query, true); } catch (e) { console.log(e); } @@ -164,7 +162,7 @@ export const ChartConfigure: React.FC<{ const { query, config, transform, mode } = form.values; const afterSave = () => { setVisible(false); - current.service?.run(collection, query); + current.service?.run(dataSource, collection, query); queryRef.current.scrollTop = 0; configRef.current.scrollTop = 0; service.mutate(undefined); @@ -172,6 +170,7 @@ export const ChartConfigure: React.FC<{ const rendererProps = { query, config, + dataSource, collection, transform, mode: mode || 'builder', @@ -309,19 +308,20 @@ ChartConfigure.Query = function Query() { const useFormatterOptions = useFormatters(fields); const collectionOptions = useCollectionOptions(); const { current, setCurrent } = useContext(ChartConfigContext); - const { collection } = current || {}; - const fieldOptions = useCollectionFieldsOptions(collection, 1); + const { dataSource, collection } = current || {}; + const fieldOptions = useCollectionFieldsOptions(dataSource, collection, 1); const compiledFieldOptions = Schema.compile(fieldOptions, { t }); - const filterOptions = useCollectionFilterOptions(collection); + const filterOptions = useCollectionFilterOptions(dataSource, collection); const { service } = useContext(ChartRendererContext); - const onCollectionChange = (value: string) => { + const onCollectionChange = (value: string[]) => { const { schema, field } = current; + const [dataSource, collection] = value; setCurrent({ schema, field, - collection: value, - dataSource: current.dataSource, + collection, + dataSource, service: current.service, initialValues: {}, data: undefined, diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts index edbdda96b..da4fadf24 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/configure/schemas/configure.ts @@ -150,9 +150,9 @@ export const querySchema: ISchema = { title: '{{t("Collection")}}', type: 'string', 'x-decorator': 'FormItem', - 'x-component': 'Select', + 'x-component': 'Cascader', + enum: '{{ collectionOptions }}', 'x-component-props': { - options: '{{ collectionOptions }}', onChange: '{{ onCollectionChange }}', placeholder: '{{t("Collection")}}', }, diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx new file mode 100644 index 000000000..5d8c33c9a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/CollectionFieldInitializer.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { InitializerWithSwitch, useSchemaInitializerItem } from '@nocobase/client'; +import { ISchema } from '@formily/react'; + +export const CollectionFieldInitializer = () => { + const schema: ISchema = {}; + const itemConfig = useSchemaInitializerItem(); + return ; +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx index da65c191c..3df5f6582 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterBlockDesigner.tsx @@ -5,7 +5,7 @@ import { useChartsTranslation } from '../locale'; export const ChartFilterBlockDesigner: React.FC = () => { const { t } = useChartsTranslation(); return ( - + {/* */} {/* */} { const { t } = useChartsTranslation(); @@ -43,6 +44,7 @@ export const ChartFilterBlockProvider: React.FC = (props) => { ArrayItems, ChartFilterCollapseDesigner, ChartFilterActionDesigner, + CollectionFieldInitializer, }} scope={{ t, useChartFilterActionProps, useChartFilterResetProps, useChartFilterCollapseProps }} > diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx index 5b40435a5..b7fbd24e4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterForm.tsx @@ -1,7 +1,7 @@ import React, { memo, useContext, useEffect, useMemo, useRef } from 'react'; import { createForm, onFieldInit, onFieldMount, onFieldUnmount } from '@formily/core'; import { ChartFilterContext } from './FilterProvider'; -import { FormV2, VariablesContext } from '@nocobase/client'; +import { DEFAULT_DATA_SOURCE_KEY, FormV2, VariablesContext } from '@nocobase/client'; import { setDefaultValue } from './utils'; import { useChartFilter } from '../hooks'; @@ -33,7 +33,10 @@ export const ChartFilterForm: React.FC = memo((props) => { if (!name) { return; } - setField(name, { title: field.title, operator: field.componentProps['filter-operator'] }); + setField(name, { + title: field.title, + operator: field.componentProps['filter-operator'], + }); // parse field title if (field.title.includes('/')) { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx index 9fa3fbc21..4e29baa32 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemDesigner.tsx @@ -13,6 +13,7 @@ import { useDesignable, SchemaSettingsSelectItem, CollectionFieldOptions_deprecated, + DEFAULT_DATA_SOURCE_KEY, } from '@nocobase/client'; import { useChartsTranslation } from '../locale'; import { Schema, useField, useFieldSchema } from '@formily/react'; @@ -21,7 +22,7 @@ import _ from 'lodash'; import { ChartFilterContext } from './FilterProvider'; import { getPropsSchemaByComponent, setDefaultValue } from './utils'; import { ChartFilterVariableInput } from './FilterVariableInput'; -import { useChartFilter, useCollectionJoinFieldTitle } from '../hooks'; +import { useChartDataSource, useChartFilter, useCollectionJoinFieldTitle } from '../hooks'; import { Typography } from 'antd'; import { getFormulaInterface } from '../utils'; const { Text } = Typography; @@ -72,29 +73,33 @@ const EditTitle = () => { const EditOperator = () => { const compile = useCompile(); const fieldSchema = useFieldSchema(); - const fieldName = fieldSchema.name as string; const field = useField(); const { t } = useChartsTranslation(); const { dn } = useDesignable(); const { setField } = useContext(ChartFilterContext); - const { getInterface, getCollectionJoinField } = useCollectionManager_deprecated(); + const fieldName = fieldSchema['x-collection-field']; + const dataSource = fieldSchema['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; + const { cm, fim } = useChartDataSource(dataSource); + if (!cm) { + return null; + } const getOperators = (props: CollectionFieldOptions_deprecated) => { let fieldInterface = props?.interface; if (fieldInterface === 'formula') { fieldInterface = getFormulaInterface(props.dataType) || props.dataType; } - const interfaceConfig = getInterface(fieldInterface); + const interfaceConfig = fim.getFieldInterface(fieldInterface); const operatorList = interfaceConfig?.filterable?.operators || []; return { operatorList, interfaceConfig }; }; - let props = getCollectionJoinField(fieldName); + let props = cm.getCollectionField(fieldName); let { operatorList, interfaceConfig } = getOperators(props); if (!operatorList.length) { const names = fieldName.split('.'); const name = names.pop(); - props = getCollectionJoinField(names.join('.')); + props = cm.getCollectionField(names.join('.')); if (!props) { return null; } @@ -159,7 +164,7 @@ const EditOperator = () => { setOperatorComponent(operator, defaultComponent); } - setField(fieldName, { operator }); + setField(fieldSchema.name as string, { operator }); dn.refresh(); }} /> @@ -300,10 +305,11 @@ export const ChartFilterItemDesigner: React.FC = () => { const { t } = useChartsTranslation(); const fieldSchema = useFieldSchema(); const fieldName = fieldSchema.name as string; + const dataSource = fieldSchema['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; const collectionField = getField(fieldName) || getCollectionJoinField(fieldSchema['x-collection-field']); const isCustom = fieldName.startsWith('custom.'); const hasProps = getPropsSchemaByComponent(fieldSchema['x-component']); - const originalTitle = useCollectionJoinFieldTitle(fieldName); + const originalTitle = useCollectionJoinFieldTitle(dataSource, fieldName); return ( {!isCustom && ( diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx index 6fa6c3fc8..9534a7558 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterItemInitializers.tsx @@ -7,7 +7,9 @@ import { ACLCollectionFieldProvider, BlockItem, CollectionFieldProvider, + CollectionManagerProvider, CollectionProvider, + DEFAULT_DATA_SOURCE_KEY, CompatibleSchemaInitializer, FormDialog, HTMLEncode, @@ -16,6 +18,7 @@ import { SchemaInitializerItem, gridRowColWrap, useCollectionManager_deprecated, + useDataSourceManager, useDesignable, useGlobalTheme, useSchemaInitializerItem, @@ -50,6 +53,7 @@ const ErrorFallback = ({ error }) => { export const ChartFilterFormItem = observer( (props: any) => { + const { t } = useChartsTranslation(); const field = useField(); const schema = useFieldSchema(); const showTitle = schema['x-decorator-props']?.showTitle ?? true; @@ -80,21 +84,31 @@ export const ChartFilterFormItem = observer( }, ); }, [showTitle]); + const dataSource = schema?.['x-data-source'] || DEFAULT_DATA_SOURCE_KEY; const collectionField = schema?.['x-collection-field'] || ''; const [collection] = collectionField.split('.'); - + // const { getIsChartCollectionExists } = useChartData(); + // const exists = (schema.name as string).startsWith('custom.') || getIsChartCollectionExists(dataSource, collection); return ( - - - - - console.log(err)} FallbackComponent={ErrorFallback}> - - - - - - + + + + + + {/* {exists ? ( */} + console.log(err)} FallbackComponent={ErrorFallback}> + + + {/* ) : ( */} + {/*
*/} + {/* {t('The chart using the collection of this field have been deleted. Please remove this field.')} */} + {/*
*/} + {/* )} */} +
+
+
+
+
); }, { displayName: 'ChartFilterFormItem' }, @@ -110,7 +124,7 @@ export const ChartFilterCustomItemInitializer: React.FC<{ const { theme } = useGlobalTheme(); const { insert } = props; const itemConfig = useSchemaInitializerItem(); - const { getCollectionJoinField, getInterface } = useCollectionManager_deprecated(); + const dm = useDataSourceManager(); const sourceFields = useChartFilterSourceFields(); const { options: fieldComponents, values: fieldComponentValues } = useFieldComponents(); const handleClick = useCallback(async () => { @@ -177,8 +191,17 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }, effects() { onFieldValueChange('source', (field) => { - const name = field.value?.join('.'); - const props = getCollectionJoinField(name); + if (!field.value) { + return; + } + const [dataSource, ...fields] = field.value; + const ds = dm.getDataSource(dataSource); + if (!ds) { + return; + } + const cm = ds.collectionManager; + const name = fields.join('.'); + const props = cm.getCollectionField(name); if (!props) { return; } @@ -204,7 +227,8 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }, }); const { name, title, component, props } = values; - const defaultSchema = getInterface(component)?.default?.uiSchema || {}; + const fim = dm.collectionFieldInterfaceManager; + const defaultSchema = fim.getFieldInterface(component)?.default?.uiSchema || {}; insert( gridRowColWrap({ 'x-component': component, @@ -227,11 +251,8 @@ export const ChartFilterCustomItemInitializer: React.FC<{ }); ChartFilterCustomItemInitializer.displayName = 'ChartFilterCustomItemInitializer'; -/** - * @deprecated - */ -export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitializer({ - name: 'ChartFilterItemInitializers', +const filterItemInitializers = { + name: 'chartFilterForm:configureFields', 'data-testid': 'configure-fields-button-of-chart-filter-item', wrap: gridRowColWrap, icon: 'SettingOutlined', @@ -242,23 +263,34 @@ export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitia name: 'displayFields', title: '{{ t("Display fields") }}', useChildren: () => { - const { getCollection } = useCollectionManager_deprecated(); - const { getChartCollections } = useChartData(); + const { t } = useChartsTranslation(); + const { chartCollections, showDataSource } = useChartData(); const { getChartFilterFields } = useChartFilter(); - const collections = getChartCollections(); + const dm = useDataSourceManager(); + const fim = dm.collectionFieldInterfaceManager; return useMemo(() => { - return collections.map((name: any) => { - const collection = getCollection(name); - const fields = getChartFilterFields(collection); + const options = Object.entries(chartCollections).map(([dataSource, collections]) => { + const ds = dm.getDataSource(dataSource); return { - name: collection.key, + name: ds.key, + title: Schema.compile(ds.displayName, { t }), type: 'subMenu', - title: collection.title, - children: fields, + children: collections.map((name) => { + const cm = ds.collectionManager; + const collection = cm.getCollection(name); + const fields = getChartFilterFields({ dataSource, collection, cm, fim }); + return { + name: collection.key, + title: Schema.compile(collection.title, { t }), + type: 'subMenu', + children: fields, + }; + }), }; }); - }, [collections]); + return showDataSource ? options : options[0]?.children || []; + }, [chartCollections, showDataSource]); }, }, { @@ -275,54 +307,17 @@ export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitia }, }, ], +}; + +/** + * @deprecated + */ +export const chartFilterItemInitializers_deprecated = new CompatibleSchemaInitializer({ + ...filterItemInitializers, + name: 'ChartFilterItemInitializers', }); export const chartFilterItemInitializers = new CompatibleSchemaInitializer( - { - name: 'chartFilterForm:configureFields', - 'data-testid': 'configure-fields-button-of-chart-filter-item', - wrap: gridRowColWrap, - icon: 'SettingOutlined', - title: '{{ t("Configure fields") }}', - items: [ - { - type: 'itemGroup', - name: 'displayFields', - title: '{{ t("Display fields") }}', - useChildren: () => { - const { getCollection } = useCollectionManager_deprecated(); - const { getChartCollections } = useChartData(); - const { getChartFilterFields } = useChartFilter(); - const collections = getChartCollections(); - - return useMemo(() => { - return collections.map((name: any) => { - const collection = getCollection(name); - const fields = getChartFilterFields(collection); - return { - name: collection.key, - type: 'subMenu', - title: collection.title, - children: fields, - }; - }); - }, [collections]); - }, - }, - { - name: 'divider', - type: 'divider', - }, - { - name: 'custom', - type: 'item', - title: lang('Custom'), - Component: () => { - const { insertAdjacent } = useDesignable(); - return insertAdjacent('beforeEnd', s)} />; - }, - }, - ], - }, + filterItemInitializers, chartFilterItemInitializers_deprecated, ); diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx index f4d602bd3..cbf6e2948 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/FilterProvider.tsx @@ -1,20 +1,22 @@ import React, { createContext, useEffect, useState } from 'react'; import { useMemoizedFn } from 'ahooks'; +type FilterField = { + title?: string; + operator?: { + value: string; + noValue?: boolean; + }; +}; + export const ChartFilterContext = createContext<{ ready: boolean; enabled: boolean; setEnabled: (enabled: boolean) => void; fields: { - [name: string]: { - title: string; - operator?: { - value: string; - noValue?: boolean; - }; - }; + [name: string]: FilterField; }; - setField: (name: string, field: { title?: string; operator?: string }) => void; + setField: (name: string, field: FilterField) => void; removeField: (name: string) => void; collapse: { collapsed: boolean; @@ -32,7 +34,7 @@ export const ChartFilterProvider: React.FC = (props) => { const [fields, setFields] = useState({}); const [collapse, _setCollapse] = useState({ collapsed: false, row: 1 }); const [form, _setForm] = useState(); - const setField = useMemoizedFn((name: string, props: { title?: string; operator?: string }) => { + const setField = useMemoizedFn((name: string, props: FilterField) => { setFields((fields) => ({ ...fields, [name]: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts index 2e3f02e11..d018de38a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/filter/utils.ts @@ -1,5 +1,7 @@ +import { DEFAULT_DATA_SOURCE_KEY } from '@nocobase/client'; import { moment2str } from '@nocobase/utils/client'; import dayjs from 'dayjs'; +import { Schema } from '@formily/react'; export const getOptionsSchema = () => { const options = { @@ -175,3 +177,39 @@ export const setDefaultValue = async (field: any, variables: any) => { field.loading = false; } }; + +export const FILTER_FIELD_PREFIX_SEPARATOR = '-'; + +export const getFilterFieldPrefix = (dataSource: string, fieldName: string) => { + return dataSource ? `${dataSource}${FILTER_FIELD_PREFIX_SEPARATOR}${fieldName}` : fieldName; +}; + +// [dataSource-]collection.fieldName.associateName +export const parseFilterFieldName = (name: string) => { + const [prefix, fieldName] = name.split(FILTER_FIELD_PREFIX_SEPARATOR); + if (fieldName) { + return { dataSource: prefix, fieldName }; + } + return { dataSource: DEFAULT_DATA_SOURCE_KEY, fieldName: prefix }; +}; + +export const findSchema = (schema: Schema, key: string, targetName: string) => { + if (!Schema.isSchemaInstance(schema)) return null; + return schema.reduceProperties((buf, s) => { + let fieldName = s[key]; + if (!fieldName.includes(FILTER_FIELD_PREFIX_SEPARATOR)) { + fieldName = `${DEFAULT_DATA_SOURCE_KEY}${FILTER_FIELD_PREFIX_SEPARATOR}${fieldName}`; + } + if (fieldName === targetName) { + return s; + } + if (s['x-component'] !== 'Action.Container' && s['x-component'] !== 'AssociationField.Viewer') { + const c = findSchema(s, key, targetName); + if (c) { + return c; + } + } + + return buf; + }); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts index a805b8127..ea29411a4 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/filter.ts @@ -1,7 +1,16 @@ -import { SchemaInitializerItemType, i18n, useActionContext, useCollectionManager_deprecated } from '@nocobase/client'; -import { useContext, useMemo } from 'react'; +import { + Collection, + CollectionFieldInterfaceManager, + CollectionFieldOptions, + CollectionManager, + SchemaInitializerItemType, + i18n, + useActionContext, + useCollectionManager_deprecated, + useDataSourceManager, +} from '@nocobase/client'; +import { useCallback, useContext, useMemo } from 'react'; import { ChartDataContext } from '../block/ChartDataProvider'; -import { CollectionOptions } from '@nocobase/database'; import { Schema } from '@formily/react'; import { useChartsTranslation } from '../locale'; import { ChartFilterContext } from '../filter/FilterProvider'; @@ -10,6 +19,7 @@ import { parse } from '@nocobase/utils/client'; import lodash from 'lodash'; import { getFormulaComponent, getValuesByPath } from '../utils'; import deepmerge from 'deepmerge'; +import { findSchema, getFilterFieldPrefix, parseFilterFieldName } from '../filter/utils'; export const useCustomFieldInterface = () => { const { getInterface } = useCollectionManager_deprecated(); @@ -44,35 +54,63 @@ export const useCustomFieldInterface = () => { export const useChartData = () => { const { charts } = useContext(ChartDataContext); - const getChartCollections = () => - Array.from( - new Set( - Object.values(charts) - .filter((chart) => chart) - .map((chart) => chart.collection), - ), - ); + const chartCollections: { + [dataSource: string]: string[]; + } = useMemo(() => { + return Object.values(charts) + .filter((chart) => chart) + .reduce((mp, chart) => { + const { dataSource, collection } = chart; + if (mp[dataSource]?.includes(collection)) { + return mp; + } + mp[dataSource] = [...(mp[dataSource] || []), collection]; + return mp; + }, {}); + }, [charts]); + + const showDataSource = useMemo(() => { + return Object.keys(chartCollections).length > 1; + }, [chartCollections]); + + const getIsChartCollectionExists = useCallback( + (dataSource: string, collection: string) => { + return chartCollections[dataSource]?.includes(collection) || false; + }, + [chartCollections], + ); return { - getChartCollections, + chartCollections, + showDataSource, + getIsChartCollectionExists, }; }; export const useChartFilter = () => { + const dm = useDataSourceManager(); const { charts } = useContext(ChartDataContext); const { fieldSchema } = useActionContext(); const action = fieldSchema?.['x-action']; - const { getCollection, getInterface, getCollectionFields, getCollectionJoinField } = - useCollectionManager_deprecated(); const { fields: fieldProps, form } = useContext(ChartFilterContext); - const getChartFilterFields = (collection: CollectionOptions) => { - const fields = getCollectionFields(collection); - const field2item = (field: any, title: string, name: string) => { + const getChartFilterFields = ({ + dataSource, + collection, + cm, + fim, + }: { + dataSource: string; + collection: Collection; + cm: CollectionManager; + fim: CollectionFieldInterfaceManager; + }) => { + const fields = cm.getCollectionFields(collection.name); + const field2item = (field: any, title: string, name: string, fieldName: string) => { const fieldTitle = field.uiSchema?.title || field.name; - const interfaceConfig = getInterface(field.interface); + const interfaceConfig = fim.getFieldInterface(field.interface); const defaultOperator = interfaceConfig?.filterable?.operators?.[0]; - const targetCollection = getCollection(field.target); + const targetCollection = cm.getCollection(field.target); title = title ? `${title} / ${fieldTitle}` : fieldTitle; let schema = { type: 'string', @@ -82,7 +120,8 @@ export const useChartFilter = () => { 'x-designer': 'ChartFilterItemDesigner', 'x-component': 'CollectionField', 'x-decorator': 'ChartFilterFormItem', - 'x-collection-field': `${name}.${field.name}`, + 'x-data-source': dataSource, + 'x-collection-field': `${fieldName}.${field.name}`, 'x-component-props': { ...field.uiSchema?.['x-component-props'], 'filter-operator': defaultOperator, @@ -104,6 +143,7 @@ export const useChartFilter = () => { type: 'item', title: field?.uiSchema?.title || field.name, Component: 'CollectionFieldInitializer', + find: findSchema, remove: (schema, cb) => { cb(schema, { breakRemoveOn: { @@ -126,7 +166,7 @@ export const useChartFilter = () => { return resultItem; }; - const children2item = (child: any, title: string, name: string) => { + const children2item = (child: any, title: string, name: string, fieldName: string) => { const childTitle = child.uiSchema?.title || child.name; title = title ? `${title} / ${childTitle}` : childTitle; const defaultOperator = child.operators[0]; @@ -136,7 +176,8 @@ export const useChartFilter = () => { required: false, 'x-designer': 'ChartFilterItemDesigner', 'x-decorator': 'ChartFilterFormItem', - 'x-collection-field': `${name}.${child.name}`, + 'x-data-source': dataSource, + 'x-collection-field': `${fieldName}.${child.name}`, ...child.schema, title, 'x-component-props': { @@ -159,6 +200,7 @@ export const useChartFilter = () => { type: 'item', title: child.title || child.name, Component: 'CollectionFieldInitializer', + find: findSchema, remove: (schema, cb) => { cb(schema, { breakRemoveOn: { @@ -172,23 +214,31 @@ export const useChartFilter = () => { return resultItem; }; - const field2option = (field: any, depth: number, title: string, name: string): SchemaInitializerItemType => { + const field2option = ( + field: any, + depth: number, + title: string, + name: string, + fieldName: string, + ): SchemaInitializerItemType => { if (!field.interface) { return; } - const fieldInterface = getInterface(field.interface); + const fieldInterface = fim.getFieldInterface(field.interface); if (!fieldInterface?.filterable) { return; } const { nested, children } = fieldInterface.filterable; const fieldTitle = field.uiSchema?.title || field.name; - const item = field2item(field, title, name); + const item = field2item(field, title, name, fieldName); if (field.target && depth > 2) { return; } title = title ? `${title} / ${fieldTitle}` : fieldTitle; - if (children?.length && !['chinaRegion', 'createdBy', 'updatedBy'].includes(field.interface)) { - const items = children.map((child: any) => children2item(child, title, `${name}.${field.name}`)); + if (children?.length && !['chinaRegion', 'createdBy', 'updatedBy', 'attachment'].includes(field.interface)) { + const items = children.map((child: any) => + children2item(child, title, `${name}.${field.name}`, `${fieldName}.${field.name}`), + ); return { key: `${name}.${field.name}`, name: field.name, @@ -201,9 +251,9 @@ export const useChartFilter = () => { return item; } if (nested) { - const targetFields = getCollectionFields(field.target); + const targetFields = cm.getCollectionFields(field.target); const items = targetFields.map((targetField) => - field2option(targetField, depth + 1, '', `${name}.${field.name}`), + field2option(targetField, depth + 1, '', `${name}.${field.name}`, `${fieldName}.${field.name}`), ); return { key: `${name}.${field.name}`, @@ -220,12 +270,12 @@ export const useChartFilter = () => { const associationOptions = []; fields.forEach((field) => { const fieldInterface = field.interface; - const option = field2option(field, 0, '', collection.name); + const option = field2option(field, 0, '', getFilterFieldPrefix(dataSource, collection.name), collection.name); if (option) { options.push(option); } if (['m2o'].includes(fieldInterface)) { - const option = field2option(field, 1, '', collection.name); + const option = field2option(field, 1, '', getFilterFieldPrefix(dataSource, collection.name), collection.name); if (option) { associationOptions.push(option); } @@ -251,40 +301,47 @@ export const useChartFilter = () => { const getFilter = () => { const values = form?.values || {}; const filter = {}; - Object.entries(fieldProps).forEach(([name, props]) => { - const { operator } = props || {}; - const field = getCollectionJoinField(name); - if (field?.target) { - name = `${name}.${field.targetKey || 'id'}`; - } - const [collection, ...fields] = name.split('.'); - const value = getValuesByPath(values, name); - const op = operator?.value || '$eq'; - if (collection !== 'custom') { - filter[collection] = filter[collection] || { $and: [] }; - const condition = {}; - lodash.set(condition, fields.join('.'), { [op]: value }); - filter[collection].$and.push(condition); - } else { - filter[collection] = filter[collection] || {}; - filter[collection][`$nFilter.${fields.join('.')}`] = value; - } - }); + Object.entries(fieldProps) + .filter(([_, props]) => props) + .forEach(([name, props]) => { + const { operator } = props || {}; + const { dataSource, fieldName } = parseFilterFieldName(name); + const ds = dm.getDataSource(dataSource); + const cm = ds.collectionManager; + const field = cm.getCollectionField(fieldName); + if (field?.target) { + name = `${fieldName}.${field.targetKey || 'id'}`; + } + const [collection, ...fields] = fieldName.split('.'); + const value = getValuesByPath(values, name); + const op = operator?.value || '$eq'; + if (collection !== 'custom') { + const key = getFilterFieldPrefix(dataSource, collection); + filter[key] = filter[key] || { $and: [] }; + const condition = {}; + lodash.set(condition, fields.join('.'), { [op]: value }); + filter[key].$and.push(condition); + } else { + filter[collection] = filter[collection] || {}; + filter[collection][`$nFilter.${fields.join('.')}`] = value; + } + }); return filter; }; - const hasFilter = (chart: { collection: string; query: any }, filterValues: any) => { - const { collection, query } = chart; + const hasFilter = (chart: { dataSource: string; collection: string; query: any }, filterValues: any) => { + const { dataSource, collection, query } = chart; const { parameters } = parse(query.filter || ''); return ( chart && - (filterValues[collection] || - (filterValues['custom'] && parameters?.find((param: { key: string }) => filterValues['custom'][param.key]))) + (filterValues[getFilterFieldPrefix(dataSource, collection)] || + (filterValues['custom'] && + parameters?.find(({ key }: { key: string }) => lodash.has(filterValues['custom'], key)))) ); }; - const appendFilter = (chart: { collection: string; query: any }, filterValues: any) => { - const { collection, query } = chart; + const appendFilter = (chart: { dataSource: string; collection: string; query: any }, filterValues: any) => { + const { dataSource, collection, query } = chart; let newQuery = { ...query }; const originFilter = { ...(newQuery.filter || {}) }; let filter = {}; @@ -297,7 +354,7 @@ export const useChartFilter = () => { newQuery = { ...newQuery, filter: { - $and: [filter, filterValues[collection]], + $and: [filter, filterValues[getFilterFieldPrefix(dataSource, collection)]], }, }; return newQuery; @@ -308,8 +365,8 @@ export const useChartFilter = () => { const requests = Object.values(charts) .filter((chart) => hasFilter(chart, filterValues)) .map((chart) => async () => { - const { service, collection } = chart; - return await service.runAsync(collection, appendFilter(chart, filterValues), true); + const { dataSource, service, collection } = chart; + return await service.runAsync(dataSource, collection, appendFilter(chart, filterValues), true); }); return await Promise.all(requests.map((request) => request())); }; @@ -320,8 +377,8 @@ export const useChartFilter = () => { return chart; }) .map((chart) => async () => { - const { service, collection, query } = chart; - await service.runAsync(collection, query, true); + const { service, dataSource, collection, query } = chart; + await service.runAsync(dataSource, collection, query, true); }); await Promise.all(requests.map((request) => request())); }; @@ -375,14 +432,16 @@ export const useFilterVariable = () => { export const useChartFilterSourceFields = () => { const { t } = useChartsTranslation(); - const { getChartCollections } = useChartData(); - const { getInterface, getCollectionFields, getCollection } = useCollectionManager_deprecated(); + const { chartCollections } = useChartData(); + const dm = useDataSourceManager(); + const fim = dm.collectionFieldInterfaceManager; + const { values } = useFieldComponents(); - const field2option = (field: any, depth: number) => { + const field2option = (cm: CollectionManager, field: any, depth: number) => { if (!field.interface) { return; } - const fieldInterface = getInterface(field.interface); + const fieldInterface = fim.getFieldInterface(field.interface); if (!fieldInterface?.filterable) { return; } @@ -398,8 +457,8 @@ export const useChartFilterSourceFields = () => { return item; } if (nested) { - const targetFields = getCollectionFields(field.target); - const items = targetFields.map((targetField) => field2option(targetField, depth + 1)); + const targetFields = cm.getCollectionFields(field.target); + const items = targetFields.map((targetField) => field2option(cm, targetField, depth + 1)); return { value: field.name, label: t(field?.uiSchema?.title || field.name), @@ -412,29 +471,28 @@ export const useChartFilterSourceFields = () => { return item; }; - const collections = getChartCollections(); return useMemo(() => { - const options = []; - collections.forEach((name) => { - const collection = getCollection(name); - const children = []; - const fields = getCollectionFields(collection); - fields.forEach((field) => { - const option = field2option(field, 1); - if (option) { - children.push(option); - } - }); - if (children.length) { - options.push({ - value: name, - label: t(collection.title), - children, - }); - } + const options = Object.entries(chartCollections).map(([dataSource, collections]) => { + const ds = dm.getDataSource(dataSource); + return { + value: dataSource, + label: Schema.compile(ds.displayName, { t }), + children: collections.map((name: string) => { + const cm = ds.collectionManager; + const collection = cm.getCollection(name); + const fields = cm.getCollectionFields(name); + const children = fields.map((field) => field2option(cm, field, 1)).filter((item) => item); + return { + value: name, + label: Schema.compile(collection.title, { t }), + children, + }; + }), + }; }); + return options; - }, [collections]); + }, [chartCollections]); }; export const useFieldComponents = () => { @@ -457,26 +515,35 @@ export const useFieldComponents = () => { }; }; -export const useCollectionJoinFieldTitle = (name: string) => { - const { getCollection, getCollectionField } = useCollectionManager_deprecated(); +export const useCollectionJoinFieldTitle = (dataSource: string, name: string) => { + const { t } = useChartsTranslation(); + const dm = useDataSourceManager(); + const { showDataSource } = useChartData(); + return useMemo(() => { + const ds = dm.getDataSource(dataSource); + if (!ds) { + return; + } + const cm = ds.collectionManager; if (!name) { return; } - const [collectionName, ...fieldNames] = name.split('.'); + const { fieldName } = parseFilterFieldName(name); + const [collectionName, ...fieldNames] = fieldName.split('.'); if (!fieldNames?.length) { return; } - const collection = getCollection(collectionName); + const collection = cm.getCollection(collectionName); let cName: any = collectionName; let field: any; - let title = Schema.compile(collection?.title, { t: i18n.t }); + let title = Schema.compile(collection?.title, { t }); while (cName && fieldNames.length > 0) { const fileName = fieldNames.shift(); - field = getCollectionField(`${cName}.${fileName}`); + field = cm.getCollectionField(`${cName}.${fileName}`); const fieldTitle = field?.uiSchema?.title || field?.name; if (fieldTitle) { - title += ` / ${Schema.compile(fieldTitle, { t: i18n.t })}`; + title += ` / ${Schema.compile(fieldTitle, { t })}`; } if (field?.target) { cName = field.target; @@ -484,6 +551,6 @@ export const useCollectionJoinFieldTitle = (name: string) => { cName = null; } } - return title; - }, [name]); + return showDataSource ? `${Schema.compile(ds.displayName, { t })} > ${title}` : title; + }, [name, dataSource, showDataSource]); }; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts index daf17dee2..7d287ada6 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/hooks/query.ts @@ -1,9 +1,13 @@ import { ArrayField } from '@formily/core'; import { ISchema, Schema, useForm } from '@formily/react'; import { + CollectionFieldOptions, CollectionFieldOptions_deprecated, + CollectionManager, + DEFAULT_DATA_SOURCE_KEY, useACLRoleContext, useCollectionManager_deprecated, + useDataSourceManager, } from '@nocobase/client'; import { useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,19 +30,24 @@ export type FieldOption = { targetFields?: FieldOption[]; }; +export const useChartDataSource = (dataSource?: string) => { + const { current } = useContext(ChartConfigContext); + const { dataSource: _dataSource = dataSource || DEFAULT_DATA_SOURCE_KEY, collection } = current || {}; + const dm = useDataSourceManager(); + const ds = dm.getDataSource(_dataSource); + const fim = dm.collectionFieldInterfaceManager; + const cm = ds?.collectionManager; + return { cm, fim, collection }; +}; + export const useFields = ( - collection?: string, -): (CollectionFieldOptions_deprecated & { + collectionFields: CollectionFieldOptions[], +): (CollectionFieldOptions & { key: string; label: string; value: string; })[] => { - const { current } = useContext(ChartConfigContext); - if (!collection) { - collection = current?.collection || ''; - } - const { getCollectionFields } = useCollectionManager_deprecated(); - const fields = (getCollectionFields(collection) || []) + const fields = (collectionFields || []) .filter((field) => { return field.interface; }) @@ -51,21 +60,22 @@ export const useFields = ( return fields; }; -export const useFieldsWithAssociation = (collection?: string) => { - const { getCollectionFields, getInterface } = useCollectionManager_deprecated(); +export const useFieldsWithAssociation = (dataSource?: string, collection?: string) => { const { t } = useTranslation(); - const fields = useFields(collection); + const { cm, fim, collection: _collection } = useChartDataSource(dataSource); + const collectionFields = cm.getCollectionFields(collection || _collection); + const fields = useFields(collectionFields); return useMemo( () => fields.map((field) => { - const filterable = getInterface(field.interface)?.filterable; + const filterable = fim.getFieldInterface(field.interface)?.filterable; const label = Schema.compile(field.uiSchema?.title || field.name, { t }); if (!(filterable && (filterable?.nested || filterable?.children?.length))) { return { ...field, label }; } let targetFields = []; if (filterable?.nested) { - const nestedFields = (getCollectionFields(field.target) || []) + const nestedFields = (cm.getCollectionFields(field.target) || []) .filter((targetField) => { return targetField.interface; }) @@ -132,20 +142,23 @@ export const useFormatters = (fields: FieldOption[]) => (field: any) => { export const useCollectionOptions = () => { const { t } = useTranslation(); - const { collections } = useCollectionManager_deprecated(); - const { allowAll, parseAction } = useACLRoleContext(); - const options = collections - .filter((collection: { name: string }) => { - if (allowAll) { - return true; - } + const dm = useDataSourceManager(); + const { parseAction } = useACLRoleContext(); + const allCollections = dm.getAllCollections({ + filterCollection: (collection) => { const params = parseAction(`${collection.name}:list`); return params; - }) - .map((collection: { name: string; title: string }) => ({ - label: collection.title, - value: collection.name, - key: collection.name, + }, + }); + const options = allCollections + .filter(({ key, isDBInstance }) => key === DEFAULT_DATA_SOURCE_KEY || isDBInstance) + .map(({ key, displayName, collections }) => ({ + value: key, + label: displayName, + children: collections.map((collection) => ({ + value: collection.name, + label: collection.title, + })), })); return useMemo(() => Schema.compile(options, { t }), [options]); }; @@ -202,11 +215,124 @@ export const useOrderReaction = (defaultOptions: any[], fields: FieldOption[]) = field.setValue(newOrders); }; -export const useData = (data?: any[], collection?: string) => { +export const useData = (data?: any[], dataSource?: string, collection?: string) => { const { t } = useChartsTranslation(); const { service, query } = useContext(ChartRendererContext); - const fields = useFieldsWithAssociation(collection); + const fields = useFieldsWithAssociation(dataSource, collection); const form = useForm(); const selectedFields = getSelectedFields(fields, form?.values?.query || query); return processData(selectedFields, service?.data || data || [], { t }); }; + +export const useCollectionFieldsOptions = (dataSource: string, collectionName: string, maxDepth = 2, excludes = []) => { + const { cm, fim, collection } = useChartDataSource(dataSource); + const collectionFields = cm.getCollectionFields(collectionName || collection); + const fields = collectionFields.filter((v) => !excludes.includes(v.interface)); + + const field2option = (field, depth, prefix?) => { + if (!field.interface) { + return; + } + const fieldInterface = fim.getFieldInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested, children } = fieldInterface.filterable; + const value = prefix ? `${prefix}.${field.name}` : field.name; + const option = { + ...field, + name: field.name, + title: field?.uiSchema?.title || field.name, + schema: field?.uiSchema, + key: value, + }; + if (field.target && depth > maxDepth) { + return; + } + if (depth > maxDepth) { + return option; + } + if (children?.length) { + option['children'] = children.map((v) => { + return { + ...v, + key: `${field.name}.${v.name}`, + }; + }); + } + if (nested) { + const targetFields = cm.getCollectionFields(field.target).filter((v) => !excludes.includes(v.interface)); + const options = getOptions(targetFields, depth + 1, field.name).filter(Boolean); + option['children'] = option['children'] || []; + option['children'].push(...options); + } + return option; + }; + const getOptions = (fields, depth, prefix?) => { + const options = []; + fields.forEach((field) => { + const option = field2option(field, depth, prefix); + if (option) { + options.push(option); + } + }); + return options; + }; + const options = getOptions(fields, 1); + return options; +}; + +export const useCollectionFilterOptions = (dataSource: string, collection: string) => { + const { cm, fim, collection: _collection } = useChartDataSource(dataSource); + return useMemo(() => { + const fields = cm.getCollectionFields(collection || _collection); + const field2option = (field, depth) => { + if (!field.interface) { + return; + } + const fieldInterface = fim.getFieldInterface(field.interface); + if (!fieldInterface?.filterable) { + return; + } + const { nested, children, operators } = fieldInterface.filterable; + const option = { + name: field.name, + title: field?.uiSchema?.title || field.name, + schema: field?.uiSchema, + operators: + operators?.filter?.((operator) => { + return !operator?.visible || operator.visible(field); + }) || [], + interface: field.interface, + }; + if (field.target && depth > 2) { + return; + } + if (depth > 2) { + return option; + } + if (children?.length) { + option['children'] = children; + } + if (nested) { + const targetFields = cm.getCollectionFields(field.target); + const options = getOptions(targetFields, depth + 1).filter(Boolean); + option['children'] = option['children'] || []; + option['children'].push(...options); + } + return option; + }; + const getOptions = (fields, depth) => { + const options = []; + fields.forEach((field) => { + const option = field2option(field, depth); + if (option) { + options.push(option); + } + }); + return options; + }; + const options = getOptions(fields, 1); + return options; + }, [collection, cm, fim]); +}; diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx index 0f27fd0a8..655bbba62 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRenderer.tsx @@ -8,10 +8,11 @@ import { gridRowColWrap, useAPIClient, useCollection_deprecated, + useDataSource, useDesignable, } from '@nocobase/client'; import { Empty, Result, Spin, Typography } from 'antd'; -import React, { useContext } from 'react'; +import React, { memo, useContext } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChartConfigContext } from '../configure'; import { useData, useFieldTransformer, useFieldsWithAssociation } from '../hooks'; @@ -21,15 +22,17 @@ import { ChartRendererContext } from './ChartRendererProvider'; import { useChart } from '../chart/group'; import { ChartDataContext } from '../block/ChartDataProvider'; const { Paragraph, Text } = Typography; +import lodash from 'lodash'; +import { Line } from '@ant-design/plots'; export const ChartRenderer: React.FC & { Designer: React.FC; } = (props) => { const { t } = useChartsTranslation(); const ctx = useContext(ChartRendererContext); - const { config, transform, collection, service, data: _data } = ctx; - const fields = useFieldsWithAssociation(collection); - const data = useData(_data, collection); + const { config, transform, dataSource, collection, service, data: _data } = ctx; + const fields = useFieldsWithAssociation(dataSource, collection); + const data = useData(_data, dataSource, collection); const general = config?.general || {}; const advanced = config?.advanced || {}; const api = useAPIClient(); @@ -37,7 +40,7 @@ export const ChartRenderer: React.FC & { const chart = useChart(config?.chartType); const locale = api.auth.getLocale(); const transformers = useFieldTransformer(transform, locale); - const Component = chart?.render({ + const chartProps = chart?.getProps({ data, general, advanced, @@ -51,30 +54,27 @@ export const ChartRenderer: React.FC & { }, {}), ctx, }); + const C = chart?.Component; - const C = () => - chart ? ( + if (!chart) { + return ; + } + if (!(data && data.length) && !service.loading) { + return ; + } + + return ( + { console.error(error); }} FallbackComponent={ErrorFallback} > - + - ) : ( - - ); - - if (service.loading) { - return ; - } - - if (!(data && data.length)) { - return ; - } - - return ; + + ); }; ChartRenderer.Designer = function Designer() { @@ -85,14 +85,15 @@ ChartRenderer.Designer = function Designer() { const field = useField(); const schema = useFieldSchema(); const { insertAdjacent } = useDesignable(); - const { name, title, dataSource } = useCollection_deprecated(); + const dataSource = useDataSource(); + const { name, title } = useCollection_deprecated(); return ( { - setCurrent({ schema, field, dataSource, collection: name, service, data: service.data }); + setCurrent({ schema, field, dataSource: dataSource.key, collection: name, service, data: service.data }); setVisible(true); }} > diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx index 08d4aa1bb..3a1edd69c 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/client/renderer/ChartRendererProvider.tsx @@ -1,6 +1,7 @@ import { useFieldSchema } from '@formily/react'; import { CollectionManagerProvider, + DEFAULT_DATA_SOURCE_KEY, MaybeCollectionProvider, useAPIClient, useDataSourceManager, @@ -69,14 +70,14 @@ export const ChartRendererContext = createContext< ChartRendererContext.displayName = 'ChartRendererContext'; export const ChartRendererProvider: React.FC = (props) => { - const { query, config, collection, transform, dataSource } = props; + const { query, config, collection, transform, dataSource = DEFAULT_DATA_SOURCE_KEY } = props; const { addChart } = useContext(ChartDataContext); const { ready, form, enabled } = useContext(ChartFilterContext); const { getFilter, hasFilter, appendFilter } = useChartFilter(); const schema = useFieldSchema(); const api = useAPIClient(); const service = useRequest( - (collection, query, manual) => + (dataSource, collection, query, manual) => new Promise((resolve, reject) => { // Check if the chart is configured if (!(collection && query?.measures?.length)) return resolve(undefined); @@ -84,8 +85,8 @@ export const ChartRendererProvider: React.FC = (props) => { if (enabled && !form) return resolve(undefined); const filterValues = getFilter(); const queryWithFilter = - !manual && hasFilter({ collection, query }, filterValues) - ? appendFilter({ collection, query }, filterValues) + !manual && hasFilter({ dataSource, collection, query }, filterValues) + ? appendFilter({ dataSource, collection, query }, filterValues) : query; api .request({ @@ -93,6 +94,7 @@ export const ChartRendererProvider: React.FC = (props) => { method: 'POST', data: { uid: schema?.['x-uid'], + dataSource, collection, ...queryWithFilter, filter: removeUnparsableFilter(queryWithFilter.filter), @@ -116,14 +118,16 @@ export const ChartRendererProvider: React.FC = (props) => { }) .then((res) => { resolve(res?.data?.data); + }) + .finally(() => { if (!manual && schema?.['x-uid']) { - addChart(schema?.['x-uid'], { collection, service, query }); + addChart(schema?.['x-uid'], { dataSource, collection, service, query }); } }) .catch(reject); }), { - defaultParams: [collection, query], + defaultParams: [dataSource, collection, query], // Wait until ChartFilterProvider is rendered and check the status of the filter form // since the filter parameters should be applied if the filter block is enabled ready: ready && (!enabled || !!form), @@ -132,9 +136,9 @@ export const ChartRendererProvider: React.FC = (props) => { return ( - + - + {props.children} diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts index 223b3aff0..d95b966c1 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/api.test.ts @@ -51,6 +51,7 @@ describe('api', () => { test('query', async () => { const ctx = { + app, db, action: { params: { @@ -82,6 +83,7 @@ describe('api', () => { test('query with sort', async () => { const ctx = { + app, db, action: { params: { diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts index dc260d0a4..d6419884a 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/__tests__/query.test.ts @@ -1,8 +1,9 @@ -import { MockServer, mockServer } from '@nocobase/test'; +import { MockServer, createMockServer } from '@nocobase/test'; import compose from 'koa-compose'; import { vi } from 'vitest'; import { cacheMiddleware, parseBuilder, parseFieldAndAssociations } from '../actions/query'; const formatter = await import('../actions/formatter'); + describe('query', () => { describe('parseBuilder', () => { const sequelize = { @@ -11,8 +12,10 @@ describe('query', () => { }; let ctx: any; let app: MockServer; - beforeAll(() => { - app = mockServer(); + beforeAll(async () => { + app = await createMockServer({ + plugins: ['data-source-manager'], + }); app.db.options.underscored = true; app.db.collection({ name: 'orders', diff --git a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts index 3ea490fed..5047e0a7b 100644 --- a/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts +++ b/packages/plugins/@nocobase/plugin-data-visualization/src/server/actions/query.ts @@ -27,6 +27,7 @@ type OrderProps = { type QueryParams = Partial<{ uid: string; + dataSource: string; collection: string; measures: MeasureProps[]; dimensions: DimensionProps[]; @@ -45,6 +46,11 @@ type QueryParams = Partial<{ refresh: boolean; }>; +const getDB = (ctx: Context, dataSource: string) => { + const ds = ctx.app.dataSourceManager.dataSources.get(dataSource); + return ds?.collectionManager.db; +}; + export const postProcess = async (ctx: Context, next: Next) => { const { data, fieldMap } = ctx.action.params.values as { data: any[]; @@ -71,8 +77,9 @@ export const postProcess = async (ctx: Context, next: Next) => { }; export const queryData = async (ctx: Context, next: Next) => { - const { collection, queryParams, fieldMap } = ctx.action.params.values; - const model = ctx.db.getModel(collection); + const { dataSource, collection, queryParams, fieldMap } = ctx.action.params.values; + const db = getDB(ctx, dataSource) || ctx.db; + const model = db.getModel(collection); const data = await model.findAll(queryParams); ctx.action.params.values = { data, @@ -89,8 +96,9 @@ export const queryData = async (ctx: Context, next: Next) => { }; export const parseBuilder = async (ctx: Context, next: Next) => { - const { sequelize } = ctx.db; - const { measures, dimensions, orders, include, where, limit } = ctx.action.params.values; + const { dataSource, measures, dimensions, orders, include, where, limit } = ctx.action.params.values; + const db = getDB(ctx, dataSource) || ctx.db; + const { sequelize } = db; const attributes = []; const group = []; const order = []; @@ -157,8 +165,16 @@ export const parseBuilder = async (ctx: Context, next: Next) => { }; export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { - const { collection: collectionName, measures, dimensions, orders, filter } = ctx.action.params.values as QueryParams; - const collection = ctx.db.getCollection(collectionName); + const { + dataSource, + collection: collectionName, + measures, + dimensions, + orders, + filter, + } = ctx.action.params.values as QueryParams; + const db = getDB(ctx, dataSource) || ctx.db; + const collection = db.getCollection(collectionName); const fields = collection.fields; const models: { [target: string]: { @@ -180,7 +196,7 @@ export const parseFieldAndAssociations = async (ctx: Context, next: Next) => { let fieldType = fields.get(name)?.type; if (target) { const targetField = fields.get(target) as Field; - const targetCollection = ctx.db.getCollection(targetField.target); + const targetCollection = db.getCollection(targetField.target); const targetFields = targetCollection.fields; fieldType = targetFields.get(name)?.type; field = `${target}.${field}`; diff --git a/packages/plugins/@nocobase/plugin-disable-pm-add/package.json b/packages/plugins/@nocobase/plugin-disable-pm-add/package.json index de1c68f05..aaf90c631 100644 --- a/packages/plugins/@nocobase/plugin-disable-pm-add/package.json +++ b/packages/plugins/@nocobase/plugin-disable-pm-add/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-disable-pm-add", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "peerDependencies": { "@nocobase/client": "0.x", diff --git a/packages/plugins/@nocobase/plugin-error-handler/package.json b/packages/plugins/@nocobase/plugin-error-handler/package.json index 6d51634b6..0609166cd 100644 --- a/packages/plugins/@nocobase/plugin-error-handler/package.json +++ b/packages/plugins/@nocobase/plugin-error-handler/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "错误处理器", "description": "Handling application errors and exceptions.", "description.zh-CN": "处理应用程序中的错误和异常。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "devDependencies": { diff --git a/packages/plugins/@nocobase/plugin-excel-formula-field/package.json b/packages/plugins/@nocobase/plugin-excel-formula-field/package.json index 9e7147ccf..03ddd392d 100644 --- a/packages/plugins/@nocobase/plugin-excel-formula-field/package.json +++ b/packages/plugins/@nocobase/plugin-excel-formula-field/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-excel-formula-field", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "AGPL-3.0", "main": "./dist/server/index.js", diff --git a/packages/plugins/@nocobase/plugin-export/package.json b/packages/plugins/@nocobase/plugin-export/package.json index 1dd653535..6a647a256 100644 --- a/packages/plugins/@nocobase/plugin-export/package.json +++ b/packages/plugins/@nocobase/plugin-export/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "操作:导出记录", "description": "Export filtered records to excel, you can configure which fields to export.", "description.zh-CN": "导出筛选后的记录到 Excel 中,可以配置导出哪些字段。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-export", diff --git a/packages/plugins/@nocobase/plugin-file-manager/package.json b/packages/plugins/@nocobase/plugin-file-manager/package.json index a3b04163d..20e117c3d 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/package.json +++ b/packages/plugins/@nocobase/plugin-file-manager/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-file-manager", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "File manager", "displayName.zh-CN": "文件管理器", "description": "Provides files storage services with files collection template and attachment field.", diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client/FileStorage.tsx b/packages/plugins/@nocobase/plugin-file-manager/src/client/FileStorage.tsx index 18279f4fc..83ab7d0f9 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/client/FileStorage.tsx +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client/FileStorage.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, DownOutlined } from '@ant-design/icons'; import { uid } from '@formily/shared'; import { ActionContext, SchemaComponent, useCompile, usePlugin, useRecord } from '@nocobase/client'; import { Button, Card, Dropdown } from 'antd'; @@ -73,7 +73,7 @@ export const CreateStorage = () => { }} > diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/client/templates/file.ts b/packages/plugins/@nocobase/plugin-file-manager/src/client/templates/file.ts index a028c17f8..efc374a05 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/client/templates/file.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/client/templates/file.ts @@ -150,7 +150,6 @@ export class FileCollectionTemplate extends CollectionTemplate { title: `{{t("File storage", { ns: "${NAMESPACE}" })}}`, type: 'hasOne', name: 'storage', - required: true, 'x-decorator': 'FormItem', 'x-component': 'Select', 'x-reactions': ['{{useAsyncDataSource(loadStorages)}}'], diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts index 05a6e3175..f6f6fb137 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts @@ -2,7 +2,7 @@ import actions from '@nocobase/actions'; import { createMiddleware, destroyMiddleware } from './attachments'; export default function ({ app }) { - app.resourcer.use(createMiddleware); + app.resourcer.use(createMiddleware, { tag: 'createMiddleware', after: 'auth' }); app.resourcer.registerActionHandler('upload', actions.create); app.resourcer.use(destroyMiddleware); diff --git a/packages/plugins/@nocobase/plugin-formula-field/package.json b/packages/plugins/@nocobase/plugin-formula-field/package.json index c6402cd4e..898ec5df8 100644 --- a/packages/plugins/@nocobase/plugin-formula-field/package.json +++ b/packages/plugins/@nocobase/plugin-formula-field/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "数据表字段:公式", "description": "Configure and store the results of calculations between multiple field values in the same record, supporting both Math.js and Excel formula functions.", "description.zh-CN": "可以配置并存储同一条记录的多字段值之间的计算结果,支持 Math.js 和 Excel formula functions 两种引擎", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/field-formula", diff --git a/packages/plugins/@nocobase/plugin-gantt/package.json b/packages/plugins/@nocobase/plugin-gantt/package.json index c7075c4ce..26d104095 100644 --- a/packages/plugins/@nocobase/plugin-gantt/package.json +++ b/packages/plugins/@nocobase/plugin-gantt/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-gantt", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "displayName": "Block: Gantt", "displayName.zh-CN": "区块:甘特图", "description": "Provides Gantt block.", diff --git a/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json b/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json index e388677a4..666137456 100644 --- a/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json +++ b/packages/plugins/@nocobase/plugin-graph-collection-manager/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "可视化数据表管理", "description": "An ER diagram-like tool. Currently only the Master database is supported.", "description.zh-CN": "类似 ER 图的工具,目前只支持主数据库。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/graph-collection-manager", diff --git a/packages/plugins/@nocobase/plugin-iframe-block/package.json b/packages/plugins/@nocobase/plugin-iframe-block/package.json index 077e5d06c..51a0f115c 100644 --- a/packages/plugins/@nocobase/plugin-iframe-block/package.json +++ b/packages/plugins/@nocobase/plugin-iframe-block/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "区块:iframe", "description": "Create an iframe block on the page to embed and display external web pages or content.", "description.zh-CN": "在页面上创建和管理iframe,用于嵌入和展示外部网页或内容。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/block-iframe", diff --git a/packages/plugins/@nocobase/plugin-import/package.json b/packages/plugins/@nocobase/plugin-import/package.json index 38f7c5299..05dcda968 100644 --- a/packages/plugins/@nocobase/plugin-import/package.json +++ b/packages/plugins/@nocobase/plugin-import/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "操作:导入记录", "description": "Import records using excel templates. You can configure which fields to import and templates will be generated automatically.", "description.zh-CN": "使用 Excel 模板导入数据,可以配置导入哪些字段,自动生成模板。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/action-import", diff --git a/packages/plugins/@nocobase/plugin-kanban/package.json b/packages/plugins/@nocobase/plugin-kanban/package.json index d6cf4b3d2..8231d6b51 100644 --- a/packages/plugins/@nocobase/plugin-kanban/package.json +++ b/packages/plugins/@nocobase/plugin-kanban/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-kanban", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/block-kanban", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/block-kanban", diff --git a/packages/plugins/@nocobase/plugin-localization-management/package.json b/packages/plugins/@nocobase/plugin-localization-management/package.json index 5168fd3ee..0b97125dc 100644 --- a/packages/plugins/@nocobase/plugin-localization-management/package.json +++ b/packages/plugins/@nocobase/plugin-localization-management/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-localization-management", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/localization-management", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/localization-management", diff --git a/packages/plugins/@nocobase/plugin-logger/package.json b/packages/plugins/@nocobase/plugin-logger/package.json index 14374f437..e2c1eff6f 100644 --- a/packages/plugins/@nocobase/plugin-logger/package.json +++ b/packages/plugins/@nocobase/plugin-logger/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "日志", "description": "Server-side logs, mainly including API request logs and system runtime logs, and allows to package and download log files.", "description.zh-CN": "服务端日志,主要包括接口请求日志和系统运行日志,并支持打包和下载日志文件。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/logger", diff --git a/packages/plugins/@nocobase/plugin-map/package.json b/packages/plugins/@nocobase/plugin-map/package.json index 184e86704..6da8d793a 100644 --- a/packages/plugins/@nocobase/plugin-map/package.json +++ b/packages/plugins/@nocobase/plugin-map/package.json @@ -2,7 +2,7 @@ "name": "@nocobase/plugin-map", "displayName": "Block: Map", "displayName.zh-CN": "区块:地图", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "Map block, support Gaode map and Google map, you can also extend more map types.", "description.zh-CN": "地图区块,支持高德地图和 Google 地图,你也可以扩展更多地图类型。", "license": "AGPL-3.0", diff --git a/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlock.Settings.tsx b/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlock.Settings.tsx index a396d73b5..2244bc7ec 100644 --- a/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlock.Settings.tsx +++ b/packages/plugins/@nocobase/plugin-map/src/client/block/MapBlock.Settings.tsx @@ -230,7 +230,8 @@ export const mapBlockSettings = new SchemaSettings({ useComponentProps() { const { name } = useCollection(); const fieldSchema = useFieldSchema(); - const defaultResource = fieldSchema?.['x-decorator-props']?.resource; + const defaultResource = + fieldSchema?.['x-decorator-props']?.resource || fieldSchema?.['x-decorator-props']?.association; return { componentName: 'Map', collectionName: name, diff --git a/packages/plugins/@nocobase/plugin-math-formula-field/package.json b/packages/plugins/@nocobase/plugin-math-formula-field/package.json index 32f3ac9d7..25a9494b6 100644 --- a/packages/plugins/@nocobase/plugin-math-formula-field/package.json +++ b/packages/plugins/@nocobase/plugin-math-formula-field/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "数学公式字段", "description": "A powerful mathematical formula field plugin designed to provide flexible mathematical and formula calculation capabilities for databases or data management systems. It seamlessly integrates with various database systems such as MySQL, PostgreSQL, and offers an intuitive user interface for defining and executing mathematical formula fields.", "description.zh-CN": "一个功能强大的数学公式字段插件,旨在为数据库或数据管理系统提供灵活的数学计算和公式计算功能。它可以与各种数据库系统(如MySQL、PostgreSQL)无缝集成,并提供直观的用户界面来定义和执行数学公式字段。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "devDependencies": { diff --git a/packages/plugins/@nocobase/plugin-mobile-client/package.json b/packages/plugins/@nocobase/plugin-mobile-client/package.json index c9ce7e7ee..bf0cdebd5 100644 --- a/packages/plugins/@nocobase/plugin-mobile-client/package.json +++ b/packages/plugins/@nocobase/plugin-mobile-client/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-mobile-client", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/mobile-client", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/mobile-client", diff --git a/packages/plugins/@nocobase/plugin-mock-collections/package.json b/packages/plugins/@nocobase/plugin-mock-collections/package.json index 5c02f8866..de73377cf 100644 --- a/packages/plugins/@nocobase/plugin-mock-collections/package.json +++ b/packages/plugins/@nocobase/plugin-mock-collections/package.json @@ -2,7 +2,7 @@ "name": "@nocobase/plugin-mock-collections", "displayName": "mock-collections", "description": "mock-collections", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "license": "AGPL-3.0", "peerDependencies": { diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/package.json b/packages/plugins/@nocobase/plugin-multi-app-manager/package.json index ddb8b705e..d9a8563ef 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-manager/package.json +++ b/packages/plugins/@nocobase/plugin-multi-app-manager/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "多应用管理器", "description": "Dynamically create multiple apps without separate deployments.", "description.zh-CN": "无需单独部署即可动态创建多个应用。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/multi-app-manager", diff --git a/packages/plugins/@nocobase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts b/packages/plugins/@nocobase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts index ef79bcdd2..93c6bf5b8 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts +++ b/packages/plugins/@nocobase/plugin-multi-app-manager/src/client/settings/schemas/applications.ts @@ -235,6 +235,7 @@ export const schema: ISchema = { title: '{{ t("Delete") }}', 'x-component': 'Action', 'x-component-props': { + icon: 'DeleteOutlined', useAction: useDestroyAll, confirm: { title: "{{t('Delete')}}", @@ -250,6 +251,7 @@ export const schema: ISchema = { 'x-component': 'Action', 'x-component-props': { type: 'primary', + icon: 'PlusOutlined', }, properties: { drawer: { diff --git a/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json b/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json index 89910b4fd..45fc0f2b0 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json +++ b/packages/plugins/@nocobase/plugin-multi-app-share-collection/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "多应用数据表共享", "description": "", "description.zh-CN": "", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "devDependencies": { "@formily/react": "2.x", diff --git a/packages/plugins/@nocobase/plugin-notifications/package.json b/packages/plugins/@nocobase/plugin-notifications/package.json index b0ec6b87d..1641330de 100644 --- a/packages/plugins/@nocobase/plugin-notifications/package.json +++ b/packages/plugins/@nocobase/plugin-notifications/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-notifications", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "description": "", "license": "AGPL-3.0", "main": "./dist/server/index.js", diff --git a/packages/plugins/@nocobase/plugin-oidc/package.json b/packages/plugins/@nocobase/plugin-oidc/package.json index 895744767..4cdcbbced 100644 --- a/packages/plugins/@nocobase/plugin-oidc/package.json +++ b/packages/plugins/@nocobase/plugin-oidc/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "认证:OIDC", "description": "OIDC (OpenID Connect) authentication.", "description.zh-CN": "通过 OIDC (OpenID Connect) 协议认证身份。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/auth-oidc", diff --git a/packages/plugins/@nocobase/plugin-saml/package.json b/packages/plugins/@nocobase/plugin-saml/package.json index 7fef44631..a98b5ce7e 100644 --- a/packages/plugins/@nocobase/plugin-saml/package.json +++ b/packages/plugins/@nocobase/plugin-saml/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "认证:SAML 2.0", "description": "SAML 2.0 authentication.", "description.zh-CN": "通过 SAML 2.0 协议认证身份。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/auth-saml", diff --git a/packages/plugins/@nocobase/plugin-sample-hello/package.json b/packages/plugins/@nocobase/plugin-sample-hello/package.json index 29a0d39c5..d970cdcfb 100644 --- a/packages/plugins/@nocobase/plugin-sample-hello/package.json +++ b/packages/plugins/@nocobase/plugin-sample-hello/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-sample-hello", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "displayName": "Hello", "displayName.zh-CN": "Hello", diff --git a/packages/plugins/@nocobase/plugin-sequence-field/package.json b/packages/plugins/@nocobase/plugin-sequence-field/package.json index 0abb9649a..8c07760e7 100644 --- a/packages/plugins/@nocobase/plugin-sequence-field/package.json +++ b/packages/plugins/@nocobase/plugin-sequence-field/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "数据表字段:自动编码", "description": "Automatically generate codes based on configured rules, supporting combinations of dates, numbers, and text.", "description.zh-CN": "根据配置的规则自动生成编码,支持日期、数字、文本的组合。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/field-sequence", diff --git a/packages/plugins/@nocobase/plugin-sms-auth/package.json b/packages/plugins/@nocobase/plugin-sms-auth/package.json index bff8e0362..082c68cf2 100644 --- a/packages/plugins/@nocobase/plugin-sms-auth/package.json +++ b/packages/plugins/@nocobase/plugin-sms-auth/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "认证:短信", "description": "SMS authentication.", "description.zh-CN": "通过短信验证码认证身份。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/auth-sms", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/auth-sms", diff --git a/packages/plugins/@nocobase/plugin-snapshot-field/package.json b/packages/plugins/@nocobase/plugin-snapshot-field/package.json index 728468055..d684b5585 100644 --- a/packages/plugins/@nocobase/plugin-snapshot-field/package.json +++ b/packages/plugins/@nocobase/plugin-snapshot-field/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "数据表字段:关系快照", "description": "When adding a new record, create a snapshot for its relational record and save in the new record. The snapshot will not be updated when the relational record is updated.", "description.zh-CN": "在添加数据时,为它的关系数据创建快照,并保存在当前的数据中。关系数据更新时,快照不会更新。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/field-snapshot", diff --git a/packages/plugins/@nocobase/plugin-system-settings/package.json b/packages/plugins/@nocobase/plugin-system-settings/package.json index d4a3fc430..c475876ea 100644 --- a/packages/plugins/@nocobase/plugin-system-settings/package.json +++ b/packages/plugins/@nocobase/plugin-system-settings/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "系统设置", "description": "Used to adjust the system title, logo, language, etc.", "description.zh-CN": "用于调整系统的标题、LOGO、语言等。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/system-settings", diff --git a/packages/plugins/@nocobase/plugin-theme-editor/package.json b/packages/plugins/@nocobase/plugin-theme-editor/package.json index 0986b1b95..55277ce30 100644 --- a/packages/plugins/@nocobase/plugin-theme-editor/package.json +++ b/packages/plugins/@nocobase/plugin-theme-editor/package.json @@ -1,6 +1,6 @@ { "name": "@nocobase/plugin-theme-editor", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "main": "dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/theme-editor", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/theme-editor", diff --git a/packages/plugins/@nocobase/plugin-ui-routes-storage/package.json b/packages/plugins/@nocobase/plugin-ui-routes-storage/package.json index 3a8284d3a..e11e757ee 100644 --- a/packages/plugins/@nocobase/plugin-ui-routes-storage/package.json +++ b/packages/plugins/@nocobase/plugin-ui-routes-storage/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "路由管理", "description": "manage the routes of nocobase", "description.zh-CN": "管理页面路由。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "devDependencies": { diff --git a/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json b/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json index ad3c18295..be6191d2f 100644 --- a/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json +++ b/packages/plugins/@nocobase/plugin-ui-schema-storage/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "UI schema 存储服务", "description": "Provides centralized UI schema storage service.", "description.zh-CN": "提供中心化的 UI schema 存储服务。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/ui-schema-storage", diff --git a/packages/plugins/@nocobase/plugin-users/package.json b/packages/plugins/@nocobase/plugin-users/package.json index cc346b6d2..562952a8e 100644 --- a/packages/plugins/@nocobase/plugin-users/package.json +++ b/packages/plugins/@nocobase/plugin-users/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "用户", "description": "Provides basic user model, as well as created by and updated by fields.", "description.zh-CN": "提供了基础的用户模型,以及创建人和最后更新人字段。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/users", diff --git a/packages/plugins/@nocobase/plugin-verification/package.json b/packages/plugins/@nocobase/plugin-verification/package.json index 137c7b157..5c4d83060 100644 --- a/packages/plugins/@nocobase/plugin-verification/package.json +++ b/packages/plugins/@nocobase/plugin-verification/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "验证码", "description": "verification setting.", "description.zh-CN": "验证码配置。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/verification", diff --git a/packages/plugins/@nocobase/plugin-verification/src/client/schemas/providers.ts b/packages/plugins/@nocobase/plugin-verification/src/client/schemas/providers.ts index d294cb8e4..0549ab362 100644 --- a/packages/plugins/@nocobase/plugin-verification/src/client/schemas/providers.ts +++ b/packages/plugins/@nocobase/plugin-verification/src/client/schemas/providers.ts @@ -91,6 +91,7 @@ export default { title: '{{t("Delete")}}', 'x-component': 'Action', 'x-component-props': { + icon: 'DeleteOutlined', useAction: '{{ cm.useBulkDestroyAction }}', confirm: { title: "{{t('Delete')}}", @@ -104,6 +105,7 @@ export default { 'x-component': 'Action', 'x-component-props': { type: 'primary', + icon: 'PlusOutlined', }, properties: { drawer: { diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json b/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json index ffda302d3..a521c898c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:数据操作触发器", "description": "Bind action buttons to trigger workflow events when clicked.", "description.zh-CN": "可对数据操作按钮绑定,在点击后触发对应的工作流事件。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/plugins/workflow-action-trigger", diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx index cbf88b9e7..a49e6ada5 100644 --- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/ActionTrigger.tsx @@ -6,7 +6,12 @@ import { useCollectionManager_deprecated, useCompile, } from '@nocobase/client'; -import { Trigger, CollectionBlockInitializer, getCollectionFieldOptions } from '@nocobase/plugin-workflow/client'; +import { + Trigger, + CollectionBlockInitializer, + getCollectionFieldOptions, + useWorkflowAnyExecuted, +} from '@nocobase/plugin-workflow/client'; import { NAMESPACE, useLang } from '../locale'; export default class extends Trigger { @@ -17,10 +22,8 @@ export default class extends Trigger { type: 'string', required: true, 'x-decorator': 'FormItem', - 'x-component': 'CollectionSelect', - 'x-component-props': { - className: 'auto-width', - }, + 'x-component': 'DataSourceCollectionCascader', + 'x-disabled': '{{ useWorkflowAnyExecuted() }}', title: `{{t("Collection", { ns: "${NAMESPACE}" })}}`, description: `{{t("Which collection record belongs to.", { ns: "${NAMESPACE}" })}}`, 'x-reactions': [ @@ -63,6 +66,7 @@ export default class extends Trigger { }; scope = { useCollectionDataSource, + useWorkflowAnyExecuted, }; isActionTriggerable = (config, context) => { return ['create', 'update', 'customize:update', 'customize:triggerWorkflows'].includes(context.action); @@ -126,7 +130,7 @@ export default class extends Trigger { title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, Component: CollectionBlockInitializer, collection: config.collection, - dataSource: '{{$context.data}}', + dataPath: '$context.data', }; } } diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/__e2e__/configuration.test.ts b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/__e2e__/configuration.test.ts index c3aab3ade..8c2024354 100644 --- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/__e2e__/configuration.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/client/__e2e__/configuration.test.ts @@ -9,6 +9,7 @@ import { apiUpdateWorkflowTrigger, appendJsonCollectionName, generalWithNoRelationalFields, + apiGetDataSourceCount, } from '@nocobase/plugin-workflow-test/e2e'; import { expect, test } from '@nocobase/test/e2e'; import { dayjs } from '@nocobase/utils'; @@ -51,7 +52,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName); await formEventTriggerNode.nodeConfigure.click(); await formEventTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await formEventTriggerNode.submitButton.click(); //配置录入数据区块 @@ -60,6 +62,10 @@ test.describe('Configuration page to configure the Trigger node', () => { await page.waitForLoadState('networkidle'); await page.getByLabel('schema-initializer-Grid-page:addBlock').hover(); await page.getByRole('menuitem', { name: 'table Table' }).hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: `${triggerNodeCollectionDisplayName}` }).click(); // 移开鼠标,关闭菜单 @@ -143,7 +149,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName); await formEventTriggerNode.nodeConfigure.click(); await formEventTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await formEventTriggerNode.submitButton.click(); //配置录入数据区块 @@ -152,6 +159,10 @@ test.describe('Configuration page to configure the Trigger node', () => { await page.waitForLoadState('networkidle'); await page.getByLabel('schema-initializer-Grid-page:addBlock').hover(); await page.getByRole('menuitem', { name: 'table Table' }).hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: `${triggerNodeCollectionDisplayName}` }).click(); // 移开鼠标,关闭菜单 @@ -649,8 +660,11 @@ test.describe('Configuration page copy to new version', () => { // 3、预期结果:新版本工作流配置内容同旧版本一样 const formEventTriggerNode = new FormEventTriggerNode(page, workFlowName, triggerNodeCollectionName); await formEventTriggerNode.nodeConfigure.click(); - await expect(page.getByRole('button', { name: triggerNodeCollectionDisplayName })).toBeVisible(); - + await expect( + page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .getByText(`Main / ${triggerNodeCollectionDisplayName}`), + ).toBeVisible(); // 4、后置处理:删除工作流 await apiDeleteWorkflow(workflowId); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts index b113924b0..0fc614183 100644 --- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts +++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/ActionTrigger.ts @@ -1,11 +1,11 @@ import { get } from 'lodash'; import { BelongsTo, HasOne } from 'sequelize'; import { Model, modelAssociationByKey } from '@nocobase/database'; -import { DefaultContext } from '@nocobase/server'; -import { ActionContext } from '@nocobase/resourcer'; -import { Next } from '@nocobase/actions'; +import Application, { DefaultContext } from '@nocobase/server'; +import { Context as ActionContext, Next } from '@nocobase/actions'; import WorkflowPlugin, { Trigger, WorkflowModel, toJSON } from '@nocobase/plugin-workflow'; +import { parseCollectionName } from '@nocobase/data-source-manager'; interface Context extends ActionContext, DefaultContext {} @@ -13,7 +13,7 @@ export default class extends Trigger { constructor(workflow: WorkflowPlugin) { super(workflow); - workflow.app.resourcer.use(this.middleware); + workflow.app.use(this.middleware, { after: 'dataSource' }); } async triggerAction(context: Context, next: Next) { @@ -55,6 +55,7 @@ export default class extends Trigger { private async trigger(context: Context) { const { triggerWorkflows = '', values } = context.action.params; + const dataSourceHeader = context.get('x-data-source') || 'main'; const { currentUser, currentRole } = context.state; const { model: UserModel } = this.workflow.db.getCollection('users'); @@ -79,12 +80,16 @@ export default class extends Trigger { const asyncGroup = []; for (const workflow of workflows) { const { collection, appends = [] } = workflow.config; + const [dataSourceName, collectionName] = parseCollectionName(collection); const trigger = triggers.find((trigger) => trigger[0] == workflow.key); const event = [workflow]; if (context.action.resourceName !== 'workflows') { if (!context.body) { continue; } + if (dataSourceName !== dataSourceHeader) { + continue; + } const { body: data } = context; for (const row of Array.isArray(data) ? data : [data]) { let payload = row; @@ -101,7 +106,7 @@ export default class extends Trigger { } const model = payload.constructor; if (payload instanceof Model) { - if (collection !== model.collection.name) { + if (collectionName !== model.collection.name) { continue; } if (appends.length) { @@ -115,7 +120,9 @@ export default class extends Trigger { event.push({ data: toJSON(payload), ...userInfo }); } } else { - const { model, repository } = context.db.getCollection(collection); + const { model, repository } = (context.app).dataSourceManager.dataSources + .get(dataSourceName) + .collectionManager.getCollection(collectionName); let data = trigger[1] ? get(values, trigger[1]) : values; const pk = get(data, model.primaryKeyAttribute); if (appends.length && pk != null) { diff --git a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts index d30cc936a..ae16cfda0 100644 --- a/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-action-trigger/src/server/__tests__/trigger.test.ts @@ -12,7 +12,7 @@ describe('workflow > action-trigger', () => { let PostRepo; let CommentRepo; let WorkflowModel; - let UserModel; + let UserRepo; let users; let userAgents; @@ -26,12 +26,14 @@ describe('workflow > action-trigger', () => { WorkflowModel = db.getCollection('workflows').model; PostRepo = db.getCollection('posts').repository; CommentRepo = db.getCollection('comments').repository; - UserModel = db.getCollection('users').model; + UserRepo = db.getCollection('users').repository; - users = await UserModel.bulkCreate([ - { id: 2, nickname: 'a' }, - { id: 3, nickname: 'b' }, - ]); + users = await UserRepo.create({ + values: [ + { id: 2, nickname: 'a', roles: [{ name: 'root' }] }, + { id: 3, nickname: 'b' }, + ], + }); userAgents = users.map((user) => app.agent().login(user)); }); @@ -569,4 +571,49 @@ describe('workflow > action-trigger', () => { expect(e3s[0].status).toBe(EXECUTION_STATUS.RESOLVED); }); }); + + describe('multiple data source', () => { + it('trigger on different data source', async () => { + const workflow = await WorkflowModel.create({ + enabled: true, + type: 'action', + config: { + collection: 'another:posts', + }, + }); + + const res1 = await userAgents[0].resource('posts').create({ + values: { title: 't1' }, + triggerWorkflows: `${workflow.key}`, + }); + expect(res1.status).toBe(200); + + await sleep(500); + + const e1s = await workflow.getExecutions(); + expect(e1s.length).toBe(0); + + // const res2 = await userAgents[0] + // .set('x-data-source', 'another') + // .resource('posts') + // .create({ + // values: { title: 't2' }, + // triggerWorkflows: `${workflow.key}`, + // }); + const res2 = await agent + .login(users[0]) + .set('x-data-source', 'another') + .post('/api/posts:create') + .query({ triggerWorkflows: `${workflow.key}` }) + .send({ title: 't2' }); + + expect(res2.status).toBe(200); + + await sleep(500); + + const e2s = await workflow.getExecutions(); + expect(e2s.length).toBe(1); + expect(e2s[0].status).toBe(EXECUTION_STATUS.RESOLVED); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json b/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json index 0b3939c89..b2caaae55 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:聚合查询节点", "description": "Used to aggregate data against the database in workflow, such as: statistics, sum, average, etc.", "description.zh-CN": "可用于在工作流中对数据库进行聚合查询,如:统计数量、求和、平均值等。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-aggregate", diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/AggregateInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/AggregateInstruction.tsx index 9ee696428..a2dc67c77 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/AggregateInstruction.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/AggregateInstruction.tsx @@ -6,6 +6,8 @@ import { SchemaComponentContext, SchemaInitializerItemType, css, + joinCollectionName, + parseCollectionName, useCollectionDataSource, useCollectionFilterOptions, useCollectionManager_deprecated, @@ -116,25 +118,34 @@ function AssociatedConfig({ value, onChange, ...props }): JSX.Element { // need to get: // * source collection (from node.config) // * target collection (from field name) - const { collectionName, target, name } = field; + const { collectionName, target, name, dataSourceKey } = field; - const collection = getCollection(collectionName); + const collection = getCollection(collectionName, dataSourceKey); const primaryKeyField = collection.fields.find((f) => f.primaryKey); - setValuesIn('collection', target); + setValuesIn('collection', `${dataSourceKey}:${target}`); onChange({ name, // primary key data path associatedKey: `{{${path.slice(0, -1).join('.')}.${primaryKeyField.name}}}`, // data associated collection name - associatedCollection: collectionName, + associatedCollection: joinCollectionName(dataSourceKey, collectionName), }); }, [onChange], ); - return ; + return ( + + ); } // based on collection: @@ -217,7 +228,7 @@ export default class extends Instruction { type: 'string', required: true, 'x-decorator': 'FormItem', - 'x-component': 'CollectionSelect', + 'x-component': 'DataSourceCollectionCascader', title: `{{t("Data of collection", { ns: "${NAMESPACE}" })}}`, 'x-reactions': [ { @@ -333,7 +344,8 @@ export default class extends Instruction { 'x-component-props': { useProps() { const { values } = useForm(); - const options = useCollectionFilterOptions(values?.collection); + const [dataSourceName, collectionName] = parseCollectionName(values?.collection); + const options = useCollectionFilterOptions(collectionName, dataSourceName); return { options, className: css` diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/__e2e__/DataOfCollection.test.ts b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/__e2e__/DataOfCollection.test.ts index f788fa7c1..aca22522a 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/__e2e__/DataOfCollection.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/client/__e2e__/DataOfCollection.test.ts @@ -80,7 +80,8 @@ test.describe('no filter', () => { const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText(); await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.submitButton.click(); @@ -179,7 +180,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.sumRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.submitButton.click(); @@ -281,7 +283,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.avgRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.submitButton.click(); @@ -385,7 +388,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.minRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.submitButton.click(); @@ -488,7 +492,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.maxRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.submitButton.click(); @@ -590,7 +595,8 @@ test.describe('no filter', () => { const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText(); await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -690,7 +696,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.sumRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -795,7 +802,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.avgRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -902,7 +910,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.minRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -1006,7 +1015,8 @@ test.describe('no filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.maxRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -1111,7 +1121,8 @@ test.describe('filter', () => { const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText(); await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); // 过滤条件 @@ -1217,7 +1228,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.sumRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); // 过滤条件 @@ -1327,7 +1339,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.avgRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); // 过滤条件 @@ -1442,7 +1455,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.minRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); // 过滤条件 @@ -1553,7 +1567,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.maxRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); // 过滤条件 @@ -1663,7 +1678,8 @@ test.describe('filter', () => { const aggregateRecordNodeId = await aggregateRecordNode.node.locator('.workflow-node-id').innerText(); await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -1770,7 +1786,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.sumRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -1885,7 +1902,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.avgRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -2009,7 +2027,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.minRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); @@ -2121,7 +2140,8 @@ test.describe('filter', () => { await aggregateRecordNode.nodeConfigure.click(); await aggregateRecordNode.maxRadio.click(); await aggregateRecordNode.collectionDropDown.click(); - await page.getByText(aggregateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: aggregateNodeCollectionDisplayName }).click(); await aggregateRecordNode.aggregatedFieldDropDown.click(); await page.getByRole('option', { name: aggregateNodeFieldDisplayName }).click(); await aggregateRecordNode.distinctCheckBox.click(); diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/AggregateInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/AggregateInstruction.ts index 50bf8e144..f5d5fc79d 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/AggregateInstruction.ts +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/AggregateInstruction.ts @@ -1,4 +1,5 @@ -import { BelongsToManyRepository, DataTypes, HasManyRepository } from '@nocobase/database'; +import { parseCollectionName } from '@nocobase/data-source-manager'; +import { DataTypes } from '@nocobase/database'; import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plugin-workflow'; const aggregators = { @@ -13,13 +14,14 @@ export default class extends Instruction { async run(node: FlowNodeModel, input, processor: Processor) { const { aggregator, associated, collection, association = {}, params = {} } = node.config; const options = processor.getParsedValue(params, node.id); - const { database } = node.constructor; + const [dataSourceName, collectionName] = parseCollectionName(collection); + const { collectionManager } = this.workflow.app.dataSourceManager.dataSources.get(dataSourceName); const repo = associated - ? database.getRepository( + ? collectionManager.getRepository( `${association?.associatedCollection}.${association.name}`, processor.getParsedValue(association?.associatedKey, node.id), ) - : database.getRepository(collection); + : collectionManager.getRepository(collectionName); if (!options.dataType && aggregator === 'avg') { options.dataType = DataTypes.DOUBLE; diff --git a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/__tests__/instruction.test.ts index 3bb1efe94..712822afa 100644 --- a/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/__tests__/instruction.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-aggregate/src/server/__tests__/instruction.test.ts @@ -3,6 +3,7 @@ import { Application } from '@nocobase/server'; import { getApp, sleep } from '@nocobase/plugin-workflow-test'; import Plugin from '..'; +import { EXECUTION_STATUS } from '@nocobase/plugin-workflow'; describe('workflow > instructions > aggregate', () => { let app: Application; @@ -295,4 +296,33 @@ describe('workflow > instructions > aggregate', () => { expect(j3.result).toBe(1); }); }); + + describe('multiple data source', () => { + it('query on another data source', async () => { + const AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts'); + const post = await AnotherPostRepo.create({ values: { title: 't1' } }); + const p1s = await AnotherPostRepo.find(); + expect(p1s.length).toBe(1); + + const n1 = await workflow.createNode({ + type: 'aggregate', + config: { + collection: 'another:posts', + aggregator: 'count', + params: { + field: 'id', + }, + }, + }); + + await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const [job] = await execution.getJobs(); + expect(job.result).toBe(1); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-delay/package.json b/packages/plugins/@nocobase/plugin-workflow-delay/package.json index dfc14fa8f..b3bb47a55 100644 --- a/packages/plugins/@nocobase/plugin-workflow-delay/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-delay/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:延时节点", "description": "Could be used in workflow parallel branch for waiting other branches.", "description.zh-CN": "可用于工作流并行分支中等待其他分支执行完成。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-delay", diff --git a/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json b/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json index eb98d15cb..4eae623d5 100644 --- a/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-dynamic-calculation/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:动态表达式计算节点", "description": "Useful plugin for doing dynamic calculation based on expression collection records in workflow.", "description.zh-CN": "用于在工作流中进行基于数据行的动态表达式计算。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-dynamic-calculation", diff --git a/packages/plugins/@nocobase/plugin-workflow-loop/package.json b/packages/plugins/@nocobase/plugin-workflow-loop/package.json index 86df41246..49610212d 100644 --- a/packages/plugins/@nocobase/plugin-workflow-loop/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-loop/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:循环节点", "description": "Used to repeat the sub-process processing of each value in an array, and can also be used for fixed times of sub-process processing.", "description.zh-CN": "用于对一个数组中的每个值进行重复的子流程处理,也可用于固定次数的重复子流程处理。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-loop", diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/package.json b/packages/plugins/@nocobase/plugin-workflow-manual/package.json index b2031c767..833458260 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-manual/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:人工处理节点", "description": "Could be used for workflows which some of decisions are made by users.", "description.zh-CN": "用于人工控制部分决策的流程。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-manual", diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx index a429c146b..3ef15cdef 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/WorkflowTodo.tsx @@ -24,10 +24,10 @@ import WorkflowPlugin, { linkNodes, useAvailableUpstreams, useFlowContext, + DetailsBlockProvider, } from '@nocobase/plugin-workflow/client'; import { NAMESPACE, useLang } from '../locale'; -import { DetailsBlockProvider } from './instruction/DetailsBlockProvider'; import { FormBlockProvider } from './instruction/FormBlockProvider'; import { ManualFormType, manualFormTypes } from './instruction/SchemaConfig'; diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/createRecordForm.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/createRecordForm.test.ts index b1988c123..7b4e2a96f 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/createRecordForm.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/createRecordForm.test.ts @@ -10,6 +10,7 @@ import { apiUpdateWorkflowTrigger, appendJsonCollectionName, generalWithNoRelationalFields, + apiGetDataSourceCount, } from '@nocobase/plugin-workflow-test/e2e'; import { expect, test } from '@nocobase/test/e2e'; import { dayjs } from '@nocobase/utils'; @@ -81,6 +82,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -216,6 +221,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -351,6 +360,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -486,6 +499,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -621,6 +638,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -756,6 +777,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -891,6 +916,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1027,6 +1056,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1162,6 +1195,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1297,6 +1334,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1440,6 +1481,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1574,6 +1619,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1715,6 +1764,10 @@ test.describe('field data entry', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1865,6 +1918,10 @@ test.describe('action button', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -2000,6 +2057,10 @@ test.describe('action button', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -2141,6 +2202,10 @@ test.describe('action button', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts index ddff9eb21..d6c32fb6c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/updateRecordForm.test.ts @@ -10,6 +10,7 @@ import { apiUpdateWorkflowTrigger, appendJsonCollectionName, generalWithNoRelationalFields, + apiGetDataSourceCount, } from '@nocobase/plugin-workflow-test/e2e'; import { expect, test } from '@nocobase/test/e2e'; import { dayjs } from '@nocobase/utils'; @@ -97,6 +98,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -249,6 +254,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -401,6 +410,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -553,6 +566,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -705,6 +722,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -857,6 +878,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1025,6 +1050,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1193,6 +1222,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1361,6 +1394,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1530,6 +1567,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1707,6 +1748,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -1875,6 +1920,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -2049,6 +2098,10 @@ test.describe('field data update', () => { await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.updateRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/workflowTodo.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/workflowTodo.test.ts index 672dd6e89..a93881f5c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/workflowTodo.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/__e2e__/workflowTodo.test.ts @@ -8,6 +8,7 @@ import { apiUpdateWorkflowTrigger, appendJsonCollectionName, generalWithNoRelationalFields, + apiGetDataSourceCount, } from '@nocobase/plugin-workflow-test/e2e'; import { expect, test } from '@nocobase/test/e2e'; import { dayjs } from '@nocobase/utils'; @@ -69,6 +70,10 @@ test('filter task node', async ({ page, mockPage, mockCollections, mockRecords } await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page @@ -179,6 +184,10 @@ test('filter workflow name', async ({ page, mockPage, mockCollections, mockRecor await manualNode.configureUserInterfaceButton.click(); await manualNode.addBlockButton.hover(); await manualNode.createRecordFormMenu.hover(); + const dataSourcesCount = await apiGetDataSourceCount(); + if (dataSourcesCount > 1) { + await page.getByRole('menuitem', { name: 'Main right' }).hover(); + } await page.getByRole('menuitem', { name: manualNodeCollectionDisplayName }).click(); await page.mouse.move(300, 0, { steps: 100 }); await page diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx index 271830b44..6af0e1b92 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/SchemaConfig.tsx @@ -35,6 +35,8 @@ import { } from '@nocobase/client'; import WorkflowPlugin, { JOB_STATUS, + DetailsBlockProvider, + SimpleDesigner, useAvailableUpstreams, useFlowContext, useNodeContext, @@ -44,7 +46,6 @@ import WorkflowPlugin, { import { Registry, lodash } from '@nocobase/utils/client'; import { NAMESPACE, useLang } from '../../locale'; -import { DetailsBlockProvider } from './DetailsBlockProvider'; import { FormBlockProvider } from './FormBlockProvider'; import createRecordForm from './forms/create'; import customRecordForm from './forms/custom'; @@ -104,24 +105,6 @@ const blockTypeNames = { record: `{{t("Data record", { ns: "${NAMESPACE}" })}}`, }; -function SimpleDesigner() { - const schema = useFieldSchema(); - const title = blockTypeNames[schema['x-designer-props']?.type] ?? '{{t("Block")}}'; - const compile = useCompile(); - return ( - - - - - - ); -} - /** * @deprecated */ @@ -153,7 +136,7 @@ export const addBlockButton_deprecated = new CompatibleSchemaInitializer({ { name: 'nodes', type: 'subMenu', - title: `{{t("Node result", { ns: "${NAMESPACE}" })}}`, + title: `{{t("Node result", { ns: "workflow" })}}`, children: nodeBlockInitializers, }, ] diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/custom.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/custom.tsx index f81a1057c..13a48ae2c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/custom.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/forms/custom.tsx @@ -3,7 +3,7 @@ import React, { useContext, useMemo, useState } from 'react'; import { ArrayTable } from '@formily/antd-v5'; import { Field, createForm } from '@formily/core'; import { useField, useFieldSchema, useForm } from '@formily/react'; -import lodash from 'lodash'; +import { cloneDeep, pick, set } from 'lodash'; import { ActionContextProvider, @@ -161,7 +161,7 @@ function getOptions(interfaces) { const schema = interfaces[type]; const { group = 'others' } = schema; fields[group] = fields[group] || {}; - lodash.set(fields, [group, type], schema); + set(fields, [group, type], schema); }); return Object.keys(GroupLabels) @@ -208,33 +208,51 @@ const CustomItemsComponent = (props) => { const items = useCommonInterfaceInitializers(); const collection = useCollection_deprecated(); const { setCollectionFields } = useContext(FormBlockContext); + const form = useMemo(() => createForm(), [interfaceOptions]); return ( = pick(item, [ + 'name', + 'group', + 'title', + 'default', + 'validateSchema', + ]); const { - properties: { unique, type, ...properties }, - ...options - } = lodash.cloneDeep(item); - delete properties.name['x-disabled']; - setInterface({ - ...options, - properties, - }); + properties: { unique, type, layout, autoIncrement, ...properties }, + } = item; + fieldInterface.properties = properties; + const result = cloneDeep(fieldInterface); + delete result.properties.name['x-disabled']; + setInterface(result); }, setCallback, }} > - + {interfaceOptions ? ( { 'x-component-props': { type: 'primary', useAction() { - const { values, query } = useForm(); + const { values, query, reset } = useForm(); const messages = [useLang('Field name existed in form')]; return { async run() { @@ -301,6 +319,7 @@ const CustomItemsComponent = (props) => { 'x-toolbar': 'FormItemSchemaToolbar', 'x-settings': 'fieldSettings:FormItem', }); + reset(); setCallback(null); setInterface(null); }, diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/index.tsx b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/index.tsx index 886f1d3f4..7167e8e17 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/index.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/index.tsx @@ -138,7 +138,7 @@ export default class extends Instruction { title: form.title ?? formKey, Component: CollectionBlockInitializer, collection: form.collection, - dataSource: `{{$jobsMapByNodeKey.${node.key}.${formKey}}}`, + dataPath: `$jobsMapByNodeKey.${node.key}.${formKey}`, } as SchemaInitializerItemType) : null; }) diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/data-source.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/data-source.test.ts new file mode 100644 index 000000000..e92dc9cdb --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/data-source.test.ts @@ -0,0 +1,223 @@ +import Database from '@nocobase/database'; +import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { getApp, sleep } from '@nocobase/plugin-workflow-test'; +import { MockServer } from '@nocobase/test'; + +// NOTE: skipped because time is not stable on github ci, but should work in local +describe('workflow > instructions > manual', () => { + let app: MockServer; + let agent; + let userAgents; + let db: Database; + let PostRepo; + let AnotherPostRepo; + let WorkflowModel; + let workflow; + let UserModel; + let users; + let UserJobModel; + + beforeEach(async () => { + app = await getApp({ + plugins: ['users', 'auth', 'workflow-manual'], + }); + // await app.getPlugin('auth').install(); + agent = app.agent(); + db = app.db; + WorkflowModel = db.getCollection('workflows').model; + PostRepo = db.getCollection('posts').repository; + AnotherPostRepo = app.dataSourceManager.dataSources.get('another').collectionManager.getRepository('posts'); + UserModel = db.getCollection('users').model; + UserJobModel = db.getModel('users_jobs'); + + users = await UserModel.bulkCreate([ + { id: 2, nickname: 'a' }, + { id: 3, nickname: 'b' }, + ]); + + userAgents = users.map((user) => app.agent().login(user)); + + workflow = await WorkflowModel.create({ + enabled: true, + type: 'collection', + config: { + mode: 1, + collection: 'posts', + }, + }); + }); + + afterEach(() => app.destroy()); + + describe('multiple data source', () => { + describe('create', () => { + it('create as configured', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'create', + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + collection: 'posts', + dataSource: 'another', + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { title: 't1' }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(500); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.RESOLVED); + expect(j1.result).toMatchObject({ f1: { title: 't1' } }); + + const posts = await AnotherPostRepo.find(); + expect(posts.length).toBe(1); + expect(posts[0]).toMatchObject({ title: 't1' }); + }); + + it('save first and then commit', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'create', + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.PENDING, key: 'pending' }, + ], + collection: 'posts', + dataSource: 'another', + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { title: 't1' }, _: 'pending' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(500); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toMatchObject({ f1: { title: 't1' } }); + + const c1 = await AnotherPostRepo.find(); + expect(c1.length).toBe(0); + + const res2 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { title: 't2' }, _: 'resolve' }, + }, + }); + + await sleep(500); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j2] = await e2.getJobs(); + expect(j2.status).toBe(JOB_STATUS.RESOLVED); + expect(j2.result).toMatchObject({ f1: { title: 't2' } }); + + const c2 = await AnotherPostRepo.find(); + expect(c2.length).toBe(1); + }); + }); + + describe('update', () => { + it('update as configured', async () => { + const post = await AnotherPostRepo.create({ values: { title: 't1' } }); + + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + type: 'update', + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + collection: 'posts', + dataSource: 'another', + filter: { + id: post.id, + }, + }, + }, + }, + }); + + await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(1); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { title: 't2' }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(500); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j1] = await e2.getJobs(); + expect(j1.status).toBe(JOB_STATUS.RESOLVED); + expect(j1.result).toMatchObject({ f1: { title: 't2' } }); + + const postsAfter = await AnotherPostRepo.find(); + expect(postsAfter.length).toBe(1); + expect(postsAfter[0]).toMatchObject({ title: 't2' }); + }); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts similarity index 56% rename from packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/instruction.test.ts rename to packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts index 4f3acca75..1aa2fee67 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/instruction.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/form.test.ts @@ -333,516 +333,6 @@ describe('workflow > instructions > manual', () => { }); }); - describe('mode: 0 (single record)', () => { - it('the only user assigned could submit', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id], - forms: { - f1: { - actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const [pending] = await workflow.getExecutions(); - expect(pending.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await pending.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - - const usersJobs = await UserJobModel.findAll(); - expect(usersJobs.length).toBe(1); - expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING); - expect(usersJobs[0].userId).toBe(users[0].id); - expect(usersJobs[0].jobId).toBe(j1.id); - - const res1 = await agent.resource('users_jobs').submit({ - filterByTk: usersJobs[0].id, - values: { result: { f1: {}, _: 'resolve' } }, - }); - expect(res1.status).toBe(401); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: usersJobs[0].id, - values: { - result: { f1: {}, _: 'resolve' }, - }, - }); - expect(res2.status).toBe(403); - - const res3 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: usersJobs[0].id, - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res3.status).toBe(202); - - await sleep(1000); - - const [j2] = await pending.getJobs(); - expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); - - const usersJobsAfter = await UserJobModel.findAll(); - expect(usersJobsAfter.length).toBe(1); - expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED); - expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' }); - - const res4 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: usersJobs[0].id, - values: { - result: { f1: { a: 2 }, _: 'resolve' }, - }, - }); - expect(res4.status).toBe(400); - }); - - it('any user assigned could submit', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - forms: { - f1: { - actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const [pending] = await workflow.getExecutions(); - expect(pending.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await pending.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - - const usersJobs = await j1.getUsersJobs(); - - const res1 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: usersJobs.find((item) => item.userId === users[1].id).id, - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [j2] = await pending.getJobs(); - expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); - - const res2 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: usersJobs.find((item) => item.userId === users[0].id).id, - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res2.status).toBe(400); - }); - - it('also could submit to users_jobs api', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id], - forms: { - f1: { - actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const usersJobs = await UserJobModel.findAll(); - expect(usersJobs.length).toBe(1); - expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING); - expect(usersJobs[0].get('userId')).toBe(users[0].id); - - const res = await userAgents[0].resource('users_jobs').submit({ - filterByTk: usersJobs[0].get('id'), - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res.status).toBe(202); - - await sleep(1000); - - const [execution] = await workflow.getExecutions(); - expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); - const [job] = await execution.getJobs(); - expect(job.status).toBe(JOB_STATUS.RESOLVED); - expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); - }); - }); - - describe('mode: 1 (multiple record, all)', () => { - it('all resolved', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: 1, - forms: { - f1: { - actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - expect(j1.result).toBe(0.5); - const usersJobs1 = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(usersJobs1.length).toBe(2); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 2 }, _: 'resolve' }, - }, - }); - expect(res2.status).toBe(202); - - await sleep(1000); - - const [e2] = await workflow.getExecutions(); - expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); - const [j2] = await e2.getJobs(); - expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toBe(1); - }); - - it('first rejected', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: 1, - forms: { - f1: { - actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.REJECTED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.REJECTED); - expect(j1.result).toBe(0.5); - const usersJobs1 = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(usersJobs1.length).toBe(2); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res2.status).toBe(400); - }); - - it('last rejected', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: 1, - forms: { - f1: { - actions: [ - { status: JOB_STATUS.RESOLVED, key: 'resolve' }, - { status: JOB_STATUS.REJECTED, key: 'reject' }, - ], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - expect(j1.result).toBe(0.5); - const usersJobs1 = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(usersJobs1.length).toBe(2); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res2.status).toBe(202); - - await sleep(1000); - - const [e2] = await workflow.getExecutions(); - expect(e2.status).toBe(EXECUTION_STATUS.REJECTED); - const [j2] = await e2.getJobs(); - expect(j2.status).toBe(JOB_STATUS.REJECTED); - expect(j2.result).toBe(1); - }); - }); - - describe('mode: -1 (multiple record, any)', () => { - it('first resolved', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: -1, - forms: { - f1: { - actions: [ - { status: JOB_STATUS.RESOLVED, key: 'resolve' }, - { status: JOB_STATUS.REJECTED, key: 'reject' }, - ], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.RESOLVED); - expect(j1.result).toBe(0.5); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res2.status).toBe(400); - }); - - it('any resolved', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: -1, - forms: { - f1: { - actions: [ - { status: JOB_STATUS.RESOLVED, key: 'resolve' }, - { status: JOB_STATUS.REJECTED, key: 'reject' }, - ], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - expect(j1.result).toBe(0.5); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 1 }, _: 'resolve' }, - }, - }); - expect(res2.status).toBe(202); - - await sleep(1000); - - const [e2] = await workflow.getExecutions(); - expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); - const [j2] = await e2.getJobs(); - expect(j2.status).toBe(JOB_STATUS.RESOLVED); - expect(j2.result).toBe(1); - }); - - it('all rejected', async () => { - const n1 = await workflow.createNode({ - type: 'manual', - config: { - assignees: [users[0].id, users[1].id], - mode: -1, - forms: { - f1: { - actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }], - }, - }, - }, - }); - - const post = await PostRepo.create({ values: { title: 't1' } }); - - await sleep(500); - - const UserJobModel = db.getModel('users_jobs'); - const pendingJobs = await UserJobModel.findAll({ - order: [['userId', 'ASC']], - }); - expect(pendingJobs.length).toBe(2); - - const res1 = await userAgents[0].resource('users_jobs').submit({ - filterByTk: pendingJobs[0].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res1.status).toBe(202); - - await sleep(1000); - - const [e1] = await workflow.getExecutions(); - expect(e1.status).toBe(EXECUTION_STATUS.STARTED); - const [j1] = await e1.getJobs(); - expect(j1.status).toBe(JOB_STATUS.PENDING); - expect(j1.result).toBe(0.5); - - const res2 = await userAgents[1].resource('users_jobs').submit({ - filterByTk: pendingJobs[1].get('id'), - values: { - result: { f1: { a: 0 }, _: 'reject' }, - }, - }); - expect(res2.status).toBe(202); - - await sleep(1000); - - const [e2] = await workflow.getExecutions(); - expect(e2.status).toBe(EXECUTION_STATUS.REJECTED); - const [j2] = await e2.getJobs(); - expect(j2.status).toBe(JOB_STATUS.REJECTED); - expect(j2.result).toBe(1); - }); - }); - describe('use result of submitted form in manual node', () => { it('result should be available and correct', async () => { const n1 = await workflow.createNode({ diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/mode.test.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/mode.test.ts new file mode 100644 index 000000000..8ea37def4 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/__tests__/mode.test.ts @@ -0,0 +1,561 @@ +import Database from '@nocobase/database'; +import { EXECUTION_STATUS, JOB_STATUS } from '@nocobase/plugin-workflow'; +import { getApp, sleep } from '@nocobase/plugin-workflow-test'; +import { MockServer } from '@nocobase/test'; + +// NOTE: skipped because time is not stable on github ci, but should work in local +describe('workflow > instructions > manual', () => { + let app: MockServer; + let agent; + let userAgents; + let db: Database; + let PostRepo; + let CommentRepo; + let WorkflowModel; + let workflow; + let UserModel; + let users; + let UserJobModel; + + beforeEach(async () => { + app = await getApp({ + plugins: ['users', 'auth', 'workflow-manual'], + }); + // await app.getPlugin('auth').install(); + agent = app.agent(); + db = app.db; + WorkflowModel = db.getCollection('workflows').model; + PostRepo = db.getCollection('posts').repository; + CommentRepo = db.getCollection('comments').repository; + UserModel = db.getCollection('users').model; + UserJobModel = db.getModel('users_jobs'); + + users = await UserModel.bulkCreate([ + { id: 2, nickname: 'a' }, + { id: 3, nickname: 'b' }, + ]); + + userAgents = users.map((user) => app.agent().login(user)); + + workflow = await WorkflowModel.create({ + enabled: true, + type: 'collection', + config: { + mode: 1, + collection: 'posts', + }, + }); + }); + + afterEach(() => app.destroy()); + + describe('mode: 0 (single record)', () => { + it('the only user assigned could submit', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [pending] = await workflow.getExecutions(); + expect(pending.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await pending.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + + const usersJobs = await UserJobModel.findAll(); + expect(usersJobs.length).toBe(1); + expect(usersJobs[0].status).toBe(JOB_STATUS.PENDING); + expect(usersJobs[0].userId).toBe(users[0].id); + expect(usersJobs[0].jobId).toBe(j1.id); + + const res1 = await agent.resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { result: { f1: {}, _: 'resolve' } }, + }); + expect(res1.status).toBe(401); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: {}, _: 'resolve' }, + }, + }); + expect(res2.status).toBe(403); + + const res3 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res3.status).toBe(202); + + await sleep(1000); + + const [j2] = await pending.getJobs(); + expect(j2.status).toBe(JOB_STATUS.RESOLVED); + expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); + + const usersJobsAfter = await UserJobModel.findAll(); + expect(usersJobsAfter.length).toBe(1); + expect(usersJobsAfter[0].status).toBe(JOB_STATUS.RESOLVED); + expect(usersJobsAfter[0].result).toEqual({ f1: { a: 1 }, _: 'resolve' }); + + const res4 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].id, + values: { + result: { f1: { a: 2 }, _: 'resolve' }, + }, + }); + expect(res4.status).toBe(400); + }); + + it('any user assigned could submit', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + forms: { + f1: { + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [pending] = await workflow.getExecutions(); + expect(pending.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await pending.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + + const usersJobs = await j1.getUsersJobs(); + + const res1 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: usersJobs.find((item) => item.userId === users[1].id).id, + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [j2] = await pending.getJobs(); + expect(j2.status).toBe(JOB_STATUS.RESOLVED); + expect(j2.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); + + const res2 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs.find((item) => item.userId === users[0].id).id, + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res2.status).toBe(400); + }); + + it('also could submit to users_jobs api', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id], + forms: { + f1: { + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const usersJobs = await UserJobModel.findAll(); + expect(usersJobs.length).toBe(1); + expect(usersJobs[0].get('status')).toBe(JOB_STATUS.PENDING); + expect(usersJobs[0].get('userId')).toBe(users[0].id); + + const res = await userAgents[0].resource('users_jobs').submit({ + filterByTk: usersJobs[0].get('id'), + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res.status).toBe(202); + + await sleep(1000); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const [job] = await execution.getJobs(); + expect(job.status).toBe(JOB_STATUS.RESOLVED); + expect(job.result).toEqual({ f1: { a: 1 }, _: 'resolve' }); + }); + }); + + describe('mode: 1 (multiple record, all)', () => { + it('all resolved', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: 1, + forms: { + f1: { + actions: [{ status: JOB_STATUS.RESOLVED, key: 'resolve' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toBe(0.5); + const usersJobs1 = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(usersJobs1.length).toBe(2); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 2 }, _: 'resolve' }, + }, + }); + expect(res2.status).toBe(202); + + await sleep(1000); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j2] = await e2.getJobs(); + expect(j2.status).toBe(JOB_STATUS.RESOLVED); + expect(j2.result).toBe(1); + }); + + it('first rejected', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: 1, + forms: { + f1: { + actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.REJECTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.REJECTED); + expect(j1.result).toBe(0.5); + const usersJobs1 = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(usersJobs1.length).toBe(2); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res2.status).toBe(400); + }); + + it('last rejected', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: 1, + forms: { + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toBe(0.5); + const usersJobs1 = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(usersJobs1.length).toBe(2); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res2.status).toBe(202); + + await sleep(1000); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.REJECTED); + const [j2] = await e2.getJobs(); + expect(j2.status).toBe(JOB_STATUS.REJECTED); + expect(j2.result).toBe(1); + }); + }); + + describe('mode: -1 (multiple record, any)', () => { + it('first resolved', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: -1, + forms: { + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.RESOLVED); + expect(j1.result).toBe(0.5); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res2.status).toBe(400); + }); + + it('any resolved', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: -1, + forms: { + f1: { + actions: [ + { status: JOB_STATUS.RESOLVED, key: 'resolve' }, + { status: JOB_STATUS.REJECTED, key: 'reject' }, + ], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toBe(0.5); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 1 }, _: 'resolve' }, + }, + }); + expect(res2.status).toBe(202); + + await sleep(1000); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.RESOLVED); + const [j2] = await e2.getJobs(); + expect(j2.status).toBe(JOB_STATUS.RESOLVED); + expect(j2.result).toBe(1); + }); + + it('all rejected', async () => { + const n1 = await workflow.createNode({ + type: 'manual', + config: { + assignees: [users[0].id, users[1].id], + mode: -1, + forms: { + f1: { + actions: [{ status: JOB_STATUS.REJECTED, key: 'reject' }], + }, + }, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const UserJobModel = db.getModel('users_jobs'); + const pendingJobs = await UserJobModel.findAll({ + order: [['userId', 'ASC']], + }); + expect(pendingJobs.length).toBe(2); + + const res1 = await userAgents[0].resource('users_jobs').submit({ + filterByTk: pendingJobs[0].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res1.status).toBe(202); + + await sleep(1000); + + const [e1] = await workflow.getExecutions(); + expect(e1.status).toBe(EXECUTION_STATUS.STARTED); + const [j1] = await e1.getJobs(); + expect(j1.status).toBe(JOB_STATUS.PENDING); + expect(j1.result).toBe(0.5); + + const res2 = await userAgents[1].resource('users_jobs').submit({ + filterByTk: pendingJobs[1].get('id'), + values: { + result: { f1: { a: 0 }, _: 'reject' }, + }, + }); + expect(res2.status).toBe(202); + + await sleep(1000); + + const [e2] = await workflow.getExecutions(); + expect(e2.status).toBe(EXECUTION_STATUS.REJECTED); + const [j2] = await e2.getJobs(); + expect(j2.status).toBe(JOB_STATUS.REJECTED); + expect(j2.result).toBe(1); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/create.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/create.ts index a8df62961..9afc9b760 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/create.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/create.ts @@ -1,8 +1,15 @@ import { Processor } from '@nocobase/plugin-workflow'; import ManualInstruction from '../ManualInstruction'; -export default async function (this: ManualInstruction, instance, { collection }, processor: Processor) { - const repo = this.workflow.db.getRepository(collection); +export default async function ( + this: ManualInstruction, + instance, + { dataSource = 'main', collection }, + processor: Processor, +) { + const repo = this.workflow.app.dataSourceManager.dataSources + .get(dataSource) + .collectionManager.getRepository(collection); if (!repo) { throw new Error(`collection ${collection} for create data on manual node not found`); } @@ -18,6 +25,6 @@ export default async function (this: ManualInstruction, instance, { collection } context: { executionId: processor.execution.id, }, - // transaction: processor.transaction, + transaction: processor.transaction, }); } diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/update.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/update.ts index 62702b83a..83adfe3b8 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/update.ts +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/forms/update.ts @@ -1,8 +1,15 @@ import { Processor } from '@nocobase/plugin-workflow'; import ManualInstruction from '../ManualInstruction'; -export default async function (this: ManualInstruction, instance, { collection, filter = {} }, processor: Processor) { - const repo = this.workflow.db.getRepository(collection); +export default async function ( + this: ManualInstruction, + instance, + { dataSource = 'main', collection, filter = {} }, + processor: Processor, +) { + const repo = this.workflow.app.dataSourceManager.dataSources + .get(dataSource) + .collectionManager.getRepository(collection); if (!repo) { throw new Error(`collection ${collection} for update data on manual node not found`); } @@ -18,6 +25,6 @@ export default async function (this: ManualInstruction, instance, { collection, context: { executionId: processor.execution.id, }, - // transaction: processor.transaction, + transaction: processor.transaction, }); } diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/server/migrations/20240325213145-fix-schema.ts b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/migrations/20240325213145-fix-schema.ts new file mode 100644 index 000000000..04cfad3bf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow-manual/src/server/migrations/20240325213145-fix-schema.ts @@ -0,0 +1,81 @@ +import { Migration } from '@nocobase/server'; + +function findSchema(root, filter, onlyLeaf = false) { + const result = []; + + if (!root) { + return result; + } + + if (filter(root) && (!onlyLeaf || !root.properties)) { + result.push(root); + return result; + } + + if (root.properties) { + Object.keys(root.properties).forEach((key) => { + result.push(...findSchema(root.properties[key], filter)); + }); + } + return result; +} + +function changeToDataPath(item) { + if (item && item['x-decorator-props']?.dataSource) { + item['x-decorator-props'].dataPath = item['x-decorator-props'].dataSource.replace(/^{{|}}$/g, ''); + delete item['x-decorator-props'].dataSource; + } +} + +function migrateSchema(schema) { + const root = { properties: schema }; + + const detailNodes = findSchema(root, (item) => { + return ( + item['x-decorator'] === 'DetailsBlockProvider' && + item['x-component'] === 'CardItem' && + item['x-designer'] === 'SimpleDesigner' + ); + }); + + detailNodes.forEach(changeToDataPath); + + return schema; +} + +export default class extends Migration { + async up() { + const { db } = this.context; + const NodeRepo = db.getRepository('flow_nodes'); + await db.sequelize.transaction(async (transaction) => { + const nodes = await NodeRepo.find({ + filter: { + type: 'manual', + }, + transaction, + }); + console.log('%d nodes need to be migrated.', nodes.length); + + await nodes.reduce( + (promise, node) => + promise.then(() => { + const { assignees, forms, schema = {}, ...tabs } = node.config; + return node.update( + { + config: { + assignees, + forms, + schema: migrateSchema({ ...tabs, ...schema }), + }, + }, + { + silent: true, + transaction, + }, + ); + }), + Promise.resolve(), + ); + }); + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow-parallel/package.json b/packages/plugins/@nocobase/plugin-workflow-parallel/package.json index 08ca2f185..6d62c58ec 100644 --- a/packages/plugins/@nocobase/plugin-workflow-parallel/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-parallel/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:并行分支节点", "description": "Could be used for parallel execution of branch processes in the workflow.", "description.zh-CN": "用于在工作流中需要并行执行的分支流程。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-parallel", diff --git a/packages/plugins/@nocobase/plugin-workflow-request/package.json b/packages/plugins/@nocobase/plugin-workflow-request/package.json index ab656b8b9..15a5b639c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-request/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-request/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:HTTP 请求节点", "description": "Send HTTP requests to any HTTP service for data interaction in workflow.", "description.zh-CN": "可用于在工作流中向任意 HTTP 服务发送请求,进行数据交互。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-request", diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/package.json b/packages/plugins/@nocobase/plugin-workflow-sql/package.json index 869471451..2ddd45046 100644 --- a/packages/plugins/@nocobase/plugin-workflow-sql/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-sql/package.json @@ -4,7 +4,7 @@ "displayName.zh-CN": "工作流:SQL 节点", "description": "Execute SQL statements in workflow.", "description.zh-CN": "可用于在工作流中对数据库执行任意 SQL 语句。", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "./dist/server/index.js", "homepage": "https://docs.nocobase.com/handbook/workflow-sql", diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/src/client/SQLInstruction.tsx b/packages/plugins/@nocobase/plugin-workflow-sql/src/client/SQLInstruction.tsx index a640ec4d6..bdb41f516 100644 --- a/packages/plugins/@nocobase/plugin-workflow-sql/src/client/SQLInstruction.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-sql/src/client/SQLInstruction.tsx @@ -12,6 +12,21 @@ export default class extends Instruction { group = 'collection'; description = `{{t("Execute a SQL statement in database.", { ns: "${NAMESPACE}" })}}`; fieldset = { + dataSource: { + type: 'string', + required: true, + title: `{{t("Data source")}}`, + description: `{{t("Select a data source to execute SQL.", { ns: "${NAMESPACE}" })}}`, + 'x-decorator': 'FormItem', + 'x-component': 'DataSourceSelect', + 'x-component-props': { + className: 'auto-width', + filter(item) { + return item.options.isDBInstance; + }, + }, + default: 'main', + }, sql: { type: 'string', required: true, diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-workflow-sql/src/locale/zh-CN.json index 4d841b8e4..bb9549300 100644 --- a/packages/plugins/@nocobase/plugin-workflow-sql/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-workflow-sql/src/locale/zh-CN.json @@ -1,5 +1,6 @@ { "SQL action": "SQL 操作", "Execute a SQL statement in database.": "在数据库中执行一个 SQL 语句", + "Select a data source to execute SQL.": "选择一个数据源来执行 SQL", "SQL query result could be used through <1>JSON query node (Commercial plugin).": "SQL 执行的结果可在 <1>JSON 解析节点 中使用(商业插件)。" } diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/src/server/SQLInstruction.ts b/packages/plugins/@nocobase/plugin-workflow-sql/src/server/SQLInstruction.ts index f75a1beaa..d47a0bfbb 100644 --- a/packages/plugins/@nocobase/plugin-workflow-sql/src/server/SQLInstruction.ts +++ b/packages/plugins/@nocobase/plugin-workflow-sql/src/server/SQLInstruction.ts @@ -2,15 +2,22 @@ import { Processor, Instruction, JOB_STATUS, FlowNodeModel } from '@nocobase/plu export default class extends Instruction { async run(node: FlowNodeModel, input, processor: Processor) { - const { sequelize } = (node.constructor).database; - const sql = processor.getParsedValue(node.config.sql ?? '', node.id).trim(); + // @ts-ignore + const { db } = this.workflow.app.dataSourceManager.dataSources.get( + node.config.dataSource || 'main', + ).collectionManager; + if (!db) { + throw new Error(`type of data source "${node.config.dataSource}" is not database`); + } + const sql = processor.getParsedValue(node.config.sql || '', node.id).trim(); if (!sql) { return { status: JOB_STATUS.RESOLVED, }; } - const result = await sequelize.query(sql, { + // @ts-ignore + const result = await db.sequelize.query(sql, { transaction: processor.transaction, // plain: true, // model: db.getCollection(node.config.collection).model diff --git a/packages/plugins/@nocobase/plugin-workflow-sql/src/server/__tests__/instruction.test.ts b/packages/plugins/@nocobase/plugin-workflow-sql/src/server/__tests__/instruction.test.ts index 4c626a66a..39bb2acb4 100644 --- a/packages/plugins/@nocobase/plugin-workflow-sql/src/server/__tests__/instruction.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow-sql/src/server/__tests__/instruction.test.ts @@ -92,6 +92,26 @@ describe('workflow > instructions > sql', () => { }); describe('sql with variables', () => { + it('$system.now', async () => { + const queryInterface = db.sequelize.getQueryInterface(); + const n1 = await workflow.createNode({ + type: 'sql', + config: { + sql: `select '{{$system.now}}' as a`, + }, + }); + + const post = await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + const [sqlJob] = await execution.getJobs({ order: [['id', 'ASC']] }); + expect(sqlJob.status).toBe(JOB_STATUS.RESOLVED); + // expect(queryJob.status).toBe(JOB_STATUS.RESOLVED); + // expect(queryJob.result.read).toBe(post.id); + }); + it('update', async () => { const queryInterface = db.sequelize.getQueryInterface(); const n1 = await workflow.createNode({ @@ -195,4 +215,35 @@ describe('workflow > instructions > sql', () => { expect(job.status).toBe(JOB_STATUS.RESOLVED); }); }); + + describe('multiple data source', () => { + it('query on another data source', async () => { + const anotherSource = app.dataSourceManager.dataSources.get('another'); + const PostCollection = anotherSource.collectionManager.getCollection('posts'); + const { repository: AnotherPostRepo } = PostCollection; + const post = await AnotherPostRepo.create({ values: { title: 't1' } }); + const p1s = await AnotherPostRepo.find(); + expect(p1s.length).toBe(1); + + const n1 = await workflow.createNode({ + type: 'sql', + config: { + dataSource: 'another', + sql: `select * from ${PostCollection.quotedTableName()}`, + }, + }); + + await PostRepo.create({ values: { title: 't1' } }); + + await sleep(500); + + const [execution] = await workflow.getExecutions(); + expect(execution.status).toBe(EXECUTION_STATUS.RESOLVED); + const [job] = await execution.getJobs(); + expect(job.result.length).toBe(2); + expect(job.result[0].length).toBe(1); + // @ts-ignore + expect(job.result[0][0].id).toBe(post.id); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-test/package.json b/packages/plugins/@nocobase/plugin-workflow-test/package.json index 986fcb12c..e9df67eee 100644 --- a/packages/plugins/@nocobase/plugin-workflow-test/package.json +++ b/packages/plugins/@nocobase/plugin-workflow-test/package.json @@ -2,7 +2,7 @@ "name": "@nocobase/plugin-workflow-test", "displayName": "Workflow: test kit", "displayName.zh-CN": "工作流:测试工具包", - "version": "0.20.0-alpha.16", + "version": "0.20.0-alpha.17", "license": "AGPL-3.0", "main": "dist/server/index.js", "types": "./dist/server/index.d.ts", diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2ePageObjectModel.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2ePageObjectModel.ts index a5bed4fc2..8f744651a 100644 --- a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2ePageObjectModel.ts +++ b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2ePageObjectModel.ts @@ -79,62 +79,102 @@ export class ApprovalTriggerNode { nodeTitle: Locator; nodeConfigure: Locator; collectionDropDown: Locator; - checkWthdrawable: Locator; - configureUserInterfaceButton: Locator; + dataBlocksInitiationRadio: Locator; + dataBlocksAndGlobalApprovalBlocksInitiationRadio: Locator; + allowedToBeWithdrawnCheckbox: Locator; + goToconfigureButton: Locator; addBlockButton: Locator; addApplyFormMenu: Locator; configureFieldsButton: Locator; configureActionsButton: Locator; saveDraftSwitch: Locator; + submitButton: Locator; + cancelButton: Locator; addNodeButton: Locator; constructor(page: Page, triggerName: string, collectionName: string) { this.page = page; this.node = page.getByText('TriggeraConfigure'); this.nodeTitle = page.locator('textarea').filter({ hasText: triggerName }); this.nodeConfigure = page.getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); - this.checkWthdrawable = page.getByLabel('Withdrawable'); - this.configureUserInterfaceButton = page.getByRole('button', { name: 'Configure user interface' }); - this.addBlockButton = page.getByRole('button', { name: 'Add block' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); + this.dataBlocksInitiationRadio = page.getByLabel('Initiate and approve in data blocks only'); + this.dataBlocksAndGlobalApprovalBlocksInitiationRadio = page.getByLabel( + 'Initiate and approve in both data blocks and global approval blocks', + ); + this.allowedToBeWithdrawnCheckbox = page.getByLabel('Allowed to be withdrawn'); + this.goToconfigureButton = page.getByRole('button', { name: 'Go to configure' }); + this.addBlockButton = page.getByLabel(`schema-initializer-Grid-ApprovalApplyAddBlockButton-${collectionName}`); this.addApplyFormMenu = page.getByRole('menuitem', { name: 'Apply form' }); - this.configureFieldsButton = page.getByTestId('configure-fields-button-of-form-item-' + collectionName); - this.configureActionsButton = page.getByTestId( - 'approval-trigger-configure-form-actions-add-action-button-' + collectionName, + this.configureFieldsButton = page.getByLabel(`schema-initializer-Grid-form:configureFields-${collectionName}`); + this.configureActionsButton = page.getByLabel( + `schema-initializer-ActionBar-ApprovalApplyAddActionButton-${collectionName}`, ); this.saveDraftSwitch = page.getByRole('menuitem', { name: 'Save draft' }).getByRole('switch'); - this.addNodeButton = page.getByLabel('add-button', { exact: true }); + this.submitButton = page.getByLabel('action-Action-Submit-workflows'); + this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); + this.addNodeButton = this.addNodeButton = page.getByLabel('add-button', { exact: true }); } } -export class ApprovalNode { +export class ApprovalPassthroughModeNode { readonly page: Page; node: Locator; nodeTitle: Locator; nodeConfigure: Locator; + addAssigneesButton: Locator; assigneesDropDown: Locator; - checkReturnable: Locator; - configureUserInterfaceButton: Locator; + OrRadio: Locator; + AndRadio: Locator; + votingRadio: Locator; + parallellyRadio: Locator; + sequentiallyRadio: Locator; + goToconfigureButton: Locator; addBlockButton: Locator; - addApplyFormMenu: Locator; - configureFieldsButton: Locator; - configureActionsButton: Locator; - saveDraftSwitch: Locator; + addDetailsMenu: Locator; + detailsConfigureFieldsButton: Locator; + addActionsMenu: Locator; + actionsConfigureFieldsButton: Locator; + actionsConfigureActionsButton: Locator; + addApproveButton: Locator; + addRejectButton: Locator; + addReturnButton: Locator; + addNodeResult: Locator; + submitButton: Locator; + cancelButton: Locator; addNodeButton: Locator; constructor(page: Page, nodeName: string, collectionName: string) { this.page = page; - this.node = page.getByText('TriggeraConfigure'); - this.nodeTitle = page.locator('textarea').filter({ hasText: nodeName }); - this.nodeConfigure = page.getByRole('button', { name: 'Configure' }); - this.assigneesDropDown = page.getByLabel('Search'); - this.checkReturnable = page.getByLabel('Returnable'); - this.configureUserInterfaceButton = page.getByRole('button', { name: 'Configure user interface' }); - this.addBlockButton = page.getByTestId('add-block-button-in-workflow-workflows'); - this.addApplyFormMenu = page.getByRole('menuitem', { name: 'Apply form' }); - this.configureFieldsButton = page.getByTestId('configure-fields-button-of-form-item-' + collectionName); - this.configureActionsButton = page.getByTestId( - 'approval-trigger-configure-form-actions-add-action-button-' + collectionName, + this.node = page.getByLabel(`Approval-${nodeName}`, { exact: true }); + this.nodeTitle = page.getByLabel(`Approval-${nodeName}`, { exact: true }).getByRole('textbox'); + this.nodeConfigure = page + .getByLabel(`Approval-${nodeName}`, { exact: true }) + .getByRole('button', { name: 'Configure' }); + this.addAssigneesButton = page.getByRole('button', { name: 'plus Add assignee' }); + this.assigneesDropDown = page.getByTestId('select-single'); + this.OrRadio = page.getByLabel('Or', { exact: true }); + this.AndRadio = page.getByLabel('And', { exact: true }); + this.votingRadio = page.getByLabel('Voting', { exact: true }); + this.parallellyRadio = page.getByLabel('Parallelly', { exact: true }); + this.sequentiallyRadio = page.getByLabel('Sequentially', { exact: true }); + this.goToconfigureButton = page.getByRole('button', { name: 'Go to configure' }); + this.addBlockButton = page.getByLabel('schema-initializer-Grid-ApprovalProcessAddBlockButton-workflows'); + this.addDetailsMenu = page.getByRole('menuitem', { name: 'Details' }); + this.detailsConfigureFieldsButton = page.getByLabel( + `schema-initializer-Grid-ReadPrettyFormItemInitializers-${collectionName}`, ); - this.saveDraftSwitch = page.getByRole('menuitem', { name: 'Save draft' }).getByRole('switch'); + this.addActionsMenu = page.getByRole('menuitem', { name: 'Actions' }).getByRole('switch'); + this.actionsConfigureFieldsButton = page.getByLabel('schema-initializer-Grid-FormItemInitializers-approvalRecords'); + this.actionsConfigureActionsButton = page.getByLabel( + 'schema-initializer-ActionBar-ApprovalProcessAddActionButton-approvalRecords', + ); + this.addApproveButton = page.getByRole('menuitem', { name: 'Approve' }).getByRole('switch'); + this.addRejectButton = page.getByRole('menuitem', { name: 'Reject' }).getByRole('switch'); + this.addReturnButton = page.getByRole('menuitem', { name: 'Return' }).getByRole('switch'); + this.addNodeResult = page.getByRole('menuitem', { name: 'Node result right' }); + this.submitButton = page.getByLabel('action-Action-Submit-workflows'); + this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); this.addNodeButton = page.getByLabel(`add-button-calculation-${nodeName}`, { exact: true }); } } @@ -164,7 +204,9 @@ export class ScheduleTriggerNode { this.RrpeatModeDropdown = page.getByLabel('block-item-RepeatField-workflows-Repeat mode'); this.dataTableTimeFieldOptions = page.getByLabel('Based on date field of collection'); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.startTimeDropdown = page.getByLabel('block-item-OnField-workflows-Starts on'); this.submitButton = page.getByLabel('action-Action-Submit-workflows'); this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); @@ -187,7 +229,10 @@ export class CollectionTriggerNode { this.node = page.getByLabel(`Trigger-${triggerName}`); this.nodeTitle = page.getByLabel(`Trigger-${triggerName}`).getByRole('textbox'); this.nodeConfigure = page.getByLabel(`Trigger-${triggerName}`).getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + // this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.triggerOnDropdown = page .getByLabel('block-item-Select-workflows-Trigger on') .getByRole('button', { name: 'Trigger on' }); @@ -212,7 +257,9 @@ export class FormEventTriggerNode { this.node = page.getByLabel(`Trigger-${triggerName}`); this.nodeTitle = page.getByLabel(`Trigger-${triggerName}`).getByRole('textbox'); this.nodeConfigure = page.getByLabel(`Trigger-${triggerName}`).getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.relationalDataDropdown = page.getByTestId('select-field-Preload associations'); this.submitButton = page.getByLabel('action-Action-Submit-workflows'); this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); @@ -269,7 +316,9 @@ export class QueryRecordNode { this.nodeConfigure = page .getByLabel(`Query record-${nodeName}`, { exact: true }) .getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.allowMultipleDataBoxesForResults = page.getByLabel('Allow multiple records as'); this.addSortFieldsButton = page.getByRole('button', { name: 'plus Add sort field' }); this.pageNumberEditBox = page.getByLabel('variable-constant'); @@ -299,7 +348,9 @@ export class CreateRecordNode { this.nodeConfigure = page .getByLabel(`Create record-${nodeName}`, { exact: true }) .getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.addFieldsButton = page.getByRole('button', { name: 'plus Add field' }); this.submitButton = page.getByLabel('action-Action-Submit-workflows'); this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); @@ -326,7 +377,9 @@ export class UpdateRecordNode { this.nodeConfigure = page .getByLabel(`Update record-${nodeName}`, { exact: true }) .getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.batchUpdateModeRadio = page .getByLabel('block-item-IndividualHooksRadioWithTooltip-workflows-Update mode') .getByLabel('Update in a batch'); @@ -356,7 +409,9 @@ export class DeleteRecordNode { this.nodeConfigure = page .getByLabel(`Delete record-${nodeName}`, { exact: true }) .getByRole('button', { name: 'Configure' }); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .locator('.ant-select-selection-search-input'); this.submitButton = page.getByLabel('action-Action-Submit-workflows'); this.cancelButton = page.getByLabel('action-Action-Cancel-workflows'); this.addNodeButton = page.getByLabel(`add-button-delete-${nodeName}`, { exact: true }); @@ -395,11 +450,12 @@ export class AggregateNode { this.minRadio = page.getByLabel('MIN', { exact: true }); this.dataTableDataRadio = page.getByLabel('Data of collection'); this.linkedDataTableDataRadio = page.getByLabel('Data of associated collection'); - this.collectionDropDown = page.getByRole('button', { name: 'Select collection' }); - // this.aggregatedFieldDropDown = page.getByLabel('block-item-FieldsSelect-workflows-Field to aggregate').getByRole('textbox').getByRole('combobox'); - this.aggregatedFieldDropDown = page.locator( - 'input.ant-select-selection-search-input[role="combobox"][aria-haspopup="listbox"]', - ); + this.collectionDropDown = page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Data of collection') + .locator('.ant-select-selection-search-input'); + this.aggregatedFieldDropDown = page + .getByLabel('block-item-FieldsSelect-workflows-Field to aggregate') + .locator('.ant-select-selection-search-input'); this.distinctCheckBox = page .getByLabel('block-item-Checkbox-workflows-Distinct') .locator('input.ant-checkbox-input[type="checkbox"]'); @@ -572,7 +628,7 @@ export default module.exports = { WorkflowManagement, WorkflowListRecords, ApprovalTriggerNode, - ApprovalNode, + ApprovalPassthroughModeNode, ScheduleTriggerNode, CollectionTriggerNode, FormEventTriggerNode, diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eUtils.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eUtils.ts index debedba5e..4d39877eb 100644 --- a/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eUtils.ts +++ b/packages/plugins/@nocobase/plugin-workflow-test/src/e2e/e2eUtils.ts @@ -1,4 +1,4 @@ -import { request } from '@nocobase/test/e2e'; +import { request, Page } from '@nocobase/test/e2e'; const PORT = process.env.APP_PORT || 20000; const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${PORT}`; @@ -722,6 +722,156 @@ export const apiSubmitRecordTriggerFormEvent = async (triggerWorkflows: string, return await result.json(); }; +// 获取数据源个数 +export const apiGetDataSourceCount = async () => { + const api = await request.newContext({ + storageState: process.env.PLAYWRIGHT_AUTH_FILE, + }); + const state = await api.storageState(); + const headers = getHeaders(state); + const result = await api.get(`/api/dataSources:list?pageSize=50`, { + headers, + }); + + if (!result.ok()) { + throw new Error(await result.text()); + } + /* + { + "data": 1 + } + */ + return (await result.json()).meta.count; +}; + +// 添加业务表单条数据触发工作流表单事件,triggerWorkflows=key1!field,key2,key3!field.subfield +export const apiCreateRecordTriggerActionEvent = async ( + collectionName: string, + triggerWorkflows: string, + data: any, +) => { + const api = await request.newContext({ + storageState: process.env.PLAYWRIGHT_AUTH_FILE, + }); + const state = await api.storageState(); + const headers = getHeaders(state); + /* + { + "title": "a11", + "enabled": true, + "description": null + } + */ + const result = await api.post(`/api/${collectionName}:create?triggerWorkflows=${triggerWorkflows}`, { + headers, + data, + }); + + if (!result.ok()) { + throw new Error(await result.text()); + } + /* + { + "data": { + "id": 1, + "createdAt": "2023-12-12T02:43:53.793Z", + "updatedAt": "2023-12-12T05:41:33.300Z", + "key": "fzk3j2oj4el", + "title": "a11", + "enabled": true, + "description": null + }, + "meta": { + "allowedActions": { + "view": [ + 1 + ], + "update": [ + 1 + ], + "destroy": [ + 1 + ] + } + } + } + */ + return (await result.json()).data; +}; + +// 审批中心发起审批 +export const apiApplyApprovalEvent = async (data: any) => { + const api = await request.newContext({ + storageState: process.env.PLAYWRIGHT_AUTH_FILE, + }); + const state = await api.storageState(); + const headers = getHeaders(state); + /* + { + "title": "a11", + "enabled": true, + "description": null + } + */ + const result = await api.post('/api/approvals:create', { + headers, + data, + }); + + if (!result.ok()) { + throw new Error(await result.text()); + } + /* + { + "data": { + "id": 35, + "collectionName": "tt_amt_orgREmwr", + "data": { + "id": 6, + "url": null, + "sort": 3, + "email": null, + "phone": null, + "address": null, + "orgcode": "区域编码000000006", + "orgname": "阿三大苏打实打实的", + "isenable": null, + "staffnum": null, + "createdAt": "2024-03-09T11:37:47.620Z", + "sharesnum": null, + "updatedAt": "2024-03-09T11:37:47.620Z", + "insurednum": null, + "range_json": null, + "regcapital": null, + "testdataid": null, + "createdById": 1, + "paidcapital": null, + "range_check": [], + "updatedById": 1, + "status_radio": null, + "establishdate": null, + "insuranceratio": null, + "range_markdown": null, + "range_richtext": null, + "status_singleselect": null, + "range_multipleselect": [], + "insuranceratio_formula": null + }, + "status": 2, + "workflowId": 39, + "dataKey": "6", + "updatedAt": "2024-03-09T11:37:47.640Z", + "createdAt": "2024-03-09T11:37:47.640Z", + "createdById": 1, + "updatedById": 1, + "workflowKey": null, + "latestExecutionId": null + } + } + */ + return (await result.json()).data; +}; + const getStorageItem = (key: string, storageState: any) => { return storageState.origins .find((item) => item.origin === APP_BASE_URL) @@ -767,6 +917,16 @@ function getHeaders(storageState: any) { return headers; } +// 用户登录新会话 +export const userLogin = async (page: Page, approvalUserEmail: string, approvalUser: string) => { + await page.goto(`${process.env.APP_BASE_URL}/signin`); + await page.getByPlaceholder('Email').fill(approvalUserEmail); + await page.getByPlaceholder('Password').fill(approvalUser); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForLoadState('networkidle'); + return page; +}; + export default module.exports = { apiCreateWorkflow, apiUpdateWorkflow, @@ -783,4 +943,8 @@ export default module.exports = { apiCreateRecordTriggerFormEvent, apiSubmitRecordTriggerFormEvent, apiFilterList, + apiGetDataSourceCount, + apiCreateRecordTriggerActionEvent, + apiApplyApprovalEvent, + userLogin, }; diff --git a/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts b/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts index 1f384d5bc..85116ef5b 100644 --- a/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts +++ b/packages/plugins/@nocobase/plugin-workflow-test/src/server/index.ts @@ -1,11 +1,14 @@ import path from 'path'; import { ApplicationOptions, Plugin } from '@nocobase/server'; -import { MockServer, createMockServer } from '@nocobase/test'; +import { MockServer, createMockServer, mockDatabase } from '@nocobase/test'; import functions from './functions'; import triggers from './triggers'; import instructions from './instructions'; +import { Resourcer } from '@nocobase/resourcer'; +import { SequelizeDataSource } from '@nocobase/data-source-manager'; +import { uid } from '@nocobase/utils'; export interface MockServerOptions extends ApplicationOptions { collectionsPath?: string; @@ -33,7 +36,7 @@ export async function getApp(options: MockServerOptions = {}): Promise Object.assign(components, workflowPlugin.instructions.get(type).components), + {}, + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionLink.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionLink.tsx index 5220a0f72..922d804a5 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionLink.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/ExecutionLink.tsx @@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useActionContext, useRecord } from '@nocobase/client'; -import { getWorkflowExecutionsPath } from './constant'; + +import { getWorkflowExecutionsPath } from './utils'; export const ExecutionLink = () => { const { t } = useTranslation(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx similarity index 100% rename from packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.ts rename to packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx index 6b654fc6b..2e826a5f3 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowCanvas.tsx @@ -21,8 +21,7 @@ import { FlowContext, useFlowContext } from './FlowContext'; import { lang } from './locale'; import { executionSchema } from './schemas/executions'; import useStyles from './style'; -import { linkNodes } from './utils'; -import { getWorkflowDetailPath } from './constant'; +import { linkNodes, getWorkflowDetailPath } from './utils'; import { ExecutionStatusColumn } from './components/ExecutionStatus'; function ExecutionResourceProvider({ request, filter = {}, ...others }) { diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowLink.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowLink.tsx index 27829791c..16cb9e888 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowLink.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/WorkflowLink.tsx @@ -2,9 +2,10 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { getWorkflowDetailPath } from './constant'; import { useActionContext, useGetAriaLabelOfAction, useRecord } from '@nocobase/client'; +import { getWorkflowDetailPath } from './utils'; + export const WorkflowLink = () => { const { t } = useTranslation(); const { id } = useRecord(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/collectionEventTrigger/configuration.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/collectionEventTrigger/configuration.test.ts index e544d178f..c74c1310b 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/collectionEventTrigger/configuration.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/collectionEventTrigger/configuration.test.ts @@ -47,7 +47,9 @@ test.describe('Configuration page to configure the Trigger node', () => { const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); await collectionTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + // await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await collectionTriggerNode.triggerOnDropdown.click(); await page.getByText('After record added', { exact: true }).click(); await collectionTriggerNode.submitButton.click(); @@ -101,7 +103,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); await collectionTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await collectionTriggerNode.triggerOnDropdown.click(); await page.getByText('After record updated', { exact: true }).click(); await collectionTriggerNode.submitButton.click(); @@ -157,7 +160,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); await collectionTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await collectionTriggerNode.triggerOnDropdown.click(); await page.getByText('After record added or updated', { exact: true }).click(); await collectionTriggerNode.submitButton.click(); @@ -218,7 +222,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); await collectionTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await collectionTriggerNode.triggerOnDropdown.click(); await page.getByText('After record added or updated', { exact: true }).click(); // 设置触发器过滤条件 @@ -285,7 +290,8 @@ test.describe('Configuration page to configure the Trigger node', () => { const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); await collectionTriggerNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await collectionTriggerNode.triggerOnDropdown.click(); await page.getByText('After record updated', { exact: true }).click(); // 设置触发器过滤条件 @@ -723,7 +729,12 @@ test.describe('Configuration page copy to new version', () => { // 3、预期结果:新版本工作流配置内容同旧版本一样 const collectionTriggerNode = new CollectionTriggerNode(page, workFlowName, triggerNodeCollectionName); await collectionTriggerNode.nodeConfigure.click(); - await expect(page.getByRole('button', { name: triggerNodeCollectionDisplayName })).toBeVisible(); + // await expect(page.getByRole('button', { name: `Main / ${triggerNodeCollectionDisplayName}` })).toBeVisible(); + await expect( + page + .getByLabel('block-item-DataSourceCollectionCascader-workflows-Collection') + .getByText(`Main / ${triggerNodeCollectionDisplayName}`), + ).toBeVisible(); // 4、后置处理:删除工作流 await apiDeleteWorkflow(workflowId); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/createRecordNode/CreateRecord.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/createRecordNode/CreateRecord.test.ts index 6c85b964e..21cdb3de6 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/createRecordNode/CreateRecord.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/createRecordNode/CreateRecord.test.ts @@ -73,7 +73,8 @@ test('Collection event add data trigger, single row text fields for common table const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -172,7 +173,8 @@ test('Collection event add data trigger, normal table single line text field, se const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -273,7 +275,8 @@ test('Collection event add data trigger, normal table integer field, set constan const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -372,7 +375,8 @@ test('Collection event add data trigger, normal table integer field, set trigger const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -473,7 +477,8 @@ test('Collection event add data trigger, normal table numeric field, set constan const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -572,7 +577,8 @@ test('Collection event add data trigger, normal table numeric field, set trigger const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -673,7 +679,8 @@ test('Collection event add data trigger, normal table dropdown radio field, set const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -769,7 +776,8 @@ test('Collection event add data trigger, normal table dropdown radio field, set const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -870,7 +878,8 @@ test('Collection event add data trigger, normal table dropdown radio fields, set const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -967,7 +976,8 @@ test('Collection event add data trigger, normal table dropdown radio fields, set const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -1068,7 +1078,8 @@ test('Collection event add data trigger, normal table date field, set constant d const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); @@ -1169,7 +1180,8 @@ test('Collection event add data trigger, normal table date field, set trigger no const createRecordNodeId = await createRecordNode.node.locator('.workflow-node-id').innerText(); await createRecordNode.nodeConfigure.click(); await createRecordNode.collectionDropDown.click(); - await page.getByText(createNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: createNodeCollectionDisplayName }).click(); // 设置字段 await createRecordNode.addFieldsButton.click(); await page.getByRole('menuitem', { name: createNodeFieldDisplayName }).click(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/deleteRecordNode/DeleteRecord.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/deleteRecordNode/DeleteRecord.test.ts index e40cb2755..c43bb7018 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/deleteRecordNode/DeleteRecord.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/deleteRecordNode/DeleteRecord.test.ts @@ -78,7 +78,8 @@ test('Collection event add data trigger, filter single line text field not null, const deleteRecordNode = new DeleteRecordNode(page, deleteRecordNodeName); await deleteRecordNode.nodeConfigure.click(); await deleteRecordNode.collectionDropDown.click(); - await page.getByText(deleteNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: deleteNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page.getByLabel('block-item-Filter-workflows-Filter').getByRole('button', { name: 'Select field' }).click(); @@ -172,7 +173,8 @@ test('Collection event add data trigger, filter single line text field is trigge const deleteRecordNode = new DeleteRecordNode(page, deleteRecordNodeName); await deleteRecordNode.nodeConfigure.click(); await deleteRecordNode.collectionDropDown.click(); - await page.getByText(deleteNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: deleteNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page.getByLabel('block-item-Filter-workflows-Filter').getByRole('button', { name: 'Select field' }).click(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/queryRecordNode/QueryRecord.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/queryRecordNode/QueryRecord.test.ts index f7c2aa531..2ef724a9a 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/queryRecordNode/QueryRecord.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/queryRecordNode/QueryRecord.test.ts @@ -61,7 +61,8 @@ test('Collection event add data trigger, no filter no sort query common table 1 const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.submitButton.click(); // 2、测试步骤:添加数据触发工作流 @@ -138,7 +139,8 @@ test('Collection event add data trigger, no filtering and no sorting, query comm const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1'); await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20'); @@ -216,7 +218,8 @@ test('Collection event add data trigger, no filter ID ascending, query common ta const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); // 设置排序条件 await queryRecordNode.addSortFieldsButton.click(); @@ -324,7 +327,8 @@ test('Collection event add data trigger, no filter ID descending, query common t const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); // 设置排序条件 await queryRecordNode.addSortFieldsButton.click(); @@ -433,7 +437,8 @@ test('Collection event add data trigger, no filtering and no sorting, query mult const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1'); await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20'); @@ -536,7 +541,8 @@ test('Collection event add data trigger, no filtering and no sorting, query the const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); // await queryRecordNode.allowMultipleDataBoxesForResults.check(); await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1'); await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20'); @@ -641,7 +647,8 @@ test('Collection event add data trigger, no filtering and no sorting, query the const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByRole('option', { name: triggerNodeCollectionDisplayName }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: triggerNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); await expect(queryRecordNode.pageNumberEditBox).toHaveValue('1'); await expect(queryRecordNode.pageSizeEditBox).toHaveValue('20'); @@ -769,7 +776,8 @@ test('Collection event add data trigger, filter to meet all conditions (status_s const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByText(queryNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: queryNodeCollectionDisplayName }).click(); // await queryRecordNode.allowMultipleDataBoxesForResults.check(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -884,7 +892,8 @@ test('Collection event add data trigger, filter to satisfy any condition (status const queryRecordNodeId = await queryRecordNode.node.locator('.workflow-node-id').innerText(); await queryRecordNode.nodeConfigure.click(); await queryRecordNode.collectionDropDown.click(); - await page.getByText(queryNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: queryNodeCollectionDisplayName }).click(); await queryRecordNode.allowMultipleDataBoxesForResults.check(); // 设置过滤条件 await page.getByTestId('filter-select-all-or-any').click(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateInABatch.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateInABatch.test.ts index e683c4366..ebbcd1c2e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateInABatch.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateInABatch.test.ts @@ -86,7 +86,8 @@ test('Collection event add data trigger, filter single line text field not empty const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -240,7 +241,8 @@ test('Collection event add data trigger, filter single line text field not empty const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -401,7 +403,8 @@ test('Collection event add data trigger, filter multi-line text field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -555,7 +558,8 @@ test('Collection event add data trigger, filter multiline text field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -710,7 +714,8 @@ test('Collection event add data trigger, filter integer field not null, common t const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -860,7 +865,8 @@ test('Collection event add data trigger, filter integer field not empty, common const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1014,7 +1020,8 @@ test('Collection event add data trigger, filter numeric field not null, common t const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1163,7 +1170,8 @@ test('Collection event add data trigger, filter numeric field not empty, common const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1315,7 +1323,8 @@ test('Collection event add data trigger, filter dropdown radio field not null, c const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1464,7 +1473,8 @@ test('Collection event add data trigger, filter dropdown radio field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1619,7 +1629,8 @@ test('Collection event add data trigger, filter dropdown radio fields not null, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1772,7 +1783,8 @@ test('Collection event add data trigger, filter dropdown radio fields not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -1931,7 +1943,8 @@ test('Collection event add data trigger, filter date field not null, common tabl const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page @@ -2086,7 +2099,8 @@ test('Collection event add data trigger, filter date field not empty, common tab const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); await page diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateOneByOne.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateOneByOne.test.ts index d0a6a3ef9..9fb74ace6 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateOneByOne.test.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/__e2e__/updateRecordNode/updateOneByOne.test.ts @@ -86,7 +86,8 @@ test('Collection event add data trigger, filter single line text field not empty const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -241,7 +242,8 @@ test('Collection event add data trigger, filter single line text field not empty const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -403,7 +405,8 @@ test('Collection event add data trigger, filter multi-line text field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -558,7 +561,8 @@ test('Collection event add data trigger, filter multiline text field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -714,7 +718,8 @@ test('Collection event add data trigger, filter integer field not null, common t const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -865,7 +870,8 @@ test('Collection event add data trigger, filter integer field not empty, common const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1020,7 +1026,8 @@ test('Collection event add data trigger, filter numeric field not null, common t const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1170,7 +1177,8 @@ test('Collection event add data trigger, filter numeric field not empty, common const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1323,7 +1331,8 @@ test('Collection event add data trigger, filter dropdown radio field not null, c const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1473,7 +1482,8 @@ test('Collection event add data trigger, filter dropdown radio field not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1629,7 +1639,8 @@ test('Collection event add data trigger, filter dropdown radio fields not null, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1783,7 +1794,8 @@ test('Collection event add data trigger, filter dropdown radio fields not empty, const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -1943,7 +1955,8 @@ test('Collection event add data trigger, filter date field not null, common tabl const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); @@ -2099,7 +2112,8 @@ test('Collection event add data trigger, filter date field not empty, common tab const updateRecordNodeId = await updateRecordNode.node.locator('.workflow-node-id').innerText(); await updateRecordNode.nodeConfigure.click(); await updateRecordNode.collectionDropDown.click(); - await page.getByText(updateNodeCollectionDisplayName).click(); + await page.getByRole('menuitemcheckbox', { name: 'Main right' }).click(); + await page.getByRole('menuitemcheckbox', { name: updateNodeCollectionDisplayName }).click(); await updateRecordNode.articleByArticleUpdateModeRadio.click(); // 设置过滤条件 await page.getByText('Add condition', { exact: true }).click(); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionBlockInitializer.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionBlockInitializer.tsx index 72bc3b079..3359b905d 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionBlockInitializer.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionBlockInitializer.tsx @@ -1,9 +1,11 @@ import React from 'react'; +import { uid } from '@formily/shared'; import { CollectionProvider_deprecated, SchemaInitializerItem, SchemaInitializerItemType, + parseCollectionName, useCollectionManager_deprecated, useRecordCollectionDataSourceItems, useSchemaInitializer, @@ -13,23 +15,29 @@ import { import { traverseSchema } from '../utils'; -function InnerCollectionBlockInitializer({ collection, dataSource, ...props }) { +function InnerCollectionBlockInitializer({ collection, dataPath, ...props }) { const { insert } = useSchemaInitializer(); const { getTemplateSchemaByMode } = useSchemaTemplateManager(); const { getCollection } = useCollectionManager_deprecated(); const items = useRecordCollectionDataSourceItems('FormItem') as SchemaInitializerItemType[]; - const resolvedCollection = getCollection(collection); + let resolvedCollection; + if (typeof collection === 'string') { + const [dataSourceName, collectionName] = parseCollectionName(collection); + resolvedCollection = getCollection(collectionName, dataSourceName); + } else { + resolvedCollection = collection; + } async function onConfirm({ item }) { const template = item.template ? await getTemplateSchemaByMode(item) : null; const result = { type: 'void', - name: resolvedCollection.name, + name: uid(), title: resolvedCollection.title, 'x-decorator': 'DetailsBlockProvider', 'x-decorator-props': { collection, - dataSource, + dataPath, }, 'x-component': 'CardItem', 'x-component-props': { @@ -66,11 +74,20 @@ function InnerCollectionBlockInitializer({ collection, dataSource, ...props }) { return ; } -export function CollectionBlockInitializer() { +export function CollectionBlockInitializer(props) { const itemConfig = useSchemaInitializerItem(); + const sourceCollection = props?.collection ?? itemConfig.collection; + let dataSource, collection; + if (typeof sourceCollection === 'string') { + const parsed = parseCollectionName(sourceCollection); + dataSource = parsed[0]; + collection = parsed[1]; + } else { + collection = sourceCollection; + } return ( - - + + ); } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionFieldset.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionFieldset.tsx index e98aaf13c..3c9d7bd3a 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionFieldset.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/CollectionFieldset.tsx @@ -6,6 +6,7 @@ import { SchemaComponent, Variable, css, + parseCollectionName, useCollectionManager_deprecated, useCompile, useToken, @@ -21,7 +22,8 @@ function AssociationInput(props) { const { path } = useField(); const fieldName = path.segments[path.segments.length - 1] as string; const { values: config } = useForm(); - const fields = getCollectionFields(config?.collection); + const [dataSourceName, collectionName] = parseCollectionName(config?.collection); + const fields = getCollectionFields(collectionName, dataSourceName); const { type } = fields.find((item) => item.name === fieldName); const value = Array.isArray(props.value) ? props.value.join(',') : props.value; @@ -39,11 +41,11 @@ const CollectionFieldSet = observer( const { t } = useTranslation(); const compile = useCompile(); const form = useForm(); - const { getCollection, getCollectionFields } = useCollectionManager_deprecated(); + const { getCollectionFields } = useCollectionManager_deprecated(); const scope = useWorkflowVariableOptions(); const { values: config } = form; - const collectionName = config?.collection; - const collectionFields = getCollectionFields(collectionName).filter((field) => field.uiSchema); + const [dataSourceName, collectionName] = parseCollectionName(config?.collection); + const collectionFields = getCollectionFields(collectionName, dataSourceName).filter((field) => field.uiSchema); const fields = filter ? collectionFields.filter(filter.bind(config)) : collectionFields; const unassignedFields = useMemo(() => fields.filter((field) => !value || !(field.name in value)), [fields, value]); @@ -79,7 +81,7 @@ const CollectionFieldSet = observer( `} > {fields.length ? ( - + {fields .filter((field) => value && field.name in value) .map((field) => { diff --git a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/DetailsBlockProvider.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/DetailsBlockProvider.tsx similarity index 71% rename from packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/DetailsBlockProvider.tsx rename to packages/plugins/@nocobase/plugin-workflow/src/client/components/DetailsBlockProvider.tsx index 434c2fb89..b378de71c 100644 --- a/packages/plugins/@nocobase/plugin-workflow-manual/src/client/instruction/DetailsBlockProvider.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/DetailsBlockProvider.tsx @@ -1,19 +1,21 @@ +import React, { useMemo, useRef } from 'react'; import { createForm } from '@formily/core'; import { useField } from '@formily/react'; +import { get } from 'lodash'; import { BlockRequestContext_deprecated, CollectionProvider_deprecated, FormBlockContext, RecordProvider, + parseCollectionName, useAPIClient, useAssociationNames, useBlockRequestContext, } from '@nocobase/client'; -import { useFlowContext } from '@nocobase/plugin-workflow/client'; -import { parse } from '@nocobase/utils/client'; -import React, { useMemo, useRef } from 'react'; -function useFlowContextData(dataSource) { +import { useFlowContext } from '../FlowContext'; + +function useFlowContextData(dataPath) { const { execution, nodes } = useFlowContext(); const nodesKeyMap = useMemo(() => { @@ -31,16 +33,24 @@ function useFlowContextData(dataSource) { [execution?.context, execution?.jobs, nodesKeyMap], ); - const result = useMemo(() => parse(dataSource)(data), [data, dataSource]); + const result = useMemo(() => get(data, dataPath), [data, dataPath]); return result; } -export function DetailsBlockProvider(props) { +export function DetailsBlockProvider({ collection, dataPath, children }) { const field = useField(); const formBlockRef = useRef(null); const { getAssociationAppends } = useAssociationNames(); const { appends, updateAssociationValues } = getAssociationAppends(); - const values = useFlowContextData(props.dataSource); + const values = useFlowContextData(dataPath); + let dataSourceName, resolvedCollection; + if (typeof collection === 'string') { + const parsed = parseCollectionName(collection); + dataSourceName = parsed[0]; + resolvedCollection = parsed[1]; + } else { + resolvedCollection = collection; + } const form = useMemo( () => @@ -61,11 +71,11 @@ export function DetailsBlockProvider(props) { }, }; const api = useAPIClient(); - const resource = api.resource(props.collection); + const resource = api.resource(resolvedCollection); const __parent = useBlockRequestContext(); return ( - + - {props.children} + {children} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/components/FieldsSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/components/FieldsSelect.tsx index 358766af8..c8b684e96 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/components/FieldsSelect.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/components/FieldsSelect.tsx @@ -2,7 +2,7 @@ import { observer, useForm } from '@formily/react'; import { Select } from 'antd'; import React from 'react'; -import { useCollectionManager_deprecated, useCompile } from '@nocobase/client'; +import { parseCollectionName, useCollectionManager_deprecated, useCompile } from '@nocobase/client'; function defaultFilter() { return true; @@ -14,7 +14,8 @@ export const FieldsSelect = observer( const compile = useCompile(); const { getCollectionFields } = useCollectionManager_deprecated(); const { values } = useForm(); - const fields = getCollectionFields(values?.collection); + const [dataSourceName, collectionName] = parseCollectionName(values?.collection); + const fields = getCollectionFields(collectionName, dataSourceName); return (