Merge branch 'main' into T-1155

This commit is contained in:
Rain 2023-08-01 11:15:50 +08:00
commit 99c1e3c879
183 changed files with 3142 additions and 1655 deletions

View File

@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
## [v0.11.1-alpha.4](https://github.com/nocobase/nocobase/compare/v0.11.1-alpha.3...v0.11.1-alpha.4) - 2023-07-29
### Merged
- refactor(plugin-workflow): allow system values to be assigned in create and update node [`#2345`](https://github.com/nocobase/nocobase/pull/2345)
- chore(database): merge fields arguments by path [`#2331`](https://github.com/nocobase/nocobase/pull/2331)
- fix(theme-editor): avoid error [`#2340`](https://github.com/nocobase/nocobase/pull/2340)
- refactor: upgrade @testing-library/react to 14.x [`#2339`](https://github.com/nocobase/nocobase/pull/2339)
- test: view collection as through model [`#2336`](https://github.com/nocobase/nocobase/pull/2336)
- fix: sub-form record provider data failed to matching [`#2337`](https://github.com/nocobase/nocobase/pull/2337)
- fix(bi): issue of formatting relation field & reference link of line chart [`#2332`](https://github.com/nocobase/nocobase/pull/2332)
- chore: tsx [`#2329`](https://github.com/nocobase/nocobase/pull/2329)
- chore: upgrade jest [`#2323`](https://github.com/nocobase/nocobase/pull/2323)
### Commits
- chore(versions): 😊 publish v0.11.1-alpha.4 [`b93f28a`](https://github.com/nocobase/nocobase/commit/b93f28a952fef20e99570ca6f19b3bf8192db465)
- fix: yarn run test [`d956c90`](https://github.com/nocobase/nocobase/commit/d956c90e91e303ae02e54f71498b92481eab0399)
- chore: update changelog [`54f2405`](https://github.com/nocobase/nocobase/commit/54f240539c5cf82d31c689bf409bcb5656ded496)
## [v0.11.1-alpha.3](https://github.com/nocobase/nocobase/compare/v0.11.1-alpha.2...v0.11.1-alpha.3) - 2023-07-26 ## [v0.11.1-alpha.3](https://github.com/nocobase/nocobase/compare/v0.11.1-alpha.2...v0.11.1-alpha.3) - 2023-07-26
### Merged ### Merged

View File

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

View File

@ -58,8 +58,8 @@
"@commitlint/cli": "^16.1.0", "@commitlint/cli": "^16.1.0",
"@commitlint/config-conventional": "^16.0.0", "@commitlint/config-conventional": "^16.0.0",
"@commitlint/prompt-cli": "^16.1.0", "@commitlint/prompt-cli": "^16.1.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",

View File

@ -1,9 +1,9 @@
{ {
"name": "@nocobase/app-client", "name": "@nocobase/app-client",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@nocobase/client": "0.11.1-alpha.3" "@nocobase/client": "0.11.1-alpha.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,12 +1,12 @@
{ {
"name": "@nocobase/app-server", "name": "@nocobase/app-server",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/preset-nocobase": "0.11.1-alpha.3" "@nocobase/preset-nocobase": "0.11.1-alpha.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,13 +1,13 @@
{ {
"name": "@nocobase/acl", "name": "@nocobase/acl",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/resourcer": "0.11.1-alpha.3", "@nocobase/resourcer": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"minimatch": "^5.1.1" "minimatch": "^5.1.1"
}, },
"repository": { "repository": {

View File

@ -1,14 +1,14 @@
{ {
"name": "@nocobase/actions", "name": "@nocobase/actions",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/cache": "0.11.1-alpha.3", "@nocobase/cache": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/resourcer": "0.11.1-alpha.3" "@nocobase/resourcer": "0.11.1-alpha.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,15 +1,15 @@
{ {
"name": "@nocobase/auth", "name": "@nocobase/auth",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"dependencies": { "dependencies": {
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/resourcer": "0.11.1-alpha.3", "@nocobase/resourcer": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3" "@nocobase/utils": "0.11.1-alpha.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/build", "name": "@nocobase/build",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "Library build tool based on rollup.", "description": "Library build tool based on rollup.",
"main": "lib/index.js", "main": "lib/index.js",
"bin": { "bin": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/cache", "name": "@nocobase/cache",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./lib/index.js", "main": "./lib/index.js",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/cli", "name": "@nocobase/cli",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./src/index.js", "main": "./src/index.js",
@ -22,7 +22,7 @@
"tsx": "^3.12.7" "tsx": "^3.12.7"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/devtools": "0.11.1-alpha.3" "@nocobase/devtools": "0.11.1-alpha.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/client", "name": "@nocobase/client",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "lib", "main": "lib",
"module": "es/index.js", "module": "es/index.js",
@ -12,12 +12,19 @@
"@dnd-kit/core": "^5.0.1", "@dnd-kit/core": "^5.0.1",
"@dnd-kit/sortable": "^6.0.0", "@dnd-kit/sortable": "^6.0.0",
"@emotion/css": "^11.7.1", "@emotion/css": "^11.7.1",
"@formily/antd-v5": "^1.1.0-beta.4", "@formily/antd-v5": "^1.1.0",
"@formily/core": "2.2.26", "@formily/core": "^2.2.27",
"@formily/react": "2.2.26", "@formily/grid": "^2.2.27",
"@nocobase/evaluators": "0.11.1-alpha.3", "@formily/json-schema": "^2.2.27",
"@nocobase/sdk": "0.11.1-alpha.3", "@formily/path": "^2.2.27",
"@nocobase/utils": "0.11.1-alpha.3", "@formily/react": "^2.2.27",
"@formily/reactive": "^2.2.27",
"@formily/reactive-react": "^2.2.27",
"@formily/shared": "^2.2.27",
"@formily/validator": "^2.2.27",
"@nocobase/evaluators": "0.11.1-alpha.5",
"@nocobase/sdk": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.5",
"ahooks": "^3.7.2", "ahooks": "^3.7.2",
"antd": "^5.6.4", "antd": "^5.6.4",
"antd-style": "^3.3.0", "antd-style": "^3.3.0",
@ -55,7 +62,7 @@
"react-is": ">=18.0.0" "react-is": ">=18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react": "^12.1.5", "@testing-library/react": "^14.0.0",
"@types/markdown-it": "12.2.3", "@types/markdown-it": "12.2.3",
"@types/markdown-it-highlightjs": "3.3.1", "@types/markdown-it-highlightjs": "3.3.1",
"@types/react-big-calendar": "^1.6.4", "@types/react-big-calendar": "^1.6.4",

View File

@ -5,7 +5,15 @@ import { useRequest } from '../api-client';
export const CurrentAppInfoContext = createContext(null); export const CurrentAppInfoContext = createContext(null);
export const useCurrentAppInfo = () => { export const useCurrentAppInfo = () => {
return useContext(CurrentAppInfoContext); return useContext<{
data: {
database: {
dialect: string;
};
lang: string;
version: string;
};
}>(CurrentAppInfoContext);
}; };
export const CurrentAppInfoProvider = (props) => { export const CurrentAppInfoProvider = (props) => {
const result = useRequest({ const result = useRequest({

View File

@ -360,6 +360,9 @@ export const useParamsFromRecord = () => {
const obj = { const obj = {
filterByTk: filterByTk, filterByTk: filterByTk,
}; };
if (record.__collection) {
obj['targetCollection'] = record.__collection;
}
if (!filterByTk) { if (!filterByTk) {
obj['filter'] = filter; obj['filter'] = filter;
} }

View File

@ -1100,8 +1100,7 @@ export const useAssociationFilterBlockProps = () => {
labelKey, labelKey,
}; };
}; };
export function getAssociationPath(str) {
function getAssociationPath(str) {
const lastIndex = str.lastIndexOf('.'); const lastIndex = str.lastIndexOf('.');
if (lastIndex !== -1) { if (lastIndex !== -1) {
return str.substring(0, lastIndex); return str.substring(0, lastIndex);

View File

@ -9,10 +9,11 @@ import { useTranslation } from 'react-i18next';
import { useRequest } from '../../api-client'; import { useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider'; import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import { useCancelAction } from '../action-hooks'; import { useCancelAction } from '../action-hooks';
import { useCollectionManager } from '../hooks'; import { useCollectionManager } from '../hooks';
import useDialect from '../hooks/useDialect';
import { IField } from '../interfaces/types'; import { IField } from '../interfaces/types';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components'; import * as components from './components';
import { getOptions } from './interfaces'; import { getOptions } from './interfaces';
@ -176,6 +177,8 @@ export const AddFieldAction = (props) => {
const [schema, setSchema] = useState({}); const [schema, setSchema] = useState({});
const compile = useCompile(); const compile = useCompile();
const { t } = useTranslation(); const { t } = useTranslation();
const { isDialect } = useDialect();
const currentCollections = useMemo(() => { const currentCollections = useMemo(() => {
return collections.map((v) => { return collections.map((v) => {
return { return {
@ -298,6 +301,8 @@ export const AddFieldAction = (props) => {
showReverseFieldConfig: true, showReverseFieldConfig: true,
targetScope, targetScope,
collections: currentCollections, collections: currentCollections,
isDialect,
disabledJSONB: false,
...scope, ...scope,
}} }}
/> />

View File

@ -8,10 +8,11 @@ import { useTranslation } from 'react-i18next';
import { useAPIClient, useRequest } from '../../api-client'; import { useAPIClient, useRequest } from '../../api-client';
import { RecordProvider, useRecord } from '../../record-provider'; import { RecordProvider, useRecord } from '../../record-provider';
import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component'; import { ActionContextProvider, SchemaComponent, useActionContext, useCompile } from '../../schema-component';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import { useCancelAction, useUpdateAction } from '../action-hooks'; import { useCancelAction, useUpdateAction } from '../action-hooks';
import { useCollectionManager } from '../hooks'; import { useCollectionManager } from '../hooks';
import useDialect from '../hooks/useDialect';
import { IField } from '../interfaces/types'; import { IField } from '../interfaces/types';
import { useResourceActionContext, useResourceContext } from '../ResourceActionProvider';
import * as components from './components'; import * as components from './components';
const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => { const getSchema = (schema: IField, record: any, compile, getContainer): ISchema => {
@ -144,6 +145,8 @@ export const EditFieldAction = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const compile = useCompile(); const compile = useCompile();
const [data, setData] = useState<any>({}); const [data, setData] = useState<any>({});
const { isDialect } = useDialect();
const currentCollections = useMemo(() => { const currentCollections = useMemo(() => {
return collections.map((v) => { return collections.map((v) => {
return { return {
@ -194,6 +197,8 @@ export const EditFieldAction = (props) => {
useCancelAction, useCancelAction,
showReverseFieldConfig: !data?.reverseField, showReverseFieldConfig: !data?.reverseField,
collections: currentCollections, collections: currentCollections,
isDialect,
disabledJSONB: true,
...scope, ...scope,
}} }}
/> />

View File

@ -0,0 +1,16 @@
import { useCurrentAppInfo } from '../../appInfo';
const useDialect = () => {
const {
data: { database },
} = useCurrentAppInfo();
const isDialect = (dialect: string) => database?.dialect === dialect;
return {
isDialect,
dialect: database?.dialect,
};
};
export default useDialect;

View File

@ -0,0 +1,41 @@
import React from 'react';
import { render, screen, waitFor } from 'testUtils';
import { CurrentAppInfoContext } from '../../../appInfo';
import { Checkbox } from '../../../schema-component/antd/checkbox';
import { Input } from '../../../schema-component/antd/input';
import { SchemaComponent } from '../../../schema-component/core/SchemaComponent';
import { SchemaComponentProvider } from '../../../schema-component/core/SchemaComponentProvider';
import { json } from '../json';
const Component = () => {
return (
<SchemaComponentProvider components={{ Input, Checkbox }}>
<SchemaComponent schema={json} />
</SchemaComponentProvider>
);
};
// TODO: 需要先修复测试中的路径问题:即某些引用路径返回的模块是 undefined
describe('JSON', () => {
it('should show JSONB when dialect is postgres', async () => {
render(<Component />, {
wrapper: ({ children }) => (
<CurrentAppInfoContext.Provider
value={{
data: {
database: {
dialect: 'postgres',
},
},
}}
>
{children}
</CurrentAppInfoContext.Provider>
),
});
await waitFor(() => {
expect(screen.queryByText('JSONB')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,26 @@
import { defaultProps, operators } from './properties';
import { IField } from './types';
export const color: IField = {
name: 'color',
type: 'object',
group: 'basic',
order: 10,
title: '{{t("Color")}}',
default: {
type: 'string',
uiSchema: {
type: 'string',
'x-component': 'ColorPicker',
default: '#1677FF',
},
},
availableTypes: ['string'],
hasDefaultValue: true,
properties: {
...defaultProps,
},
filterable: {
operators: operators.string,
},
};

View File

@ -2,6 +2,7 @@ export * from './checkbox';
export * from './checkboxGroup'; export * from './checkboxGroup';
export * from './chinaRegion'; export * from './chinaRegion';
export * from './collection'; export * from './collection';
export * from './color';
export * from './createdAt'; export * from './createdAt';
export * from './createdBy'; export * from './createdBy';
export * from './datetime'; export * from './datetime';

View File

@ -1,4 +1,6 @@
import { FormItem, FormLayout } from '@formily/antd-v5';
import { registerValidateRules } from '@formily/core'; import { registerValidateRules } from '@formily/core';
import React from 'react';
import { defaultProps } from './properties'; import { defaultProps } from './properties';
import { IField } from './types'; import { IField } from './types';
@ -43,6 +45,19 @@ export const json: IField = {
hasDefaultValue: true, hasDefaultValue: true,
properties: { properties: {
...defaultProps, ...defaultProps,
jsonb: {
type: 'boolean',
title: 'JSONB',
// 不直接用 `FormItem` 的原因是为了想要设置 `FormLayout` 的 `layout` 属性为 `horizontal` (默认就是 horizontal
'x-decorator': ({ children }) => (
<FormLayout>
<FormItem>{children}</FormItem>
</FormLayout>
),
'x-component': 'Checkbox',
'x-hidden': `{{ !isDialect('postgres') }}`,
'x-disabled': `{{ disabledJSONB }}`,
},
}, },
filterable: {}, filterable: {},
}; };

View File

@ -710,5 +710,10 @@ export default {
"Allow add new, update and delete actions":"Allow add new, update and delete actions", "Allow add new, update and delete actions":"Allow add new, update and delete actions",
"Date display format":"Date display format", "Date display format":"Date display format",
"Assign data scope for the template":"Assign data scope for the template", "Assign data scope for the template":"Assign data scope for the template",
"Table selected records":"Table selected records" "Table selected records":"Table selected records",
"Tag":"Tag",
"Tag color field":"Tag color field",
"Sync successfully":"Sync successfully",
"Sync from form fields":"Sync from form fields",
"Select all":"Select all"
}; };

View File

@ -621,4 +621,9 @@ export default {
"Allow add new, update and delete actions":"削除変更操作の許可", "Allow add new, update and delete actions":"削除変更操作の許可",
"Date display format":"日付表示形式", "Date display format":"日付表示形式",
"Assign data scope for the template":"テンプレートのデータ範囲の指定", "Assign data scope for the template":"テンプレートのデータ範囲の指定",
"Tag":"タブ",
"Tag color field":"ラベルの色フィールド",
"Sync successfully":"同期成功",
"Sync from form fields":"フォームフィールドの同期",
"Select all":"すべて選択"
} }

View File

@ -795,5 +795,10 @@ export default {
"Allow add new, update and delete actions":"允许增删改操作", "Allow add new, update and delete actions":"允许增删改操作",
"Date display format":"日期显示格式", "Date display format":"日期显示格式",
"Assign data scope for the template":"为模板指定数据范围", "Assign data scope for the template":"为模板指定数据范围",
"Table selected records":"表格中选中的记录" "Table selected records":"表格中选中的记录",
"Tag":"标签",
"Tag color field":"标签颜色字段",
"Sync successfully":"同步成功",
"Sync from form fields":"同步表单字段",
"Select all":"全选"
} }

View File

@ -1,8 +1,8 @@
import { connect, ISchema, mapProps, useField, useFieldSchema } from '@formily/react'; import { connect, ISchema, mapProps, useField, useFieldSchema, useForm } from '@formily/react';
import { isValid, uid } from '@formily/shared'; import { isValid, uid } from '@formily/shared';
import { Tree as AntdTree } from 'antd'; import { Tree as AntdTree } from 'antd';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDesignable } from '../..'; import { useDesignable } from '../..';
import { useCollection, useCollectionManager } from '../../../collection-manager'; import { useCollection, useCollectionManager } from '../../../collection-manager';
@ -12,15 +12,21 @@ import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks
import { useLinkageAction } from './hooks'; import { useLinkageAction } from './hooks';
import { requestSettingsSchema } from './utils'; import { requestSettingsSchema } from './utils';
import { useRecord } from '../../../record-provider'; import { useRecord } from '../../../record-provider';
import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils';
const Tree = connect( const Tree = connect(
AntdTree, AntdTree,
mapProps((props, field: any) => { mapProps((props, field: any) => {
const [checkedKeys, setCheckedKeys] = useState(props.defaultCheckedKeys || []);
const onCheck = (checkedKeys) => {
setCheckedKeys(checkedKeys);
field.value = checkedKeys;
};
field.onCheck = onCheck;
return { return {
...props, ...props,
onCheck: (checkedKeys) => { checkedKeys,
field.value = checkedKeys; onCheck,
},
}; };
}), }),
); );
@ -218,6 +224,28 @@ function SaveMode() {
); );
} }
const findFormBlock = (schema) => {
const formSchema = schema.reduceProperties((_, s) => {
if (s['x-decorator'] === 'FormBlockProvider') {
return s;
} else {
return findFormBlock(s);
}
}, null);
return formSchema;
};
const getAllkeys = (data, result) => {
for (let i = 0; i < data?.length; i++) {
const { children, ...rest } = data[i];
result.push(rest.key);
if (children) {
getAllkeys(children, result);
}
}
return result;
};
function DuplicationMode() { function DuplicationMode() {
const { dn } = useDesignable(); const { dn } = useDesignable();
const { t } = useTranslation(); const { t } = useTranslation();
@ -227,7 +255,27 @@ function DuplicationMode() {
const { collectionList, getEnableFieldTree, getOnLoadData, getOnCheck } = useCollectionState(name); const { collectionList, getEnableFieldTree, getOnLoadData, getOnCheck } = useCollectionState(name);
const duplicateValues = cloneDeep(fieldSchema['x-component-props'].duplicateFields || []); const duplicateValues = cloneDeep(fieldSchema['x-component-props'].duplicateFields || []);
const record = useRecord(); const record = useRecord();
const syncCallBack = useCallback((treeData, selectFields, form) => {
form.query('duplicateFields').take((f) => {
f.componentProps.treeData = treeData;
f.componentProps.defaultCheckedKeys = selectFields;
f.setInitialValue(selectFields);
f?.onCheck(selectFields);
form.setValues({ ...form.values, treeData });
});
}, []);
const useSelectAllFields = (form) => {
return {
async run() {
form.query('duplicateFields').take((f) => {
const selectFields = getAllkeys(f.componentProps.treeData, []);
f.componentProps.defaultCheckedKeys = selectFields;
f.setInitialValue(selectFields);
f?.onCheck(selectFields);
});
},
};
};
return ( return (
<SchemaSettings.ModalItem <SchemaSettings.ModalItem
title={t('Duplicate mode')} title={t('Duplicate mode')}
@ -238,6 +286,7 @@ function DuplicationMode() {
currentCollection: record?.__collection || name, currentCollection: record?.__collection || name,
getOnLoadData, getOnLoadData,
getOnCheck, getOnCheck,
treeData: fieldSchema['x-component-props']?.treeData,
}} }}
schema={ schema={
{ {
@ -278,11 +327,60 @@ function DuplicationMode() {
}, },
], ],
}, },
syncFromForm: {
type: 'void',
title: '{{ t("Sync from form fields") }}',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
style: { float: 'right', position: 'relative', zIndex: 1200 },
useAction: () => {
const formSchema = useMemo(() => findFormBlock(fieldSchema), [fieldSchema]);
return useSyncFromForm(
formSchema,
fieldSchema['x-component-props']?.duplicateCollection || record?.__collection || name,
syncCallBack,
);
},
},
'x-reactions': [
{
dependencies: ['.duplicateMode'],
fulfill: {
state: {
visible: `{{ $deps[0]!=="quickDulicate" }}`,
},
},
},
],
},
selectAll: {
type: 'void',
title: '{{ t("Select all") }}',
'x-component': 'Action.Link',
'x-reactions': [
{
dependencies: ['.duplicateMode'],
fulfill: {
state: {
visible: `{{ $deps[0]==="quickDulicate" }}`,
},
},
},
],
'x-component-props': {
type: 'primary',
style: { float: 'right', position: 'relative', zIndex: 1200 },
useAction: () => {
const from = useForm();
return useSelectAllFields(from);
},
},
},
duplicateFields: { duplicateFields: {
type: 'array', type: 'array',
title: '{{ t("Data fields") }}', title: '{{ t("Data fields") }}',
required: true, required: true,
default: duplicateValues,
description: t('Only the selected fields will be used as the initialization data for the form'), description: t('Only the selected fields will be used as the initialization data for the form'),
'x-decorator': 'FormItem', 'x-decorator': 'FormItem',
'x-component': Tree, 'x-component': Tree,
@ -310,7 +408,7 @@ function DuplicationMode() {
state: { state: {
disabled: '{{ !$deps[0] }}', disabled: '{{ !$deps[0] }}',
componentProps: { componentProps: {
treeData: '{{ getEnableFieldTree($deps[0], $self) }}', treeData: '{{ getEnableFieldTree($deps[0], $self,treeData) }}',
}, },
}, },
}, },
@ -320,7 +418,7 @@ function DuplicationMode() {
}, },
} as ISchema } as ISchema
} }
onSubmit={({ duplicateMode, collection, duplicateFields }) => { onSubmit={({ duplicateMode, collection, duplicateFields, treeData }) => {
const fields = Array.isArray(duplicateFields) ? duplicateFields : duplicateFields.checked || []; const fields = Array.isArray(duplicateFields) ? duplicateFields : duplicateFields.checked || [];
field.componentProps.duplicateMode = duplicateMode; field.componentProps.duplicateMode = duplicateMode;
field.componentProps.duplicateFields = fields; field.componentProps.duplicateFields = fields;
@ -328,6 +426,7 @@ function DuplicationMode() {
fieldSchema['x-component-props'].duplicateMode = duplicateMode; fieldSchema['x-component-props'].duplicateMode = duplicateMode;
fieldSchema['x-component-props'].duplicateFields = fields; fieldSchema['x-component-props'].duplicateFields = fields;
fieldSchema['x-component-props'].duplicateCollection = collection; fieldSchema['x-component-props'].duplicateCollection = collection;
fieldSchema['x-component-props'].treeData = treeData;
dn.emit('patch', { dn.emit('patch', {
schema: { schema: {
['x-uid']: fieldSchema['x-uid'], ['x-uid']: fieldSchema['x-uid'],
@ -580,7 +679,7 @@ export const ActionDesigner = (props) => {
const { name } = useCollection(); const { name } = useCollection();
const { getChildrenCollections } = useCollectionManager(); const { getChildrenCollections } = useCollectionManager();
const isAction = useLinkageAction(); const isAction = useLinkageAction();
const isPopupAction = ['create', 'update', 'view', 'customize:popup', 'duplicate','customize:create'].includes( const isPopupAction = ['create', 'update', 'view', 'customize:popup', 'duplicate', 'customize:create'].includes(
fieldSchema['x-action'] || '', fieldSchema['x-action'] || '',
); );
const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']); const isUpdateModePopupAction = ['customize:bulkUpdate', 'customize:bulkEdit'].includes(fieldSchema['x-action']);

View File

@ -1,5 +1,5 @@
import { observer, RecursionField, useField, useFieldSchema, useForm } from '@formily/react'; import { observer, RecursionField, useField, useFieldSchema, useForm } from '@formily/react';
import { lodash } from '@nocobase/utils'; import { lodash } from '@nocobase/utils/client';
import { App, Button, Popover } from 'antd'; import { App, Button, Popover } from 'antd';
import classnames from 'classnames'; import classnames from 'classnames';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, sleep, userEvent } from 'testUtils'; import { fireEvent, render, screen, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
import App3 from '../demos/demo3'; import App3 from '../demos/demo3';
@ -10,9 +10,10 @@ describe('Action', () => {
const { getByText } = render(<App1 />); const { getByText } = render(<App1 />);
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300); await waitFor(() => {
// drawer // drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
// mask // mask
expect(document.querySelector('.ant-drawer-mask')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer-mask')).toBeInTheDocument();
// title // title
@ -22,23 +23,30 @@ describe('Action', () => {
// close button // close button
await userEvent.click(getByText('Close')); await userEvent.click(getByText('Close'));
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask // should also close when click the mask
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement); await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the close icon // should also close when click the close icon
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement); await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
}); });
});
it('openMode', async () => { it('openMode', async () => {
const { getByText } = render(<App3 />); const { getByText } = render(<App3 />);
@ -50,31 +58,36 @@ describe('Action', () => {
// drawer // drawer
await userEvent.click(getByText('Drawer')); await userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument(); expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument(); expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close')); await userEvent.click(getByText('Close'));
// modal // modal
await userEvent.click(getByText('Modal')); await userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument(); expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument(); expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close')); await userEvent.click(getByText('Close'));
// page // page
await userEvent.click(getByText('Page')); await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument(); expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).toBeInTheDocument(); expect(document.querySelector('.nb-action-page')).toBeInTheDocument();
});
await userEvent.click(getByText('Close')); await userEvent.click(getByText('Close'));
// TODO: 点击关闭按钮时应该消失 // TODO: 点击关闭按钮时应该消失
@ -87,7 +100,7 @@ describe('Action.Drawer without Action', () => {
const { getByText } = render(<App2 />); const { getByText } = render(<App2 />);
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300); await waitFor(() => {
// drawer // drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
// mask // mask
@ -96,30 +109,37 @@ describe('Action.Drawer without Action', () => {
expect(getByText('Drawer Title')).toBeInTheDocument(); expect(getByText('Drawer Title')).toBeInTheDocument();
// content // content
expect(getByText('Hello')).toBeInTheDocument(); expect(getByText('Hello')).toBeInTheDocument();
});
// close button // close button
await userEvent.click(getByText('Close')); await userEvent.click(getByText('Close'));
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask // should also close when click the mask
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement); await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
await sleep(300); await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the close icon // should also close when click the close icon
await userEvent.click(getByText('Open')); await userEvent.click(getByText('Open'));
await sleep(300);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement); });
await sleep(300);
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument(); expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
}); });
});
}); });
describe('Action.Popover', () => { describe('Action.Popover', () => {
@ -129,16 +149,16 @@ describe('Action.Popover', () => {
fireEvent.mouseEnter(btn); fireEvent.mouseEnter(btn);
// wait for the popover to show await waitFor(() => {
await sleep(300);
// popover // popover
expect(document.querySelector('.ant-popover')).toBeInTheDocument(); expect(document.querySelector('.ant-popover')).toBeInTheDocument();
// content // content
expect(screen.getByText('Hello')).toBeInTheDocument(); expect(screen.getByText('Hello')).toBeInTheDocument();
});
fireEvent.mouseLeave(btn); fireEvent.mouseLeave(btn);
// wait for the popover to hide await waitFor(() => {
await sleep(300);
expect(document.querySelector('.ant-popover')).not.toBeInTheDocument(); expect(document.querySelector('.ant-popover')).not.toBeInTheDocument();
}); });
});
}); });

View File

@ -0,0 +1,123 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { toArr } from '@formily/shared';
import flat from 'flat';
import React, { Fragment, useRef, useState } from 'react';
import { useDesignable } from '../../';
import { BlockAssociationContext, WithoutTableFieldResource } from '../../../block-provider';
import { CollectionProvider } from '../../../collection-manager';
import { RecordProvider, useRecord } from '../../../record-provider';
import { FormProvider } from '../../core';
import { useCompile } from '../../hooks';
import { ActionContextProvider, useActionContext } from '../action';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema';
import { getTabFormatValue, useLabelUiSchema } from './util';
interface IEllipsisWithTooltipRef {
setPopoverVisible: (boolean) => void;
}
const toValue = (value, placeholder) => {
if (value === null || value === undefined) {
return placeholder;
}
return value;
};
export const ReadPrettyInternalTag: React.FC = observer(
(props: any) => {
const fieldSchema = useFieldSchema();
const recordCtx = useRecord();
const { enableLink, tagColorField } = fieldSchema['x-component-props'];
// value 做了转换,但 props.value 和原来 useField().value 的值不一致
const field = useField();
const fieldNames = useFieldNames(props);
const [visible, setVisible] = useState(false);
const insertViewer = useInsertSchema('Viewer');
const { options: collectionField } = useAssociationFieldContext();
const [record, setRecord] = useState({});
const compile = useCompile();
const { designable } = useDesignable();
const labelUiSchema = useLabelUiSchema(collectionField, fieldNames?.label || 'label');
const { snapshot } = useActionContext();
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const tagColor = flat(recordCtx)[`${fieldSchema.name}.${tagColorField}`];
const renderRecords = () =>
toArr(props.value).map((record, index, arr) => {
const val = toValue(compile(record?.[fieldNames?.label || 'label']), 'N/A');
const text = getTabFormatValue(compile(labelUiSchema), val, tagColor);
return (
<Fragment key={`${record.id}_${index}`}>
<span>
{snapshot ? (
text
) : enableLink !== false ? (
<a
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (designable) {
insertViewer(schema.Viewer);
}
setVisible(true);
setRecord(record);
ellipsisWithTooltipRef?.current?.setPopoverVisible(false);
}}
>
{text}
</a>
) : (
text
)}
</span>
{index < arr.length - 1 ? <span style={{ marginRight: 4, color: '#aaa' }}>,</span> : null}
</Fragment>
);
});
const renderWithoutTableFieldResourceProvider = () => (
<WithoutTableFieldResource.Provider value={true}>
<FormProvider>
<RecursionField
schema={fieldSchema}
onlyRenderProperties
basePath={field.address}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Viewer';
}}
/>
</FormProvider>
</WithoutTableFieldResource.Provider>
);
const renderRecordProvider = () => {
const collectionFieldNames = fieldSchema?.['x-collection-field']?.split('.');
return collectionFieldNames && collectionFieldNames.length > 2 ? (
<RecordProvider record={recordCtx[collectionFieldNames[1]]}>
<RecordProvider record={record}>{renderWithoutTableFieldResourceProvider()}</RecordProvider>
</RecordProvider>
) : (
<RecordProvider record={record}>{renderWithoutTableFieldResourceProvider()}</RecordProvider>
);
};
return (
<div>
<BlockAssociationContext.Provider value={`${collectionField?.collectionName}.${collectionField?.name}`}>
<CollectionProvider name={collectionField?.target ?? collectionField?.targetCollection}>
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
<ActionContextProvider
value={{ visible, setVisible, openMode: 'drawer', snapshot: collectionField?.interface === 'snapshot' }}
>
{renderRecordProvider()}
</ActionContextProvider>
</CollectionProvider>
</BlockAssociationContext.Provider>
</div>
);
},
{ displayName: 'ReadPrettyInternalTag' },
);

View File

@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react'; import { observer } from '@formily/react';
import { useField, observer } from '@formily/react'; import React from 'react';
import { AssociationFieldProvider } from './AssociationFieldProvider'; import { AssociationFieldProvider } from './AssociationFieldProvider';
import { InternalNester } from './InternalNester';
import { ReadPrettyInternalViewer } from './InternalViewer';
import { InternalSubTable } from './InternalSubTable';
import { FileManageReadPretty } from './FileManager'; import { FileManageReadPretty } from './FileManager';
import { useAssociationFieldContext } from './hooks'; import { useAssociationFieldContext } from './hooks';
import { InternalNester } from './InternalNester';
import { InternalSubTable } from './InternalSubTable';
import { ReadPrettyInternalTag } from './InternalTag';
import { ReadPrettyInternalViewer } from './InternalViewer';
const ReadPrettyAssociationField = observer( const ReadPrettyAssociationField = observer(
(props: any) => { (props: any) => {
@ -14,6 +15,7 @@ const ReadPrettyAssociationField = observer(
return ( return (
<> <>
{['Select', 'Picker'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />} {['Select', 'Picker'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />}
{currentMode === 'Tag' && <ReadPrettyInternalTag {...props} />}
{currentMode === 'Nester' && <InternalNester {...props} />} {currentMode === 'Nester' && <InternalNester {...props} />}
{currentMode === 'SubTable' && <InternalSubTable {...props} />} {currentMode === 'SubTable' && <InternalSubTable {...props} />}
{currentMode === 'FileManager' && <FileManageReadPretty {...props} />} {currentMode === 'FileManager' && <FileManageReadPretty {...props} />}

View File

@ -48,6 +48,28 @@ export const getLabelFormatValue = (labelUiSchema: ISchema, value: any, isTag =
} }
}; };
export const getTabFormatValue = (labelUiSchema: ISchema, value: any, tagColor): any => {
const options = labelUiSchema?.enum;
if (Array.isArray(options) && value) {
const values = toArr(value).map((val) => {
const opt: any = options.find((option: any) => option.value === val);
return React.createElement(Tag, { color: tagColor||opt?.color }, opt?.label);
});
return values;
}
switch (labelUiSchema?.['x-component']) {
case 'DatePicker':
return React.createElement(
Tag,
{ color: tagColor },
getDatePickerLabels({ ...labelUiSchema?.['x-component-props'], value }),
);
default:
return React.createElement(Tag, { color: tagColor }, value);
}
};
export function flatData(data) { export function flatData(data) {
const newArr = []; const newArr = [];
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { fireEvent, render, screen, sleep, userEvent } from 'testUtils'; import { fireEvent, render, screen, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
@ -33,8 +33,9 @@ describe('Cascader', () => {
// 因为是异步加载,所以需要等待一下 // 因为是异步加载,所以需要等待一下
expect(screen.queryByText('Zhejiang Dynamic 1')).not.toBeInTheDocument(); expect(screen.queryByText('Zhejiang Dynamic 1')).not.toBeInTheDocument();
await sleep(300); await waitFor(() => {
expect(screen.getByText('Zhejiang Dynamic 1')).toBeInTheDocument(); expect(screen.getByText('Zhejiang Dynamic 1')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Zhejiang Dynamic 1')); fireEvent.click(screen.getByText('Zhejiang Dynamic 1'));

View File

@ -0,0 +1,68 @@
import { css } from '@emotion/css';
import { usePrefixCls } from '@formily/antd-v5/esm/__builtins__';
import { connect, mapProps, mapReadPretty } from '@formily/react';
import { ColorPicker as AntdColorPicker } from 'antd';
import cls from 'classnames';
import React from 'react';
export const ColorPicker = connect(
(props) => {
const { value, onChange, ...others } = props;
return (
<AntdColorPicker
value={value}
trigger="hover"
{...others}
destroyTooltipOnHide
getPopupContainer={(current) => current}
presets={[
{
label: 'Recommended',
colors: [
'#8BBB11',
'#52C41A',
'#13A8A8',
'#1677FF',
'#F5222D',
'#FADB14',
'#FA8C164D',
'#FADB144D',
'#52C41A4D',
'#1677FF4D',
'#2F54EB4D',
'#722ED14D',
'#EB2F964D',
],
},
]}
onChange={(color) => onChange(color.toHexString())}
/>
);
},
mapProps((props, field) => {
return {
...props,
};
}),
mapReadPretty((props) => {
const prefixCls = usePrefixCls('description-color-picker', props);
return (
<div
className={cls(
prefixCls,
css`
display: inline-flex;
.ant-color-picker-trigger-disabled {
cursor: default;
}
`,
props.className,
)}
>
<AntdColorPicker disabled value={props.value} size="small" {...props} />
</div>
);
}),
);
export default ColorPicker;

View File

@ -0,0 +1,24 @@
import { usePrefixCls } from '@formily/antd-v5/esm/__builtins__';
import type { ColorPickerProps as AntdColorPickerProps } from 'antd/es/color-picker';
import cls from 'classnames';
import React from 'react';
type Composed = {
ColorPicker: React.FC<AntdColorPickerProps>;
};
export const ReadPretty: Composed = () => null;
ReadPretty.ColorPicker = function ColorPicker(props: any) {
const prefixCls = usePrefixCls('description-color-picker', props);
if (!props.value) {
return <div></div>;
}
return (
<div className={cls(prefixCls, props.className)}>
<ColorPicker showText disabled value={props.value} size='small'/>
</div>
);
};

View File

@ -0,0 +1,49 @@
/**
* title: ColorPicker
*/
import { FormItem } from '@formily/antd-v5';
import { ColorPicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'ColorPicker',
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'ColorPicker',
},
read2: {
type: 'string',
title: `Value`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {},
},
},
};
export default () => {
return (
<SchemaComponentProvider components={{ Input, ColorPicker, FormItem }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -0,0 +1,18 @@
---
group:
title: Schema Components
order: 3
---
# ColorPicker
## Examples
### Basic
<code src="./demos/demo1.tsx"></code>

View File

@ -0,0 +1 @@
export * from './ColorPicker';

View File

@ -0,0 +1,158 @@
import { dayjs, getDefaultFormat, str2moment, toGmt, toLocal } from '@nocobase/utils/client';
import type { Dayjs } from 'dayjs';
const toStringByPicker = (value, picker, timezone: 'gmt' | 'local') => {
if (!dayjs.isDayjs(value)) return value;
if (timezone === 'local') {
const offset = new Date().getTimezoneOffset();
return dayjs(toStringByPicker(value, picker, 'gmt')).add(offset, 'minutes').toISOString();
}
if (picker === 'year') {
return value.format('YYYY') + '-01-01T00:00:00.000Z';
}
if (picker === 'month') {
return value.format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'quarter') {
return value.startOf('quarter').format('YYYY-MM') + '-01T00:00:00.000Z';
}
if (picker === 'week') {
return value.startOf('week').add(1, 'day').format('YYYY-MM-DD') + 'T00:00:00.000Z';
}
return value.format('YYYY-MM-DDTHH:mm:ss.SSS') + 'Z';
};
const toGmtByPicker = (value: Dayjs, picker?: any) => {
if (!value || !dayjs.isDayjs(value)) {
return value;
}
return toStringByPicker(value, picker, 'gmt');
};
const toLocalByPicker = (value: Dayjs, picker?: any) => {
if (!value || !dayjs.isDayjs(value)) {
return value;
}
return toStringByPicker(value, picker, 'local');
};
export interface Moment2strOptions {
showTime?: boolean;
gmt?: boolean;
utc?: boolean;
picker?: 'year' | 'month' | 'week' | 'quarter';
}
export const moment2str = (value?: Dayjs | null, options: Moment2strOptions = {}) => {
const { showTime, gmt, picker, utc = true } = options;
if (!value) {
return value;
}
if (!utc) {
const format = showTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
return value.format(format);
}
if (showTime) {
return gmt ? toGmt(value) : toLocal(value);
}
if (typeof gmt === 'boolean') {
return gmt ? toGmtByPicker(value, picker) : toLocalByPicker(value, picker);
}
return toGmtByPicker(value, picker);
};
export const mapDatePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: Dayjs | null) => {
if (onChange) {
if (!props.showTime && value) {
value = value.startOf('day');
}
onChange(moment2str(value, props));
}
},
};
};
};
export const mapRangePicker = function () {
return (props: any) => {
const format = getDefaultFormat(props) as any;
const onChange = props.onChange;
return {
...props,
format: format,
value: str2moment(props.value, props),
onChange: (value: Dayjs[]) => {
if (onChange) {
onChange(
value
? [moment2str(getRangeStart(value[0], props), props), moment2str(getRangeEnd(value[1], props), props)]
: [],
);
}
},
} as any;
};
};
function getRangeStart(value: Dayjs, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.startOf('day');
}
function getRangeEnd(value: Dayjs, options: Moment2strOptions) {
const { showTime } = options;
if (showTime) {
return value;
}
return value.endOf('day');
}
const getStart = (offset: any, unit: any) => {
return dayjs()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.startOf(unit);
};
const getEnd = (offset: any, unit: any) => {
return dayjs()
.add(offset, unit === 'isoWeek' ? 'week' : unit)
.endOf(unit);
};
export const getDateRanges = () => {
return {
today: () => [getStart(0, 'day'), getEnd(0, 'day')],
lastWeek: () => [getStart(-1, 'isoWeek'), getEnd(-1, 'isoWeek')],
thisWeek: () => [getStart(0, 'isoWeek'), getEnd(0, 'isoWeek')],
nextWeek: () => [getStart(1, 'isoWeek'), getEnd(1, 'isoWeek')],
lastMonth: () => [getStart(-1, 'month'), getEnd(-1, 'month')],
thisMonth: () => [getStart(0, 'month'), getEnd(0, 'month')],
nextMonth: () => [getStart(1, 'month'), getEnd(1, 'month')],
lastQuarter: () => [getStart(-1, 'quarter'), getEnd(-1, 'quarter')],
thisQuarter: () => [getStart(0, 'quarter'), getEnd(0, 'quarter')],
nextQuarter: () => [getStart(1, 'quarter'), getEnd(1, 'quarter')],
lastYear: () => [getStart(-1, 'year'), getEnd(-1, 'year')],
thisYear: () => [getStart(0, 'year'), getEnd(0, 'year')],
nextYear: () => [getStart(1, 'year'), getEnd(1, 'year')],
last7Days: () => [getStart(-6, 'days'), getEnd(0, 'days')],
next7Days: () => [getStart(1, 'day'), getEnd(7, 'days')],
last30Days: () => [getStart(-29, 'days'), getEnd(0, 'days')],
next30Days: () => [getStart(1, 'day'), getEnd(30, 'days')],
last90Days: () => [getStart(-89, 'days'), getEnd(0, 'days')],
next90Days: () => [getStart(1, 'day'), getEnd(90, 'days')],
};
};

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen, userEvent, waitFor } from 'testUtils'; import { render, screen, sleep, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
import App3 from '../demos/demo3'; import App3 from '../demos/demo3';
@ -13,6 +13,9 @@ import App9 from '../demos/demo9';
describe('DatePicker', () => { describe('DatePicker', () => {
it('basic', async () => { it('basic', async () => {
const { container, getByText } = render(<App1 />); const { container, getByText } = render(<App1 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement; const input = container.querySelector('input') as HTMLElement;
@ -35,6 +38,9 @@ describe('DatePicker', () => {
it('GMT', async () => { it('GMT', async () => {
const { container, getByText } = render(<App2 />); const { container, getByText } = render(<App2 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement; const input = container.querySelector('input') as HTMLElement;
@ -53,6 +59,9 @@ describe('DatePicker', () => {
it('non-UTC', async () => { it('non-UTC', async () => {
const { container } = render(<App3 />); const { container } = render(<App3 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement; const input = container.querySelector('input') as HTMLElement;
@ -74,6 +83,9 @@ describe('DatePicker', () => {
describe('RangePicker', () => { describe('RangePicker', () => {
it('GMT', async () => { it('GMT', async () => {
const { container, getByPlaceholderText } = render(<App4 />); const { container, getByPlaceholderText } = render(<App4 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date'); const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date'); const endInput = getByPlaceholderText('End date');
@ -92,6 +104,9 @@ describe('RangePicker', () => {
it('non-GMT', async () => { it('non-GMT', async () => {
const { container, getByPlaceholderText } = render(<App5 />); const { container, getByPlaceholderText } = render(<App5 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date'); const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date'); const endInput = getByPlaceholderText('End date');
@ -115,6 +130,9 @@ describe('RangePicker', () => {
it('non-UTC', async () => { it('non-UTC', async () => {
const { container, getByPlaceholderText } = render(<App6 />); const { container, getByPlaceholderText } = render(<App6 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date'); const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date'); const endInput = getByPlaceholderText('End date');
@ -133,6 +151,9 @@ describe('RangePicker', () => {
it('showTime=false,gmt=true,utc=true', async () => { it('showTime=false,gmt=true,utc=true', async () => {
const { container } = render(<App7 />); const { container } = render(<App7 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement; const input = container.querySelector('input') as HTMLElement;
@ -152,6 +173,9 @@ describe('RangePicker', () => {
it('showTime=false,gmt=false,utc=true', async () => { it('showTime=false,gmt=false,utc=true', async () => {
const { container } = render(<App8 />); const { container } = render(<App8 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement; const input = container.querySelector('input') as HTMLElement;
@ -175,6 +199,9 @@ describe('RangePicker', () => {
it('showTime=false,gmt=true,utc=true & not input', async () => { it('showTime=false,gmt=true,utc=true & not input', async () => {
const currentDateString = new Date().toISOString().split('T')[0]; const currentDateString = new Date().toISOString().split('T')[0];
const { container } = render(<App9 />); const { container } = render(<App9 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement; const picker = container.querySelector('.ant-picker') as HTMLElement;
await userEvent.click(picker); await userEvent.click(picker);

View File

@ -7,8 +7,7 @@ import App5 from '../demos/demo5';
import App6 from '../demos/demo6'; import App6 from '../demos/demo6';
describe('Filter', () => { describe('Filter', () => {
// TODO: 等 @Testing-Library 升级到 14.x it('Filter & Action', async () => {
it.skip('Filter & Action', async () => {
render(<App3 />); render(<App3 />);
await waitFor( await waitFor(

View File

@ -28,6 +28,7 @@ import { BlockItem } from '../block-item';
import { removeNullCondition } from '../filter'; import { removeNullCondition } from '../filter';
import { HTMLEncode } from '../input/shared'; import { HTMLEncode } from '../input/shared';
import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent'; import { FilterDynamicComponent } from '../table-v2/FilterDynamicComponent';
import { useColorFields } from '../table-v2/Table.Column.Designer';
import { FilterFormDesigner } from './FormItem.FilterFormDesigner'; import { FilterFormDesigner } from './FormItem.FilterFormDesigner';
import { useEnsureOperatorsValid } from './SchemaSettingOptions'; import { useEnsureOperatorsValid } from './SchemaSettingOptions';
@ -183,6 +184,7 @@ FormItem.Designer = function Designer() {
value: field?.name, value: field?.name,
label: compile(field?.uiSchema?.title) || field?.name, label: compile(field?.uiSchema?.title) || field?.name,
})); }));
const colorFieldOptions = useColorFields(collectionField?.target ?? collectionField?.targetCollection);
let readOnlyMode = 'editable'; let readOnlyMode = 'editable';
if (fieldSchema['x-disabled'] === true) { if (fieldSchema['x-disabled'] === true) {
@ -529,10 +531,6 @@ FormItem.Designer = function Designer() {
schema['x-component-props'] = fieldSchema['x-component-props']; schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps = field.componentProps || {}; field.componentProps = field.componentProps || {};
field.componentProps.mode = mode; field.componentProps.mode = mode;
// if (mode === 'Nester') {
// const initValue = ['hasMany', 'belongsToMany'].includes(collectionField?.type) ? [{}] : {};
// field.value = field.value || initValue;
// }
dn.emit('patch', { dn.emit('patch', {
schema, schema,
}); });
@ -811,6 +809,29 @@ FormItem.Designer = function Designer() {
/> />
)} )}
{isDateField && <SchemaSettings.DataFormat fieldSchema={fieldSchema} />} {isDateField && <SchemaSettings.DataFormat fieldSchema={fieldSchema} />}
{isAssociationField && ['Tag'].includes(fieldMode) && (
<SchemaSettings.SelectItem
key="title-field"
title={t('Tag color field')}
options={colorFieldOptions}
value={field?.componentProps?.tagColorField}
onChange={(tagColorField) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['tagColorField'] = tagColorField;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps.tagColorField = tagColorField;
dn.emit('patch', {
schema,
});
dn.refresh();
}}
/>
)}
{collectionField && <SchemaSettings.Divider />} {collectionField && <SchemaSettings.Divider />}
<SchemaSettings.Remove <SchemaSettings.Remove
key="remove" key="remove"

View File

@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { render, screen } from 'testUtils'; import { render, screen, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
describe('FormItem', () => { describe('FormItem', () => {
it('should render correctly', () => { it('should render correctly', async () => {
render(<App1 />); render(<App1 />);
await waitFor(() => {
expect(screen.getByText('title')).toBeInTheDocument(); expect(screen.getByText('title')).toBeInTheDocument();
}); });
});
}); });

View File

@ -1,5 +1,18 @@
import { FormItem, FormProvider, Input, SchemaComponent } from '@nocobase/client'; import {
APIClientProvider,
CurrentUserProvider,
FormItem,
FormProvider,
Input,
SchemaComponent,
} from '@nocobase/client';
import React from 'react'; import React from 'react';
import { mockAPIClient } from '../../../../test';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const schema = { const schema = {
type: 'object', type: 'object',
@ -15,8 +28,12 @@ const schema = {
export default () => { export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<FormProvider> <FormProvider>
<SchemaComponent components={{ FormItem, Input }} schema={schema} /> <SchemaComponent components={{ FormItem, Input }} schema={schema} />
</FormProvider> </FormProvider>
</CurrentUserProvider>
</APIClientProvider>
); );
}; };

View File

@ -163,7 +163,7 @@ export const Templates = ({ style = {}, form }) => {
{targetTemplate !== 'none' && ( {targetTemplate !== 'none' && (
<RemoteSelect <RemoteSelect
style={{ width: 220 }} style={{ width: 220 }}
fieldNames={{ label: template.titleField, value: 'id' }} fieldNames={{ label: template?.titleField, value: 'id' }}
target={template?.collection} target={template?.collection}
value={targetTemplateData} value={targetTemplateData}
objectValue objectValue

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen, sleep, userEvent, waitFor } from 'testUtils'; import { render, screen, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
import App3 from '../demos/demo3'; import App3 from '../demos/demo3';
@ -8,20 +8,22 @@ describe('FormV2', () => {
it('basic', async () => { it('basic', async () => {
render(<App1 />); render(<App1 />);
const input = document.querySelector('.ant-input') as HTMLInputElement; let input, submit;
const submit = screen.getByText('Submit'); await waitFor(() => {
input = document.querySelector('.ant-input') as HTMLInputElement;
submit = screen.getByText('Submit');
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
expect(screen.getByText('Nickname')).toBeInTheDocument(); expect(screen.queryByText('Nickname')).toBeInTheDocument();
});
await userEvent.type(input, '李四'); await userEvent.type(input, '李四');
await userEvent.click(submit); await userEvent.click(submit);
await sleep(100); await waitFor(() => {
// notification 的内容 // notification 的内容
expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument(); expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument();
}); });
});
it('initial values', async () => { it('initial values', async () => {
render(<App2 />); render(<App2 />);
@ -41,8 +43,7 @@ describe('FormV2', () => {
}); });
}); });
// TODO: 等 @Testing-Library 升级到 14.x it('read pretty', async () => {
it.skip('read pretty', async () => {
render(<App3 />); render(<App3 />);
await waitFor(() => { await waitFor(() => {

View File

@ -4,6 +4,7 @@ import {
Action, Action,
CollectionField, CollectionField,
CollectionManagerProvider, CollectionManagerProvider,
CurrentUserProvider,
FormBlockProvider, FormBlockProvider,
FormItem, FormItem,
FormV2, FormV2,
@ -26,6 +27,9 @@ mockRequest.onPost('/users:update').reply((params) => {
}); });
return [200, JSON.parse(params.data)]; return [200, JSON.parse(params.data)];
}); });
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
function useAction() { function useAction() {
const ctx = useFormBlockContext(); const ctx = useFormBlockContext();
@ -82,11 +86,13 @@ const schema: ISchema = {
export default () => { export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}> <APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<CollectionManagerProvider collections={collections}> <CollectionManagerProvider collections={collections}>
<SchemaComponentProvider components={{ FormBlockProvider, FormV2, FormItem, CollectionField, Action, Input }}> <SchemaComponentProvider components={{ FormBlockProvider, FormV2, FormItem, CollectionField, Action, Input }}>
<SchemaComponent schema={schema} /> <SchemaComponent schema={schema} />
</SchemaComponentProvider> </SchemaComponentProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
</CurrentUserProvider>
</APIClientProvider> </APIClientProvider>
); );
}; };

View File

@ -5,6 +5,7 @@ import {
BlockSchemaComponentProvider, BlockSchemaComponentProvider,
CollectionField, CollectionField,
CollectionManagerProvider, CollectionManagerProvider,
CurrentUserProvider,
FormBlockProvider, FormBlockProvider,
FormItem, FormItem,
FormV2, FormV2,
@ -36,6 +37,9 @@ mockRequest.onPost('/users:update').reply((params) => {
}); });
return [200, JSON.parse(params.data)]; return [200, JSON.parse(params.data)];
}); });
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const useAction = () => { const useAction = () => {
const ctx = useFormBlockContext(); const ctx = useFormBlockContext();
@ -107,6 +111,7 @@ const schema: ISchema = {
export default () => { export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}> <APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<CollectionManagerProvider collections={collections}> <CollectionManagerProvider collections={collections}>
<SchemaComponentProvider <SchemaComponentProvider
components={{ FormBlockProvider, FormItem, CollectionField, Input, Action, FormV2, Password }} components={{ FormBlockProvider, FormItem, CollectionField, Input, Action, FormV2, Password }}
@ -116,6 +121,7 @@ export default () => {
</BlockSchemaComponentProvider> </BlockSchemaComponentProvider>
</SchemaComponentProvider> </SchemaComponentProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
</CurrentUserProvider>
</APIClientProvider> </APIClientProvider>
); );
}; };

View File

@ -5,9 +5,11 @@ import {
BlockSchemaComponentProvider, BlockSchemaComponentProvider,
CollectionField, CollectionField,
CollectionManagerProvider, CollectionManagerProvider,
CurrentUserProvider,
FormBlockProvider, FormBlockProvider,
FormItem, FormItem,
FormV2, FormV2,
Grid,
Input, Input,
Password, Password,
SchemaComponent, SchemaComponent,
@ -26,6 +28,9 @@ mockRequest.onGet('/users:get').reply(200, {
password: '123456', password: '123456',
}, },
}); });
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const schema: ISchema = { const schema: ISchema = {
type: 'object', type: 'object',
@ -93,15 +98,17 @@ const schema: ISchema = {
export default () => { export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}> <APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<CollectionManagerProvider collections={collections}> <CollectionManagerProvider collections={collections}>
<SchemaComponentProvider <SchemaComponentProvider
components={{ FormBlockProvider, FormItem, CollectionField, Input, Action, FormV2, Password }} components={{ FormBlockProvider, FormItem, CollectionField, Input, Action, FormV2, Password, Grid }}
> >
<BlockSchemaComponentProvider> <BlockSchemaComponentProvider>
<SchemaComponent schema={schema} /> <SchemaComponent schema={schema} />
</BlockSchemaComponentProvider> </BlockSchemaComponentProvider>
</SchemaComponentProvider> </SchemaComponentProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
</CurrentUserProvider>
</APIClientProvider> </APIClientProvider>
); );
}; };

View File

@ -38,9 +38,12 @@ describe('Form', () => {
expect(submit).toBeInTheDocument(); expect(submit).toBeInTheDocument();
expect(input).toBeInTheDocument(); expect(input).toBeInTheDocument();
await waitFor(() => {
expect(input).toHaveValue('aaa'); expect(input).toHaveValue('aaa');
expect(screen.getByText('T1')).toBeInTheDocument(); expect(screen.getByText('T1')).toBeInTheDocument();
expect(screen.getByText(/\{ "field1": "aaa" \}/i)).toBeInTheDocument(); expect(screen.getByText(/\{ "field1": "aaa" \}/i)).toBeInTheDocument();
});
await userEvent.type(input, '123'); await userEvent.type(input, '123');
expect(screen.getByText(/\{ "field1": "aaa123" \}/i)).toBeInTheDocument(); expect(screen.getByText(/\{ "field1": "aaa123" \}/i)).toBeInTheDocument();

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, sleep } from 'testUtils'; import { render, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
// jsdom does not support canvas, so we need to skip this test // jsdom does not support canvas, so we need to skip this test
@ -7,9 +7,9 @@ describe.skip('G2Plot', () => {
it('basic', async () => { it('basic', async () => {
render(<App1 />); render(<App1 />);
await sleep(100); await waitFor(() => {
const g2plot = document.querySelector('.g2plot') as HTMLDivElement; const g2plot = document.querySelector('.g2plot') as HTMLDivElement;
expect(g2plot).toBeInTheDocument(); expect(g2plot).toBeInTheDocument();
}); });
});
}); });

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, screen } from 'testUtils'; import { render, screen, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import App2 from '../demos/demo2'; import App2 from '../demos/demo2';
import App3 from '../demos/demo3'; import App3 from '../demos/demo3';
@ -14,13 +14,14 @@ describe('Grid', () => {
expect(screen.getByText('Block 1')).toBeInTheDocument(); expect(screen.getByText('Block 1')).toBeInTheDocument();
}); });
it('input', () => { it('input', async () => {
render(<App2 />); render(<App2 />);
await waitFor(() => {
const inputs = document.querySelectorAll('.ant-input'); const inputs = document.querySelectorAll('.ant-input');
expect(inputs.length).toBe(3); expect(inputs.length).toBe(3);
}); });
});
it('initializer', () => { it('initializer', () => {
render(<App3 />); render(<App3 />);

View File

@ -1,8 +1,24 @@
import { ISchema } from '@formily/react'; import { ISchema } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { Form, FormItem, Grid, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client'; import {
APIClientProvider,
CurrentUserProvider,
Form,
FormItem,
Grid,
Input,
SchemaComponent,
SchemaComponentProvider,
} from '@nocobase/client';
import React from 'react'; import React from 'react';
import { mockAPIClient } from '../../../../test';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const schema: ISchema = { const schema: ISchema = {
type: 'void', type: 'void',
name: 'grid1', name: 'grid1',
@ -54,8 +70,12 @@ const schema: ISchema = {
export default function App() { export default function App() {
return ( return (
<APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<SchemaComponentProvider components={{ Form, Grid, Input, FormItem }}> <SchemaComponentProvider components={{ Form, Grid, Input, FormItem }}>
<SchemaComponent schema={schema} /> <SchemaComponent schema={schema} />
</SchemaComponentProvider> </SchemaComponentProvider>
</CurrentUserProvider>
</APIClientProvider>
); );
} }

View File

@ -9,6 +9,7 @@ export * from './card-item';
export * from './cascader'; export * from './cascader';
export * from './checkbox'; export * from './checkbox';
export * from './collection-select'; export * from './collection-select';
export * from './color-picker';
export * from './color-select'; export * from './color-select';
export * from './cron'; export * from './cron';
export * from './date-picker'; export * from './date-picker';
@ -49,4 +50,5 @@ export * from './time-picker';
export * from './tree-select'; export * from './tree-select';
export * from './upload'; export * from './upload';
export * from './variable'; export * from './variable';
import './index.less'; import './index.less';

View File

@ -2,8 +2,7 @@ import React from 'react';
import { render, screen, waitFor } from 'testUtils'; import { render, screen, waitFor } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
// TODO: 等 @Testing-Library 升级到 14.x describe('Kanban', () => {
describe.skip('Kanban', () => {
it('should render correctly', async () => { it('should render correctly', async () => {
render(<App1 />); render(<App1 />);

View File

@ -4,6 +4,7 @@ import {
APIClientProvider, APIClientProvider,
BlockSchemaComponentProvider, BlockSchemaComponentProvider,
CollectionManagerProvider, CollectionManagerProvider,
CurrentUserProvider,
SchemaComponent, SchemaComponent,
SchemaComponentProvider, SchemaComponentProvider,
} from '@nocobase/client'; } from '@nocobase/client';
@ -19,6 +20,9 @@ mockRequest.onGet('/t_j6omof6tza8:list').reply(async (config) => {
await sleep(200); await sleep(200);
return [200, data]; return [200, data];
}); });
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const schema: ISchema = { const schema: ISchema = {
type: 'object', type: 'object',
@ -69,6 +73,7 @@ const schema: ISchema = {
export default () => { export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}> <APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<SchemaComponentProvider> <SchemaComponentProvider>
<CollectionManagerProvider collections={collections}> <CollectionManagerProvider collections={collections}>
<AntdSchemaComponentProvider> <AntdSchemaComponentProvider>
@ -78,6 +83,7 @@ export default () => {
</AntdSchemaComponentProvider> </AntdSchemaComponentProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
</SchemaComponentProvider> </SchemaComponentProvider>
</CurrentUserProvider>
</APIClientProvider> </APIClientProvider>
); );
}; };

View File

@ -1,26 +1,32 @@
import React from 'react'; import React from 'react';
import { render, screen, sleep, userEvent, within } from 'testUtils'; import { render, screen, userEvent, waitFor, within } from 'testUtils';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
describe('RecordPicker', () => { describe('RecordPicker', () => {
it('should show selected options', async () => { it('should show selected options', async () => {
render(<App1 />); render(<App1 />);
const selector = document.querySelector('.ant-select-selector') as HTMLElement; let selector;
await waitFor(() => {
selector = document.querySelector('.ant-select-selector') as HTMLElement;
expect(selector).toBeInTheDocument(); expect(selector).toBeInTheDocument();
});
await userEvent.click(selector); await userEvent.click(selector);
await sleep(100); await waitFor(() => {
// 弹窗标题 // 弹窗标题
expect(screen.getByText(/select record/i)).toBeInTheDocument(); expect(screen.queryByText(/select record/i)).toBeInTheDocument();
});
const checkboxes = document.querySelectorAll('.ant-checkbox'); const checkboxes = document.querySelectorAll('.ant-checkbox');
// 第 3 个选项的内容是: “软件开发” // 第 3 个选项的内容是: “软件开发”
await userEvent.click(checkboxes[2]); await userEvent.click(checkboxes[2]);
await userEvent.click(screen.getByText(/submit/i)); await userEvent.click(screen.getByText(/submit/i));
expect(within(selector).getByText(/软件开发/i)).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByText(/软件开发/i, { selector: '.test-record-picker-read-pretty-item' })).toBeInTheDocument(); expect(within(selector).queryByText(/软件开发/i)).toBeInTheDocument();
expect(screen.queryByText(/软件开发/i, { selector: '.test-record-picker-read-pretty-item' })).toBeInTheDocument();
});
}); });
}); });

View File

@ -9,6 +9,7 @@ import {
BlockItem, BlockItem,
CollectionField, CollectionField,
CollectionManagerProvider, CollectionManagerProvider,
CurrentUserProvider,
FormItem, FormItem,
Input, Input,
RecordPicker, RecordPicker,
@ -23,6 +24,9 @@ import data from './mockData';
const { apiClient, mockRequest } = mockAPIClient(); const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
mockRequest.onGet('/tt_bd_range:list').reply(({ params }) => { mockRequest.onGet('/tt_bd_range:list').reply(({ params }) => {
// 已选中的 id // 已选中的 id
const ids = JSON.parse(params.filter).$and?.[0]?.['id.$ne'] || []; const ids = JSON.parse(params.filter).$and?.[0]?.['id.$ne'] || [];
@ -172,11 +176,13 @@ export default () => {
return ( return (
<APIClientProvider apiClient={apiClient}> <APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<CollectionManagerProvider collections={mainCollections}> <CollectionManagerProvider collections={mainCollections}>
<SchemaComponentProvider components={components}> <SchemaComponentProvider components={components}>
<SchemaComponent schema={schema} /> <SchemaComponent schema={schema} />
</SchemaComponentProvider> </SchemaComponentProvider>
</CollectionManagerProvider> </CollectionManagerProvider>
</CurrentUserProvider>
</APIClientProvider> </APIClientProvider>
); );
}; };

View File

@ -27,6 +27,22 @@ const useLabelFields = (collectionName?: any) => {
}); });
}; };
export const useColorFields = (collectionName?: any) => {
const compile = useCompile();
const { getCollectionFields } = useCollectionManager();
if (!collectionName) {
return [];
}
const targetFields = getCollectionFields(collectionName);
return targetFields
?.filter?.((field) => field?.interface === 'color')
?.map?.((field) => {
return {
value: field.name,
label: compile(field?.uiSchema?.title || field.name),
};
});
};
export const TableColumnDesigner = (props) => { export const TableColumnDesigner = (props) => {
const { uiSchema, fieldSchema, collectionField } = props; const { uiSchema, fieldSchema, collectionField } = props;
const { getInterface, getCollection } = useCollectionManager(); const { getInterface, getCollection } = useCollectionManager();
@ -37,6 +53,7 @@ export const TableColumnDesigner = (props) => {
const fieldNames = const fieldNames =
fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames']; fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames'];
const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection); const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection);
const colorFieldOptions = useColorFields(collectionField?.target ?? collectionField?.targetCollection);
const intefaceCfg = getInterface(collectionField?.interface); const intefaceCfg = getInterface(collectionField?.interface);
const targetCollection = getCollection(collectionField?.target); const targetCollection = getCollection(collectionField?.target);
const isFileField = isFileCollection(targetCollection); const isFileField = isFileCollection(targetCollection);
@ -45,6 +62,7 @@ export const TableColumnDesigner = (props) => {
const defaultFilter = fieldSchema?.['x-component-props']?.service?.params?.filter || {}; const defaultFilter = fieldSchema?.['x-component-props']?.service?.params?.filter || {};
const dataSource = useCollectionFilterOptions(collectionField?.target); const dataSource = useCollectionFilterOptions(collectionField?.target);
const isDateField = ['datetime', 'createdAt', 'updatedAt'].includes(collectionField?.interface); const isDateField = ['datetime', 'createdAt', 'updatedAt'].includes(collectionField?.interface);
const fieldMode = fieldSchema?.['x-component-props']?.['mode'] || 'Select';
let readOnlyMode = 'editable'; let readOnlyMode = 'editable';
if (fieldSchema['x-disabled'] === true) { if (fieldSchema['x-disabled'] === true) {
readOnlyMode = 'readonly'; readOnlyMode = 'readonly';
@ -232,6 +250,55 @@ export const TableColumnDesigner = (props) => {
}} }}
/> />
)} )}
{readOnlyMode === 'read-pretty' &&
['linkTo', 'm2m', 'm2o', 'o2m', 'obo', 'oho', 'snapshot'].includes(collectionField?.interface) && (
<SchemaSettings.SelectItem
key="field-mode"
title={t('Field component')}
options={[
{ label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
]}
value={fieldMode}
onChange={(mode) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['mode'] = mode;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps = field.componentProps || {};
field.componentProps.mode = mode;
dn.emit('patch', {
schema,
});
dn.refresh();
}}
/>
)}
{['Tag'].includes(fieldMode) && (
<SchemaSettings.SelectItem
key="title-field"
title={t('Tag color field')}
options={colorFieldOptions}
value={fieldSchema?.['x-component-props']?.tagColorField}
onChange={(tagColorField) => {
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
fieldSchema['x-component-props'] = fieldSchema['x-component-props'] || {};
fieldSchema['x-component-props']['tagColorField'] = tagColorField;
schema['x-component-props'] = fieldSchema['x-component-props'];
field.componentProps.tagColorField = tagColorField;
dn.emit('patch', {
schema,
});
dn.refresh();
}}
/>
)}
{isSubTableColumn && !field.readPretty && !uiSchema?.['x-read-pretty'] && ( {isSubTableColumn && !field.readPretty && !uiSchema?.['x-read-pretty'] && (
<SchemaSettings.SwitchItem <SchemaSettings.SwitchItem

View File

@ -297,7 +297,6 @@ export const Table: any = observer(
console.warn('move cancel'); console.warn('move cancel');
return; return;
} }
const fromIndex = e.active?.data.current?.sortable?.index; const fromIndex = e.active?.data.current?.sortable?.index;
const toIndex = e.over?.data.current?.sortable?.index; const toIndex = e.over?.data.current?.sortable?.index;
const from = field.value[fromIndex]; const from = field.value[fromIndex];
@ -324,6 +323,11 @@ export const Table: any = observer(
.nb-read-pretty-input-number { .nb-read-pretty-input-number {
text-align: right; text-align: right;
} }
.ant-color-picker-trigger{
position:absolute;
top:50%;
transform: translateY(-50%);
}
`, `,
)} )}
/> />

View File

@ -14,18 +14,18 @@ type VariablesCtx = {
}; };
export const useVariablesCtx = (): VariablesCtx => { export const useVariablesCtx = (): VariablesCtx => {
const { data } = useCurrentUserContext() || {}; const currentUser = useCurrentUserContext();
const { field, service, rowKey } = useTableBlockContext(); const { field, service, rowKey } = useTableBlockContext();
const contextData = service?.data?.data?.filter((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey])); const contextData = service?.data?.data?.filter((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey]));
return useMemo(() => { return useMemo(() => {
return { return {
$user: data?.data || {}, $user: currentUser?.data?.data || {},
$date: { $date: {
now: () => dayjs().toISOString(), now: () => dayjs().toISOString(),
}, },
$context: contextData, $context: contextData,
}; };
}, [data]); }, [contextData, currentUser?.data?.data]);
}; };
export const isVariable = (str: unknown) => { export const isVariable = (str: unknown) => {

View File

@ -24,6 +24,7 @@ export const useFieldModeOptions = () => {
? [ ? [
{ label: t('Title'), value: 'Select' }, { label: t('Title'), value: 'Select' },
{ label: t('File manager'), value: 'FileManager' }, { label: t('File manager'), value: 'FileManager' },
{ label: t('Tag'), value: 'Tag' },
] ]
: [ : [
{ label: t('Select'), value: 'Select' }, { label: t('Select'), value: 'Select' },
@ -37,6 +38,7 @@ export const useFieldModeOptions = () => {
return isReadPretty return isReadPretty
? [ ? [
{ label: t('Title'), value: 'Select' }, { label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
{ label: t('Sub-table'), value: 'SubTable' }, { label: t('Sub-table'), value: 'SubTable' },
{ label: t('Sub-details'), value: 'Nester' }, { label: t('Sub-details'), value: 'Nester' },
] ]
@ -50,6 +52,7 @@ export const useFieldModeOptions = () => {
return isReadPretty return isReadPretty
? [ ? [
{ label: t('Title'), value: 'Select' }, { label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
{ label: t('Sub-details'), value: 'Nester' }, { label: t('Sub-details'), value: 'Nester' },
{ label: t('Sub-table'), value: 'SubTable' }, { label: t('Sub-table'), value: 'SubTable' },
] ]
@ -64,6 +67,7 @@ export const useFieldModeOptions = () => {
return isReadPretty return isReadPretty
? [ ? [
{ label: t('Title'), value: 'Select' }, { label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
{ label: t('Sub-details'), value: 'Nester' }, { label: t('Sub-details'), value: 'Nester' },
] ]
: [ : [
@ -76,6 +80,7 @@ export const useFieldModeOptions = () => {
return isReadPretty return isReadPretty
? [ ? [
{ label: t('Title'), value: 'Select' }, { label: t('Title'), value: 'Select' },
{ label: t('Tag'), value: 'Tag' },
{ label: t('Sub-details'), value: 'Nester' }, { label: t('Sub-details'), value: 'Nester' },
] ]
: [ : [

View File

@ -13,6 +13,7 @@ import { AsDefaultTemplate } from './components/AsDefaultTemplate';
import { ArrayCollapse } from './components/DataTemplateTitle'; import { ArrayCollapse } from './components/DataTemplateTitle';
import { getSelectedIdFilter } from './components/Designer'; import { getSelectedIdFilter } from './components/Designer';
import { useCollectionState } from './hooks/useCollectionState'; import { useCollectionState } from './hooks/useCollectionState';
import { useSyncFromForm } from './utils';
const Tree = connect( const Tree = connect(
AntdTree, AntdTree,
@ -48,7 +49,6 @@ export const FormDataTemplates = observer(
} = useCollectionState(collectionName); } = useCollectionState(collectionName);
const { getCollection, getCollectionField } = useCollectionManager(); const { getCollection, getCollectionField } = useCollectionManager();
const { t } = useTranslation(); const { t } = useTranslation();
// 不要在后面的数组中依赖 defaultValues否则会因为 defaultValues 的变化导致 activeData 响应性丢失 // 不要在后面的数组中依赖 defaultValues否则会因为 defaultValues 的变化导致 activeData 响应性丢失
const activeData = useMemo<ITemplate>( const activeData = useMemo<ITemplate>(
() => () =>
@ -61,7 +61,6 @@ export const FormDataTemplates = observer(
), ),
[], [],
); );
console.log(activeData);
const getTargetField = (collectionName: string) => { const getTargetField = (collectionName: string) => {
const collection = getCollection(collectionName); const collection = getCollection(collectionName);
return getCollectionField( return getCollectionField(
@ -170,6 +169,16 @@ export const FormDataTemplates = observer(
required: true, required: true,
'x-reactions': '{{useTitleFieldDataSource}}', 'x-reactions': '{{useTitleFieldDataSource}}',
}, },
syncFromForm: {
type: 'void',
title: '{{ t("Sync from form fields") }}',
'x-component': 'Action.Link',
'x-component-props': {
type: 'primary',
style: { float: 'right', position: 'relative', zIndex: 1200 },
useAction: () => useSyncFromForm(formSchema),
},
},
fields: { fields: {
type: 'array', type: 'array',
title: '{{ t("Data fields") }}', title: '{{ t("Data fields") }}',

View File

@ -48,10 +48,9 @@ const DataTemplateTitle = observer<{ index: number; item: any }>((props) => {
export interface IArrayCollapseProps extends CollapseProps { export interface IArrayCollapseProps extends CollapseProps {
defaultOpenPanelCount?: number; defaultOpenPanelCount?: number;
} }
type ComposedArrayCollapse = type ComposedArrayCollapse = React.FC<React.PropsWithChildren<IArrayCollapseProps>> & {
| React.FC<React.PropsWithChildren<IArrayCollapseProps>> & {
CollapsePanel?: React.FC<React.PropsWithChildren<CollapsePanelProps>>; CollapsePanel?: React.FC<React.PropsWithChildren<CollapsePanelProps>>;
}; };
const isAdditionComponent = (schema: ISchema) => { const isAdditionComponent = (schema: ISchema) => {
return schema['x-component']?.indexOf?.('Addition') > -1; return schema['x-component']?.indexOf?.('Addition') > -1;
@ -218,6 +217,9 @@ export const ArrayCollapse: ComposedArrayCollapse = observer(
onAdd={(index) => { onAdd={(index) => {
setActiveKeys(insertActiveKeys(activeKeys, index)); setActiveKeys(insertActiveKeys(activeKeys, index));
}} }}
onRemove={() => {
field.initialValue = field.value;
}}
> >
{renderEmpty()} {renderEmpty()}
{renderItems()} {renderItems()}

View File

@ -1,22 +1,12 @@
import { ArrayField } from '@formily/core'; import { ArrayField } from '@formily/core';
import { useField } from '@formily/react';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useCollectionManager } from '../../../collection-manager'; import { useCollectionManager } from '../../../collection-manager';
import { isTitleField } from '../../../collection-manager/Configuration/CollectionFields';
import { useCompile } from '../../../schema-component'; import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel'; import { TreeNode } from '../TreeLabel';
export const useCollectionState = (currentCollectionName: string) => { // 过滤掉系统字段
const { getCollectionFields, getAllCollectionsInheritChain, getCollection, getInterface } = useCollectionManager(); export const systemKeys = [
const [collectionList] = useState(getCollectionList);
const compile = useCompile();
function getCollectionList() {
const collections = getAllCollectionsInheritChain(currentCollectionName);
return collections.map((name) => ({ label: getCollection(name)?.title, value: name }));
}
// 过滤掉系统字段
const systemKeys = [
// 'id', // 'id',
'sort', 'sort',
'createdById', 'createdById',
@ -25,7 +15,19 @@ export const useCollectionState = (currentCollectionName: string) => {
'updatedById', 'updatedById',
'updatedBy', 'updatedBy',
'updatedAt', 'updatedAt',
]; 'password',
'sequence',
];
export const useCollectionState = (currentCollectionName: string) => {
const { getCollectionFields, getAllCollectionsInheritChain, getCollection, getInterface } = useCollectionManager();
const [collectionList] = useState(getCollectionList);
const compile = useCompile();
const templateField: any = useField();
function getCollectionList() {
const collections = getAllCollectionsInheritChain(currentCollectionName);
return collections.map((name) => ({ label: getCollection(name)?.title, value: name }));
}
/** /**
* maxDepth: 0 0 1 * maxDepth: 0 0 1
@ -115,12 +117,25 @@ export const useCollectionState = (currentCollectionName: string) => {
}) })
.filter(Boolean); .filter(Boolean);
}; };
const parseTreeData = (data) => {
return data.map((v) => {
return {
...v,
title: React.createElement(TreeNode, { ...v, type: v.type }),
children: v.children ? parseTreeData(v.children) : null,
};
});
};
const getEnableFieldTree = useCallback((collectionName: string) => { const getEnableFieldTree = useCallback((collectionName: string, field, treeData?) => {
const index = field.index;
const targetTemplate = templateField.initialValue?.items?.[index];
if (!collectionName) { if (!collectionName) {
return []; return [];
} }
if (targetTemplate?.treeData || treeData) {
return parseTreeData(treeData || targetTemplate.treeData);
}
try { try {
return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 1 }); return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 1 });
} catch (error) { } catch (error) {
@ -242,9 +257,8 @@ function findNode(treeData, item) {
} }
function loadChildren({ node, traverseAssociations, traverseFields, systemKeys, fields }) { function loadChildren({ node, traverseAssociations, traverseFields, systemKeys, fields }) {
const activeNode = findNode(fields.componentProps.treeData, node); const activeNode = findNode(fields.dataSource || fields.componentProps.treeData, node);
let children = []; let children = [];
// 多对多和多对一只展示关系字段 // 多对多和多对一只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(node.field.type)) { if (['belongsTo', 'belongsToMany'].includes(node.field.type)) {
children = traverseAssociations(node.field.target, { children = traverseAssociations(node.field.target, {

View File

@ -0,0 +1,232 @@
import { ArrayBase } from '@formily/antd-v5';
import { useForm } from '@formily/react';
import { message } from 'antd';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getAssociationPath } from '../../block-provider/hooks';
import { useCollectionManager } from '../../collection-manager';
import { useCompile } from '../../schema-component';
import { TreeNode } from './TreeLabel';
import { systemKeys } from './hooks/useCollectionState';
import LRUCache from 'lru-cache';
export const useSyncFromForm = (fieldSchema, collection?, callBack?) => {
const { getCollectionJoinField, getCollectionFields } = useCollectionManager();
const array = ArrayBase.useArray();
const index = ArrayBase.useIndex();
const record = ArrayBase.useRecord();
const compile = useCompile();
const { t } = useTranslation();
const from = useForm();
const traverseFields = ((cache) => {
return (collectionName, { exclude = [], depth = 0, maxDepth, prefix = '', disabled = false }, formData) => {
const cacheKey = `${collectionName}-${exclude.join(',')}-${depth}-${maxDepth}-${prefix}`;
const cachedResult = cache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
if (depth > maxDepth) {
return [];
}
const result = getCollectionFields(collectionName)
.map((field) => {
if (exclude.includes(field.name)) {
return;
}
if (!field.interface) {
return;
}
if (['sort', 'password', 'sequence'].includes(field.type)) {
return;
}
const node = {
type: 'duplicate',
tag: compile(field.uiSchema?.title) || field.name,
};
const option = {
...node,
title: React.createElement(TreeNode, node),
key: prefix ? `${prefix}.${field.name}` : field.name,
isLeaf: true,
field,
disabled,
};
const tatgetFormField = formData.find((v) => v.name === field.name);
if (
['belongsTo', 'belongsToMany'].includes(field.type) &&
(!tatgetFormField || ['Select', 'Picker'].includes(tatgetFormField?.fieldMode))
) {
node['type'] = 'reference';
option['type'] = 'reference';
option['title'] = React.createElement(TreeNode, { ...node, type: 'reference' });
option.isLeaf = false;
option['children'] = traverseAssociations(field.target, {
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: systemKeys,
});
} else if (
['hasOne', 'hasMany'].includes(field.type) ||
['Nester', 'SubTable'].includes(tatgetFormField?.fieldMode)
) {
let childrenDisabled = false;
if (
['hasOne', 'hasMany'].includes(field.type) &&
['Select', 'Picker'].includes(tatgetFormField?.fieldMode)
) {
childrenDisabled = true;
}
option.disabled = true;
option.isLeaf = false;
option['children'] = traverseFields(
field.target,
{
depth: depth + 1,
maxDepth,
prefix: option.key,
exclude: ['id', ...systemKeys],
disabled: childrenDisabled,
},
formData,
);
}
return option;
})
.filter(Boolean);
cache.set(cacheKey, result);
return result;
};
})(
new LRUCache<string, any>({ max: 100 }),
);
const traverseAssociations = ((cache) => {
return (collectionName, { prefix, maxDepth, depth = 0, exclude = [] }) => {
const cacheKey = `${collectionName}-${exclude.join(',')}-${depth}-${maxDepth}-${prefix}`;
const cachedResult = cache.get(cacheKey);
if (cachedResult) {
return cachedResult;
}
if (depth > maxDepth) {
return [];
}
const result = getCollectionFields(collectionName)
.map((field) => {
if (!field.target || !field.interface) {
return;
}
if (exclude.includes(field.name)) {
return;
}
const option = {
type: 'preloading',
tag: compile(field.uiSchema?.title) || field.name,
};
const value = prefix ? `${prefix}.${field.name}` : field.name;
return {
type: 'preloading',
tag: compile(field.uiSchema?.title) || field.name,
title: React.createElement(TreeNode, option),
key: value,
isLeaf: false,
field,
children: traverseAssociations(field.target, {
prefix: value,
depth: depth + 1,
maxDepth,
exclude,
}),
};
})
.filter(Boolean);
cache.set(cacheKey, result);
return result;
};
})(
new LRUCache<string, any>({ max: 100 }),
);
const getEnableFieldTree = useCallback((collectionName: string, formData) => {
if (!collectionName) {
return [];
}
try {
return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 1, disabled: false }, formData);
} catch (error) {
console.error(error);
return [];
}
}, []);
return {
async run() {
const formData = new Set([]);
const selectFields = new Set([]);
const getAssociationAppends = (schema, str) => {
schema.reduceProperties((pre, s) => {
const prefix = pre || str;
const collectionfield = s['x-collection-field'] && getCollectionJoinField(s['x-collection-field']);
const isAssociationSubfield = s.name.includes('.');
const isAssociationField =
collectionfield && ['hasOne', 'hasMany', 'belongsTo', 'belongsToMany'].includes(collectionfield.type);
const fieldPath = !isAssociationField && isAssociationSubfield ? getAssociationPath(s.name) : s.name;
const path = prefix === '' || !prefix ? fieldPath : prefix + '.' + fieldPath;
if (
collectionfield &&
!(
['hasOne', 'hasMany'].includes(collectionfield.type) ||
['SubForm', 'Nester'].includes(s['x-component-props']?.mode)
)
) {
selectFields.add(path);
}
if (collectionfield && (isAssociationField || isAssociationSubfield) && s['x-component'] !== 'TableField') {
formData.add({ name: path, fieldMode: s['x-component-props']['mode'] || 'Select' });
if (['Nester', 'SubTable'].includes(s['x-component-props']?.mode)) {
const bufPrefix = prefix && prefix !== '' ? prefix + '.' + s.name : s.name;
getAssociationAppends(s, bufPrefix);
}
} else if (
![
'ActionBar',
'Action',
'Action.Link',
'Action.Modal',
'Selector',
'Viewer',
'AddNewer',
'AssociationField.Selector',
'AssociationField.AddNewer',
'TableField',
].includes(s['x-component'])
) {
getAssociationAppends(s, str);
}
}, str);
};
getAssociationAppends(fieldSchema, '');
const treeData = getEnableFieldTree(record?.collection || collection, [...formData]);
if (callBack) {
callBack(treeData, [...selectFields], from);
} else {
array?.field.form.query(`fieldReaction.items.${index}.layout.fields`).take((f: any) => {
f.componentProps.treeData = [];
setTimeout(() => (f.componentProps.treeData = treeData));
});
array?.field.value.splice(index, 1, {
...array?.field?.value[index],
fields: [...selectFields],
treeData: treeData,
});
}
message.success(t('Sync successfully'));
},
};
};

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { ArrayCollapse, ArrayItems, FormItem, FormLayout, Input } from '@formily/antd-v5'; import { ArrayCollapse, ArrayItems, FormItem, FormLayout, Input } from '@formily/antd-v5';
import { Field, GeneralField, createForm } from '@formily/core'; import { createForm, Field, GeneralField } from '@formily/core';
import { ISchema, Schema, SchemaOptionsContext, useField, useFieldSchema, useForm } from '@formily/react'; import { ISchema, Schema, SchemaOptionsContext, useField, useFieldSchema, useForm } from '@formily/react';
import { uid } from '@formily/shared'; import { uid } from '@formily/shared';
import { error } from '@nocobase/utils/client'; import { error } from '@nocobase/utils/client';
@ -21,32 +21,32 @@ import {
} from 'antd'; } from 'antd';
import _, { cloneDeep } from 'lodash'; import _, { cloneDeep } from 'lodash';
import React, { import React, {
ReactNode,
createContext, createContext,
ReactNode,
useCallback, useCallback,
useContext, useContext,
useMemo, useMemo,
useState,
// @ts-ignore // @ts-ignore
useTransition as useReactTransition, useTransition as useReactTransition,
useState,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
APIClientProvider,
ActionContextProvider, ActionContextProvider,
APIClientProvider,
CollectionFieldOptions, CollectionFieldOptions,
CollectionManagerContext, CollectionManagerContext,
CollectionProvider, CollectionProvider,
createDesignable,
Designable, Designable,
findFormBlock,
FormDialog, FormDialog,
FormProvider, FormProvider,
RemoteSchemaComponent, RemoteSchemaComponent,
SchemaComponent, SchemaComponent,
SchemaComponentContext, SchemaComponentContext,
SchemaComponentOptions, SchemaComponentOptions,
createDesignable,
findFormBlock,
useAPIClient, useAPIClient,
useBlockRequestContext, useBlockRequestContext,
useCollection, useCollection,
@ -1146,7 +1146,6 @@ SchemaSettings.DataTemplates = function DataTemplates(props) {
const { t } = useTranslation(); const { t } = useTranslation();
const formSchema = findFormBlock(fieldSchema) || fieldSchema; const formSchema = findFormBlock(fieldSchema) || fieldSchema;
const { templateData } = useDataTemplates(); const { templateData } = useDataTemplates();
const schema = useMemo( const schema = useMemo(
() => ({ () => ({
type: 'object', type: 'object',
@ -1171,7 +1170,6 @@ SchemaSettings.DataTemplates = function DataTemplates(props) {
); );
const onSubmit = useCallback((v) => { const onSubmit = useCallback((v) => {
const data = { ...(formSchema['x-data-templates'] || {}), ...v.fieldReaction }; const data = { ...(formSchema['x-data-templates'] || {}), ...v.fieldReaction };
// 当 Tree 组件开启 checkStrictly 属性时,会导致 checkedKeys 的值是一个对象,而不是数组,所以这里需要转换一下以支持旧版本 // 当 Tree 组件开启 checkStrictly 属性时,会导致 checkedKeys 的值是一个对象,而不是数组,所以这里需要转换一下以支持旧版本
data.items.forEach((item) => { data.items.forEach((item) => {
item.fields = Array.isArray(item.fields) ? item.fields : item.fields.checked; item.fields = Array.isArray(item.fields) ? item.fields : item.fields.checked;

View File

@ -1,6 +1,6 @@
{ {
"name": "create-nocobase-app", "name": "create-nocobase-app",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "src/index.js", "main": "src/index.js",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {

View File

@ -1,13 +1,13 @@
{ {
"name": "@nocobase/database", "name": "@nocobase/database",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@nocobase/logger": "0.11.1-alpha.3", "@nocobase/logger": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"async-mutex": "^0.3.2", "async-mutex": "^0.3.2",
"cron-parser": "4.4.0", "cron-parser": "4.4.0",
"dayjs": "^1.11.8", "dayjs": "^1.11.8",

View File

@ -112,81 +112,115 @@ describe('option parser', () => {
]); ]);
}); });
test('option parser with fields option', async () => { describe('options parser with fields option', () => {
let options: any = { it('should handle field and association', () => {
const options: any = {
fields: ['id', 'posts'], fields: ['id', 'posts'],
}; };
// 转换为 attributes: ['id'], include: [{association: 'posts'}] // 转换为 attributes: ['id'], include: [{association: 'posts'}]
let parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
let params = parser.toSequelizeParams();
const params = parser.toSequelizeParams();
console.log(params);
expect(params['attributes']).toContain('id'); expect(params['attributes']).toContain('id');
expect(params['include'][0]['association']).toEqual('posts'); expect(params['include'][0]['association']).toEqual('posts');
});
// only appends it('should handle field with association', () => {
options = { const options = {
appends: ['posts'], appends: ['posts'],
}; };
parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
params = parser.toSequelizeParams(); const params = parser.toSequelizeParams();
expect(params['attributes']['include']).toEqual([]); expect(params['attributes']['include']).toEqual([]);
expect(params['include'][0]['association']).toEqual('posts'); expect(params['include'][0]['association']).toEqual('posts');
});
it('should handle field with association field', () => {
// fields with association field // fields with association field
options = { const options = {
fields: ['id', 'posts.title'], fields: ['id', 'posts.title'],
}; };
parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
params = parser.toSequelizeParams();
const params = parser.toSequelizeParams();
expect(params['attributes']).toContain('id'); expect(params['attributes']).toContain('id');
expect(params['include'][0]['association']).toEqual('posts'); expect(params['include'][0]['association']).toEqual('posts');
expect(params['include'][0]['attributes']).toContain('title'); expect(params['include'][0]['attributes']).toContain('title');
});
it('should handle nested fields option', () => {
const options = {
fields: ['posts', 'posts.title'],
};
const parser = new OptionsParser(options, {
collection: User,
});
const params = parser.toSequelizeParams();
const postAssociationParams = params['include'][0];
expect(postAssociationParams['attributes']).toEqual({ include: [] });
});
it('should handle fields with association & association field', () => {
// fields with nested field // fields with nested field
options = { const options = {
fields: ['id', 'posts', 'posts.comments.content'], fields: ['id', 'posts', 'posts.comments.content'],
}; };
parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
params = parser.toSequelizeParams();
expect(params['attributes']).toContain('id');
expect(params['include'][0]['association']).toEqual('posts');
expect(params['include'][0]['attributes']).toEqual({ include: [] });
expect(params['include'][0]['include'][0]['association']).toEqual('comments');
const params = parser.toSequelizeParams();
const postAssociationParams = params['include'][0];
expect(params['attributes']).toContain('id');
expect(postAssociationParams['association']).toEqual('posts');
expect(postAssociationParams['attributes']).toEqual({ include: [] });
expect(postAssociationParams['include'][0]['association']).toEqual('comments');
});
it('should handle except option', () => {
// fields with expect // fields with expect
options = { const options = {
except: ['id'], except: ['id'],
}; };
parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
params = parser.toSequelizeParams();
expect(params['attributes']['exclude']).toContain('id');
const params = parser.toSequelizeParams();
expect(params['attributes']['exclude']).toContain('id');
});
it('should handle fields with except option', () => {
// expect with association // expect with association
options = { const options = {
fields: ['posts'], fields: ['posts'],
except: ['posts.id'], except: ['posts.id'],
}; };
parser = new OptionsParser(options, { const parser = new OptionsParser(options, {
collection: User, collection: User,
}); });
params = parser.toSequelizeParams(); const params = parser.toSequelizeParams();
expect(params['include'][0]['attributes']['exclude']).toContain('id'); expect(params['include'][0]['attributes']['exclude']).toContain('id');
}); });
});
test('option parser with multiple association', () => { test('option parser with multiple association', () => {
// fields with association field // fields with association field

View File

@ -378,23 +378,33 @@ describe('repository find', () => {
}); });
}); });
describe('find with fields', () => {
it('should only output filed in fields args', async () => { it('should only output filed in fields args', async () => {
const resp = await User.model.findOne({
attributes: [],
include: [
{
association: 'profile',
attributes: ['salary'],
},
],
});
const users = await User.repository.find({ const users = await User.repository.find({
fields: ['profile', 'profile.salary', 'profile.id'], fields: ['profile.salary'],
}); });
const firstUser = users[0].toJSON(); const firstUser = users[0].toJSON();
expect(Object.keys(firstUser)).toEqual(['profile']); expect(Object.keys(firstUser)).toEqual(['profile']);
expect(Object.keys(firstUser.profile)).toEqual(['salary']);
});
it('should output all fields when field has relation field', async () => {
const users = await User.repository.find({
fields: ['profile.salary', 'profile'],
});
const firstUser = users[0].toJSON();
expect(Object.keys(firstUser)).toEqual(['profile']);
expect(Object.keys(firstUser.profile)).toEqual([
'id',
'createdAt',
'updatedAt',
'salary',
'userId',
'description',
]);
});
}); });
it('append with associations', async () => { it('append with associations', async () => {

View File

@ -18,8 +18,14 @@ pgOnly()('', () => {
await db.close(); await db.close();
}); });
it('should skip on delete on view collection', async () => { describe('view as through table', () => {
const Order = db.collection({ let Order;
let OrderItem;
let Item;
let OrderItemView;
beforeEach(async () => {
Order = db.collection({
name: 'orders', name: 'orders',
fields: [ fields: [
{ {
@ -35,7 +41,7 @@ pgOnly()('', () => {
], ],
}); });
const OrderItem = db.collection({ OrderItem = db.collection({
name: 'orderItems', name: 'orderItems',
timestamps: false, timestamps: false,
fields: [ fields: [
@ -59,7 +65,7 @@ pgOnly()('', () => {
], ],
}); });
const Item = db.collection({ Item = db.collection({
name: 'items', name: 'items',
fields: [{ name: 'name', type: 'string' }], fields: [{ name: 'name', type: 'string' }],
}); });
@ -71,11 +77,11 @@ pgOnly()('', () => {
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`; const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
await db.sequelize.query(dropViewSQL); await db.sequelize.query(dropViewSQL);
const viewSQL = `CREATE VIEW ${viewName} as SELECT orders.*, items.name as item_name FROM ${OrderItem.quotedTableName()} as orders INNER JOIN ${Item.quotedTableName()} as items ON orders.item_id = items.id`; const viewSQL = `CREATE VIEW ${viewName} as SELECT order_item.order_id as order_id, order_item.item_id as item_id, items.name as item_name FROM ${OrderItem.quotedTableName()} as order_item INNER JOIN ${Item.quotedTableName()} as items ON order_item.item_id = items.id`;
await db.sequelize.query(viewSQL); await db.sequelize.query(viewSQL);
const OrderItemView = db.collection({ OrderItemView = db.collection({
name: viewName, name: viewName,
view: true, view: true,
schema: db.inDialect('postgres') ? 'public' : undefined, schema: db.inDialect('postgres') ? 'public' : undefined,
@ -96,7 +102,7 @@ pgOnly()('', () => {
Order.setField('items', { Order.setField('items', {
type: 'belongsToMany', type: 'belongsToMany',
target: 'orderItems', target: 'items',
through: viewName, through: viewName,
foreignKey: 'order_id', foreignKey: 'order_id',
otherKey: 'item_id', otherKey: 'item_id',
@ -107,7 +113,7 @@ pgOnly()('', () => {
await db.sync(); await db.sync();
const order1 = await db.getRepository('orders').create({ await db.getRepository('orders').create({
values: { values: {
name: 'order1', name: 'order1',
orderItems: [ orderItems: [
@ -126,6 +132,10 @@ pgOnly()('', () => {
], ],
}, },
}); });
});
it('should skip on delete on view collection', async () => {
const order1 = await db.getRepository('orders').findOne({});
const item1 = await db.getRepository('items').findOne({ const item1 = await db.getRepository('items').findOne({
filter: { filter: {
@ -145,6 +155,20 @@ pgOnly()('', () => {
expect(error).toBeUndefined(); expect(error).toBeUndefined();
}); });
it('should filter by view collection as through table', async () => {
const orders = await db.getRepository('orders').find({
appends: ['items'],
filter: {
items: {
name: 'not exists',
},
},
});
expect(orders).toHaveLength(0);
});
});
it('should update view collection', async () => { it('should update view collection', async () => {
const UserCollection = db.collection({ const UserCollection = db.collection({
name: 'users', name: 'users',

View File

@ -3,6 +3,11 @@ import { BaseColumnFieldOptions, Field } from './field';
export class JsonField extends Field { export class JsonField extends Field {
get dataType() { get dataType() {
const dialect = this.context.database.sequelize.getDialect();
const { jsonb } = this.options;
if (dialect === 'postgres' && jsonb) {
return DataTypes.JSONB;
}
return DataTypes.JSON; return DataTypes.JSON;
} }
} }

View File

@ -240,6 +240,9 @@ export class OptionsParser {
protected parseAppends(appends: Appends, filterParams: any) { protected parseAppends(appends: Appends, filterParams: any) {
if (!appends) return filterParams; if (!appends) return filterParams;
// sort appends by path length
appends = lodash.sortBy(appends, (append) => append.split('.').length);
/** /**
* set include params * set include params
* @param model * @param model
@ -287,6 +290,25 @@ export class OptionsParser {
// if include from filter, remove fromFilter attribute // if include from filter, remove fromFilter attribute
if (existIncludeIndex != -1) { if (existIncludeIndex != -1) {
delete queryParams['include'][existIncludeIndex]['fromFilter']; delete queryParams['include'][existIncludeIndex]['fromFilter'];
// set include attributes to all attributes
if (
Array.isArray(queryParams['include'][existIncludeIndex]['attributes']) &&
queryParams['include'][existIncludeIndex]['attributes'].length == 0
) {
queryParams['include'][existIncludeIndex]['attributes'] = {
include: [],
};
}
}
if (
lastLevel &&
existIncludeIndex != -1 &&
lodash.get(queryParams, ['include', existIncludeIndex, 'attributes', 'include'])?.length == 0
) {
// if append is last level and association exists, ignore it
return;
} }
// if association not exist, create it // if association not exist, create it

View File

@ -1,12 +1,12 @@
{ {
"name": "@nocobase/devtools", "name": "@nocobase/devtools",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./src/index.js", "main": "./src/index.js",
"dependencies": { "dependencies": {
"@nocobase/build": "0.11.1-alpha.3", "@nocobase/build": "0.11.1-alpha.5",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^14.0.0",
"@types/jest": "^29.0.0", "@types/jest": "^29.0.0",
"@types/koa": "^2.13.4", "@types/koa": "^2.13.4",
"@types/koa-bodyparser": "^4.3.4", "@types/koa-bodyparser": "^4.3.4",

View File

@ -1,13 +1,13 @@
{ {
"name": "@nocobase/evaluators", "name": "@nocobase/evaluators",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@formulajs/formulajs": "4.2.0", "@formulajs/formulajs": "4.2.0",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"mathjs": "^10.6.0" "mathjs": "^10.6.0"
}, },
"repository": { "repository": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/logger", "name": "@nocobase/logger",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "nocobase logging library", "description": "nocobase logging library",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "./lib/index.js", "main": "./lib/index.js",

View File

@ -1,12 +1,12 @@
{ {
"name": "@nocobase/resourcer", "name": "@nocobase/resourcer",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"description": "", "description": "",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"koa-compose": "^4.1.0", "koa-compose": "^4.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/sdk", "name": "@nocobase/sdk",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "lib", "main": "lib",
"module": "es/index.js", "module": "es/index.js",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/server", "name": "@nocobase/server",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "lib/index.js", "main": "lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -8,13 +8,13 @@
"@hapi/topo": "^6.0.0", "@hapi/topo": "^6.0.0",
"@koa/cors": "^3.1.0", "@koa/cors": "^3.1.0",
"@koa/router": "^9.4.0", "@koa/router": "^9.4.0",
"@nocobase/acl": "0.11.1-alpha.3", "@nocobase/acl": "0.11.1-alpha.5",
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/auth": "0.11.1-alpha.3", "@nocobase/auth": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/logger": "0.11.1-alpha.3", "@nocobase/logger": "0.11.1-alpha.5",
"@nocobase/resourcer": "0.11.1-alpha.3", "@nocobase/resourcer": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"chalk": "^4.1.1", "chalk": "^4.1.1",
"commander": "^9.2.0", "commander": "^9.2.0",
"dayjs": "^1.11.8", "dayjs": "^1.11.8",

View File

@ -1,11 +1,11 @@
{ {
"name": "@nocobase/test", "name": "@nocobase/test",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "lib/index.js", "main": "lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@types/supertest": "^2.0.11", "@types/supertest": "^2.0.11",
"mockjs": "^1.1.0", "mockjs": "^1.1.0",
"mysql2": "^2.3.3", "mysql2": "^2.3.3",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/utils", "name": "@nocobase/utils",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "lib/index.js", "main": "lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"license": "Apache-2.0", "license": "Apache-2.0",

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "权限控制", "displayName.zh-CN": "权限控制",
"description": "A simple access control based on roles, resources and actions", "description": "A simple access control based on roles, resources and actions",
"description.zh-CN": "基于角色、资源和操作的权限控制。", "description.zh-CN": "基于角色、资源和操作的权限控制。",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
@ -19,13 +19,13 @@
"client.d.ts" "client.d.ts"
], ],
"devDependencies": { "devDependencies": {
"@nocobase/acl": "0.11.1-alpha.3", "@nocobase/acl": "0.11.1-alpha.5",
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"@types/jsonwebtoken": "^8.5.8", "@types/jsonwebtoken": "^8.5.8",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "API keys", "displayName.zh-CN": "API keys",
"description": "Allow users to use API key to access NocoBase's api", "description": "Allow users to use API key to access NocoBase's api",
"description.zh-CN": "允许用户使用 API key 访问 NocoBase 的 api", "description.zh-CN": "允许用户使用 API key 访问 NocoBase 的 api",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
@ -19,15 +19,15 @@
"client.d.ts" "client.d.ts"
], ],
"devDependencies": { "devDependencies": {
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@formily/shared": "2.2.26", "@formily/shared": "^2.2.27",
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/resourcer": "0.11.1-alpha.3", "@nocobase/resourcer": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"antd": "^5.6.4", "antd": "^5.6.4",
"dayjs": "^1.11.8", "dayjs": "^1.11.8",
"i18next": "^22.4.9", "i18next": "^22.4.9",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/plugin-audit-logs", "name": "@nocobase/plugin-audit-logs",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"displayName": "audit-logs", "displayName": "audit-logs",
"displayName.zh-CN": "审计日志", "displayName.zh-CN": "审计日志",
"description": "audit logs plugin", "description": "audit logs plugin",
@ -20,13 +20,13 @@
"license": "AGPL-3.0", "license": "AGPL-3.0",
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.1.4", "@ant-design/icons": "^5.1.4",
"@formily/antd-v5": "1.1.0-beta.4", "@formily/antd-v5": "^1.1.0",
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@formily/shared": "2.2.26", "@formily/shared": "^2.2.27",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-i18next": "^11.15.1" "react-i18next": "^11.15.1"
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/plugin-auth", "name": "@nocobase/plugin-auth",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
"lib", "lib",
@ -15,20 +15,20 @@
], ],
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.1.4", "@ant-design/icons": "^5.1.4",
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@formily/shared": "2.2.26", "@formily/shared": "^2.2.27",
"@nocobase/auth": "0.11.1-alpha.3", "@nocobase/auth": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@types/cron": "^2.0.1", "@types/cron": "^2.0.1",
"antd": "^5.6.4", "antd": "^5.6.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-i18next": "^11.15.1" "react-i18next": "^11.15.1"
}, },
"dependencies": { "dependencies": {
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"cron": "^2.3.1" "cron": "^2.3.1"
}, },
"displayName": "Authentication", "displayName": "Authentication",

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "图表", "displayName.zh-CN": "图表",
"description": "Out-of-the-box, feature-rich chart plugins.", "description": "Out-of-the-box, feature-rich chart plugins.",
"description.zh-CN": "开箱即用、丰富的报表。", "description.zh-CN": "开箱即用、丰富的报表。",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
"lib", "lib",
@ -22,15 +22,15 @@
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.1.4", "@ant-design/icons": "^5.1.4",
"@formily/antd-v5": "1.1.0-beta.4", "@formily/antd-v5": "^1.1.0",
"@formily/core": "2.2.26", "@formily/core": "^2.2.27",
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@formily/shared": "2.2.26", "@formily/shared": "^2.2.27",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"antd": "^5.6.4", "antd": "^5.6.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-i18next": "^11.15.1", "react-i18next": "^11.15.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/plugin-china-region", "name": "@nocobase/plugin-china-region",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"displayName": "china-region", "displayName": "china-region",
"displayName.zh-CN": "中国行政区", "displayName.zh-CN": "中国行政区",
"description": "Chinese Administrative Division Plugin, including all administrative regions of China.", "description": "Chinese Administrative Division Plugin, including all administrative regions of China.",
@ -22,12 +22,12 @@
"china-division": "^2.4.0" "china-division": "^2.4.0"
}, },
"devDependencies": { "devDependencies": {
"@formily/core": "2.2.26", "@formily/core": "^2.2.27",
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3" "@nocobase/test": "0.11.1-alpha.5"
}, },
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
} }

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "客户端", "displayName.zh-CN": "客户端",
"description": "client", "description": "client",
"description.zh-CN": "客户端。", "description.zh-CN": "客户端。",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
"lib", "lib",
@ -25,11 +25,11 @@
"koa-static": "^5.0.0" "koa-static": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3" "@nocobase/utils": "0.11.1-alpha.5"
}, },
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
} }

View File

@ -5,6 +5,9 @@ export default class extends Migration {
async up() { async up() {
const systemSettings = this.db.getRepository('systemSettings'); const systemSettings = this.db.getRepository('systemSettings');
let instance: Model = await systemSettings.findOne(); let instance: Model = await systemSettings.findOne();
if (instance?.options?.adminSchemaUid) {
return;
}
const uiRoutes = this.db.getRepository('uiRoutes'); const uiRoutes = this.db.getRepository('uiRoutes');
const routes = await uiRoutes.find(); const routes = await uiRoutes.find();
for (const route of routes) { for (const route of routes) {

View File

@ -4,7 +4,7 @@
"displayName.zh-CN": "数据库管理", "displayName.zh-CN": "数据库管理",
"description": " database management plugin designed to simplify the process of managing and operating databases. It seamlessly integrates with various relational database systems such as MySQL and PostgreSQL, and provides an intuitive user interface for performing various database tasks.", "description": " database management plugin designed to simplify the process of managing and operating databases. It seamlessly integrates with various relational database systems such as MySQL and PostgreSQL, and provides an intuitive user interface for performing various database tasks.",
"description.zh-CN": "可以与多种关系型数据库系统如MySQL、PostgreSQL无缝集成并提供直观的用户界面来执行各种数据库任务。", "description.zh-CN": "可以与多种关系型数据库系统如MySQL、PostgreSQL无缝集成并提供直观的用户界面来执行各种数据库任务。",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"main": "./lib/server/index.js", "main": "./lib/server/index.js",
"files": [ "files": [
"lib", "lib",
@ -24,12 +24,12 @@
"toposort": "^2.0.2" "toposort": "^2.0.2"
}, },
"devDependencies": { "devDependencies": {
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/plugin-error-handler": "0.11.1-alpha.3", "@nocobase/plugin-error-handler": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3" "@nocobase/utils": "0.11.1-alpha.5"
}, },
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@nocobase/plugin-data-visualization", "name": "@nocobase/plugin-data-visualization",
"version": "0.11.1-alpha.3", "version": "0.11.1-alpha.5",
"displayName": "Data Visualization", "displayName": "Data Visualization",
"displayName.zh-CN": "数据可视化", "displayName.zh-CN": "数据可视化",
"description": "Provides business intelligence and data visualization features", "description": "Provides business intelligence and data visualization features",
@ -13,18 +13,18 @@
}, },
"devDependencies": { "devDependencies": {
"@ant-design/icons": "^5.1.4", "@ant-design/icons": "^5.1.4",
"@formily/antd-v5": "1.1.0-beta.4", "@formily/antd-v5": "^1.1.0",
"@formily/core": "2.2.26", "@formily/core": "^2.2.27",
"@formily/react": "2.2.26", "@formily/react": "^2.2.27",
"@formily/shared": "2.2.26", "@formily/shared": "^2.2.27",
"@nocobase/actions": "0.11.1-alpha.3", "@nocobase/actions": "0.11.1-alpha.5",
"@nocobase/cache": "0.11.1-alpha.3", "@nocobase/cache": "0.11.1-alpha.5",
"@nocobase/client": "0.11.1-alpha.3", "@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.3", "@nocobase/database": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.3", "@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.3", "@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.3", "@nocobase/utils": "0.11.1-alpha.5",
"@testing-library/react": "^12.1.5", "@testing-library/react": "^14.0.0",
"antd": "^5.6.4", "antd": "^5.6.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -5,7 +5,7 @@ import { Button, Card } from 'antd';
import cls from 'classnames'; import cls from 'classnames';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { useChartsTranslation } from './locale'; import { useChartsTranslation } from './locale';
import { ChartLibraryContext, useToggleChartLibrary } from './renderer'; import { ChartLibraryContext, useToggleChartLibrary } from './chart/library';
export const Settings = () => { export const Settings = () => {
const { t } = useChartsTranslation(); const { t } = useChartsTranslation();

View File

@ -1,5 +1,6 @@
import { Chart } from '../chart/chart';
import { FieldOption } from '../hooks'; import { FieldOption } from '../hooks';
import { infer } from '../renderer'; const chart = new Chart('test', 'Test', null);
describe('library', () => { describe('library', () => {
describe('auto infer', () => { describe('auto infer', () => {
@ -37,7 +38,7 @@ describe('library', () => {
] as FieldOption[]; ] as FieldOption[];
test('1 measure, 1 dimension', () => { test('1 measure, 1 dimension', () => {
const { xField, yField } = infer(fields, { const { xField, yField } = chart.infer(fields, {
measures: [{ field: ['price'] }], measures: [{ field: ['price'] }],
dimensions: [{ field: ['title'] }], dimensions: [{ field: ['title'] }],
}); });
@ -46,7 +47,7 @@ describe('library', () => {
}); });
test('1 measure, 2 dimensions with date', () => { test('1 measure, 2 dimensions with date', () => {
const { xField, yField, seriesField } = infer(fields, { const { xField, yField, seriesField } = chart.infer(fields, {
measures: [{ field: ['price'] }], measures: [{ field: ['price'] }],
dimensions: [{ field: ['title'] }, { field: ['createdAt'] }], dimensions: [{ field: ['title'] }, { field: ['createdAt'] }],
}); });
@ -56,7 +57,7 @@ describe('library', () => {
}); });
test('1 measure, 2 dimensions without date', () => { test('1 measure, 2 dimensions without date', () => {
const { xField, yField, seriesField } = infer(fields, { const { xField, yField, seriesField } = chart.infer(fields, {
measures: [{ field: ['price'] }], measures: [{ field: ['price'] }],
dimensions: [{ field: ['title'] }, { field: ['name'] }], dimensions: [{ field: ['title'] }, { field: ['name'] }],
}); });
@ -66,7 +67,7 @@ describe('library', () => {
}); });
test('2 measures, 1 dimension', () => { test('2 measures, 1 dimension', () => {
const { xField, yField, yFields } = infer(fields, { const { xField, yField, yFields } = chart.infer(fields, {
measures: [{ field: ['price'] }, { field: ['count'] }], measures: [{ field: ['price'] }, { field: ['count'] }],
dimensions: [{ field: ['title'] }], dimensions: [{ field: ['title'] }],
}); });

View File

@ -1,5 +1,5 @@
import * as client from '@nocobase/client'; import * as client from '@nocobase/client';
// import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { vi } from 'vitest'; import { vi } from 'vitest';
import formatters from '../block/formatters'; import formatters from '../block/formatters';
import transformers from '../block/transformers'; import transformers from '../block/transformers';
@ -13,8 +13,7 @@ import {
useTransformers, useTransformers,
} from '../hooks'; } from '../hooks';
// TODO: 为了暂时解决很多 Warning 的问题把 `@testing-library/react` 降级到了 12.x 所以不支持 `renderHook`,等到再次升级到 14.x 时再把 skip 去掉 describe('hooks', () => {
describe.skip('hooks', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(client, 'useCollectionManager').mockReturnValue({ vi.spyOn(client, 'useCollectionManager').mockReturnValue({
getCollectionFields: (name: string) => getCollectionFields: (name: string) =>

View File

@ -17,6 +17,7 @@ import React, { createContext, useContext, useMemo, useRef } from 'react';
import { import {
useChartFields, useChartFields,
useCollectionOptions, useCollectionOptions,
useData,
useFieldTypes, useFieldTypes,
useFieldsWithAssociation, useFieldsWithAssociation,
useFormatters, useFormatters,
@ -25,9 +26,10 @@ import {
useTransformers, useTransformers,
} from '../hooks'; } from '../hooks';
import { useChartsTranslation } from '../locale'; import { useChartsTranslation } from '../locale';
import { ChartRenderer, ChartRendererContext, useChartTypes, useCharts, useDefaultChartType } from '../renderer'; import { ChartRenderer, ChartRendererContext } from '../renderer';
import { createRendererSchema, getField, getSelectedFields, processData } from '../utils'; import { createRendererSchema, getField, getSelectedFields } from '../utils';
import { getConfigSchema, querySchema, transformSchema } from './schemas/configure'; import { getConfigSchema, querySchema, transformSchema } from './schemas/configure';
import { useChartTypes, useCharts, useDefaultChartType } from '../chart/library';
const { Paragraph, Text } = Typography; const { Paragraph, Text } = Typography;
export type ChartConfigCurrent = { export type ChartConfigCurrent = {
@ -97,7 +99,7 @@ export const ChartConfigure: React.FC<{
} }
const query = form.values.query; const query = form.values.query;
const selectedFields = getSelectedFields(fields, query); const selectedFields = getSelectedFields(fields, query);
const { general, advanced } = init(selectedFields, query); const { general, advanced } = chart.init(selectedFields, query);
if (general || overwrite) { if (general || overwrite) {
form.values.config.general = general; form.values.config.general = general;
} }
@ -363,7 +365,7 @@ ChartConfigure.Config = function Config() {
const charts = useCharts(); const charts = useCharts();
const getChartFields = useChartFields(fields); const getChartFields = useChartFields(fields);
const getReference = (chartType: string) => { const getReference = (chartType: string) => {
const reference = charts[chartType]?.reference; const reference = charts[chartType]?.getReference?.();
if (!reference) return ''; if (!reference) return '';
const { title, link } = reference; const { title, link } = reference;
return ( return (
@ -409,11 +411,10 @@ ChartConfigure.Transform = function Transform() {
}; };
ChartConfigure.Data = function Data() { ChartConfigure.Data = function Data() {
const { t } = useChartsTranslation();
const { current } = useContext(ChartConfigContext);
const { service } = useContext(ChartRendererContext); const { service } = useContext(ChartRendererContext);
const { current } = useContext(ChartConfigContext);
const fields = useFieldsWithAssociation(); const fields = useFieldsWithAssociation();
const data = processData(fields, service?.data || current?.data || [], { t }); const data = useData(current?.data);
const error = service?.error; const error = service?.error;
return !error ? ( return !error ? (
<div <div

View File

@ -0,0 +1,10 @@
import { Chart } from '../chart';
export class AntdChart extends Chart {
getReference() {
return {
title: this.title,
link: `https://ant.design/components/${this.name}`,
};
}
}

View File

@ -0,0 +1,4 @@
import { Statistic } from './statistic';
import { Table } from './table';
export default [new Statistic(), new Table()];

View File

@ -0,0 +1,64 @@
import { ISchema } from '@formily/react';
import { AntdChart } from './antd';
import { Statistic as AntdStatistic } from 'antd';
import { lang } from '../../locale';
import { FieldOption } from '../../hooks';
import { QueryProps } from '../../renderer';
import { RenderProps } from '../chart';
export class Statistic extends AntdChart {
schema: ISchema = {
type: 'object',
properties: {
field: {
title: lang('Field'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-reactions': '{{ useChartFields }}',
required: true,
},
title: {
title: lang('Title'),
type: 'string',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
},
};
constructor() {
super('statistic', 'Statistic', AntdStatistic);
}
init(
fields: FieldOption[],
{
measures,
dimensions,
}: {
measures?: QueryProps['measures'];
dimensions?: QueryProps['dimensions'];
},
) {
const { yField } = this.infer(fields, { measures, dimensions });
return {
general: {
field: yField?.value,
title: yField?.label,
},
};
}
getProps({ data, fieldProps, general, advanced }: RenderProps) {
const record = data[0] || {};
const field = general?.field;
const props = fieldProps[field];
return {
value: record[field],
formatter: props?.transformer,
...general,
...advanced,
};
}
}

View File

@ -0,0 +1,43 @@
import { RenderProps } from '../chart';
import { AntdChart } from './antd';
import { Table as AntdTable } from 'antd';
export class Table extends AntdChart {
constructor() {
super('table', 'Table', AntdTable);
}
getProps({ data, fieldProps, general, advanced }: RenderProps) {
const columns = data.length
? Object.keys(data[0]).map((item) => ({
title: fieldProps[item]?.label || item,
dataIndex: item,
key: item,
}))
: [];
const dataSource = data.map((item: any) => {
Object.keys(item).map((key: string) => {
const props = fieldProps[key];
if (props?.transformer) {
item[key] = props.transformer(item[key]);
}
});
return item;
});
const pageSize = advanced?.pagination?.pageSize || 10;
return {
bordered: true,
size: 'middle',
pagination:
dataSource.length < pageSize
? false
: {
pageSize,
},
dataSource,
columns,
...general,
...advanced,
};
}
}

View File

@ -0,0 +1,134 @@
import React from 'react';
import { FieldOption } from '../hooks';
import { QueryProps } from '../renderer';
import { parseField } from '../utils';
import { ISchema } from '@formily/react';
export type RenderProps = {
data: any[];
general: any;
advanced: any;
fieldProps: {
[field: string]: FieldOption & {
transformer: (val: any) => string;
};
};
};
export interface ChartType {
name: string;
title: string;
component: React.FC<any>;
schema: ISchema;
infer: (
fields: FieldOption[],
{
measures,
dimensions,
}: {
measures?: QueryProps['measures'];
dimensions?: QueryProps['dimensions'];
},
) => {
xField: FieldOption;
yField: FieldOption;
seriesField: FieldOption;
yFields: FieldOption[];
};
init?: (
fields: FieldOption[],
query: {
measures?: QueryProps['measures'];
dimensions?: QueryProps['dimensions'];
},
) => {
general?: any;
advanced?: any;
};
/**
* getProps
* Accept the information that the chart component needs to render,
* process it and return the props of the chart component.
*/
getProps: (props: RenderProps) => any;
getReference?: () => {
title: string;
link: string;
};
render: (props: RenderProps) => React.FC<any>;
}
export class Chart implements ChartType {
name: string;
title: string;
component: React.FC<any>;
schema = {};
constructor(name: string, title: string, component: React.FC<any>) {
this.name = name;
this.title = title;
this.component = component;
}
infer(
fields: FieldOption[],
{
measures,
dimensions,
}: {
measures?: QueryProps['measures'];
dimensions?: QueryProps['dimensions'];
},
) {
let xField: FieldOption;
let yField: FieldOption;
let seriesField: FieldOption;
let yFields: FieldOption[];
const getField = (fields: FieldOption[], selected: { field: string | string[]; alias?: string }) => {
if (selected.alias) {
return fields.find((f) => f.value === selected.alias);
}
const { alias } = parseField(selected.field);
return fields.find((f) => f.value === alias);
};
if (measures?.length) {
yField = getField(fields, measures[0]);
yFields = measures.map((m) => getField(fields, m));
}
if (dimensions) {
if (dimensions.length === 1) {
xField = getField(fields, dimensions[0]);
} else if (dimensions.length > 1) {
// If there is a time field, it is used as the x-axis field by default.
let xIndex: number;
dimensions.forEach((d, i) => {
const field = getField(fields, d);
if (['date', 'time', 'datetime'].includes(field?.type)) {
xField = field;
xIndex = i;
}
});
if (xIndex) {
// If there is a time field, the other field is used as the series field by default.
const index = xIndex === 0 ? 1 : 0;
seriesField = getField(fields, dimensions[index]);
} else {
xField = getField(fields, dimensions[0]);
seriesField = getField(fields, dimensions[1]);
}
}
}
return { xField, yField, seriesField, yFields };
}
getProps(props: RenderProps) {
return props;
}
render({ data, general, advanced, fieldProps }: RenderProps) {
return () =>
React.createElement(this.component, {
...this.getProps({ data, general, advanced, fieldProps }),
});
}
}

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