Merge remote-tracking branch 'upstream/main' into merge_from_upstream

This commit is contained in:
sealday 2024-03-27 17:03:22 +08:00
commit f364b40e79
315 changed files with 6149 additions and 2523 deletions

View File

@ -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/**'

View File

@ -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

View File

@ -1,5 +1,5 @@
{
"version": "0.20.0-alpha.16",
"version": "0.20.0-alpha.17",
"npmClient": "yarn",
"useWorkspaces": true,
"npmClientArgs": ["--ignore-engines"],

View File

@ -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": {

View File

@ -45,14 +45,40 @@ interface CanArgs {
}
export class ACL extends EventEmitter {
/**
* @internal
*/
public availableStrategy = new Map<string, ACLAvailableStrategy>();
/**
* @internal
*/
public allowManager = new AllowManager(this);
/**
* @internal
*/
public snippetManager = new SnippetManager();
/**
* @internal
*/
roles = new Map<string, ACLRole>();
/**
* @internal
*/
actionAlias = new Map<string, string>();
/**
* @internal
*/
configResources: string[] = [];
protected availableActions = new Map<string, ACLAvailableAction>();
protected fixedParamsManager = new FixedParamsManager();
protected middlewares: Toposort<any>;
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);

View File

@ -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",

View File

@ -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",

View File

@ -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"
},

View File

@ -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",

View File

@ -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<any>,
) {
for await (const pkg of packages) {
writeToCache(BUILD_ERROR, { pkg: pkg.name })
await buildPackage(pkg, targetDir, doBuildPackage);
}
}

View File

@ -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(','))

View File

@ -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[]) {

View File

@ -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<string, any>) {
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 {};
}

View File

@ -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",

View File

@ -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",

View File

@ -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);
});

View File

@ -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) {

View File

@ -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(),
});

View File

@ -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') {

View File

@ -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",

View File

@ -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
*/

View File

@ -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(<Demo />);
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(<Demo />);
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(<Demo />);
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(<Demo />);
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(<Demo />);
expect(getByTestId('component')).toHaveTextContent(JSON.stringify({ a: 'a' }));
});
});

View File

@ -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<T = any>(Component: ComponentType<T>, options: WithSchemaHookOptions = {}) {
export function withDynamicSchemaProps<T = any>(Component: any, options: WithSchemaHookOptions = {}) {
const displayName = options.displayName || Component.displayName || Component.name;
const ComponentWithProps: ComponentType<T> = (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<T = any>(Component: ComponentType<T>, 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 <Component {...memoProps}>{props.children}</Component>;

View File

@ -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);

View File

@ -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,
});
}
}

View File

@ -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<any>({});
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 (
<SchemaComponentOptions scope={{ treeTable }}>
<FormContext.Provider value={form}>
<BlockProvider name={props.name || 'table'} {...props} params={paramsWithFilter} runWhenParamsChanged>
<InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={paramsWithFilter} />
<BlockProvider name={props.name || 'table'} {...props} params={params} runWhenParamsChanged>
<InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={params} />
</BlockProvider>
</FormContext.Provider>
</SchemaComponentOptions>
);
};
});
export const useTableBlockContext = () => {
return useContext(TableBlockContext);
};
export const useTableBlockProps = () => {
const field = useField<ArrayField>();
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);
},
};
};

View File

@ -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<InheritanceCollectionMixin>();
if (!association) return;
const associationField = cm.getCollectionField(association);
const associationCollection = cm.getCollection(associationField.collectionName);
const associationCollection = cm.getCollection<InheritanceCollectionMixin>(associationField.collectionName);
if (collectionOutsideBlock.name === associationCollection.name) {
if (
collectionOutsideBlock.name === associationCollection.name ||
collectionOutsideBlock.getParentCollectionsName?.().includes(associationCollection.name)
) {
return recordData?.[
associationField.sourceKey ||
associationCollection.filterTargetKey ||

View File

@ -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 = {

View File

@ -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;

View File

@ -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":"常规"
}

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -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',
});

View File

@ -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', () => {

View File

@ -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,
},
};

View File

