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).
## [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
### Merged

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@nocobase/client",
"version": "0.11.1-alpha.3",
"version": "0.11.1-alpha.5",
"license": "Apache-2.0",
"main": "lib",
"module": "es/index.js",
@ -12,12 +12,19 @@
"@dnd-kit/core": "^5.0.1",
"@dnd-kit/sortable": "^6.0.0",
"@emotion/css": "^11.7.1",
"@formily/antd-v5": "^1.1.0-beta.4",
"@formily/core": "2.2.26",
"@formily/react": "2.2.26",
"@nocobase/evaluators": "0.11.1-alpha.3",
"@nocobase/sdk": "0.11.1-alpha.3",
"@nocobase/utils": "0.11.1-alpha.3",
"@formily/antd-v5": "^1.1.0",
"@formily/core": "^2.2.27",
"@formily/grid": "^2.2.27",
"@formily/json-schema": "^2.2.27",
"@formily/path": "^2.2.27",
"@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",
"antd": "^5.6.4",
"antd-style": "^3.3.0",
@ -55,7 +62,7 @@
"react-is": ">=18.0.0"
},
"devDependencies": {
"@testing-library/react": "^12.1.5",
"@testing-library/react": "^14.0.0",
"@types/markdown-it": "12.2.3",
"@types/markdown-it-highlightjs": "3.3.1",
"@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 useCurrentAppInfo = () => {
return useContext(CurrentAppInfoContext);
return useContext<{
data: {
database: {
dialect: string;
};
lang: string;
version: string;
};
}>(CurrentAppInfoContext);
};
export const CurrentAppInfoProvider = (props) => {
const result = useRequest({

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import { FormItem, FormLayout } from '@formily/antd-v5';
import { registerValidateRules } from '@formily/core';
import React from 'react';
import { defaultProps } from './properties';
import { IField } from './types';
@ -43,6 +45,19 @@ export const json: IField = {
hasDefaultValue: true,
properties: {
...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: {},
};

View File

@ -710,5 +710,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"
"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":"削除変更操作の許可",
"Date display format":"日付表示形式",
"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":"允许增删改操作",
"Date display format":"日期显示格式",
"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 { Tree as AntdTree } from 'antd';
import { cloneDeep } from 'lodash';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesignable } from '../..';
import { useCollection, useCollectionManager } from '../../../collection-manager';
@ -12,15 +12,21 @@ import { useCollectionState } from '../../../schema-settings/DataTemplates/hooks
import { useLinkageAction } from './hooks';
import { requestSettingsSchema } from './utils';
import { useRecord } from '../../../record-provider';
import { useSyncFromForm } from '../../../schema-settings/DataTemplates/utils';
const Tree = connect(
AntdTree,
mapProps((props, field: any) => {
const [checkedKeys, setCheckedKeys] = useState(props.defaultCheckedKeys || []);
const onCheck = (checkedKeys) => {
setCheckedKeys(checkedKeys);
field.value = checkedKeys;
};
field.onCheck = onCheck;
return {
...props,
onCheck: (checkedKeys) => {
field.value = checkedKeys;
},
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() {
const { dn } = useDesignable();
const { t } = useTranslation();
@ -227,7 +255,27 @@ function DuplicationMode() {
const { collectionList, getEnableFieldTree, getOnLoadData, getOnCheck } = useCollectionState(name);
const duplicateValues = cloneDeep(fieldSchema['x-component-props'].duplicateFields || []);
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 (
<SchemaSettings.ModalItem
title={t('Duplicate mode')}
@ -238,6 +286,7 @@ function DuplicationMode() {
currentCollection: record?.__collection || name,
getOnLoadData,
getOnCheck,
treeData: fieldSchema['x-component-props']?.treeData,
}}
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: {
type: 'array',
title: '{{ t("Data fields") }}',
required: true,
default: duplicateValues,
description: t('Only the selected fields will be used as the initialization data for the form'),
'x-decorator': 'FormItem',
'x-component': Tree,
@ -310,7 +408,7 @@ function DuplicationMode() {
state: {
disabled: '{{ !$deps[0] }}',
componentProps: {
treeData: '{{ getEnableFieldTree($deps[0], $self) }}',
treeData: '{{ getEnableFieldTree($deps[0], $self,treeData) }}',
},
},
},
@ -320,7 +418,7 @@ function DuplicationMode() {
},
} as ISchema
}
onSubmit={({ duplicateMode, collection, duplicateFields }) => {
onSubmit={({ duplicateMode, collection, duplicateFields, treeData }) => {
const fields = Array.isArray(duplicateFields) ? duplicateFields : duplicateFields.checked || [];
field.componentProps.duplicateMode = duplicateMode;
field.componentProps.duplicateFields = fields;
@ -328,6 +426,7 @@ function DuplicationMode() {
fieldSchema['x-component-props'].duplicateMode = duplicateMode;
fieldSchema['x-component-props'].duplicateFields = fields;
fieldSchema['x-component-props'].duplicateCollection = collection;
fieldSchema['x-component-props'].treeData = treeData;
dn.emit('patch', {
schema: {
['x-uid']: fieldSchema['x-uid'],
@ -580,7 +679,7 @@ export const ActionDesigner = (props) => {
const { name } = useCollection();
const { getChildrenCollections } = useCollectionManager();
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'] || '',
);
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 { lodash } from '@nocobase/utils';
import { lodash } from '@nocobase/utils/client';
import { App, Button, Popover } from 'antd';
import classnames from 'classnames';
import React, { useEffect, useState } from 'react';

View File

@ -1,5 +1,5 @@
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 App2 from '../demos/demo2';
import App3 from '../demos/demo3';
@ -10,9 +10,10 @@ describe('Action', () => {
const { getByText } = render(<App1 />);
await userEvent.click(getByText('Open'));
await sleep(300);
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await waitFor(() => {
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
// mask
expect(document.querySelector('.ant-drawer-mask')).toBeInTheDocument();
// title
@ -22,22 +23,29 @@ describe('Action', () => {
// close button
await userEvent.click(getByText('Close'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the close icon
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
});
it('openMode', async () => {
@ -50,31 +58,36 @@ describe('Action', () => {
// drawer
await userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// modal
await userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// TODO: 点击关闭按钮时应该消失
@ -87,38 +100,45 @@ describe('Action.Drawer without Action', () => {
const { getByText } = render(<App2 />);
await userEvent.click(getByText('Open'));
await sleep(300);
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
// mask
expect(document.querySelector('.ant-drawer-mask')).toBeInTheDocument();
// title
expect(getByText('Drawer Title')).toBeInTheDocument();
// content
expect(getByText('Hello')).toBeInTheDocument();
await waitFor(() => {
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
// mask
expect(document.querySelector('.ant-drawer-mask')).toBeInTheDocument();
// title
expect(getByText('Drawer Title')).toBeInTheDocument();
// content
expect(getByText('Hello')).toBeInTheDocument();
});
// close button
await userEvent.click(getByText('Close'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the close icon
await userEvent.click(getByText('Open'));
await sleep(300);
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
});
});
@ -129,16 +149,16 @@ describe('Action.Popover', () => {
fireEvent.mouseEnter(btn);
// wait for the popover to show
await sleep(300);
// popover
expect(document.querySelector('.ant-popover')).toBeInTheDocument();
// content
expect(screen.getByText('Hello')).toBeInTheDocument();
await waitFor(() => {
// popover
expect(document.querySelector('.ant-popover')).toBeInTheDocument();
// content
expect(screen.getByText('Hello')).toBeInTheDocument();
});
fireEvent.mouseLeave(btn);
// wait for the popover to hide
await sleep(300);
expect(document.querySelector('.ant-popover')).not.toBeInTheDocument();
await waitFor(() => {
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 { useField, observer } from '@formily/react';
import { observer } from '@formily/react';
import React from 'react';
import { AssociationFieldProvider } from './AssociationFieldProvider';
import { InternalNester } from './InternalNester';
import { ReadPrettyInternalViewer } from './InternalViewer';
import { InternalSubTable } from './InternalSubTable';
import { FileManageReadPretty } from './FileManager';
import { useAssociationFieldContext } from './hooks';
import { InternalNester } from './InternalNester';
import { InternalSubTable } from './InternalSubTable';
import { ReadPrettyInternalTag } from './InternalTag';
import { ReadPrettyInternalViewer } from './InternalViewer';
const ReadPrettyAssociationField = observer(
(props: any) => {
@ -14,6 +15,7 @@ const ReadPrettyAssociationField = observer(
return (
<>
{['Select', 'Picker'].includes(currentMode) && <ReadPrettyInternalViewer {...props} />}
{currentMode === 'Tag' && <ReadPrettyInternalTag {...props} />}
{currentMode === 'Nester' && <InternalNester {...props} />}
{currentMode === 'SubTable' && <InternalSubTable {...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) {
const newArr = [];
for (let i = 0; i < data.length; i++) {

View File

@ -1,5 +1,5 @@
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 App2 from '../demos/demo2';
@ -33,8 +33,9 @@ describe('Cascader', () => {
// 因为是异步加载,所以需要等待一下
expect(screen.queryByText('Zhejiang Dynamic 1')).not.toBeInTheDocument();
await sleep(300);
expect(screen.getByText('Zhejiang Dynamic 1')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Zhejiang Dynamic 1')).toBeInTheDocument();
});
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 { render, screen, userEvent, waitFor } from 'testUtils';
import { render, screen, sleep, userEvent, waitFor } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
@ -13,6 +13,9 @@ import App9 from '../demos/demo9';
describe('DatePicker', () => {
it('basic', async () => {
const { container, getByText } = render(<App1 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
@ -35,6 +38,9 @@ describe('DatePicker', () => {
it('GMT', async () => {
const { container, getByText } = render(<App2 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
@ -53,6 +59,9 @@ describe('DatePicker', () => {
it('non-UTC', async () => {
const { container } = render(<App3 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
@ -74,6 +83,9 @@ describe('DatePicker', () => {
describe('RangePicker', () => {
it('GMT', async () => {
const { container, getByPlaceholderText } = render(<App4 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
@ -92,6 +104,9 @@ describe('RangePicker', () => {
it('non-GMT', async () => {
const { container, getByPlaceholderText } = render(<App5 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
@ -115,6 +130,9 @@ describe('RangePicker', () => {
it('non-UTC', async () => {
const { container, getByPlaceholderText } = render(<App6 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
@ -133,6 +151,9 @@ describe('RangePicker', () => {
it('showTime=false,gmt=true,utc=true', async () => {
const { container } = render(<App7 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
@ -152,6 +173,9 @@ describe('RangePicker', () => {
it('showTime=false,gmt=false,utc=true', async () => {
const { container } = render(<App8 />);
await sleep();
const picker = container.querySelector('.ant-picker') 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 () => {
const currentDateString = new Date().toISOString().split('T')[0];
const { container } = render(<App9 />);
await sleep();
const picker = container.querySelector('.ant-picker') as HTMLElement;
await userEvent.click(picker);

View File

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

View File

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

View File

@ -1,11 +1,13 @@
import React from 'react';
import { render, screen } from 'testUtils';
import { render, screen, waitFor } from 'testUtils';
import App1 from '../demos/demo1';
describe('FormItem', () => {
it('should render correctly', () => {
it('should render correctly', async () => {
render(<App1 />);
expect(screen.getByText('title')).toBeInTheDocument();
await waitFor(() => {
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 { mockAPIClient } from '../../../../test';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
const schema = {
type: 'object',
@ -15,8 +28,12 @@ const schema = {
export default () => {
return (
<FormProvider>
<SchemaComponent components={{ FormItem, Input }} schema={schema} />
</FormProvider>
<APIClientProvider apiClient={apiClient}>
<CurrentUserProvider>
<FormProvider>
<SchemaComponent components={{ FormItem, Input }} schema={schema} />
</FormProvider>
</CurrentUserProvider>
</APIClientProvider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +1,32 @@
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';
describe('RecordPicker', () => {
it('should show selected options', async () => {
render(<App1 />);
const selector = document.querySelector('.ant-select-selector') as HTMLElement;
expect(selector).toBeInTheDocument();
let selector;
await waitFor(() => {
selector = document.querySelector('.ant-select-selector') as HTMLElement;
expect(selector).toBeInTheDocument();
});
await userEvent.click(selector);
await sleep(100);
await waitFor(() => {
// 弹窗标题
expect(screen.queryByText(/select record/i)).toBeInTheDocument();
});
// 弹窗标题
expect(screen.getByText(/select record/i)).toBeInTheDocument();
const checkboxes = document.querySelectorAll('.ant-checkbox');
// 第 3 个选项的内容是: “软件开发”
await userEvent.click(checkboxes[2]);
await userEvent.click(screen.getByText(/submit/i));
expect(within(selector).getByText(/软件开发/i)).toBeInTheDocument();
expect(screen.getByText(/软件开发/i, { selector: '.test-record-picker-read-pretty-item' })).toBeInTheDocument();
await waitFor(() => {
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,
CollectionField,
CollectionManagerProvider,
CurrentUserProvider,
FormItem,
Input,
RecordPicker,
@ -23,6 +24,9 @@ import data from './mockData';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/auth:check').reply(() => {
return [200, { data: {} }];
});
mockRequest.onGet('/tt_bd_range:list').reply(({ params }) => {
// 已选中的 id
const ids = JSON.parse(params.filter).$and?.[0]?.['id.$ne'] || [];
@ -172,11 +176,13 @@ export default () => {
return (
<APIClientProvider apiClient={apiClient}>
<CollectionManagerProvider collections={mainCollections}>
<SchemaComponentProvider components={components}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
</CollectionManagerProvider>
<CurrentUserProvider>
<CollectionManagerProvider collections={mainCollections}>
<SchemaComponentProvider components={components}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
</CollectionManagerProvider>
</CurrentUserProvider>
</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) => {
const { uiSchema, fieldSchema, collectionField } = props;
const { getInterface, getCollection } = useCollectionManager();
@ -37,6 +53,7 @@ export const TableColumnDesigner = (props) => {
const fieldNames =
fieldSchema?.['x-component-props']?.['fieldNames'] || uiSchema?.['x-component-props']?.['fieldNames'];
const options = useLabelFields(collectionField?.target ?? collectionField?.targetCollection);
const colorFieldOptions = useColorFields(collectionField?.target ?? collectionField?.targetCollection);
const intefaceCfg = getInterface(collectionField?.interface);
const targetCollection = getCollection(collectionField?.target);
const isFileField = isFileCollection(targetCollection);
@ -45,6 +62,7 @@ export const TableColumnDesigner = (props) => {
const defaultFilter = fieldSchema?.['x-component-props']?.service?.params?.filter || {};
const dataSource = useCollectionFilterOptions(collectionField?.target);
const isDateField = ['datetime', 'createdAt', 'updatedAt'].includes(collectionField?.interface);
const fieldMode = fieldSchema?.['x-component-props']?.['mode'] || 'Select';
let readOnlyMode = 'editable';
if (fieldSchema['x-disabled'] === true) {
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'] && (
<SchemaSettings.SwitchItem

View File

@ -297,7 +297,6 @@ export const Table: any = observer(
console.warn('move cancel');
return;
}
const fromIndex = e.active?.data.current?.sortable?.index;
const toIndex = e.over?.data.current?.sortable?.index;
const from = field.value[fromIndex];
@ -324,6 +323,11 @@ export const Table: any = observer(
.nb-read-pretty-input-number {
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 => {
const { data } = useCurrentUserContext() || {};
const currentUser = useCurrentUserContext();
const { field, service, rowKey } = useTableBlockContext();
const contextData = service?.data?.data?.filter((v) => (field?.data?.selectedRowKeys || [])?.includes(v[rowKey]));
return useMemo(() => {
return {
$user: data?.data || {},
$user: currentUser?.data?.data || {},
$date: {
now: () => dayjs().toISOString(),
},
$context: contextData,
};
}, [data]);
}, [contextData, currentUser?.data?.data]);
};
export const isVariable = (str: unknown) => {

View File

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

View File

@ -13,6 +13,7 @@ import { AsDefaultTemplate } from './components/AsDefaultTemplate';
import { ArrayCollapse } from './components/DataTemplateTitle';
import { getSelectedIdFilter } from './components/Designer';
import { useCollectionState } from './hooks/useCollectionState';
import { useSyncFromForm } from './utils';
const Tree = connect(
AntdTree,
@ -48,7 +49,6 @@ export const FormDataTemplates = observer(
} = useCollectionState(collectionName);
const { getCollection, getCollectionField } = useCollectionManager();
const { t } = useTranslation();
// 不要在后面的数组中依赖 defaultValues否则会因为 defaultValues 的变化导致 activeData 响应性丢失
const activeData = useMemo<ITemplate>(
() =>
@ -61,7 +61,6 @@ export const FormDataTemplates = observer(
),
[],
);
console.log(activeData);
const getTargetField = (collectionName: string) => {
const collection = getCollection(collectionName);
return getCollectionField(
@ -170,6 +169,16 @@ export const FormDataTemplates = observer(
required: true,
'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: {
type: 'array',
title: '{{ t("Data fields") }}',

View File

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

View File

@ -1,32 +1,34 @@
import { ArrayField } from '@formily/core';
import { useField } from '@formily/react';
import React, { useCallback, useState } from 'react';
import { useCollectionManager } from '../../../collection-manager';
import { isTitleField } from '../../../collection-manager/Configuration/CollectionFields';
import { useCompile } from '../../../schema-component';
import { TreeNode } from '../TreeLabel';
// 过滤掉系统字段
export const systemKeys = [
// 'id',
'sort',
'createdById',
'createdBy',
'createdAt',
'updatedById',
'updatedBy',
'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 }));
}
// 过滤掉系统字段
const systemKeys = [
// 'id',
'sort',
'createdById',
'createdBy',
'createdAt',
'updatedById',
'updatedBy',
'updatedAt',
];
/**
* maxDepth: 0 0 1
*/
@ -115,12 +117,25 @@ export const useCollectionState = (currentCollectionName: string) => {
})
.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) {
return [];
}
if (targetTemplate?.treeData || treeData) {
return parseTreeData(treeData || targetTemplate.treeData);
}
try {
return traverseFields(collectionName, { exclude: ['id', ...systemKeys], maxDepth: 1 });
} catch (error) {
@ -242,9 +257,8 @@ function findNode(treeData, item) {
}
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 = [];
// 多对多和多对一只展示关系字段
if (['belongsTo', 'belongsToMany'].includes(node.field.type)) {
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 { 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 { uid } from '@formily/shared';
import { error } from '@nocobase/utils/client';
@ -21,32 +21,32 @@ import {
} from 'antd';
import _, { cloneDeep } from 'lodash';
import React, {
ReactNode,
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
// @ts-ignore
useTransition as useReactTransition,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
APIClientProvider,
ActionContextProvider,
APIClientProvider,
CollectionFieldOptions,
CollectionManagerContext,
CollectionProvider,
createDesignable,
Designable,
findFormBlock,
FormDialog,
FormProvider,
RemoteSchemaComponent,
SchemaComponent,
SchemaComponentContext,
SchemaComponentOptions,
createDesignable,
findFormBlock,
useAPIClient,
useBlockRequestContext,
useCollection,
@ -1146,7 +1146,6 @@ SchemaSettings.DataTemplates = function DataTemplates(props) {
const { t } = useTranslation();
const formSchema = findFormBlock(fieldSchema) || fieldSchema;
const { templateData } = useDataTemplates();
const schema = useMemo(
() => ({
type: 'object',
@ -1171,7 +1170,6 @@ SchemaSettings.DataTemplates = function DataTemplates(props) {
);
const onSubmit = useCallback((v) => {
const data = { ...(formSchema['x-data-templates'] || {}), ...v.fieldReaction };
// 当 Tree 组件开启 checkStrictly 属性时,会导致 checkedKeys 的值是一个对象,而不是数组,所以这里需要转换一下以支持旧版本
data.items.forEach((item) => {
item.fields = Array.isArray(item.fields) ? item.fields : item.fields.checked;

View File

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

View File

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

View File

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

View File

@ -378,23 +378,33 @@ describe('repository find', () => {
});
});
it('should only output filed in fields args', async () => {
const resp = await User.model.findOne({
attributes: [],
include: [
{
association: 'profile',
attributes: ['salary'],
},
],
describe('find with fields', () => {
it('should only output filed in fields args', async () => {
const users = await User.repository.find({
fields: ['profile.salary'],
});
const firstUser = users[0].toJSON();
expect(Object.keys(firstUser)).toEqual(['profile']);
expect(Object.keys(firstUser.profile)).toEqual(['salary']);
});
const users = await User.repository.find({
fields: ['profile', 'profile.salary', 'profile.id'],
});
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']);
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 () => {

View File

@ -18,131 +18,155 @@ pgOnly()('', () => {
await db.close();
});
it('should skip on delete on view collection', async () => {
const Order = db.collection({
name: 'orders',
fields: [
{
type: 'string',
name: 'name',
},
{
type: 'hasMany',
name: 'orderItems',
foreignKey: 'order_id',
target: 'orderItems',
},
],
});
describe('view as through table', () => {
let Order;
let OrderItem;
let Item;
let OrderItemView;
const OrderItem = db.collection({
name: 'orderItems',
timestamps: false,
fields: [
{
type: 'integer',
name: 'count',
},
{
type: 'belongsTo',
name: 'item',
target: 'items',
foreignKey: 'item_id',
},
{
type: 'belongsTo',
name: 'order',
target: 'orders',
foreignKey: 'order_id',
onDelete: 'NO ACTION',
},
],
});
const Item = db.collection({
name: 'items',
fields: [{ name: 'name', type: 'string' }],
});
await db.sync();
const viewName = 'order_item_view';
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
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`;
await db.sequelize.query(viewSQL);
const OrderItemView = db.collection({
name: viewName,
view: true,
schema: db.inDialect('postgres') ? 'public' : undefined,
fields: [
{
type: 'bigInt',
name: 'order_id',
},
{
type: 'bigInt',
name: 'item_id',
onDelete: 'CASCADE',
},
],
});
await db.sync();
Order.setField('items', {
type: 'belongsToMany',
target: 'orderItems',
through: viewName,
foreignKey: 'order_id',
otherKey: 'item_id',
sourceKey: 'id',
targetKey: 'id',
onDelete: 'CASCADE',
});
await db.sync();
const order1 = await db.getRepository('orders').create({
values: {
name: 'order1',
orderItems: [
beforeEach(async () => {
Order = db.collection({
name: 'orders',
fields: [
{
count: 1,
item: {
name: 'item1',
},
type: 'string',
name: 'name',
},
{
count: 2,
item: {
name: 'item2',
},
type: 'hasMany',
name: 'orderItems',
foreignKey: 'order_id',
target: 'orderItems',
},
],
},
});
const item1 = await db.getRepository('items').findOne({
filter: {
name: 'item1',
},
});
let error;
try {
await db.getRepository('orders').destroy({
filterByTk: order1.get('id'),
});
} catch (err) {
error = err;
}
expect(error).toBeUndefined();
OrderItem = db.collection({
name: 'orderItems',
timestamps: false,
fields: [
{
type: 'integer',
name: 'count',
},
{
type: 'belongsTo',
name: 'item',
target: 'items',
foreignKey: 'item_id',
},
{
type: 'belongsTo',
name: 'order',
target: 'orders',
foreignKey: 'order_id',
onDelete: 'NO ACTION',
},
],
});
Item = db.collection({
name: 'items',
fields: [{ name: 'name', type: 'string' }],
});
await db.sync();
const viewName = 'order_item_view';
const dropViewSQL = `DROP VIEW IF EXISTS ${viewName}`;
await db.sequelize.query(dropViewSQL);
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);
OrderItemView = db.collection({
name: viewName,
view: true,
schema: db.inDialect('postgres') ? 'public' : undefined,
fields: [
{
type: 'bigInt',
name: 'order_id',
},
{
type: 'bigInt',
name: 'item_id',
onDelete: 'CASCADE',
},
],
});
await db.sync();
Order.setField('items', {
type: 'belongsToMany',
target: 'items',
through: viewName,
foreignKey: 'order_id',
otherKey: 'item_id',
sourceKey: 'id',
targetKey: 'id',
onDelete: 'CASCADE',
});
await db.sync();
await db.getRepository('orders').create({
values: {
name: 'order1',
orderItems: [
{
count: 1,
item: {
name: 'item1',
},
},
{
count: 2,
item: {
name: 'item2',
},
},
],
},
});
});
it('should skip on delete on view collection', async () => {
const order1 = await db.getRepository('orders').findOne({});
const item1 = await db.getRepository('items').findOne({
filter: {
name: 'item1',
},
});
let error;
try {
await db.getRepository('orders').destroy({
filterByTk: order1.get('id'),
});
} catch (err) {
error = err;
}
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 () => {
@ -196,7 +220,7 @@ pgOnly()('', () => {
});
// create INSTEAD OF INSERT trigger
await db.sequelize.query(`
await db.sequelize.query(`
CREATE OR REPLACE FUNCTION insert_users_with_group() RETURNS TRIGGER AS $$
DECLARE
new_group_id BIGINT;

View File

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

View File

@ -240,6 +240,9 @@ export class OptionsParser {
protected parseAppends(appends: Appends, filterParams: any) {
if (!appends) return filterParams;
// sort appends by path length
appends = lodash.sortBy(appends, (append) => append.split('.').length);
/**
* set include params
* @param model
@ -287,6 +290,25 @@ export class OptionsParser {
// if include from filter, remove fromFilter attribute
if (existIncludeIndex != -1) {
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
"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.zh-CN": "可以与多种关系型数据库系统如MySQL、PostgreSQL无缝集成并提供直观的用户界面来执行各种数据库任务。",
"version": "0.11.1-alpha.3",
"version": "0.11.1-alpha.5",
"main": "./lib/server/index.js",
"files": [
"lib",
@ -24,12 +24,12 @@
"toposort": "^2.0.2"
},
"devDependencies": {
"@nocobase/client": "0.11.1-alpha.3",
"@nocobase/database": "0.11.1-alpha.3",
"@nocobase/plugin-error-handler": "0.11.1-alpha.3",
"@nocobase/server": "0.11.1-alpha.3",
"@nocobase/test": "0.11.1-alpha.3",
"@nocobase/utils": "0.11.1-alpha.3"
"@nocobase/client": "0.11.1-alpha.5",
"@nocobase/database": "0.11.1-alpha.5",
"@nocobase/plugin-error-handler": "0.11.1-alpha.5",
"@nocobase/server": "0.11.1-alpha.5",
"@nocobase/test": "0.11.1-alpha.5",
"@nocobase/utils": "0.11.1-alpha.5"
},
"gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644"
}

View File

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

View File

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

View File

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

View File

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

View File

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