test: add tests for client (#1960)

* feat: add test utils

* test: fix compatibility problem

* refactor: use esm

* refactor: change require to import

* test: add a test

* chore: optimize launch.json

* chore(jest): optimize config

* test: fix failed

* test(Action): fix failed

* test: migration test from nocobase-next

* test: fix failed

* chore: set css to false

* fix: fix errors

* test(Filter): add test

* test(BlockItem): add test

* test(Calendar): add test

* refactor: migrate testUtils to client

* fix: avoid error

* refactor: fix lint error

* Revert "refactor: migrate testUtils to client"

This reverts commit ae569e2021a5c720111518cf3f2236e761a7186d.

* test: fix test failed

* refactor: migrate tesetUtils to @nocobase/test/client

* fix(Select): fix BUG

* refactor: remove useless code

* Revert "refactor: migrate tesetUtils to @nocobase/test/client"

This reverts commit 70d2211d5af546ae3fffaf5b53a326be8e194b34.

* test(AssociationSelect): add test

* test(CardItem): add test

* test(CollectionSelect): add test

* test(Cron): add test

* test(Details): add test

* test(ErrorFallback): add test

* test(Form): add test

* fix: fix CI

* test(FormItem): add test

* test(FormV2): add test

* test(G2Plot): add test

* test(Gantt): add test

* test(Grid): add test

* test(GridCard): add test

* test(Kanban): add test

* test(List): add test

* test(Menu): add test

* test(Page): add test

* test(Pagination): add test

* test(Preview): add test

* test(RemoteSelect): add test

* test: fix: failed

* refactor: remove useless code

* test(Table): add test

* test(TableV2): add test

* test(Tabs): add test

* test(Variable): add test

* test(Upload): add test

* chore: comment

* Revert "refactor: change require to import"

This reverts commit 3a23607e6faeda6f8b5bee02b9d8fa33bf9d2684.

* refactor: remove useless code

* fix: fix translation bug

* fix: fix export position

* test: update snap
This commit is contained in:
被雨水过滤的空气-Rairn 2023-06-07 10:37:10 +08:00 committed by GitHub
parent ac070de216
commit 47838889f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
239 changed files with 48576 additions and 4654 deletions

37
.vscode/launch.json vendored
View File

@ -6,11 +6,17 @@
"request": "launch",
"name": "Debug Server",
"runtimeArgs": [
"-r", "dotenv/config",
"-r", "tsconfig-paths/register",
"-r", "ts-node/register"
"-r",
"dotenv/config",
"-r",
"tsconfig-paths/register",
"-r",
"ts-node/register"
],
"args": [
"${workspaceRoot}/packages/app/server/src/index.ts",
"start"
],
"args": ["${workspaceRoot}/packages/app/server/src/index.ts", "start"],
"skipFiles": [
"<node_internals>/**"
],
@ -44,6 +50,29 @@
],
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Client Tests",
"runtimeExecutable": "yarn",
"runtimeArgs": [
"run",
"test:client",
"${file}"
],
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"runtimeArgs": [
"run",
"test:client",
"${relativeFile}"
],
}
},
{
"type": "node",
"request": "launch",

View File

@ -1 +0,0 @@
module.exports = {};

View File

@ -15,7 +15,6 @@ module.exports = {
...pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
'\\.(css|less)$': '<rootDir>/__mocks__/styleMock.js',
},
globals: {
'ts-jest': {

View File

@ -19,6 +19,7 @@
"build": "nocobase build",
"test": "nocobase test",
"test:client": "vitest",
"tc": "yarn test:client",
"doc": "nocobase doc",
"postinstall": "nocobase postinstall",
"lint": "eslint .",
@ -43,10 +44,12 @@
"@commitlint/config-conventional": "^16.0.0",
"@commitlint/prompt-cli": "^16.1.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/react": "^12.1.5",
"@testing-library/user-event": "^14.4.3",
"@vitejs/plugin-react": "^4.0.0",
"auto-changelog": "^2.4.0",
"ghooks": "^2.0.4",
"jsdom-worker": "^0.3.0",
"prettier": "^2.2.1",
"pretty-format": "^24.0.0",
"pretty-quick": "^3.1.0",

View File

@ -1,6 +1,6 @@
import { createCache, createDefaultCacheConfig } from '@nocobase/cache';
export function sleep(ms: number) {
export function sleep(ms?: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});

View File

@ -29,20 +29,6 @@ const getRouteUrl = (props) => {
return props && getRouteUrl(props?.children?.props);
};
const getRouteAclCheck = (match, snippets) => {
const { url, params } = match;
if (url === '/admin/pm/list' || params?.pluginName || params?.name?.includes('settings')) {
const pmAclCheck = url === '/admin/pm/list' && snippets.includes('pm');
const pluginTabByName = params?.name.split('/');
pluginTabByName.shift();
const pluginName = params.pluginName || pluginTabByName[0];
const tabName = params.tabName || pluginTabByName[1];
const pluginTabSnippet = pluginName && tabName && `!pm.${pluginName}.${tabName}`;
const pluginTabAclCheck = pluginTabSnippet && !snippets.includes(pluginTabSnippet);
return pmAclCheck || pluginTabAclCheck;
}
return true;
};
export const ACLRolesCheckProvider = (props) => {
const route = getRouteUrl(props.children.props);
const { setDesignable } = useDesignable();
@ -180,11 +166,11 @@ export const ACLCollectionProvider = (props) => {
const { allowAll, parseAction } = useACLRoleContext();
const schema = useFieldSchema();
if (allowAll) {
return <>{props.children}</>;
return props.children;
}
const actionPath = schema['x-acl-action'];
if (!actionPath) {
return <>{props.children}</>;
return props.children;
}
const params = parseAction(actionPath, { schema });
if (!params) {
@ -288,5 +274,3 @@ export const ACLMenuItemProvider = (props) => {
}
return null;
};
export default ACLProvider;

View File

@ -1,5 +1,5 @@
import { APIClient as APIClientSDK } from '@nocobase/sdk';
import { Result } from 'ahooks/lib/useRequest/src/types';
import { Result } from 'ahooks/es/useRequest/src/types';
import { notification } from 'antd';
import React from 'react';

View File

@ -1,3 +1,4 @@
import { isPlainObject } from '@nocobase/utils/client';
import deepmerge from 'deepmerge';
import uniq from 'lodash/uniq';
@ -10,15 +11,6 @@ export interface MergeStrategies {
[key: string]: MergeStrategy;
}
export default function isPlainObject(value) {
if (Object.prototype.toString.call(value) !== '[object Object]') {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === null || prototype === Object.prototype;
}
function getEnumerableOwnPropertySymbols(target: any): any[] {
return Object.getOwnPropertySymbols
? Object.getOwnPropertySymbols(target).filter((symbol) => target.propertyIsEnumerable(symbol))

View File

@ -1,7 +1,7 @@
import { merge } from '@formily/shared';
import { useSetState } from 'ahooks';
import { default as useReq } from 'ahooks/lib/useRequest';
import { Options } from 'ahooks/lib/useRequest/src/types';
import { default as useReq } from 'ahooks/es/useRequest';
import { Options } from 'ahooks/es/useRequest/src/types';
import { AxiosRequestConfig } from 'axios';
import cloneDeep from 'lodash/cloneDeep';
import { useContext } from 'react';

View File

@ -75,79 +75,3 @@ exports[`compose > case 5 1`] = `
</div>
</div>
`;
exports[`compose case 1 1`] = `
<div>
<div>
<h1>
A
</h1>
</div>
</div>
`;
exports[`compose case 2 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
</h1>
</div>
</div>
</div>
`;
exports[`compose case 3 1`] = `
<div>
<div>
<h1>
A
</h1>
</div>
</div>
`;
exports[`compose case 4 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
</h1>
<div>
<h1>
C
</h1>
</div>
</div>
</div>
</div>
`;
exports[`compose case 5 1`] = `
<div>
<div>
<h1>
A
</h1>
<div>
<h1>
B
1
</h1>
<div>
<h1>
C
</h1>
</div>
</div>
</div>
</div>
`;

View File

@ -1,4 +1,4 @@
import { Result } from 'ahooks/lib/useRequest/src/types';
import { Result } from 'ahooks/es/useRequest/src/types';
import React, { createContext, useContext } from 'react';
import { useRequest } from '../api-client';

View File

@ -2,13 +2,12 @@ import { css } from '@emotion/css';
import { Field } from '@formily/core';
import { RecursionField, useField, useFieldSchema } from '@formily/react';
import { useRequest } from 'ahooks';
import merge from 'deepmerge';
import { Col, Row } from 'antd';
import merge from 'deepmerge';
import template from 'lodash/template';
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
ACLCollectionProvider,
TableFieldResource,
WithoutTableFieldResource,
useAPIClient,
@ -16,11 +15,11 @@ import {
useDesignable,
useRecord,
} from '../';
import { ACLCollectionProvider } from '../acl/ACLProvider';
import { CollectionProvider, useCollection, useCollectionManager } from '../collection-manager';
import { FilterBlockRecord } from '../filter-provider/FilterProvider';
import { useRecordIndex } from '../record-provider';
import { SharedFilterProvider } from './SharedFilterProvider';
import _ from 'lodash';
export const BlockResourceContext = createContext(null);
export const BlockAssociationContext = createContext(null);
@ -161,7 +160,7 @@ export const MaybeCollectionProvider = (props) => {
<ACLCollectionProvider>{props.children}</ACLCollectionProvider>
</CollectionProvider>
) : (
<>{props.children}</>
props.children
);
};

View File

@ -1,4 +1,3 @@
import { useField } from '@formily/react';
import React, { forwardRef, useState } from 'react';
import { DragDropContext } from 'react-beautiful-dnd';
import Column from './Column';

View File

@ -1,10 +1,10 @@
import { createForm, Field } from '@formily/core';
import { FieldContext, FormContext, observer, useField, useFieldSchema } from '@formily/react';
import { Options, Result } from 'ahooks/lib/useRequest/src/types';
import { useAttach } from '@formily/react/esm/hooks/useAttach';
import { Options, Result } from 'ahooks/es/useRequest/src/types';
import { TableProps } from 'antd';
import React, { useMemo } from 'react';
import { AsyncDataProvider, useAsyncData, useRequest } from '../..';
import { useAttach } from '@formily/react/lib/hooks/useAttach';
import { TableProps } from 'antd';
import { CollectionFieldsTableArray } from './CollectionFieldsTableArray';
type TableVoidProps = TableProps<any> & {

View File

@ -1,5 +1,5 @@
import { useField } from '@formily/react';
import { Result } from 'ahooks/lib/useRequest/src/types';
import { Result } from 'ahooks/es/useRequest/src/types';
import React, { createContext, useContext, useEffect } from 'react';
import { useCollectionManager } from '.';
import { CollectionProvider, useRecord } from '..';

View File

@ -1,7 +1,6 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
import { cloneDeep } from 'lodash';
import { defaultProps, recordPickerSelector, recordPickerViewer } from './properties';
import { defaultProps } from './properties';
import { IField } from './types';
export const linkTo: IField = {

View File

@ -335,47 +335,6 @@ export const defaultProps = {
},
};
export const recordPickerSelector: ISchema = {
type: 'void',
title: '{{ t("Select record") }}',
'x-component': 'RecordPicker.Selector',
'x-component-props': {
className: 'nb-record-picker-selector',
},
properties: {
grid: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'TableSelectorInitializers',
properties: {},
},
footer: {
'x-component': 'Action.Container.Footer',
'x-component-props': {},
properties: {
actions: {
type: 'void',
'x-component': 'ActionBar',
'x-component-props': {},
properties: {
submit: {
title: '{{ t("Submit") }}',
'x-action': 'submit',
'x-component': 'Action',
'x-designer': 'Action.Designer',
'x-component-props': {
type: 'primary',
htmlType: 'submit',
useProps: '{{ usePickActionProps }}',
},
},
},
},
},
},
},
};
export const recordPickerViewer = {
type: 'void',
title: '{{ t("View record") }}',

View File

@ -1,11 +1,11 @@
import { useField, useFieldSchema } from '@formily/react';
import { uniqBy } from 'lodash';
import React, { createContext, useEffect, useRef } from 'react';
import { useBlockRequestContext } from '../block-provider';
import { useBlockRequestContext } from '../block-provider/BlockProvider';
import { SharedFilter, mergeFilter } from '../block-provider/SharedFilterProvider';
import { CollectionFieldOptions, useCollection } from '../collection-manager';
import { removeNullCondition } from '../schema-component';
import { useAssociatedFields } from './utils';
import { uniqBy } from 'lodash';
type Collection = ReturnType<typeof useCollection>;

View File

@ -1,7 +1,6 @@
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import locale from '../locale';
const log = require('debug')('i18next');
export const i18n = i18next.createInstance();

View File

@ -0,0 +1,98 @@
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/antd-icon';
import App3 from '../demos/custom-icon';
import App2 from '../demos/iconfont';
import App4 from '../demos/register-icon';
describe('Icon', () => {
it('antd icon', () => {
render(<App1 />);
expect(screen.getByRole('img')).toMatchInlineSnapshot(`
<span
aria-label="book"
class="anticon anticon-book"
role="img"
>
<svg
aria-hidden="true"
data-icon="book"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M832 64H192c-17.7 0-32 14.3-32 32v832c0 17.7 14.3 32 32 32h640c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32zm-260 72h96v209.9L621.5 312 572 347.4V136zm220 752H232V136h280v296.9c0 3.3 1 6.6 3 9.3a15.9 15.9 0 0022.3 3.7l83.8-59.9 81.4 59.4c2.7 2 6 3.1 9.4 3.1 8.8 0 16-7.2 16-16V136h64v752z"
/>
</svg>
</span>
`);
});
it('iconfont', () => {
render(<App2 />);
expect(screen.getByRole('img')).toMatchInlineSnapshot(`
<span
class="anticon"
role="img"
>
<svg
aria-hidden="true"
class=""
fill="currentColor"
focusable="false"
height="1em"
width="1em"
>
<use
xlink:href="#icon-tuichu"
/>
</svg>
</span>
`);
});
it('custom icon', () => {
render(<App3 />);
expect(screen.getByRole('img')).toMatchInlineSnapshot(`
<span
class="anticon"
role="img"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 1024 1024"
width="1em"
>
<path
d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z"
/>
</svg>
</span>
`);
});
it('register icon', () => {
render(<App4 />);
expect(screen.getByRole('img')).toMatchInlineSnapshot(`
<span
class="anticon"
role="img"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 1024 1024"
width="1em"
>
<path
d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z"
/>
</svg>
</span>
`);
});
});

View File

@ -0,0 +1,4 @@
import { Icon } from '@nocobase/client';
import React from 'react';
export default () => <Icon type="BookOutlined" />;

View File

@ -0,0 +1,10 @@
import { Icon } from '@nocobase/client';
import React from 'react';
const HeartSvg = () => (
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z" />
</svg>
);
export default () => <Icon component={HeartSvg} />;

View File

@ -0,0 +1,8 @@
import { Icon } from '@nocobase/client';
import React from 'react';
Icon.createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
});
export default () => <Icon type="icon-tuichu" />;

View File

@ -0,0 +1,14 @@
import { Icon } from '@nocobase/client';
import React from 'react';
const HeartSvg = () => (
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z" />
</svg>
);
const CustomIcon = (props) => <Icon component={HeartSvg} {...props} />;
Icon.register({ CustomIcon });
export default () => <Icon type={'CustomIcon'} />;

View File

@ -21,56 +21,16 @@ Icon.createFromIconfontCN({
Use @ant-design/icons
```tsx
import React from 'react';
import { Icon } from '@nocobase/client';
export default () => <Icon type="BookOutlined" />
```
<code src="./demos/antd-icon.tsx"></code>
Use iconfont.cn
```tsx
import React from 'react';
import { Icon } from '@nocobase/client';
Icon.createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/font_8d5l8fzk5b87iudi.js',
});
export default () => <Icon type="icon-tuichu" />
```
<code src="./demos/iconfont.tsx"/>
Custom Icon
```tsx
import React from 'react';
import { Icon } from '@nocobase/client';
const HeartSvg = () => (
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z" />
</svg>
);
export default () => <Icon component={HeartSvg} />
```
<code src="./demos/custom-icon.tsx"/>
Register Icon
```tsx
import React from 'react';
import { Icon } from '@nocobase/client';
const HeartSvg = () => (
<svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M923 283.6c-13.4-31.1-32.6-58.9-56.9-82.8-24.3-23.8-52.5-42.4-84-55.5-32.5-13.5-66.9-20.3-102.4-20.3-49.3 0-97.4 13.5-139.2 39-10 6.1-19.5 12.8-28.5 20.1-9-7.3-18.5-14-28.5-20.1-41.8-25.5-89.9-39-139.2-39-35.5 0-69.9 6.8-102.4 20.3-31.4 13-59.7 31.7-84 55.5-24.4 23.9-43.5 51.7-56.9 82.8-13.9 32.3-21 66.6-21 101.9 0 33.3 6.8 68 20.3 103.3 11.3 29.5 27.5 60.1 48.2 91 32.8 48.9 77.9 99.9 133.9 151.6 92.8 85.7 184.7 144.9 188.6 147.3l23.7 15.2c10.5 6.7 24 6.7 34.5 0l23.7-15.2c3.9-2.5 95.7-61.6 188.6-147.3 56-51.7 101.1-102.7 133.9-151.6 20.7-30.9 37-61.5 48.2-91 13.5-35.3 20.3-70 20.3-103.3 0.1-35.3-7-69.6-20.9-101.9z" />
</svg>
);
const CustomIcon = (props) => <Icon component={HeartSvg} {...props} />;
Icon.register({ CustomIcon });
export default () => <Icon type={'CustomIcon'} />
```
<code src="./demos/register-icon.tsx"/>

View File

@ -7,11 +7,3 @@ exports[`route-switch > case 1 1`] = `
</h1>
</div>
`;
exports[`route-switch case 1 1`] = `
<div>
<h1>
Home
</h1>
</div>
`;

View File

@ -28,7 +28,7 @@ export const ActionPage: ComposedActionDrawer = observer(
{containerRef?.current &&
visible &&
createPortal(
<div>
<div className="nb-action-page">
<RecursionField
basePath={field.address}
schema={schema}

View File

@ -0,0 +1,144 @@
import React from 'react';
import { fireEvent, render, screen, sleep, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4';
describe('Action', () => {
it('show the drawer when click the button', async () => {
const { getByText } = render(<App1 />);
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();
// close button
await userEvent.click(getByText('Close'));
await sleep(300);
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 userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
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 userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
it('openMode', async () => {
const { getByText } = render(<App3 />);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
// 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 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 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 userEvent.click(getByText('Close'));
// TODO: 点击关闭按钮时应该消失
// expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
});
describe('Action.Drawer without Action', () => {
it('show the drawer when click the button', async () => {
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();
// close button
await userEvent.click(getByText('Close'));
await sleep(300);
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 userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
await sleep(300);
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 userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
await sleep(300);
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
});
describe('Action.Popover', () => {
it('show the popover when hover the button', async () => {
const { container } = render(<App4 />);
const btn = container.querySelector('.ant-btn') as HTMLElement;
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();
fireEvent.mouseLeave(btn);
// wait for the popover to hide
await sleep(300);
expect(document.querySelector('.ant-popover')).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,13 @@
import { FormItem, Input } from '@formily/antd';
import { ISchema, observer, useForm } from '@formily/react';
import { Action, Form, SchemaComponent, SchemaComponentProvider, useActionContext } from '@nocobase/client';
import {
Action,
Form,
FormItem,
Input,
SchemaComponent,
SchemaComponentProvider,
useActionContext,
} from '@nocobase/client';
import React from 'react';
const useCloseAction = () => {

View File

@ -1,9 +1,10 @@
import { FormItem, Input } from '@formily/antd';
import { ISchema, observer, useForm } from '@formily/react';
import {
Action,
ActionContext,
Form,
FormItem,
Input,
SchemaComponent,
SchemaComponentProvider,
useActionContext,

View File

@ -1,19 +1,14 @@
import { FormItem, Input } from '@formily/antd';
import { Field } from '@formily/core';
import { ISchema, observer, useField, useFieldSchema, useForm } from '@formily/react';
import { Action, Form, SchemaComponent, SchemaComponentProvider, useActionContext } from '@nocobase/client';
import { Select } from 'antd';
import { ISchema, observer, useField, useFieldSchema } from '@formily/react';
import { Action, SchemaComponent, SchemaComponentProvider, useActionContext } from '@nocobase/client';
import { Radio } from 'antd';
import React, { useRef } from 'react';
const useCloseAction = () => {
const { setVisible } = useActionContext();
const form = useForm();
return {
async run() {
setVisible(false);
form.submit((values) => {
console.log(values);
});
},
};
};
@ -26,18 +21,17 @@ const Editable = observer(
<div>
<div style={{ marginBottom: 24 }}>
<Select
<Radio.Group
value={field.componentProps.openMode}
style={{ width: 100 }}
onChange={(value) => {
field.componentProps.openMode = value;
schema['x-component-props']['openMode'] = value;
onChange={(e) => {
field.componentProps.openMode = e.target.value;
schema['x-component-props']['openMode'] = e.target.value;
}}
>
<Select.Option value={'drawer'}>Drawer</Select.Option>
<Select.Option value={'modal'}>Modal</Select.Option>
<Select.Option value={'page'}>Page</Select.Option>
</Select>
<Radio.Button value="drawer">Drawer</Radio.Button>
<Radio.Button value="modal">Modal</Radio.Button>
<Radio.Button value="page">Page</Radio.Button>
</Radio.Group>
</div>
{props.children}
</div>
@ -50,6 +44,8 @@ const schema: ISchema = {
type: 'object',
properties: {
action1: {
type: 'void',
title: 'Open',
'x-decorator': 'Editable',
'x-component': 'Action',
'x-component-props': {
@ -57,22 +53,20 @@ const schema: ISchema = {
openMode: 'modal',
containerRefKey: 'containerRef',
},
type: 'void',
title: 'Open',
properties: {
drawer1: {
'x-component': 'Action.Container',
'x-component-props': {},
type: 'void',
title: 'Modal Title',
'x-component': 'Action.Container',
'x-component-props': {},
properties: {
hello1: {
title: 'T1',
'x-content': 'Hello',
},
footer1: {
'x-component': 'Action.Container.Footer',
type: 'void',
'x-component': 'Action.Container.Footer',
properties: {
close1: {
title: 'Close',
@ -99,12 +93,9 @@ const schema: ISchema = {
};
export default observer(() => {
const containerRef = useRef();
const containerRef = useRef(null);
return (
<SchemaComponentProvider
scope={{ containerRef, useCloseAction }}
components={{ Editable, Form, Action, Input, FormItem }}
>
<SchemaComponentProvider scope={{ useCloseAction, containerRef }} components={{ Editable, Action }}>
<SchemaComponent schema={schema} />
<div ref={containerRef}></div>
</SchemaComponentProvider>

View File

@ -1,6 +1,13 @@
import { FormItem, Input } from '@formily/antd';
import { ISchema, observer, useForm } from '@formily/react';
import { Action, Form, SchemaComponent, SchemaComponentProvider, useActionContext } from '@nocobase/client';
import {
Action,
Form,
FormItem,
Input,
SchemaComponent,
SchemaComponentProvider,
useActionContext,
} from '@nocobase/client';
import React from 'react';
const useCloseAction = () => {

View File

@ -10,9 +10,9 @@ import {
NumberPicker,
Submit,
} from '@formily/antd';
import { Select } from 'antd';
import { createForm, Field, onFieldValueChange } from '@formily/core';
import { Field, createForm, onFieldValueChange } from '@formily/core';
import { connect, createSchemaField, observer, useField, useForm, useFormEffects } from '@formily/react';
import { Select } from 'antd';
import React from 'react';
const ViewOptions = connect((props) => {
@ -110,6 +110,10 @@ const EditOptions = observer(
})
.then((values) => {
field.value = values;
return values;
})
.catch((err) => {
console.error(err);
});
}}
>

View File

@ -1,7 +1,7 @@
import { RecursionField, observer, useField, useFieldSchema } from '@formily/react';
import { Input, Select } from 'antd';
import { differenceBy, unionBy } from 'lodash';
import React, { useContext, useEffect, useMemo, useState } from 'react';
import React, { useContext, useMemo, useState } from 'react';
import {
FormProvider,
RecordPickerContext,
@ -9,6 +9,7 @@ import {
SchemaComponentOptions,
useActionContext,
} from '../..';
import { RecordProvider } from '../../../';
import {
TableSelectorParamsProvider,
useTableSelectorProps as useTsp,
@ -19,7 +20,6 @@ import { ActionContext } from '../action';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema';
import { flatData, getLabelFormatValue, useLabelUiSchema } from './util';
import { RecordProvider } from '../../../';
const useTableSelectorProps = () => {
const field: any = useField();

View File

@ -0,0 +1,23 @@
import React from 'react';
import { render, screen, sleep, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
describe('AssociationSelect', () => {
it('should render correctly', async () => {
render(<App1 />);
const selector = document.querySelector('.ant-select-selector');
expect(selector).toBeInTheDocument();
await userEvent.click(selector);
// 下拉框中显示 loading
expect(document.querySelector('.ant-spin')).toBeInTheDocument();
// 等待接口返回数据
await sleep(500);
expect(screen.getByText('title1')).toBeInTheDocument();
expect(screen.getByText('title2')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,57 @@
import { APIClientProvider, AssociationSelect, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { mockAPIClient } from '../../../../test';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/posts:list').reply(() => {
return [
200,
{
data: [
{
id: 1,
title: 'title1',
},
{
id: 2,
title: 'title2',
},
],
},
];
});
// 写一个简单的 schema
const schema = {
type: 'object',
properties: {
association: {
type: 'array',
'x-component': 'AssociationSelect',
'x-component-props': {
multiple: true,
fieldNames: {
label: 'title',
value: 'id',
},
service: {
resource: 'posts',
action: 'list',
},
style: {
width: '100%',
},
},
},
},
};
export default () => {
return (
<FormProvider>
<APIClientProvider apiClient={apiClient}>
<SchemaComponent components={{ AssociationSelect }} schema={schema} />
</APIClientProvider>
</FormProvider>
);
};

View File

@ -5,10 +5,12 @@ group:
path: /schema-components
---
# RemoteSelect
# AssociationSelect
## Examples
<code src="./demos/demo1.tsx"/>
## API
基于 Ant Design 的 [Select](https://ant.design/components/select/#API),相关扩展属性有:

View File

@ -0,0 +1,15 @@
import { render } from '@testing-library/react';
import React from 'react';
import App1 from '../demos/demo1';
describe('BlockItem', () => {
it('should render correctly', () => {
render(<App1 />);
const items = document.querySelectorAll('.nc-block-item');
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent('Block block1');
expect(items[1]).toHaveTextContent('Block block2');
expect(items[2]).toHaveTextContent('Block block3');
});
});

View File

@ -4,10 +4,13 @@ import { BlockItem, DndContext, DragHandler, SchemaComponent, SchemaComponentPro
import React from 'react';
const Block = observer(
(props) => {
() => {
const fieldSchema = useFieldSchema();
return (
<div style={{ marginBottom: 20, padding: '0 20px', height: 50, lineHeight: '50px', background: '#f1f1f1' }}>
<div
className="nc-block-item"
style={{ marginBottom: 20, padding: '0 20px', height: 50, lineHeight: '50px', background: '#f1f1f1' }}
>
Block {fieldSchema.name}
<DragHandler />
</div>
@ -16,11 +19,7 @@ const Block = observer(
{ displayName: 'Block' },
);
export default function App() {
return (
<SchemaComponentProvider components={{ DndContext, BlockItem, Block }}>
<SchemaComponent
schema={{
const schema = {
type: 'void',
name: 'page',
'x-component': 'DndContext',
@ -42,8 +41,12 @@ export default function App() {
'x-uid': uid(),
},
},
}}
/>
};
export default function App() {
return (
<SchemaComponentProvider components={{ DndContext, BlockItem, Block }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
}

View File

@ -1,12 +1,12 @@
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { createForm } from '@formily/core';
import { observer, RecursionField, Schema, useFieldSchema } from '@formily/react';
import { RecursionField, Schema, observer, useFieldSchema } from '@formily/react';
import { parseExpression } from 'cron-parser';
import { eq } from 'date-arithmetic';
import get from 'lodash/get';
import moment from 'moment';
import React, { useCallback, useMemo, useState } from 'react';
import { Calendar as BigCalendar, momentLocalizer } from 'react-big-calendar';
import * as dates from 'react-big-calendar/lib/utils/dates';
import { useTranslation } from 'react-i18next';
import { RecordProvider } from '../../../';
import { i18n } from '../../../i18n';
@ -247,7 +247,7 @@ export const Calendar: any = observer(
agendaDateFormat: 'M-DD',
dayHeaderFormat: 'Y-M-DD',
dayRangeHeaderFormat: ({ start, end }, culture, local) => {
if (dates.eq(start, end, 'month')) {
if (eq(start, end, 'month')) {
return local.format(start, 'Y-M', culture);
}
return `${local.format(start, 'Y-M', culture)} - ${local.format(end, 'Y-M', culture)}`;

View File

@ -2,7 +2,7 @@ import { LeftOutlined, RightOutlined } from '@ant-design/icons';
import { observer } from '@formily/react';
import { Button } from 'antd';
import React, { useContext } from 'react';
import { navigate } from 'react-big-calendar/lib/utils/constants';
import { Navigate } from 'react-big-calendar/dist/react-big-calendar.esm';
import { useDesignable } from '../../hooks';
import { CalendarToolbarContext } from './context';
@ -12,8 +12,8 @@ export const Nav = observer(
const { onNavigate } = useContext(CalendarToolbarContext);
return (
<div className="ant-btn-group">
<Button icon={<LeftOutlined />} onClick={() => onNavigate(navigate.PREVIOUS)}></Button>
<Button icon={<RightOutlined />} onClick={() => onNavigate(navigate.NEXT)}></Button>
<Button icon={<LeftOutlined />} onClick={() => onNavigate(Navigate.PREVIOUS)}></Button>
<Button icon={<RightOutlined />} onClick={() => onNavigate(Navigate.NEXT)}></Button>
<DesignableBar />
</div>
);

View File

@ -1,7 +1,7 @@
import { observer } from '@formily/react';
import { Button } from 'antd';
import React, { useContext } from 'react';
import { navigate } from 'react-big-calendar/lib/utils/constants';
import { Navigate } from 'react-big-calendar/dist/react-big-calendar.esm';
import { useTranslation } from 'react-i18next';
import { useDesignable } from '../../hooks';
import { CalendarToolbarContext } from './context';
@ -14,7 +14,7 @@ export const Today = observer(
return (
<Button
onClick={() => {
onNavigate(navigate.TODAY);
onNavigate(Navigate.TODAY);
}}
>
{t('Today')}

View File

@ -17,7 +17,9 @@ export const ViewSelect = observer(
<div className="ant-btn-group">
<Select value={view} onChange={onView}>
{views.map((name) => (
<Select.Option value={name}>{messages[name]}</Select.Option>
<Select.Option key={name} value={name}>
{messages[name]}
</Select.Option>
))}
</Select>
<DesignableBar />

View File

@ -0,0 +1,27 @@
import moment from 'moment';
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
describe('Calendar', () => {
it('basic', () => {
render(<App1 />);
const currentDate = moment().format('YYYY-M');
expect(screen.getByText('Today')).toBeInTheDocument();
expect(screen.getByText(currentDate)).toBeInTheDocument();
expect(screen.getByText('Month')).toBeInTheDocument();
});
it('use CalendarBlockProvider', () => {
render(<App2 />);
const currentDate = moment().format('YYYY-M');
expect(screen.getByText('Today')).toBeInTheDocument();
expect(screen.getByText(currentDate)).toBeInTheDocument();
expect(screen.getByText('Month')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,14 @@
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/demo1';
describe('CardItem', () => {
it('should works', () => {
render(<App1 />);
// 标题
expect(screen.getByText('Card')).toBeInTheDocument();
// 内容
expect(screen.getByText('Hello Card!')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,30 @@
import { CardItem, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
card: {
type: 'void',
'x-component': 'CardItem',
'x-component-props': {
title: 'Card',
},
properties: {
hello: {
type: 'void',
'x-component': 'div',
'x-content': 'Hello Card!',
},
},
},
},
};
export default () => {
return (
<FormProvider>
<SchemaComponent components={{ CardItem }} schema={schema} />
</FormProvider>
);
};

View File

@ -8,3 +8,7 @@ group:
# CardItem
卡片装饰器。除此之外,也继承了 BlockItem 的功能。
## Example
<code src="./demos/demo1.tsx" />

View File

@ -0,0 +1,45 @@
import React from 'react';
import { fireEvent, render, screen, sleep, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
describe('Cascader', () => {
it('sync', async () => {
const { container } = render(<App1 />);
const select = container.querySelector('.ant-select-selector') as HTMLElement;
// 显示下拉框
await userEvent.click(select);
// 点击选项
fireEvent.click(screen.getByText('Zhejiang'));
fireEvent.click(screen.getByText('Hangzhou'));
fireEvent.click(screen.getByText('West Lake'));
// 页面中显示的内容
// 因为内容被不同的标签分开了,所以需要分开查找
expect(screen.getByText('Zhejiang /')).toBeInTheDocument();
expect(screen.getByText('Hangzhou /')).toBeInTheDocument();
});
it('async', async () => {
const { container } = render(<App2 />);
const select = container.querySelector('.ant-select-selector') as HTMLElement;
// 显示下拉框
await userEvent.click(select);
// 点击选项
fireEvent.click(screen.getByText('Zhejiang'));
// 因为是异步加载,所以需要等待一下
expect(screen.queryByText('Zhejiang Dynamic 1')).not.toBeInTheDocument();
await sleep(300);
expect(screen.getByText('Zhejiang Dynamic 1')).toBeInTheDocument();
fireEvent.click(screen.getByText('Zhejiang Dynamic 1'));
// 页面中显示的内容
// 因为内容被不同的标签分开了,所以需要分开查找
expect(screen.getByText('Zhejiang /')).toBeInTheDocument();
});
});

View File

@ -64,7 +64,7 @@ const useLoadData = () => {
},
];
field.dataSource = [...field.dataSource];
}, 500);
}, 300);
};
};

View File

@ -2,7 +2,7 @@ import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { connect, mapProps, mapReadPretty, useField } from '@formily/react';
import { isValid } from '@formily/shared';
import { Checkbox as AntdCheckbox, Tag } from 'antd';
import type { CheckboxGroupProps, CheckboxProps } from 'antd/lib/checkbox';
import type { CheckboxGroupProps, CheckboxProps } from 'antd/es/checkbox';
import uniq from 'lodash/uniq';
import React from 'react';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';

View File

@ -0,0 +1,86 @@
import React from 'react';
import { render, screen, userEvent } from 'testUtils';
import App1 from '../demos/checkbox';
import App2 from '../demos/checkbox.group';
describe('Checkbox', () => {
it('should display the title', () => {
render(<App1 />);
expect(screen.getByText('编辑模式').innerHTML).toBe('编辑模式');
expect(screen.getByText('阅读模式').innerHTML).toBe('阅读模式');
});
it('should display the icon when checked', async () => {
const { container } = render(<App1 />);
const input = container.querySelector('input') as HTMLInputElement;
await userEvent.click(input);
const icon = container.querySelector('svg') as SVGElement;
expect(icon).toMatchInlineSnapshot(`
<svg
aria-hidden="true"
data-icon="check"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474a32 32 0 00-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1.4-12.8-6.3-12.8z"
/>
</svg>
`);
// icon should be hidden when unchecked
await userEvent.click(input);
expect(container.querySelector('svg')).toBeNull();
});
});
describe('Checkbox.Group', () => {
it('should display the title', () => {
render(<App2 />);
expect(screen.getByText('编辑模式').innerHTML).toBe('编辑模式');
expect(screen.getByText('阅读模式').innerHTML).toBe('阅读模式');
});
it('should display the tag when checked', async () => {
const { container } = render(<App2 />);
const option1 = screen.getByLabelText('选项1');
const option2 = screen.getByLabelText('选项2');
await userEvent.click(option1);
expect(container.querySelectorAll('.ant-tag')).toMatchInlineSnapshot(`
NodeList [
<span
class="ant-tag ant-tag-red"
>
1
</span>,
]
`);
await userEvent.click(option2);
expect(container.querySelectorAll('.ant-tag')).toMatchInlineSnapshot(`
NodeList [
<span
class="ant-tag ant-tag-red"
>
1
</span>,
<span
class="ant-tag ant-tag-blue"
>
2
</span>,
]
`);
// should be hidden when unchecked
await userEvent.click(option1);
await userEvent.click(option2);
expect(container.querySelectorAll('.ant-tag')).toMatchInlineSnapshot(`NodeList []`);
});
});

View File

@ -11,8 +11,8 @@ group:
### 勾选
<code src="./demos/demo1.tsx" />
<code src="./demos/checkbox.tsx" />
### 组
<code src="./demos/demo2.tsx" />
<code src="./demos/checkbox.group.tsx" />

View File

@ -0,0 +1,22 @@
import React from 'react';
import { render, screen, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
describe('CollectionSelect', () => {
it('should works', async () => {
render(<App1 />);
// 标题
expect(screen.getByText('Collection')).toBeInTheDocument();
const selector = document.querySelector('.ant-select-selector');
expect(selector).toBeInTheDocument();
await userEvent.click(selector);
// 下拉框内容
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(screen.getByText('测试表')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,29 @@
import { FormItem } from '@formily/antd';
import { CollectionManagerProvider, CollectionSelect, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { collections } from '../../../../test';
const schema = {
type: 'object',
properties: {
select: {
type: 'string',
title: 'Collection',
'x-decorator': 'FormItem',
'x-component': 'CollectionSelect',
},
},
};
export default () => {
const { t } = useTranslation();
return (
<FormProvider>
<CollectionManagerProvider collections={collections}>
<SchemaComponent components={{ FormItem, CollectionSelect }} scope={{ t }} schema={schema} />
</CollectionManagerProvider>
</FormProvider>
);
};

View File

@ -0,0 +1,12 @@
---
nav:
path: /client
group:
path: /schema-components
---
# CollectionSelect
## Example
<code src="./demos/demo1.tsx" />

View File

@ -0,0 +1,41 @@
import React from 'react';
import { fireEvent, render, screen } from 'testUtils';
import App from '../demos/demo1';
describe('ColorSelect', () => {
it('should display the value of user selected', async () => {
const { container } = render(<App />);
const selector = container.querySelector('.ant-select-selector');
// @ts-ignore
fireEvent.mouseDown(selector);
// @ts-ignore
expect(screen.getByText('Red')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Magenta')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Volcano')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Orange')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Gold')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Lime')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Green')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Cyan')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Blue')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Geek blue')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Purple')).toBeInTheDocument();
// @ts-ignore
expect(screen.getByText('Default')).toBeInTheDocument();
// select red
fireEvent.click(screen.getByText('Red'));
expect(screen.getAllByText('Red').length).toBe(3);
});
});

View File

@ -1,11 +1,11 @@
import { css } from '@emotion/css';
import { connect, mapReadPretty } from '@formily/react';
import { error } from '@nocobase/utils/client';
import cronstrue from 'cronstrue';
import React, { useMemo } from 'react';
import { Cron as ReactCron, CronProps } from 'react-js-cron';
import { useAPIClient } from '../../../api-client';
import { CronProps, Cron as ReactCron } from 'react-js-cron';
import 'react-js-cron/dist/styles.css';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../api-client';
const Input = (props: Omit<CronProps, 'setValue'> & { onChange: (value: string) => void }) => {
const { onChange, ...rest } = props;
@ -46,7 +46,7 @@ const ReadPretty = (props) => {
use24HourTimeFormat: true,
});
} catch {
console.error(`The '${props.value}' is not a valid cron expression`);
error(`The '${props.value}' is not a valid cron expression`);
return props.value;
}
}, [props.value]);

View File

@ -0,0 +1,39 @@
import React from 'react';
import { render, screen, userEvent } from 'testUtils';
import Cron from '../demos/demo1';
import CronSet from '../demos/demo2';
describe('Cron', () => {
it('should render correctly', () => {
render(<Cron />);
// title
expect(screen.getByText('Cron')).toBeInTheDocument();
expect(screen.getByText('Every')).toBeInTheDocument();
expect(screen.getByText('day')).toBeInTheDocument();
expect(screen.getByText('at')).toBeInTheDocument();
expect(screen.getByText('every hour')).toBeInTheDocument();
expect(screen.getByText('every minute')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
});
describe('CronSet', () => {
it('should render correctly', async () => {
render(<CronSet />);
// title
expect(screen.getByText('CronSet')).toBeInTheDocument();
const selector = document.querySelector('.ant-select-selector');
expect(selector).toBeInTheDocument();
await userEvent.click(selector);
await userEvent.click(screen.getByText('Custom'));
expect(screen.getByText('Every')).toBeInTheDocument();
expect(screen.getByText('day')).toBeInTheDocument();
expect(screen.getByText('Clear')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,26 @@
import { FormItem } from '@formily/antd';
import { Cron, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
const schema = {
type: 'object',
properties: {
cron: {
type: 'string',
title: 'Cron',
'x-decorator': 'FormItem',
'x-component': 'Cron',
},
},
};
export default () => {
const { t } = useTranslation();
return (
<FormProvider>
<SchemaComponent components={{ Cron, FormItem }} scope={{ t }} schema={schema} />
</FormProvider>
);
};

View File

@ -0,0 +1,45 @@
import { FormItem } from '@formily/antd';
import { CronSet, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
const schema = {
type: 'object',
properties: {
cronSet: {
type: 'string',
title: 'CronSet',
'x-decorator': 'FormItem',
'x-component': 'CronSet',
'x-component-props': 'allowClear',
enum: [
{
label: '{{t("Daily")}}',
value: '0 0 0 * * ?',
},
{
label: '{{t("Weekly")}}',
value: 'every_week',
},
{
label: '{{t("Monthly")}}',
value: 'every_month',
},
{
label: '{{t("Yearly")}}',
value: 'every_year',
},
],
},
},
};
export default () => {
const { t } = useTranslation();
return (
<FormProvider>
<SchemaComponent components={{ CronSet, FormItem }} scope={{ t }} schema={schema} />
</FormProvider>
);
};

View File

@ -0,0 +1,18 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Cron
## Example
#### Cron
<code src="./demos/demo1.tsx" />
#### CronSet
<code src="./demos/demo2.tsx" />

View File

@ -3,7 +3,7 @@ import { DatePicker as AntdDatePicker } from 'antd';
import type {
DatePickerProps as AntdDatePickerProps,
RangePickerProps as AntdRangePickerProps,
} from 'antd/lib/date-picker';
} from 'antd/es/date-picker';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ReadPretty } from './ReadPretty';
@ -40,7 +40,7 @@ export const DatePicker = (props) => {
return <_DatePicker {...props} />;
};
DatePicker.RangePicker = (props) => {
DatePicker.RangePicker = function RangePicker(props) {
const { t } = useTranslation();
const { utc = true } = useDatePickerContext();
const rangesValue = getDateRanges();

View File

@ -1,10 +1,10 @@
import { usePrefixCls } from '@formily/antd/lib/__builtins__';
import { usePrefixCls } from '@formily/antd/esm/__builtins__';
import { isArr } from '@formily/shared';
import { getDefaultFormat, str2moment } from '@nocobase/utils/client';
import type {
DatePickerProps as AntdDatePickerProps,
RangePickerProps as AntdRangePickerProps,
} from 'antd/lib/date-picker';
} from 'antd/es/date-picker';
import cls from 'classnames';
import moment from 'moment';
import React from 'react';

View File

@ -0,0 +1,127 @@
import React from 'react';
import { render, screen, sleep, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4';
import App5 from '../demos/demo5';
import App6 from '../demos/demo6';
describe('DatePicker', () => {
it('basic', async () => {
const { container, getByText } = render(<App1 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01 00:00:00');
await userEvent.click(getByText('OK'));
await sleep(100);
expect(input).toHaveValue('2023/05/01 00:00:00');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// TODO: 本地没有错,但是 CI 上会报错,暂时注释掉
// Value
// expect(screen.getByText('2023-04-30T16:00:00.000Z')).toBeInTheDocument();
});
it('GMT', async () => {
const { container, getByText } = render(<App2 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
// 清空默认值
await userEvent.clear(input);
await userEvent.type(input, '2023/05/01 00:00:00');
await userEvent.click(getByText('OK'));
expect(input).toHaveValue('2023/05/01 00:00:00');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01T00:00:00.000Z')).toBeInTheDocument();
});
it('non-UTC', async () => {
const { container } = render(<App3 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const input = container.querySelector('input') as HTMLElement;
await userEvent.click(picker);
await userEvent.type(input, '2023/05/01');
const selected = document.querySelector('.ant-picker-cell-selected') as HTMLElement;
expect(selected).toBeInTheDocument();
await userEvent.click(selected);
expect(input).toHaveValue('2023/05/01');
// Read pretty
expect(screen.getByText('2023/05/01 00:00:00', { selector: '.ant-description-date-picker' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01')).toBeInTheDocument();
});
});
describe('RangePicker', () => {
it('GMT', async () => {
const { container, getByPlaceholderText } = render(<App4 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
expect(startInput).toHaveValue('2023-05-01');
expect(endInput).toHaveValue('2023-05-02');
// Read pretty
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01T00:00:00.000Z ~ 2023-05-02T23:59:59.999Z')).toBeInTheDocument();
});
it('non-GMT', async () => {
const { container, getByPlaceholderText } = render(<App5 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
await sleep(100);
expect(startInput).toHaveValue('2023-05-01');
expect(endInput).toHaveValue('2023-05-02');
// Read pretty
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument();
// TODO: 本地没有错,但是 CI 上会报错,暂时注释掉
// Value
// expect(screen.getByText(/2023-04-30t16:00:00\.000z ~ 2023-05-02t15:59:59\.999z/i)).toBeInTheDocument();
});
it('non-UTC', async () => {
const { container, getByPlaceholderText } = render(<App6 />);
const picker = container.querySelector('.ant-picker') as HTMLElement;
const startInput = getByPlaceholderText('Start date');
const endInput = getByPlaceholderText('End date');
await userEvent.click(picker);
await userEvent.click(document.querySelector('[title="2023-05-01"]') as HTMLElement);
await userEvent.click(document.querySelector('[title="2023-05-02"]') as HTMLElement);
expect(startInput).toHaveValue('2023-05-01');
expect(endInput).toHaveValue('2023-05-02');
// Read pretty
expect(screen.getByText('2023-05-01~2023-05-02', { selector: '.ant-description-text' })).toBeInTheDocument();
// Value
expect(screen.getByText('2023-05-01 ~ 2023-05-02')).toBeInTheDocument();
});
});

View File

@ -1,5 +1,5 @@
/**
* title: DatePicker.RangePicker
* title: DatePicker (GMT)
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
@ -12,36 +12,31 @@ const schema = {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
},
'x-reactions': [
{
target: 'read1',
default: '2022-06-04T15:00:00.000Z',
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
type: 'string',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
},
},

View File

@ -1,5 +1,5 @@
/**
* title: DatePicker (GMT)
* title: DatePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
@ -15,10 +15,9 @@ const schema = {
'x-component': 'DatePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
showTime: false,
utc: false,
},
default: '2022-06-04T15:00:00.000Z',
'x-reactions': {
target: '*(read1,read2)',
fulfill: {
@ -29,7 +28,7 @@ const schema = {
},
},
read1: {
type: 'string',
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
@ -37,7 +36,6 @@ const schema = {
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
gmt: true,
},
},
read2: {

View File

@ -3,6 +3,7 @@
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import moment from 'moment';
import React from 'react';
const schema = {
@ -14,7 +15,8 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
utc: false,
gmt: true,
defaultPickerValue: [moment('2023-05-01')],
},
'x-reactions': [
{
@ -41,6 +43,10 @@ const schema = {
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: true,
defaultPickerValue: [moment('2023-05-01')],
},
},
read2: {
type: 'string',

View File

@ -1,8 +1,9 @@
/**
* title: DatePicker
* title: DatePicker.RangePicker
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import moment from 'moment';
import React from 'react';
const schema = {
@ -12,30 +13,38 @@ const schema = {
type: 'boolean',
title: `Editable`,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: false,
utc: false,
gmt: false,
defaultPickerValue: [moment('2023-05-01')],
},
'x-reactions': {
target: '*(read1,read2)',
'x-reactions': [
{
target: 'read1',
fulfill: {
state: {
value: '{{$self.value}}',
},
},
},
{
target: 'read2',
fulfill: {
state: {
value: '{{$self.value && $self.value.join(" ~ ")}}',
},
},
},
],
},
read1: {
type: 'boolean',
title: `Read pretty`,
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
dateFormat: 'YYYY/MM/DD',
showTime: true,
defaultPickerValue: [moment('2023-05-01')],
},
},
read2: {

View File

@ -3,6 +3,7 @@
*/
import { FormItem } from '@formily/antd';
import { DatePicker, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import moment from 'moment';
import React from 'react';
const schema = {
@ -14,7 +15,8 @@ const schema = {
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
gmt: false,
utc: false,
defaultPickerValue: [moment('2023-05-01')],
},
'x-reactions': [
{
@ -41,6 +43,9 @@ const schema = {
'x-read-pretty': true,
'x-decorator': 'FormItem',
'x-component': 'DatePicker.RangePicker',
'x-component-props': {
defaultPickerValue: [moment('2023-05-01')],
},
},
read2: {
type: 'string',

View File

@ -15,23 +15,23 @@ group:
### DatePicker (GMT)
<code src="./demos/demo3.tsx" />
<code src="./demos/demo2.tsx" />
### DatePicker (non-UTC)
<code src="./demos/demo5.tsx" />
<code src="./demos/demo3.tsx" />
### RangePicker (GMT)
<code src="./demos/demo2.tsx" />
<code src="./demos/demo4.tsx" />
### RangePicker (non-GMT)
<code src="./demos/demo6.tsx" />
<code src="./demos/demo5.tsx" />
### RangePicker (non-UTC)
<code src="./demos/demo7.tsx" />
<code src="./demos/demo6.tsx" />
## API

View File

@ -0,0 +1,12 @@
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/demo1';
describe('Details', () => {
it('should render correctly', () => {
render(<App1 />);
expect(screen.getByText('this is name')).toBeInTheDocument();
expect(screen.getByText('this is age')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,39 @@
import { FormItem } from '@formily/antd';
import { Details, FormProvider, Input, SchemaComponent } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
details: {
type: 'string',
'x-component': 'Details',
properties: {
name: {
type: 'string',
title: 'Name',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-read-pretty': true,
default: 'this is name',
},
age: {
type: 'string',
title: 'Age',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-read-pretty': true,
default: 'this is age',
},
},
},
},
};
export default () => {
return (
<FormProvider>
<SchemaComponent components={{ Details, FormItem, Input }} schema={schema} />
</FormProvider>
);
};

View File

@ -0,0 +1,12 @@
---
nav:
path: /client
group:
path: /schema-components
---
# Details
## Example
<code src="./demos/demo1.tsx" />

View File

@ -0,0 +1,18 @@
import React from 'react';
import { render, screen } from 'testUtils';
import App1 from '../demos/demo1';
describe('ErrorFallback', () => {
it('should render correctly', () => {
render(<App1 />);
expect(screen.getByText(/render failed/i)).toBeInTheDocument();
expect(screen.getByText(/this is likely a nocobase internals bug\. please open an issue at/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /feedback/i })).toBeInTheDocument();
expect(screen.getByText(/try again/i)).toBeInTheDocument();
expect(screen.getByText(/error: error message/i)).toBeInTheDocument();
// 底部复制按钮
expect(document.querySelector('.ant-typography-copy')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,15 @@
import React from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../ErrorFallback';
const App = () => {
throw new Error('error message');
};
export default () => {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<App />
</ErrorBoundary>
);
};

View File

@ -0,0 +1,12 @@
---
nav:
path: /client
group:
path: /schema-components
---
# ErrorFallback
## Example
<code src="./demos/demo1.tsx" />

View File

@ -1,15 +1,15 @@
import { ISchema, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { useTranslation } from 'react-i18next';
import { useFieldSchema, useField, ISchema } from '@formily/react';
import { useDesignable } from '../..';
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
export const ExpandActionDesign = (props) => {
const { t } = useTranslation();
const fieldSchema = useFieldSchema();
const field = useField();
const { dn } = useDesignable();
const { titleExpand, titleCollapse, iconExpand, iconCollapse } = fieldSchema['x-component-props'] || {}
const { titleExpand, titleCollapse, iconExpand, iconCollapse } = fieldSchema['x-component-props'] || {};
return (
<GeneralSchemaDesigner {...props} disableInitializer>
@ -24,13 +24,13 @@ export const ExpandActionDesign = (props) => {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: `${t('Button title')} - ${t('Expand all')}`,
default: titleExpand
default: titleExpand,
},
titleCollapse: {
'x-decorator': 'FormItem',
'x-component': 'Input',
title: `${t('Button title')} - ${t('Collapse all')}`,
default: titleCollapse
default: titleCollapse,
},
iconExpand: {
'x-decorator': 'FormItem',
@ -75,14 +75,14 @@ export const ExpandActionDesign = (props) => {
iconExpand,
iconCollapse,
type,
danger: type === 'danger'
danger: type === 'danger',
};
dn.emit('patch', {
schema: {
'x-uid': fieldSchema['x-uid'],
'x-component-props': fieldSchema['x-component-props'],
title: fieldSchema.title,
}
},
});
dn.refresh();
}}

View File

@ -1,9 +1,8 @@
import React from 'react';
import { useFieldSchema } from '@formily/react';
import { useTranslation } from 'react-i18next';
import { useTableBlockContext, useTableSelectorContext } from '../../../block-provider';
import { Button } from 'antd';
import { css } from '@emotion/css';
import { useFieldSchema } from '@formily/react';
import { Button } from 'antd';
import React from 'react';
import { useTableBlockContext, useTableSelectorContext } from '../../../block-provider';
import { Icon } from '../../../icon';
const actionDesignerCss = css`
@ -46,10 +45,9 @@ const actionDesignerCss = css`
`;
export const ExpandAction = (props) => {
const { t } = useTranslation();
const schema = useFieldSchema();
const ctxSelector = useTableSelectorContext()
const ctxBlock = useTableBlockContext()
const ctxSelector = useTableSelectorContext();
const ctxBlock = useTableBlockContext();
const isTableSelector = schema.parent?.parent?.['x-decorator'] === 'TableSelectorProvider';
const ctx = isTableSelector ? ctxSelector : ctxBlock;
const { titleExpand, titleCollapse, iconExpand, iconCollapse } = schema['x-component-props'] || {};

View File

@ -1,7 +1,7 @@
import { ExpandAction } from './Expand.Action'
import { ExpandActionDesign } from './Expand.Action.Design'
import { ExpandAction } from './Expand.Action';
import { ExpandActionDesign } from './Expand.Action.Design';
export const Expand: any = ExpandAction
export const Expand: any = ExpandAction;
Expand.Action = ExpandAction
Expand.Action.Design = ExpandActionDesign
Expand.Action = ExpandAction;
Expand.Action.Design = ExpandActionDesign;

View File

@ -4,11 +4,11 @@ import React from 'react';
import { useRequest } from '../../../api-client';
import { useProps } from '../../hooks/useProps';
import { DatePickerProvider } from '../date-picker';
import { FilterContext } from './context';
import { FilterActionDesigner } from './Filter.Action.Designer';
import { FilterAction } from './FilterAction';
import { FilterGroup } from './FilterGroup';
import { SaveDefaultValue } from './SaveDefaultValue';
import { FilterContext } from './context';
const useDef = (options) => {
const field = useField<ObjectFieldModel>();

View File

@ -2,11 +2,11 @@ import { CloseCircleOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { observer } from '@formily/react';
import { Cascader, Select, Space } from 'antd';
import React, { useContext, useEffect } from 'react';
import React, { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useCompile } from '../..';
import { RemoveConditionContext } from './context';
import { DynamicComponent } from './DynamicComponent';
import { RemoveConditionContext } from './context';
import { useValues } from './useValues';
export const FilterItem = observer(
@ -16,7 +16,8 @@ export const FilterItem = observer(
const remove = useContext(RemoveConditionContext);
const { schema, fields, operators, dataIndex, operator, setDataIndex, setOperator, value, setValue } = useValues();
return (
<div style={{ marginBottom: 8 }}>
// 添加 nc-filter-item 类名是为了帮助编写测试时更容易选中该元素
<div style={{ marginBottom: 8 }} className="nc-filter-item">
<Space>
<Cascader
className={css`
@ -46,14 +47,7 @@ export const FilterItem = observer(
}}
placeholder={t('Comparision')}
/>
{!operator?.noValue &&
React.createElement(DynamicComponent, {
value: value ? value : undefined,
schema,
onChange(value) {
setValue(value);
},
})}
{!operator?.noValue ? <DynamicComponent value={value} schema={schema} onChange={setValue} /> : null}
{!props.disabled && (
<a>
<CloseCircleOutlined onClick={() => remove()} style={{ color: '#bfbfbf' }} />

View File

@ -0,0 +1,123 @@
import React from 'react';
import { render, screen, userEvent, within } from 'testUtils';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
import App4 from '../demos/demo4';
import App5 from '../demos/demo5';
import App6 from '../demos/demo6';
describe('Filter', () => {
it('Filter & Action', async () => {
render(<App3 />);
await userEvent.click(screen.getByText(/open/i));
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
// 弹窗中显示的内容
expect(within(tooltip).getByText(/name/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/ne/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/tags \/ title/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/eq/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/^Add condition$/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/^Add condition group$/i)).toBeInTheDocument();
const inputs = tooltip.querySelectorAll('.ant-input');
expect(inputs).toHaveLength(2);
// 输入框中的默认值
expect(inputs[0]).toHaveValue('aa');
expect(inputs[1]).toHaveValue('aaa');
});
it('default value', () => {
render(<App2 />);
expect(screen.getByText(/name/i)).toBeInTheDocument();
expect(screen.getByText(/ne/i)).toBeInTheDocument();
expect(screen.getByText(/tags \/ title/i)).toBeInTheDocument();
expect(screen.getByText(/eq/i)).toBeInTheDocument();
expect(screen.getByText(/^Add condition$/i)).toBeInTheDocument();
expect(screen.getByText(/^Add condition group$/i)).toBeInTheDocument();
const inputs = document.querySelectorAll('.ant-input');
expect(inputs).toHaveLength(2);
// 输入框中的默认值
expect(inputs[0]).toHaveValue('');
expect(inputs[1]).toHaveValue('aaa');
});
it('custom dynamic component', async () => {
render(<App4 />);
expect(screen.getByText(/name/i)).toBeInTheDocument();
expect(screen.getByText(/ne/i)).toBeInTheDocument();
expect(screen.getByText(/tags \/ title/i)).toBeInTheDocument();
expect(screen.getByText(/eq/i)).toBeInTheDocument();
expect(screen.getByText(/^Add condition$/i)).toBeInTheDocument();
expect(screen.getByText(/^Add condition group$/i)).toBeInTheDocument();
const selects = screen.getAllByText('默认');
// 自定义组件中的选择框
expect(selects[0]).toBeInTheDocument();
expect(selects[1]).toBeInTheDocument();
const inputs = document.querySelectorAll('.ant-input');
expect(inputs).toHaveLength(2);
// 输入框中的默认值
expect(inputs[0]).toHaveValue('aaa');
expect(inputs[1]).toHaveValue('bbb');
});
it('FilterAction', async () => {
render(<App5 />);
await userEvent.click(screen.getByText(/filter/i));
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
// 弹窗中显示的内容
expect(within(tooltip).getByText(/name/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/ne/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/tags \/ title/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/eq/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/^Add condition$/i)).toBeInTheDocument();
expect(within(tooltip).getByText(/^Add condition group$/i)).toBeInTheDocument();
const inputs = tooltip.querySelectorAll('.ant-input');
expect(inputs).toHaveLength(2);
// 输入框中的默认值
expect(inputs[0]).toHaveValue('');
expect(inputs[1]).toHaveValue('aaa');
});
it('dynamic options', async () => {
render(<App6 />);
const addBtn = screen.getByText(/^Add condition$/i);
const addGroupBtn = screen.getByText(/^Add condition group$/i);
expect(screen.getByText(/test1/i)).toBeInTheDocument();
expect(addBtn).toBeInTheDocument();
expect(addGroupBtn).toBeInTheDocument();
await userEvent.click(addBtn);
const item = document.querySelector('.nc-filter-item');
const selector = item.querySelector('.ant-select-selector');
expect(item).toBeInTheDocument();
await userEvent.click(selector);
// 选中 Title1
await userEvent.click(screen.getByText(/title1/i));
expect(screen.getByText(/title1/i, { selector: '.ant-select-selection-item' })).toBeInTheDocument();
expect(screen.getByText(/contains/i, { selector: '.ant-select-selection-item' })).toBeInTheDocument();
// 切换为 test2
await userEvent.click(screen.getByText(/test1/i));
await userEvent.click(screen.getByText(/test2/i, { selector: '.ant-select-item-option-content' }));
await userEvent.click(selector);
// 选中 Title2
await userEvent.click(screen.getByText(/title2/i));
expect(screen.getByText(/title2/i, { selector: '.ant-select-selection-item' })).toBeInTheDocument();
expect(screen.getByText(/contains/i, { selector: '.ant-select-selection-item' })).toBeInTheDocument();
});
});

View File

@ -1,19 +1,7 @@
import {
AntdSchemaComponentProvider,
SharedFilter,
Input,
SchemaComponent,
SchemaComponentProvider,
} from '@nocobase/client';
import { AntdSchemaComponentProvider, Filter, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema: any = {
type: 'void',
properties: {
demo: {
name: 'filter',
type: 'object',
enum: [
const options = [
{
name: 'name',
title: 'Name',
@ -75,8 +63,9 @@ const schema: any = {
},
],
},
],
default: {
];
const defaultValue = {
$or: [
{
name: {
@ -89,7 +78,16 @@ const schema: any = {
},
},
],
},
};
const schema: any = {
type: 'void',
properties: {
demo: {
name: 'filter',
type: 'object',
enum: options,
default: defaultValue,
'x-component': 'Filter',
'x-component-props': {},
},

View File

@ -1,12 +1,11 @@
import { ISchema, useForm } from '@formily/react';
import {
AntdSchemaComponentProvider,
SharedFilter,
Filter,
Input,
SchemaComponent,
SchemaComponentProvider,
useActionContext,
useRequest,
useActionContext
} from '@nocobase/client';
import React from 'react';
@ -74,6 +73,21 @@ const dataSource = [
},
];
const defaultValue = {
$or: [
{
name: {
$ne: 'aa',
},
},
{
'tags.title': {
$eq: 'aaa',
},
},
],
};
const schema: ISchema = {
type: 'object',
properties: {
@ -99,31 +113,10 @@ const schema: ISchema = {
properties: {
filter: {
type: 'object',
default: {
$or: [
{
name: {
$ne: 'aa',
},
},
{
'tags.title': {
$eq: 'aaa',
},
},
],
},
default: defaultValue,
'x-component': 'Filter',
'x-component-props': {
useDataSource(options) {
return useRequest(
() =>
Promise.resolve({
data: dataSource,
}),
options,
);
},
options: dataSource,
},
},
footer: {

View File

@ -1,7 +1,7 @@
import { ISchema } from '@formily/react';
import {
AntdSchemaComponentProvider,
SharedFilter,
Filter,
Input,
SchemaComponent,
SchemaComponentProvider,
@ -78,12 +78,12 @@ const defaultValue = {
$or: [
{
name: {
$ne: '{{node1.field1}}',
$ne: 'aaa',
},
},
{
'tags.title': {
$eq: 'aaa',
$eq: 'bbb',
},
},
],

View File

@ -1,12 +1,8 @@
import { AntdSchemaComponentProvider, FilterAction, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import { ISchema } from '@formily/json-schema';
import { Filter, FilterAction, Input, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import React from 'react';
const schema: any = {
type: 'void',
properties: {
demo: {
type: 'object',
enum: [
const options = [
{
name: 'name',
title: 'Name',
@ -68,8 +64,9 @@ const schema: any = {
},
],
},
],
default: {
];
const defaultValue = {
$or: [
{
name: {
@ -82,7 +79,16 @@ const schema: any = {
},
},
],
},
};
const schema: ISchema = {
type: 'void',
properties: {
demo: {
type: 'object',
title: 'Filter',
enum: '{{ options }}',
default: defaultValue,
'x-component': 'FilterAction',
'x-component-props': {},
},
@ -91,10 +97,8 @@ const schema: any = {
export default () => {
return (
<SchemaComponentProvider>
<AntdSchemaComponentProvider>
<SchemaComponent components={{ FilterAction }} schema={schema} />
</AntdSchemaComponentProvider>
<SchemaComponentProvider components={{ FilterAction, Filter, Input }} scope={{ options }}>
<SchemaComponent schema={schema} />
</SchemaComponentProvider>
);
};

View File

@ -2,7 +2,7 @@ import {
AntdSchemaComponentProvider,
CollectionManagerProvider,
CollectionProvider,
SharedFilter,
Filter,
Input,
SchemaComponent,
SchemaComponentProvider,

View File

@ -1,2 +1,4 @@
export * from './Filter';
export * from './FilterAction';
export * from './useFilterActionProps';

View File

@ -1,6 +1,6 @@
import { useField } from '@formily/react';
import { merge } from '@formily/shared';
import flat from 'flat';
import flat, { unflatten } from 'flat';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { useContext, useEffect } from 'react';
@ -31,7 +31,7 @@ export const useValues = () => {
};
const value2data = () => {
field.data = field.data || {};
const values = flat(field.value || {});
const values: object = flat(field.value || {});
const path = Object.keys(values).shift() || '';
if (!path || !options) {
return;
@ -46,7 +46,7 @@ export const useValues = () => {
field.data.operators = operators;
field.data.operator = operator;
field.data.schema = merge(option?.schema, operator?.schema);
field.data.value = get(field.value, `${fieldPath}.$${operatorValue}`);
field.data.value = get(unflatten(field.value), `${fieldPath}.$${operatorValue}`);
};
useEffect(value2data, [field.path.entire]);
return {

View File

@ -2,14 +2,14 @@ import { css, cx } from '@emotion/css';
import { ArrayCollapse, ArrayItems, FormLayout, FormItem as Item } from '@formily/antd';
import { Field } from '@formily/core';
import { ISchema, Schema, observer, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import { Select } from 'antd';
import _ from 'lodash';
import moment from 'moment';
import React, { useContext, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ACLCollectionFieldProvider } from '../../../acl/ACLProvider';
import { BlockRequestContext, useFormBlockContext } from '../../../block-provider';
import { useBlockRequestContext } from '../../../block-provider/BlockProvider';
import { useFormBlockContext } from '../../../block-provider/FormBlockProvider';
import {
Collection,
CollectionFieldOptions,
@ -40,25 +40,12 @@ const defaultInputStyle = css`
}
`;
const divWrap = (schema: ISchema) => {
return {
type: 'void',
'x-component': 'div',
'x-component-props': {
className: 'nb-block-wrap',
},
properties: {
[schema.name || uid()]: schema,
},
};
};
export const FormItem: any = observer(
(props: any) => {
useEnsureOperatorsValid();
const field = useField<Field>();
const ctx = useContext(BlockRequestContext);
const ctx = useBlockRequestContext();
const schema = useFieldSchema();
const variablesCtx = useVariablesCtx();

View File

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

View File

@ -0,0 +1,22 @@
import { FormItem, FormProvider, Input, SchemaComponent } from '@nocobase/client';
import React from 'react';
const schema = {
type: 'object',
properties: {
input: {
type: 'string',
title: 'title',
'x-decorator': 'FormItem',
'x-component': 'Input',
},
},
};
export default () => {
return (
<FormProvider>
<SchemaComponent components={{ FormItem, Input }} schema={schema} />
</FormProvider>
);
};

View File

@ -8,3 +8,7 @@ group:
# FormItem
表单字段装饰器。除此之外,也继承了 BlockItem 的功能。
## Example
<code src="./demos/demo1.tsx"/>

View File

@ -0,0 +1,52 @@
import React from 'react';
import { render, screen, sleep, userEvent } from 'testUtils';
import App1 from '../demos/demo1';
import App2 from '../demos/demo2';
import App3 from '../demos/demo3';
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();
await userEvent.type(input, '李四');
await userEvent.click(submit);
// notification 的内容
expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument();
});
it('initial values', async () => {
render(<App2 />);
await sleep(100);
const nicknameInput = document.querySelector('.nickname .ant-input') as HTMLInputElement;
const passwordInput = document.querySelector('.password .ant-input') as HTMLInputElement;
const submit = screen.getByText('Submit');
expect(submit).toBeInTheDocument();
expect(nicknameInput).toBeInTheDocument();
expect(passwordInput).toBeInTheDocument();
expect(nicknameInput).toHaveValue('张三');
expect(passwordInput).toHaveValue('123456');
expect(screen.getByText('Nickname')).toBeInTheDocument();
expect(screen.getByText('Password')).toBeInTheDocument();
});
it('read pretty', async () => {
render(<App3 />);
await sleep(100);
expect(screen.getByText('Nickname')).toBeInTheDocument();
expect(screen.getByText('Password')).toBeInTheDocument();
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText(/\*\*\*\*\*\*\*\*/i)).toBeInTheDocument();
});
});

View File

@ -1,5 +1,4 @@
export default {
data: [
export default [
{
key: '17zb9wfq0vt',
name: 'product',
@ -1317,5 +1316,4 @@ export default {
updatedBy: true,
sortable: true,
},
],
};
];

View File

@ -1,26 +1,43 @@
import { ISchema, useForm } from '@formily/react';
import {
AntdSchemaComponentProvider,
APIClient,
APIClientProvider,
BlockSchemaComponentProvider,
Action,
CollectionField,
CollectionManagerProvider,
FormBlockProvider,
FormItem,
FormV2,
Input,
SchemaComponent,
SchemaComponentProvider,
useFormBlockContext,
} from '@nocobase/client';
import { notification } from 'antd';
import React from 'react';
import { useFilterByTk } from '../../../../block-provider/BlockProvider';
import { mockAPIClient } from '../../../../test';
import collections from './collections';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onPost('/users:update').reply((params) => {
notification.success({
message: params.data,
});
return [200, JSON.parse(params.data)];
});
function useAction() {
const ctx = useFormBlockContext();
const form = useForm();
const filterByTk = useFilterByTk();
return {
async run() {
console.log('form.values', form.values);
// await ctx.resource.create({
// values: form.values,
// });
await ctx.resource.update({
filterByTk,
values: form.values,
});
},
};
}
@ -47,7 +64,7 @@ const schema: ISchema = {
'x-component': 'CollectionField',
},
button: {
title: '提交',
title: 'Submit',
'x-component': 'Action',
'x-component-props': {
htmlType: 'submit',
@ -62,22 +79,14 @@ const schema: ISchema = {
},
};
const apiClient = new APIClient({
baseURL: 'http://localhost:3000/api',
});
export default () => {
return (
<APIClientProvider apiClient={apiClient}>
<SchemaComponentProvider>
<CollectionManagerProvider collections={collections.data}>
<AntdSchemaComponentProvider>
<BlockSchemaComponentProvider>
<CollectionManagerProvider collections={collections}>
<SchemaComponentProvider components={{ FormBlockProvider, FormV2, FormItem, CollectionField, Action, Input }}>
<SchemaComponent schema={schema} />
</BlockSchemaComponentProvider>
</AntdSchemaComponentProvider>
</CollectionManagerProvider>
</SchemaComponentProvider>
</CollectionManagerProvider>
</APIClientProvider>
);
};

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