@ -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",
}
`);
});
});

View File

@ -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: '|',
},
},
},
},
},
},
},
};
};

View File

@ -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;
}

View File

@ -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<ArrayField>();
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);
},
};
};

View File

@ -5,3 +5,5 @@ export * from './TableColumnSchemaToolbar';
export * from './tableBlockSettings';
export * from './tableColumnSettings';
export * from './TableColumnInitializers';
export * from './createTableBlockUISchema';
export * from './hooks/useTableBlockDecoratorProps';

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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;
},
},
],
});

View File

@ -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,

View File

@ -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,6 +381,7 @@ function WorkflowSelect({ actionType, direct = false, ...props }) {
);
return (
<DataSourceProvider dataSource="main">
<RemoteSelect
manual={false}
placeholder={t('Select workflow', { ns: 'workflow' })}
@ -407,6 +414,7 @@ function WorkflowSelect({ actionType, direct = false, ...props }) {
}}
{...props}
/>
</DataSourceProvider>
);
}
@ -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' }),

View File

@ -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<TreeSelectProps & AppendsTreeSelectProp
loadData: propsLoadData,
...restProps
} = props;
const { getCollectionFields } = useCollectionManager_deprecated();
const compile = useCompile();
const { t } = useTranslation();
const [optionsMap, setOptionsMap] = useState({});
const baseCollection = useCollection({ collection });
const collectionString = useCollection({ collection });
const [dataSourceName, collectionName] = parseCollectionName(collectionString);
const { getCollectionFields } = useCollectionManager_deprecated(dataSourceName);
const treeData = Object.values(optionsMap);
const value: string | DefaultOptionType[] = useMemo(() => {
if (props.multiple) {
@ -111,7 +118,7 @@ export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProp
},
[propsLoadData],
);
// NOTE:
useEffect(() => {
const parentNode = rootOption
? {
@ -123,17 +130,19 @@ export const AppendsTreeSelect: React.FC<TreeSelectProps & AppendsTreeSelectProp
isLeaf: false,
}
: null;
const treeData =
const tData =
propsLoadData === null
? []
: getCollectionFieldOptions.call({ compile, getCollectionFields, filter }, baseCollection, parentNode);
const map = treeData.reduce((result, item) => 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]))) {

View File

@ -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 (
<GeneralSchemaDesigner template={template} title={title || name}>

View File

@ -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<HTMLButtonElement | HTMLAnchorElement>();
const buttonRef = composeRef(ref, internalRef);
return (
<div className={actionDesignerCss}>
//@ts-ignore
<div className={actionDesignerCss} ref={buttonRef as React.Ref<HTMLButtonElement>}>
{ctx?.params['tree'] && (
<Button
onClick={() => {
@ -63,6 +67,7 @@ export const ExpandAction = (props) => {
}}
icon={<Icon type={ctx?.expandFlag ? iconCollapse : iconExpand} />}
type={props.type}
style={props?.style}
>
{props.children?.[1]}
<span style={{ marginLeft: 10 }}>{ctx?.expandFlag ? compile(titleCollapse) : compile(titleExpand)}</span>
@ -71,3 +76,5 @@ export const ExpandAction = (props) => {
</div>
);
};
export const ExpandAction = forwardRef<HTMLButtonElement | HTMLAnchorElement, any>(InternalExpandAction);

View File

@ -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,

View File

@ -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(() => {

View File

@ -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<sup>${exponent.slice(1)}</sup>`;
} else {
// 负数指数,显示 "-" 符号
return `×10<sup>-${exponent.slice(1)}</sup>`;
}
});
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<InputProps & InputNumberProps> = (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 (
<div className={'nb-read-pretty-input-number'}>
{addonBefore}
{result}
<span dangerouslySetInnerHTML={{ __html: result }} />
{addonAfter}
</div>
);

View File

@ -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×10<sup>6</sup>');
});
// 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×10<sup>6</sup>');
});
});

View File

@ -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('-')
? {

View File

@ -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,7 +243,8 @@ const usePaginationProps = (pagination1, pagination2) => {
return result.total <= result.pageSize ? false : result;
};
export const Table: any = observer(
export const Table: any = withDynamicSchemaProps(
observer(
(props: {
useProps?: () => any;
onChange?: (pagination, filters, sorter, extra) => void;
@ -259,8 +261,11 @@ export const Table: any = observer(
isSubTable?: boolean;
}) => {
const { token } = useToken();
const { pagination: pagination1, useProps, onChange, ...others1 } = props;
const { pagination: pagination2, onClickRow, ...others2 } = useProps?.() || {};
const { pagination: pagination1, useProps, ...others1 } = props;
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { pagination: pagination2, ...others2 } = useProps?.() || {};
const {
dragSort = false,
showIndex = true,
@ -270,6 +275,7 @@ export const Table: any = observer(
rowKey,
required,
onExpand,
onClickRow,
...others
} = { ...others1, ...others2 } as any;
const field = useArrayField(others);
@ -566,6 +572,10 @@ export const Table: any = observer(
className={css`
height: 100%;
overflow: hidden;
.ant-table-thead {
.ant-table-cell {
text-align: center;
}
.ant-table-wrapper {
height: 100%;
.ant-spin-nested-loading {
@ -574,11 +584,6 @@ export const Table: any = observer(
height: 100%;
display: flex;
flex-direction: column;
.ant-table-thead {
.ant-table-cell {
text-align: center;
}
}
}
}
}
@ -627,5 +632,6 @@ export const Table: any = observer(
</div>
);
},
),
{ displayName: 'Table' },
);

View File

@ -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('-')

View File

@ -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,8 +97,7 @@ function useAclCheckFn() {
return actionAclCheck;
}
export const CreateRecordAction = observer(
(props: any) => {
const InternalCreateRecordAction = (props: any, ref) => {
const [visible, setVisible] = useState(false);
const collection = useCollection_deprecated();
const fieldSchema = useFieldSchema();
@ -125,8 +125,11 @@ export const CreateRecordAction = observer(
});
});
}, [field, linkageRules, localVariables, variables]);
const internalRef = createRef<HTMLButtonElement | HTMLAnchorElement>();
const buttonRef = composeRef(ref, internalRef);
return (
<div className={actionDesignerCss}>
//@ts-ignore
<div className={actionDesignerCss} ref={buttonRef as React.Ref<HTMLButtonElement>}>
<ActionContextProvider value={{ ...ctx, visible, setVisible }}>
<CreateAction
{...props}
@ -142,9 +145,7 @@ export const CreateRecordAction = observer(
</ActionContextProvider>
</div>
);
},
{ displayName: 'CreateRecordAction' },
);
};
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({
</Button>
);
}
export const CreateRecordAction = forwardRef<HTMLButtonElement | HTMLAnchorElement, any>(InternalCreateRecordAction);

View File

@ -10,6 +10,7 @@ export const CreateFilterActionInitializer = (props) => {
'x-designer': 'Action.Designer',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useProps: '{{ useFilterBlockActionProps }}',
},
};

View File

@ -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 (
<SchemaInitializerItem
icon={<TableOutlined />}
@ -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}`,
}),

View File

@ -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 &&

View File

@ -56,13 +56,22 @@ export interface GeneralSchemaDesignerProps {
* @default true
*/
draggable?: boolean;
showDataSource?: boolean;
}
/**
* @deprecated use `SchemaToolbar` instead
*/
export const GeneralSchemaDesigner: FC<GeneralSchemaDesignerProps> = (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<GeneralSchemaDesignerProps> = (props: any
<div className={classNames('general-schema-designer-title', titleCss)}>
<Space size={2}>
<span className={'title-tag'}>
{dataSource ? `${compile(dataSource?.displayName)} > ${compile(title)}` : compile(title)}
{showDataSource && dataSource
? `${compile(dataSource?.displayName)} > ${compile(title)}`
: compile(title)}
</span>
{template && (
<span className={'title-tag'}>

View File

@ -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;

View File

@ -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 (
<Select
defaultValue={unitConversionType || '*'}
style={{ width: 160 }}
onChange={(value) => {
form.setValuesIn('unitConversionType', value);
}}
>
<Select.Option value="*">{t('Multiply by')}</Select.Option>
<Select.Option value="/">{t('Divide by')}</Select.Option>
</Select>
);
};
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 (
<SchemaSettingsModalItem
title={t('Format')}
schema={
{
type: 'object',
properties: {
formatStyle: {
type: 'string',
default: formatStyle || 'normal',
enum: [
{
value: 'normal',
label: t('Normal'),
},
{
value: 'scientifix',
label: t('Scientifix notation'),
},
],
'x-decorator': 'FormItem',
'x-component': 'Select',
title: "{{t('Style')}}",
},
unitConversion: {
type: 'number',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
title: "{{t('Unit conversion')}}",
default: unitConversion,
'x-component-props': {
style: { width: '100%' },
addonBefore: <UnitConversion unitConversionType={unitConversionType} />,
},
},
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();
}}
/>
);
};

View File

@ -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);

View File

@ -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": {

View File

@ -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"
},

View File

@ -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}`;
}

View File

@ -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",

View File

@ -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');
});
});

View File

@ -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',

View File

@ -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';

View File

@ -1,76 +0,0 @@
export interface Reference {
sourceCollectionName: string;
sourceField: string;
targetField: string;
targetCollectionName: string;
onDelete: string;
}
class ReferencesMap {
protected map: Map<string, Reference[]> = 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;

View File

@ -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>): 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<string, Reference[]> = 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;

View File

@ -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;

View File

@ -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() {

View File

@ -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']),

View File

@ -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() {

View File

@ -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() {

View File

@ -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",

View File

@ -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": {

View File

@ -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",

View File

@ -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",

View File

@ -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<Middleware> = [];
/**
* @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]) => {

View File

@ -150,6 +150,9 @@ export interface ImportOptions {
}
export class Resourcer {
/**
* @internal
*/
public readonly options: ResourcerOptions;
protected resources = new Map<string, Resource>();
/**
@ -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<Map<string, Resource>> {
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<any>) => {
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;

View File

@ -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",

View File

@ -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",

View File

@ -142,19 +142,41 @@ export type MaintainingCommandStatus = {
};
export class Application<StateT = DefaultState, ContextT = DefaultContext> extends Koa implements AsyncEmitter {
/**
* @internal
*/
declare middleware: any;
/**
* @internal
*/
stopped = false;
/**
* @internal
*/
ready = false;
declare emitAsync: (event: string | symbol, ...args: any[]) => Promise<boolean>;
/**
* @internal
*/
public rawOptions: ApplicationOptions;
/**
* @internal
*/
public activatedCommand: {
name: string;
} = null;
/**
* @internal
*/
public running = false;
/**
* @internal
*/
public perfHistograms = new Map<string, RecordableHistogram>();
protected plugins = new Map<string, Plugin>();
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<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> exten
return this.mainDataSource.collectionManager.db;
}
protected _logger: SystemLogger;
get logger() {
return this._logger;
}
@ -244,6 +270,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this._cache;
}
/**
* @internal
*/
set cache(cache: Cache) {
this._cache = cache;
}
@ -278,6 +307,11 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> exten
return this._dataSourceManager;
}
/**
* @internal
*/
getMaintaining() {
return this._maintainingCommandStatus;
}
/**
* @internal
*/
setMaintaining(_maintainingCommandStatus: MaintainingCommandStatus) {
this._maintainingCommandStatus = _maintainingCommandStatus;
@ -329,6 +369,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this._maintaining = true;
}
/**
* @internal
*/
setMaintainingMessage(message: string) {
this._maintainingMessage = message;
@ -338,11 +381,18 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> 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<O = any>(pluginClass: any, options?: O) {
@ -359,6 +409,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this;
}
/**
* @internal
*/
callback() {
const fn = compose(this.middleware.nodes);
@ -372,14 +425,29 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> exten
}
/**
* This method is deprecated and should not be used.
* Use {@link this.pm.get()} instead.
* @deprecated
*/
getPlugin<P extends Plugin>(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<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> exten
return command;
}
/**
* @internal
*/
async loadMigrations(options) {
const { directory, context, namespace } = options;
const migrations = {
@ -594,6 +670,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return migrations;
}
/**
* @internal
*/
async loadCoreMigrations() {
const migrations = await this.loadMigrations({
directory: resolve(__dirname, 'migrations'),
@ -624,11 +703,17 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> 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<StateT = DefaultState, ContextT = DefaultContext> exten
return this._started;
}
/**
* @internal
*/
async tryReloadOrRestart(options: StartOptions = {}) {
if (this._started) {
await this.restart(options);
@ -941,6 +1032,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
};
}
/**
* @internal
*/
reInitEvents() {
for (const eventName of this.eventNames()) {
for (const listener of this.listeners(eventName)) {
@ -986,7 +1080,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this._cronJobManager = new CronJobManager(this);
this._cli = this.createCli();
this._cli = this.createCLI();
this._i18n = createI18n(options);
this.context.db = this.db;

View File

@ -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));
}

View File

@ -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) {

View File

@ -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);

View File

@ -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<typeof Plugin, Plugin>();
/**
* @internal
*/
pluginAliases = new Map<string, Plugin>();
/**
* @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;

View File

@ -33,9 +33,22 @@ export interface PluginOptions {
export abstract class Plugin<O = any> 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<O = any> 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<O = any> 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<O = any> 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<O = any> implements PluginInterface {
}
}
/**
* @deprecated
*/
requiredPlugins() {
return [];
}
@ -205,6 +232,9 @@ export abstract class Plugin<O = any> 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<O = any> implements PluginInterface {
return false;
}
/**
* @experimental
*/
async toJSON(options: any = {}) {
const { locale = 'en-US' } = options;
const { name, packageName, packageJson } = this.options;

View File

@ -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",

View File

@ -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"
},

View File

@ -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, {

View File

@ -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'],
},
],

Some files were not shown because too many files have changed in this diff Show More