test: add frontend unit test (#3991)

* test: add unit test

* fix: build error

* fix: exclude requirejs

* fix: e2e bug

* fix: block delete refresh(T-3936)

* fix: add test utils

* fix: build bug

* fix: remove test only

* fix: kanban bug

* fix: add more unit tests

* fix: coverage bug

* fix: update

* fix: refactor

* fix: add more tests

* fix: unit test bug

* fix: refactor code

* fix: refactor nocobase test

* test: add test case

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
jack zhang 2024-04-12 21:55:37 +08:00 committed by GitHub
parent 17793c2ab9
commit 91254bdf55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
121 changed files with 5557 additions and 416 deletions

View File

@ -4,15 +4,21 @@ import { PkgLog, UserConfig } from './utils';
import { build as viteBuild } from 'vite'; import { build as viteBuild } from 'vite';
import fg from 'fast-glob'; import fg from 'fast-glob';
const clientExt = '.{ts,tsx,js,jsx}';
function getSingleEntry(file: string, cwd: string) {
return fg.sync([`${file}${clientExt}`], { cwd, absolute: true, onlyFiles: true })?.[0]?.replaceAll(/\\/g, '/');
}
export async function buildEsm(cwd: string, userConfig: UserConfig, sourcemap: boolean = false, log: PkgLog) { export async function buildEsm(cwd: string, userConfig: UserConfig, sourcemap: boolean = false, log: PkgLog) {
log('build esm'); log('build esm');
const indexEntry = path.join(cwd, 'src/index.ts').replaceAll(/\\/g, '/'); const indexEntry = getSingleEntry('src/index', cwd);
const outDir = path.resolve(cwd, 'es'); const outDir = path.resolve(cwd, 'es');
await build(cwd, indexEntry, outDir, userConfig, sourcemap, log); await build(cwd, indexEntry, outDir, userConfig, sourcemap, log);
const clientEntry = fg.sync(['src/client/index.ts', 'src/client.ts'], { cwd, absolute: true, onlyFiles: true })?.[0]?.replaceAll(/\\/g, '/'); const clientEntry = getSingleEntry('src/client/index', cwd) || getSingleEntry('src/client', cwd);
const clientOutDir = path.resolve(cwd, 'es/client'); const clientOutDir = path.resolve(cwd, 'es/client');
if (clientEntry) { if (clientEntry) {
await build(cwd, clientEntry, clientOutDir, userConfig, sourcemap, log); await build(cwd, clientEntry, clientOutDir, userConfig, sourcemap, log);
@ -20,9 +26,13 @@ export async function buildEsm(cwd: string, userConfig: UserConfig, sourcemap: b
const pkg = require(path.join(cwd, 'package.json')); const pkg = require(path.join(cwd, 'package.json'));
if (pkg.name === '@nocobase/test') { if (pkg.name === '@nocobase/test') {
const e2eEntry = path.join(cwd, 'src/e2e/index.ts').replaceAll(/\\/g, '/'); const e2eEntry = getSingleEntry('src/e2e/index', cwd);
const e2eOutDir = path.resolve(cwd, 'es/e2e'); const e2eOutDir = path.resolve(cwd, 'es/e2e');
await build(cwd, e2eEntry, e2eOutDir, userConfig, sourcemap, log); await build(cwd, e2eEntry, e2eOutDir, userConfig, sourcemap, log);
const webEntry = getSingleEntry('src/web/index', cwd);
const webOutDir = path.resolve(cwd, 'es/web');
await build(cwd, webEntry, webOutDir, userConfig, sourcemap, log);
} }
} }

View File

@ -0,0 +1,69 @@
/**
* defaultShowCode: true
*/
import React from 'react';
import {
Action,
Application,
CardItem,
Form,
FormItem,
Grid,
Input,
SchemaInitializer,
SchemaInitializerActionModal,
useSchemaInitializer,
} from '@nocobase/client';
import { appOptions } from './schema-initializer-common';
import { useFieldSchema } from '@formily/react';
const myInitializer = new SchemaInitializer({
name: 'MyInitializer',
wrap: Grid.wrap,
Component: () => {
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerActionModal
title="Add Card"
buttonText="Add Card"
onSubmit={({ title }) => {
insert({
type: 'void',
title,
'x-decorator': 'CardItem',
'x-component': 'Hello',
});
}}
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
},
});
const Hello = () => {
const schema = useFieldSchema();
return <h1>Hello, world! {schema.title}</h1>;
};
const app = new Application({
...appOptions,
components: {
FormItem,
Action,
Input,
Form,
Hello,
CardItem,
},
schemaInitializers: [myInitializer],
});
export default app.getRootComponent();

View File

@ -0,0 +1,76 @@
/**
* defaultShowCode: true
*/
import React from 'react';
import {
Action,
Application,
CardItem,
Form,
FormItem,
Grid,
Input,
SchemaInitializer,
SchemaInitializerActionModal,
useSchemaInitializer,
} from '@nocobase/client';
import { appOptions } from './schema-initializer-common';
import { useFieldSchema } from '@formily/react';
const myInitializer = new SchemaInitializer({
name: 'MyInitializer',
title: 'Button Text',
wrap: Grid.wrap,
items: [
{
name: 'a',
Component: () => {
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerActionModal
title="Add Card"
buttonText="Add Card"
isItem
onSubmit={({ title }) => {
insert({
type: 'void',
title,
'x-decorator': 'CardItem',
'x-component': 'Hello',
});
}}
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
},
},
],
});
const Hello = () => {
const schema = useFieldSchema();
return <h1>Hello, world! {schema.title}</h1>;
};
const app = new Application({
...appOptions,
components: {
FormItem,
Action,
Input,
Form,
Hello,
CardItem,
},
schemaInitializers: [myInitializer],
});
export default app.getRootComponent();

View File

@ -48,15 +48,16 @@ const myInitializer = new SchemaInitializer({
name: 'c', name: 'c',
Component: () => { Component: () => {
return ( return (
<SchemaInitializerItemGroup title="C Group Title"> <SchemaInitializerItemGroup
{[ title="C Group Title"
items={[
{ {
name: 'c1', name: 'c1',
type: 'item', type: 'item',
title: 'C1', title: 'C1',
}, },
]} ]}
</SchemaInitializerItemGroup> ></SchemaInitializerItemGroup>
); );
}, },
}, },

View File

@ -1,8 +1,9 @@
/** /**
* defaultShowCode: true * defaultShowCode: true
*/ */
import { Application, SchemaInitializer } from '@nocobase/client'; import { Application, SchemaInitializer, SchemaInitializerSubMenu } from '@nocobase/client';
import { appOptions } from './schema-initializer-common'; import { appOptions } from './schema-initializer-common';
import React from 'react';
const myInitializer = new SchemaInitializer({ const myInitializer = new SchemaInitializer({
name: 'MyInitializer', name: 'MyInitializer',
@ -42,6 +43,24 @@ const myInitializer = new SchemaInitializer({
}, },
], ],
}, },
{
name: 'c',
Component: () => {
return (
<SchemaInitializerSubMenu
name={'c'}
title="C subMenu"
items={[
{
name: 'c1',
type: 'item',
title: 'C1',
},
]}
/>
);
},
},
], ],
}); });

View File

@ -657,6 +657,18 @@ interface SchemaInitializerSwitchItemProps extends SchemaInitializerItemProps {
<code src="./demos/schema-initializer-components-divider.tsx"></code> <code src="./demos/schema-initializer-components-divider.tsx"></code>
### `type: 'actionModal'` & SchemaInitializerActionModal
#### Component Mode
<code src="./demos/schema-initializer-components-action-modal-1.tsx"></code>
#### Item Mode
`SchemaInitializerActionModal` 需要加上 `isItem` 属性
<code src="./demos/schema-initializer-components-action-modal-2.tsx"></code>
## 渲染组件 ## 渲染组件
### SchemaInitializerChildren ### SchemaInitializerChildren

View File

@ -0,0 +1,69 @@
/**
* defaultShowCode: true
*/
import React from 'react';
import {
Action,
Application,
CardItem,
Form,
FormItem,
Grid,
Input,
SchemaInitializer,
SchemaInitializerActionModal,
useSchemaInitializer,
} from '@nocobase/client';
import { appOptions } from './schema-initializer-common';
import { useFieldSchema } from '@formily/react';
const myInitializer = new SchemaInitializer({
name: 'MyInitializer',
wrap: Grid.wrap,
Component: () => {
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerActionModal
title="Add Card"
buttonText="Add Card"
onSubmit={({ title }) => {
insert({
type: 'void',
title,
'x-decorator': 'CardItem',
'x-component': 'Hello',
});
}}
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
},
});
const Hello = () => {
const schema = useFieldSchema();
return <h1>Hello, world! {schema.title}</h1>;
};
const app = new Application({
...appOptions,
components: {
FormItem,
Action,
Input,
Form,
Hello,
CardItem,
},
schemaInitializers: [myInitializer],
});
export default app.getRootComponent();

View File

@ -0,0 +1,76 @@
/**
* defaultShowCode: true
*/
import React from 'react';
import {
Action,
Application,
CardItem,
Form,
FormItem,
Grid,
Input,
SchemaInitializer,
SchemaInitializerActionModal,
useSchemaInitializer,
} from '@nocobase/client';
import { appOptions } from './schema-initializer-common';
import { useFieldSchema } from '@formily/react';
const myInitializer = new SchemaInitializer({
name: 'MyInitializer',
title: 'Button Text',
wrap: Grid.wrap,
items: [
{
name: 'a',
Component: () => {
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerActionModal
title="Add Card"
buttonText="Add Card"
isItem
onSubmit={({ title }) => {
insert({
type: 'void',
title,
'x-decorator': 'CardItem',
'x-component': 'Hello',
});
}}
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
},
},
],
});
const Hello = () => {
const schema = useFieldSchema();
return <h1>Hello, world! {schema.title}</h1>;
};
const app = new Application({
...appOptions,
components: {
FormItem,
Action,
Input,
Form,
Hello,
CardItem,
},
schemaInitializers: [myInitializer],
});
export default app.getRootComponent();

View File

@ -48,15 +48,16 @@ const myInitializer = new SchemaInitializer({
name: 'c', name: 'c',
Component: () => { Component: () => {
return ( return (
<SchemaInitializerItemGroup title="C Group Title"> <SchemaInitializerItemGroup
{[ title="C Group Title"
items={[
{ {
name: 'c1', name: 'c1',
type: 'item', type: 'item',
title: 'C1', title: 'C1',
}, },
]} ]}
</SchemaInitializerItemGroup> ></SchemaInitializerItemGroup>
); );
}, },
}, },

View File

@ -1,8 +1,9 @@
/** /**
* defaultShowCode: true * defaultShowCode: true
*/ */
import { Application, SchemaInitializer } from '@nocobase/client'; import { Application, SchemaInitializer, SchemaInitializerSubMenu } from '@nocobase/client';
import { appOptions } from './schema-initializer-common'; import { appOptions } from './schema-initializer-common';
import React from 'react';
const myInitializer = new SchemaInitializer({ const myInitializer = new SchemaInitializer({
name: 'MyInitializer', name: 'MyInitializer',
@ -42,6 +43,24 @@ const myInitializer = new SchemaInitializer({
}, },
], ],
}, },
{
name: 'c',
Component: () => {
return (
<SchemaInitializerSubMenu
name={'c'}
title="C subMenu"
items={[
{
name: 'c1',
type: 'item',
title: 'C1',
},
]}
/>
);
},
},
], ],
}); });

View File

@ -657,6 +657,18 @@ interface SchemaInitializerSwitchItemProps extends SchemaInitializerItemProps {
<code src="./demos/schema-initializer-components-divider.tsx"></code> <code src="./demos/schema-initializer-components-divider.tsx"></code>
### `type: 'actionModal'` & SchemaInitializerActionModal
#### Component Mode
<code src="./demos/schema-initializer-components-action-modal-1.tsx"></code>
#### Item Mode
`SchemaInitializerActionModal` 需要加上 `isItem` 属性
<code src="./demos/schema-initializer-components-action-modal-2.tsx"></code>
## 渲染组件 ## 渲染组件
### SchemaInitializerChildren ### SchemaInitializerChildren

View File

@ -0,0 +1,29 @@
import { renderHookWithApp, waitFor } from '@nocobase/test/client';
import { CurrentAppInfoProvider, useCurrentAppInfo } from '@nocobase/client';
describe('CurrentAppInfoProvider', () => {
it('should work', async () => {
const { result } = await renderHookWithApp({
hook: useCurrentAppInfo,
Wrapper: CurrentAppInfoProvider,
apis: {
'app:getInfo': {
database: {
dialect: 'mysql',
},
lang: 'zh-CN',
version: '1.0.0',
},
},
});
await waitFor(() => {
expect(result.current).toEqual({
database: {
dialect: 'mysql',
},
lang: 'zh-CN',
version: '1.0.0',
});
});
});
});

View File

@ -4,7 +4,7 @@ import { i18n as i18next } from 'i18next';
import get from 'lodash/get'; import get from 'lodash/get';
import merge from 'lodash/merge'; import merge from 'lodash/merge';
import set from 'lodash/set'; import set from 'lodash/set';
import React, { ComponentType, FC, ReactElement } from 'react'; import React, { ComponentType, FC, ReactElement, ReactNode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { Link, NavLink, Navigate } from 'react-router-dom'; import { Link, NavLink, Navigate } from 'react-router-dom';
@ -177,7 +177,7 @@ export class Application {
} }
getRouteUrl(pathname: string) { getRouteUrl(pathname: string) {
return this.options.publicPath.replace(/\/$/g, '') + pathname; return this.getPublicPath().replace(/\/$/g, '') + pathname;
} }
getCollectionManager(dataSource?: string) { getCollectionManager(dataSource?: string) {
@ -288,8 +288,8 @@ export class Application {
return; return;
} }
renderComponent<T extends {}>(Component: ComponentTypeAndString, props?: T): ReactElement { renderComponent<T extends {}>(Component: ComponentTypeAndString, props?: T, children?: ReactNode): ReactElement {
return React.createElement(this.getComponent(Component), props); return React.createElement(this.getComponent(Component), props, children);
} }
/** /**
@ -315,7 +315,9 @@ export class Application {
} }
getRootComponent() { getRootComponent() {
const Root: FC = () => <AppComponent app={this} />; const Root: FC<{ children?: React.ReactNode }> = ({ children }) => (
<AppComponent app={this}>{children}</AppComponent>
);
return Root; return Root;
} }

View File

@ -112,7 +112,7 @@ export class RouterManager {
/** /**
* @internal * @internal
*/ */
getRouterComponent() { getRouterComponent(children?: React.ReactNode) {
const { type = 'browser', ...opts } = this.options; const { type = 'browser', ...opts } = this.options;
const Routers = { const Routers = {
hash: HashRouter, hash: HashRouter,
@ -134,6 +134,7 @@ export class RouterManager {
<ReactRouter {...opts}> <ReactRouter {...opts}>
<BaseLayout> <BaseLayout>
<RenderRoutes /> <RenderRoutes />
{children}
</BaseLayout> </BaseLayout>
</ReactRouter> </ReactRouter>
</RouterContextCleaner> </RouterContextCleaner>

View File

@ -6,6 +6,7 @@ import { Link, Outlet } from 'react-router-dom';
import { describe } from 'vitest'; import { describe } from 'vitest';
import { Application } from '../Application'; import { Application } from '../Application';
import { Plugin } from '../Plugin'; import { Plugin } from '../Plugin';
import { useApp } from '../hooks';
describe('Application', () => { describe('Application', () => {
beforeAll(() => { beforeAll(() => {
@ -18,7 +19,9 @@ describe('Application', () => {
const router: any = { type: 'memory', initialEntries: ['/'] }; const router: any = { type: 'memory', initialEntries: ['/'] };
const initialProvidersLength = 7; const initialProvidersLength = 7;
it('basic', () => { it('basic', () => {
const app = new Application({ router }); const options = { router };
const app = new Application(options);
expect(app.getOptions()).toEqual(options);
expect(app.i18n).toBeDefined(); expect(app.i18n).toBeDefined();
expect(app.apiClient).toBeDefined(); expect(app.apiClient).toBeDefined();
expect(app.components).toBeDefined(); expect(app.components).toBeDefined();
@ -30,6 +33,26 @@ describe('Application', () => {
expect(Object.keys(app.components).length).toBeGreaterThan(1); expect(Object.keys(app.components).length).toBeGreaterThan(1);
}); });
describe('publicPath', () => {
it('default', () => {
const app = new Application({});
expect(app.getPublicPath()).toBe('/');
expect(app.getRouteUrl('/test')).toBe('/test');
});
it('custom', () => {
const app = new Application({ publicPath: '/admin' });
expect(app.getPublicPath()).toBe('/admin');
expect(app.getRouteUrl('/test')).toBe('/admin/test');
});
it('custom end with /', () => {
const app = new Application({ publicPath: '/admin/' });
expect(app.getPublicPath()).toBe('/admin/');
expect(app.getRouteUrl('/test')).toBe('/admin/test');
});
});
describe('components', () => { describe('components', () => {
const Hello = () => <div>Hello</div>; const Hello = () => <div>Hello</div>;
Hello.displayName = 'Hello'; Hello.displayName = 'Hello';
@ -242,6 +265,30 @@ describe('Application', () => {
expect(screen.getByText('AboutComponent')).toBeInTheDocument(); expect(screen.getByText('AboutComponent')).toBeInTheDocument();
}); });
it('Root with children', async () => {
const app = new Application({ name: 'test' });
const Demo = () => {
const app = useApp();
return <div>{app.name}</div>;
};
const Root = app.getRootComponent();
render(
<Root>
<Demo />
</Root>,
);
await waitFor(() => {
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('test')).toBeInTheDocument();
});
});
it('mount', async () => { it('mount', async () => {
const Hello = () => <div>Hello</div>; const Hello = () => <div>Hello</div>;
const app = new Application({ const app = new Application({

View File

@ -159,4 +159,21 @@ describe('PluginManager', () => {
await app.load(); await app.load();
expect(app.pm.get('demo')).toBeInstanceOf(DemoPlugin); expect(app.pm.get('demo')).toBeInstanceOf(DemoPlugin);
}); });
it('i18n', async () => {
class DemoPlugin extends Plugin {
async load() {
expect(this.t('test', { lng: 'zh-CN' })).toBe('测试');
expect(this.t('test', { lng: 'en' })).toBe('test');
}
}
const app = new Application({ plugins: [[DemoPlugin, { packageName: 'plugin-demo' }]] });
app.i18n.addResourceBundle('zh-CN', 'plugin-demo', {
test: '测试',
});
app.i18n.addResourceBundle('en', 'plugin-demo', {
test: 'test',
});
await app.load();
});
}); });

View File

@ -86,6 +86,32 @@ describe('PluginSettingsManager', () => {
expect(app.pluginSettingsManager.get('test1').children[0]).toMatchObject(test2); expect(app.pluginSettingsManager.get('test1').children[0]).toMatchObject(test2);
}); });
it('children should be sorted in asc order', () => {
const test3 = {
title: 'test3 title',
sort: 2,
Component: () => null,
};
const test4 = {
title: 'test4 title',
sort: 1,
Component: () => null,
};
app.pluginSettingsManager.add('test1', test1);
app.pluginSettingsManager.add('test1.test2', test2);
app.pluginSettingsManager.add('test1.test3', test3);
app.pluginSettingsManager.add('test1.test4', test4);
expect(app.pluginSettingsManager.get('test1').children.length).toBe(3);
expect(app.pluginSettingsManager.get('test1').children.map((item) => item.name)).toEqual([
'test1.test2',
'test1.test4',
'test1.test3',
]);
});
it('remove', () => { it('remove', () => {
app.pluginSettingsManager.add('test1', test1); app.pluginSettingsManager.add('test1', test1);
app.pluginSettingsManager.add('test1.test2', test2); app.pluginSettingsManager.add('test1.test2', test2);
@ -121,6 +147,15 @@ describe('PluginSettingsManager', () => {
}); });
}); });
it('no acl', () => {
app.pluginSettingsManager.setAclSnippets(['!pm.*']);
app.pluginSettingsManager.add('test', test);
expect(app.pluginSettingsManager.get('test')).toBeFalsy();
expect(app.pluginSettingsManager.hasAuth('test')).toBeFalsy();
expect(app.pluginSettingsManager.has('test')).toBeFalsy();
});
it('getAclSnippet()', () => { it('getAclSnippet()', () => {
app.pluginSettingsManager.add('test1', test1); app.pluginSettingsManager.add('test1', test1);
app.pluginSettingsManager.add('test2', { app.pluginSettingsManager.add('test2', {

View File

@ -188,6 +188,7 @@ describe('Router', () => {
const RouterComponent = router.getRouterComponent(); const RouterComponent = router.getRouterComponent();
render(<RouterComponent />); render(<RouterComponent />);
expect(screen.queryByTestId('content')).not.toBeInTheDocument(); expect(screen.queryByTestId('content')).not.toBeInTheDocument();
expect(router.getBasename()).toBe('/admin');
window.location.hash = '#/admin'; window.location.hash = '#/admin';

View File

@ -1,6 +1,6 @@
import { render } from '@nocobase/test/client';
import React from 'react'; import React from 'react';
import { SchemaComponent, SchemaComponentProvider } from '../../../schema-component'; import { SchemaComponent, SchemaComponentProvider } from '../../../schema-component';
import { render } from '@nocobase/test/client';
import { withDynamicSchemaProps } from '../../hoc'; import { withDynamicSchemaProps } from '../../hoc';
const HelloComponent = withDynamicSchemaProps((props: any) => ( const HelloComponent = withDynamicSchemaProps((props: any) => (
@ -213,4 +213,41 @@ describe('withDynamicSchemaProps', () => {
const { getByTestId } = render(<Demo />); const { getByTestId } = render(<Demo />);
expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ b: 'b' })); expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ b: 'b' }));
}); });
test('override scope', () => {
function useDecoratorProps() {
return {
b: 'b',
};
}
function useDecoratorProps2() {
return {
c: 'c',
};
}
const schema = {
'x-use-decorator-props': 'cm.useDecoratorProps',
};
const scopes = { cm: { useDecoratorProps } };
const Demo = function () {
return (
<SchemaComponentProvider components={{ HelloComponent, HelloDecorator }} scope={scopes}>
<SchemaComponent
scope={{
cm: { useDecoratorProps: useDecoratorProps2 },
}}
schema={{
type: 'void',
name: 'hello',
'x-component': 'HelloComponent',
'x-decorator': 'HelloDecorator',
...schema,
}}
/>
</SchemaComponentProvider>
);
};
const { getByTestId } = render(<Demo />);
expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ c: 'c' }));
});
}); });

View File

@ -0,0 +1,98 @@
import { SchemaInitializer } from '@nocobase/client';
describe('SchemaInitializer', () => {
let initializer: SchemaInitializer;
beforeEach(() => {
initializer = new SchemaInitializer({ name: 'test' });
});
test('initializes with default options if none are provided', () => {
expect(initializer).toBeDefined();
expect(initializer.name).toEqual('test');
expect(initializer.items).toEqual([]);
});
test('adds an item', () => {
initializer.add('item1', { title: 'title' });
expect(initializer.items).toEqual([{ name: 'item1', title: 'title' }]);
});
test('updates an item if it already exists', () => {
initializer.add('item1', { title: 'title' });
initializer.add('item1', { title: 'new title' });
expect(initializer.items).toEqual([{ name: 'item1', title: 'new title' }]);
});
test('adds a nested item', () => {
initializer.add('parent', { title: 'parent title' });
initializer.add('parent.item1', { title: 'item1 title' });
expect(initializer.get('parent')).toEqual({
name: 'parent',
title: 'parent title',
children: [{ name: 'item1', title: 'item1 title' }],
});
});
test('updates a nested item if it already exists', () => {
initializer.add('parent', { title: 'parent title' });
initializer.add('parent.item1', { title: 'title' });
initializer.add('parent.item1', { title: 'new title' });
expect(initializer.get('parent')).toEqual({
name: 'parent',
title: 'parent title',
children: [{ name: 'item1', title: 'new title' }],
});
});
test('gets an item', () => {
initializer.add('item1', { title: 'title' });
expect(initializer.get('item1')).toEqual({ name: 'item1', title: 'title' });
expect(initializer.get('no-exist')).toEqual(undefined);
expect(initializer.get(undefined)).toEqual(undefined);
});
test('gets a nested item', () => {
initializer.add('parent', { title: 'parent title' });
initializer.add('parent.item1', { title: 'title' });
expect(initializer.get('parent.item1')).toEqual({
name: 'item1',
title: 'title',
});
expect(initializer.get('parent.item1.not-exist')).toEqual(undefined);
expect(initializer.get('parent.not-exist')).toEqual(undefined);
expect(initializer.get('parent.not-exist.not-exist')).toEqual(undefined);
});
test('returns undefined for a non-existent item', () => {
expect(initializer.get('nonExistent')).toBeUndefined();
});
test('removes an item', () => {
initializer.add('item1', { title: 'title' });
initializer.remove('item1');
expect(initializer.items).toEqual([]);
});
test('removes a nested item', () => {
initializer.add('parent', { title: 'title' });
initializer.add('parent.item1', { title: 'title' });
initializer.remove('parent');
expect(initializer.get('parent')).toEqual(undefined);
expect(initializer.get('parent.item1')).toEqual(undefined);
});
test('removes a nested child item', () => {
initializer.add('parent', { title: 'title' });
initializer.add('parent.item1', { title: 'title' });
initializer.remove('parent.item1');
expect(initializer.get('parent')).toBeDefined();
expect(initializer.get('parent.item1')).toEqual(undefined);
});
test('does nothing when removing a non-existent item', () => {
const initialItems = [...initializer.items];
initializer.remove('nonExistent');
expect(initializer.items).toEqual(initialItems);
});
});

View File

@ -0,0 +1,89 @@
import { SchemaInitializer, Application } from '@nocobase/client';
describe('SchemaInitializerManager', () => {
let app: Application;
beforeEach(() => {
app = new Application();
});
it('should add schema initializers correctly', () => {
const demo = new SchemaInitializer({ name: 'test' });
app.schemaInitializerManager.add(demo);
expect(app.schemaInitializerManager.has('test')).toBeTruthy();
});
it('should handle addition of items to non-existent schema initializers', () => {
app.schemaInitializerManager.addItem('test', 'item1', {});
expect(app.schemaInitializerManager.has('test')).toBeFalsy();
const demo = new SchemaInitializer({ name: 'test' });
app.schemaInitializerManager.add(demo);
expect(app.schemaInitializerManager.has('test')).toBeTruthy();
const demoRes = app.schemaInitializerManager.get('test');
expect(demoRes.get('item1')).toBeDefined();
});
it('should add item to existing schema initializer', () => {
const mockInitializer = new SchemaInitializer({ name: 'test' });
const fn = vitest.spyOn(mockInitializer, 'add');
app.schemaInitializerManager.add(mockInitializer);
app.schemaInitializerManager.addItem('test', 'item1', {});
expect(fn).toHaveBeenCalledWith('item1', {});
});
it('should return a specific schema initializer when it exists', () => {
const mockInitializer = new SchemaInitializer({ name: 'test' });
app.schemaInitializerManager.add(mockInitializer);
const fetchedInitializer = app.schemaInitializerManager.get('test');
expect(fetchedInitializer).toBe(mockInitializer);
});
it('should return undefined when schema initializer does not exist', () => {
const fetchedInitializer = app.schemaInitializerManager.get('nonExistent');
expect(fetchedInitializer).toBeUndefined();
});
it('should return all schema initializers', () => {
const mockInitializer1 = new SchemaInitializer({ name: 'test1' });
const mockInitializer2 = new SchemaInitializer({ name: 'test2' });
app.schemaInitializerManager.add(mockInitializer1, mockInitializer2);
const initializers = app.schemaInitializerManager.getAll();
expect(initializers).toEqual({
test1: mockInitializer1,
test2: mockInitializer2,
});
});
it('should correctly check if schema initializer exists', () => {
const mockInitializer = new SchemaInitializer({ name: 'test' });
app.schemaInitializerManager.add(mockInitializer);
expect(app.schemaInitializerManager.has('test')).toBeTruthy();
expect(app.schemaInitializerManager.has('nonExistent')).toBeFalsy();
});
it('should remove schema initializer', () => {
const mockInitializer = new SchemaInitializer({ name: 'test' });
app.schemaInitializerManager.add(mockInitializer);
app.schemaInitializerManager.remove('test');
expect(app.schemaInitializerManager.has('test')).toBeFalsy();
});
it('should handle removal of items for non-existent schema initializers', () => {
expect(() => app.schemaInitializerManager.removeItem('nonExistent', 'item1')).not.toThrowError();
});
it('should remove item from existing schema initializer', () => {
const mockInitializer = new SchemaInitializer({ name: 'test' });
const fn = vitest.spyOn(mockInitializer, 'remove');
app.schemaInitializerManager.add(mockInitializer);
app.schemaInitializerManager.removeItem('test', 'item1');
expect(fn).toHaveBeenCalledWith('item1');
});
});

View File

@ -0,0 +1,118 @@
import { screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react';
import { Action, Form, FormItem, Input, SchemaInitializerActionModal } from '@nocobase/client';
import { createApp } from '../fixures/createApp';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerDivider', () => {
it('component mode', async () => {
const onSubmit = vitest.fn();
const Demo = () => {
return (
<SchemaInitializerActionModal
title="Modal title"
buttonText="button text"
onSubmit={onSubmit}
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
};
await createApp(
{
Component: Demo,
items: [],
},
{
components: {
FormItem,
Action,
Input,
Form,
},
},
);
expect(screen.getByText('button text')).toBeInTheDocument();
await userEvent.click(screen.getByText('button text'));
await waitFor(() => {
expect(screen.queryByText('Modal title')).toBeInTheDocument();
});
await userEvent.type(screen.getByRole('textbox'), 'test');
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('test');
});
await userEvent.click(screen.getByText('Submit'));
expect(onSubmit).toBeCalled();
});
it('item mode', async () => {
const onSubmit = vitest.fn();
const Demo = () => {
return (
<SchemaInitializerActionModal
title="Modal title"
buttonText="button text"
onSubmit={onSubmit}
isItem
schema={{
title: {
type: 'string',
title: 'Title',
required: true,
'x-component': 'Input',
'x-decorator': 'FormItem',
},
}}
></SchemaInitializerActionModal>
);
};
await createAndHover(
[
{
name: 'a',
Component: Demo,
},
],
{
components: {
FormItem,
Action,
Input,
Form,
},
},
);
expect(screen.getByText('button text')).toBeInTheDocument();
await userEvent.click(screen.getByText('button text'));
await waitFor(() => {
expect(screen.queryByText('Modal title')).toBeInTheDocument();
});
await userEvent.type(screen.getByRole('textbox'), 'test');
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('test');
});
await userEvent.click(screen.getByText('Submit'));
expect(onSubmit).toBeCalled();
});
});

View File

@ -0,0 +1,353 @@
import { screen, userEvent } from '@nocobase/test/client';
import { SchemaInitializerItem, useSchemaInitializerItem } from '@nocobase/client';
import React from 'react';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerChildren', () => {
it('basic', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
title: 'Item1',
},
{
name: 'item2',
type: 'item',
title: 'Item2',
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(document.body.querySelector('.ant-popover-content').textContent).toBe('Item1Item2');
});
it('sort', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
title: 'Item1',
sort: 2,
},
{
name: 'item2',
type: 'item',
title: 'Item2',
sort: 1,
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(document.body.querySelector('.ant-popover-content').textContent).toBe('Item2Item1');
});
it('component', async () => {
await createAndHover(
[
{
name: 'item1',
type: 'item',
title: 'Item1',
// 小写
component: () => {
return <div>Item1</div>;
},
},
{
name: 'item2',
type: 'item',
title: 'Item2',
// 大写
Component: () => {
return <div>Item2</div>;
},
},
{
name: 'item3',
Component: 'Item3',
},
{
name: 'item4',
Component: 'not-exists',
},
],
{
components: {
Item3: () => {
return <div>Item3</div>;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(screen.queryByText('Item3')).toBeInTheDocument();
});
it('useVisible()', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
title: 'Item1',
useVisible() {
return true;
},
},
{
name: 'item2',
type: 'item',
title: 'Item2',
useVisible() {
return false;
},
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).not.toBeInTheDocument();
});
it('children', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
title: 'A',
children: [
{
name: 'item1',
type: 'item',
title: 'Item1',
},
{
name: 'item2',
type: 'item',
title: 'Item2',
},
],
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
});
it('useChildren()', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
title: 'A',
useChildren() {
return [
{
name: 'item1',
type: 'item',
title: 'Item1',
},
{
name: 'item2',
type: 'item',
title: 'Item2',
},
];
},
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
});
it('should merge `children` and `useChildren()`', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
title: 'A',
useChildren() {
return [
{
name: 'item1',
type: 'item',
title: 'Item1',
},
{
name: 'item2',
type: 'item',
title: 'Item2',
},
];
},
children: [
{
name: 'item3',
type: 'item',
title: 'Item3',
},
],
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(screen.queryByText('Item3')).toBeInTheDocument();
});
it('hideIfNoChildren', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
title: 'A',
hideIfNoChildren: true,
children: [],
},
{
name: 'b',
type: 'itemGroup',
title: 'B',
hideIfNoChildren: true,
},
{
name: 'c',
type: 'itemGroup',
title: 'C',
hideIfNoChildren: true,
useChildren() {
return undefined;
},
},
{
name: 'd',
type: 'itemGroup',
title: 'D',
hideIfNoChildren: true,
useChildren() {
return [];
},
},
]);
expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
expect(screen.queryByText('D')).not.toBeInTheDocument();
});
it('componentProps', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
componentProps: {
title: 'Item1',
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
it('useComponentProps', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
useComponentProps() {
return {
title: 'Item1',
};
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
it('should merge `componentProps` and `useComponentProps()`', async () => {
const onClick = vitest.fn();
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
componentProps: {
title: 'Item1',
},
useComponentProps() {
return {
onClick,
};
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} onClick={props.onClick} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
await userEvent.click(screen.getByText('Item1'));
expect(onClick).toBeCalledWith(
expect.objectContaining({
item: expect.objectContaining({
title: 'Item1',
}),
}),
);
});
it('public props', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
title: 'Item1',
},
],
{
components: {
CommonDemo: () => {
const { title } = useSchemaInitializerItem();
return <SchemaInitializerItem title={title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,28 @@
import { screen } from '@nocobase/test/client';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerDivider', () => {
it('basic', async () => {
await createAndHover([
{
name: 'a',
type: 'item',
title: 'A',
},
{
type: 'divider',
name: 'divider1',
},
{
name: 'b',
type: 'item',
title: 'B',
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).toBeInTheDocument();
expect(document.querySelector('.ant-divider')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,80 @@
import { screen, userEvent, sleep, act } from '@nocobase/test/client';
import { SchemaInitializerItem } from '@nocobase/client';
import React from 'react';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerItem', () => {
it('basic', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
title: 'Item1',
},
{
name: 'item2',
Component: () => {
return <SchemaInitializerItem title="Item2" />;
},
},
{
name: 'item3',
icon: 'ApiOutlined',
title: 'Item3',
type: 'item',
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(screen.queryByText('Item3')).toBeInTheDocument();
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('items', async () => {
const fn = vitest.fn();
await createAndHover([
{
name: 'item1',
type: 'item',
title: 'Item1',
onClick: fn,
items: [
{
label: 'aaa',
value: 'aaa',
},
{
label: 'bbb',
value: 'bbb',
icon: 'ApiOutlined',
},
],
},
]);
const user = userEvent.setup();
await act(async () => {
await user.hover(screen.getByText('Item1'));
await sleep(300);
});
expect(screen.queryByText('aaa')).toBeInTheDocument();
expect(screen.queryByText('bbb')).toBeInTheDocument();
expect(document.querySelector('svg')).toBeInTheDocument();
await act(async () => {
await user.click(screen.getByText('aaa'));
await sleep(100);
});
expect(fn).toBeCalledWith(
expect.objectContaining({
item: {
label: 'aaa',
value: 'aaa',
},
}),
);
});
});

View File

@ -0,0 +1,83 @@
import { screen } from '@nocobase/test/client';
import { SchemaInitializerItemGroup } from '@nocobase/client';
import React from 'react';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerItemGroup', () => {
it('basic', async () => {
await createAndHover([
{
name: 'a',
title: 'A Group Title',
type: 'itemGroup',
children: [
{
name: 'a1',
type: 'item',
title: 'A1',
},
],
},
{
name: 'b',
title: 'B Group Title',
type: 'itemGroup',
divider: true,
useChildren() {
return [
{
name: 'b1',
type: 'item',
title: 'B1',
},
];
},
},
{
name: 'c',
Component: () => {
return (
<SchemaInitializerItemGroup title="C Group Title" divider>
{[
{
name: 'c1',
type: 'item',
title: 'C1',
},
]}
</SchemaInitializerItemGroup>
);
},
},
{
name: 'd',
Component: () => {
return (
<SchemaInitializerItemGroup
title="D Group Title"
divider
items={[
{
name: 'd1',
type: 'item',
title: 'D1',
},
]}
></SchemaInitializerItemGroup>
);
},
},
]);
expect(screen.queryByText('A Group Title')).toBeInTheDocument();
expect(screen.queryByText('A1')).toBeInTheDocument();
expect(screen.queryByText('B Group Title')).toBeInTheDocument();
expect(screen.queryByText('B1')).toBeInTheDocument();
expect(screen.queryByText('C Group Title')).toBeInTheDocument();
expect(screen.queryByText('C1')).toBeInTheDocument();
expect(screen.queryByText('D Group Title')).toBeInTheDocument();
expect(screen.queryByText('D1')).toBeInTheDocument();
expect(document.querySelectorAll('.ant-divider')).toHaveLength(3);
});
});

View File

@ -0,0 +1,149 @@
import { screen, userEvent, waitFor } from '@nocobase/test/client';
import { SchemaInitializerSubMenu } from '@nocobase/client';
import React from 'react';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerSubMenu', () => {
async function valid(onClick) {
expect(screen.getByText('A Title')).toBeInTheDocument();
await userEvent.hover(screen.getByText('A Title'));
await waitFor(() => {
expect(screen.queryByText('A1')).toBeInTheDocument();
expect(screen.queryByText('A2')).not.toBeInTheDocument();
expect(document.querySelector('svg')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('A1'));
await waitFor(() => {
expect(onClick).toBeCalledWith(
expect.objectContaining({
item: expect.objectContaining({
name: 'a1',
}),
}),
);
});
}
test('basic', async () => {
const onClick = vitest.fn();
await createAndHover([
{
name: 'a',
title: 'A Title',
type: 'subMenu',
children: [
{
name: 'a1',
type: 'item',
title: 'A1',
icon: 'ApiOutlined',
onClick: onClick,
},
{
name: 'a2',
type: 'item',
title: 'A2',
useVisible() {
return false;
},
},
],
},
]);
await valid(onClick);
});
test('component mode', async () => {
const onClick = vitest.fn();
const TestDemo = () => {
return (
<SchemaInitializerSubMenu
name="a"
title="A Title"
items={[
{
name: 'a1',
type: 'item',
title: 'A1',
onClick: onClick,
useVisible() {
return true;
},
},
{
name: 'a2',
type: 'item',
title: 'A2',
useVisible() {
return false;
},
},
]}
></SchemaInitializerSubMenu>
);
};
await createAndHover(
[
{
name: 'a',
Component: 'TestDemo',
},
],
{
components: {
TestDemo,
},
},
);
await valid(onClick);
});
test('component children mode', async () => {
const onClick = vitest.fn();
const TestDemo = () => {
return (
<SchemaInitializerSubMenu name="a" title="A Title">
{[
{
name: 'a1',
type: 'item',
title: 'A1',
onClick: onClick,
useVisible() {
return true;
},
},
{
name: 'a2',
type: 'item',
title: 'A2',
useVisible() {
return false;
},
},
]}
</SchemaInitializerSubMenu>
);
};
await createAndHover(
[
{
name: 'a',
Component: 'TestDemo',
},
],
{
components: {
TestDemo,
},
},
);
await valid(onClick);
});
});

View File

@ -0,0 +1,103 @@
import { screen, userEvent, waitFor } from '@nocobase/test/client';
import { useSchemaInitializer, useCurrentSchema, SchemaInitializerSwitch } from '@nocobase/client';
import React from 'react';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaInitializerSwitch', () => {
async function valid() {
expect(screen.getByText('A Title')).toBeInTheDocument();
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false');
await userEvent.click(screen.getByText('A Title'));
await waitFor(() => {
expect(screen.getByText('A-Content')).toBeInTheDocument();
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true');
});
await userEvent.click(screen.getByText('A Title'));
await waitFor(() => {
expect(screen.queryByText('A-Content')).not.toBeInTheDocument();
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('false');
});
}
const actionKey = 'x-action';
const schema = {
type: 'void',
[actionKey]: 'create',
title: "{{t('Add New')}}",
'x-component': 'div',
'x-content': 'A-Content',
};
test('component mode', async () => {
const AddNewButton = () => {
// 判断是否已插入
const { exists, remove } = useCurrentSchema(schema[actionKey], actionKey);
const { insert } = useSchemaInitializer();
return (
<SchemaInitializerSwitch
checked={exists}
title={'A Title'}
onClick={() => {
// 如果已插入,则移除
if (exists) {
return remove();
}
// 新插入子节点
insert(schema);
}}
/>
);
};
await createAndHover(
[
{
name: 'a',
Component: AddNewButton,
},
],
{
components: {
AddNewButton,
},
},
);
await valid();
});
test('type mode', async () => {
await createAndHover([
{
name: 'a',
type: 'switch',
useComponentProps() {
const { exists, remove } = useCurrentSchema(schema[actionKey], actionKey);
const { insert } = useSchemaInitializer();
return {
checked: exists,
title: 'A Title',
onClick() {
// 如果已插入,则移除
if (exists) {
return remove();
}
// 新插入子节点
insert(schema);
},
};
},
},
]);
await valid();
});
});

View File

@ -0,0 +1,13 @@
import { screen, userEvent, waitFor } from '@nocobase/test/client';
import { createApp } from '../../fixures/createApp';
import { SchemaInitializerItemType } from '@nocobase/client';
export async function createAndHover(items: SchemaInitializerItemType[], appOptions: any = {}) {
await createApp({ items }, appOptions);
await userEvent.hover(screen.getByText('Test'));
await waitFor(async () => {
expect(screen.queryByRole('tooltip')).toBeInTheDocument();
});
}

View File

@ -0,0 +1,85 @@
import React from 'react';
import { observer } from '@formily/reactive-react';
import { renderApp } from '@nocobase/test/client';
import {
SchemaComponent,
SchemaInitializer,
useSchemaInitializer,
useSchemaInitializerItem,
SchemaInitializerItem,
useSchemaInitializerRender,
} from '@nocobase/client';
export async function createApp(options = {}, appOptions = {}) {
const testInitializers = new SchemaInitializer({
name: 'test',
title: 'Test',
items: [
{
name: 'demo',
title: 'Item',
Component: () => {
const { insert } = useSchemaInitializer();
const { title } = useSchemaInitializerItem();
const handleClick = () => {
insert({
type: 'void',
'x-component': 'div',
'x-content': 'Hello World!',
});
};
return <SchemaInitializerItem title={title} onClick={handleClick} />;
},
},
],
...options,
});
const AddBlockButton = observer(() => {
const { render } = useSchemaInitializerRender('test');
return <div data-testid="render">{render()}</div>;
});
const WrapDemo = ({ children }) => {
return (
<div>
<h1>WrapDemo</h1>
{children}
</div>
);
};
const Page = observer(
(props) => {
return (
<div>
{props.children}
<AddBlockButton />
</div>
);
},
{ displayName: 'Page' },
);
const Root = () => {
return (
<SchemaComponent
components={{ Page, WrapDemo }}
schema={{
name: 'root',
type: 'void',
'x-component': 'Page',
}}
/>
);
};
await renderApp({
appOptions: {
providers: [Root],
schemaInitializers: [testInitializers],
designable: true,
...appOptions,
},
});
}

View File

@ -0,0 +1,16 @@
import { renderHook } from '@testing-library/react-hooks';
import { useAriaAttributeOfMenuItem, SchemaInitializerMenuProvider } from '@nocobase/client';
describe('useAriaAttributeOfMenuItem', () => {
test('should return attribute with role "menuitem" when not in menu', () => {
const { result } = renderHook(() => useAriaAttributeOfMenuItem());
expect(result.current.attribute).toEqual({ role: 'menuitem' });
});
test('should return empty attribute when in menu', () => {
const { result } = renderHook(() => useAriaAttributeOfMenuItem(), {
wrapper: SchemaInitializerMenuProvider,
});
expect(result.current.attribute).toEqual({});
});
});

View File

@ -0,0 +1,319 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useGetSchemaInitializerMenuItems, useSchemaInitializerMenuItems } from '@nocobase/client'; // Adjust the import according to the file structure
import React from 'react';
describe('useGetSchemaInitializerMenuItems', () => {
const mockOnClick = vitest.fn();
it('should compile and return an empty array if items are empty', () => {
const { result } = renderHook(() => useGetSchemaInitializerMenuItems(mockOnClick));
act(() => {
const menuItems = result.current([], 'parent-0');
expect(menuItems).toEqual([]);
});
});
it('should process and return compiled items with a divider', () => {
const items = [{ type: 'divider' }];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems(mockOnClick));
act(() => {
const menuItems = result.current(items, 'parent-1');
expect(menuItems).toEqual([{ type: 'divider', key: 'divider-0' }]);
});
});
it('returns items and click', () => {
const items = [
{
type: 'item',
title: 'item1',
onClick: vitest.fn(),
},
{
type: 'item',
label: 'item2',
},
{
type: 'item',
label: 'item3',
associationField: 'a.b',
},
{
type: 'item',
label: 'item4',
key: 'item4',
component: () => <div>item4</div>,
},
{
type: 'item',
label: 'item5',
component: () => <div>item5</div>,
},
];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems(mockOnClick));
act(() => {
const menuItems = result.current(items, 'parent-2');
menuItems[0].onClick({ domEvent: { stopPropagation: vitest.fn() }, key: menuItems[0].key });
expect(items[0].onClick).toHaveBeenCalled();
expect(result.current(items, 'parent-2')).toMatchInlineSnapshot(`
[
{
"key": "parent-2-item1-0",
"label": "item1",
"onClick": [Function],
},
{
"key": "parent-2-item2-1",
"label": "item2",
"onClick": [Function],
},
{
"associationField": "a.b",
"key": "parent-2-item3-2",
"label": "item3",
"onClick": [Function],
},
{
"key": "item4",
"label": <Memo(SchemaInitializerChild)
component={[Function]}
label="item4"
title="item4"
type="item"
/>,
},
{
"key": "item5-4",
"label": <Memo(SchemaInitializerChild)
component={[Function]}
label="item5"
title="item5"
type="item"
/>,
},
]
`);
expect(mockOnClick).not.toHaveBeenCalled();
menuItems[1].onClick({ domEvent: { stopPropagation: vitest.fn() }, key: menuItems[1].key });
expect(mockOnClick).toHaveBeenCalled();
});
});
it('handles item group with children', () => {
const items = [
{
type: 'itemGroup',
title: 'Group',
key: 'group-0',
children: [{ type: 'item', title: 'Item 1' }],
},
{
type: 'itemGroup',
title: () => <div>123</div>,
children: [{ type: 'item', title: 'Item 1' }],
},
{
type: 'itemGroup',
title: 'Group3',
},
];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems());
act(() => {
const menuItems = result.current(items, 'parent');
expect(menuItems).toMatchInlineSnapshot(`
[
{
"children": [
{
"key": "group-0-Item 1-0",
"label": "Item 1",
"onClick": [Function],
},
],
"key": "group-0",
"label": "Group",
"type": "group",
},
{
"children": [
{
"key": "parent-item-group-1-Item 1-0",
"label": "Item 1",
"onClick": [Function],
},
],
"key": "parent-item-group-1",
"label": [Function],
"type": "group",
},
{
"children": [],
"key": "parent-item-group-2",
"label": "Group3",
"type": "group",
},
]
`);
});
});
it('handles subMenu with compiled title and children', () => {
const items = [
{
type: 'subMenu',
title: 'SubMenu1',
name: 'submenu-1',
children: [{ type: 'item', title: 'SubItem 1' }],
},
{
type: 'subMenu',
title: 'SubMenu2',
name: 'submenu-2',
children: [{ type: 'item', title: 'SubItem 1' }],
},
{
type: 'subMenu',
title: 'SubMenu3',
key: 'submenu-3',
children: [{ type: 'item', title: 'SubItem 1' }],
},
{
type: 'subMenu',
title: 'SubMenu4',
},
];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems());
act(() => {
const menuItems = result.current(items, 'parent');
expect(menuItems).toMatchInlineSnapshot(`
[
{
"children": [
{
"key": "submenu-1-SubItem 1-0",
"label": "SubItem 1",
"onClick": [Function],
},
],
"key": "submenu-1",
"label": "SubMenu1",
},
{
"children": [
{
"key": "submenu-2-SubItem 1-0",
"label": "SubItem 1",
"onClick": [Function],
},
],
"key": "submenu-2",
"label": "SubMenu2",
},
{
"children": [
{
"key": "submenu-3-SubItem 1-0",
"label": "SubItem 1",
"onClick": [Function],
},
],
"key": "submenu-3",
"label": "SubMenu3",
},
{
"children": [],
"key": "parent-sub-menu-3",
"label": "SubMenu4",
},
]
`);
});
});
it('processes items with isMenuType property and excludes it in the return', () => {
const items = [{ isMenuType: true, type: 'item', title: 'Special Item', key: 'special-0' }];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems(mockOnClick));
act(() => {
const menuItems = result.current(items, 'parent');
expect(menuItems).toEqual([
{
type: 'item',
title: 'Special Item',
key: 'special-0',
},
]);
});
});
it('returns items with associationField', () => {
const items = [{ type: 'item', title: 'Item w/ Association', associationField: 'field-1' }];
const { result } = renderHook(() => useGetSchemaInitializerMenuItems(mockOnClick));
act(() => {
const menuItems = result.current(items, 'parent');
expect(menuItems[0]).toHaveProperty('associationField', 'field-1');
});
});
});
describe('useSchemaInitializerMenuItems', () => {
const items = [
{
type: 'item',
key: 1,
label: 'item1',
onClick: vitest.fn(),
},
{
type: 'item',
key: 2,
label: 'item2',
},
];
it('should call useGetSchemaInitializerMenuItems with provided items and name', () => {
const name = 'TestName';
const { result } = renderHook(() => useSchemaInitializerMenuItems(items, name));
expect(result.current).toMatchInlineSnapshot(`
[
{
"key": 1,
"label": "item1",
"onClick": [Function],
},
{
"key": 2,
"label": "item2",
"onClick": [Function],
},
]
`);
});
it('should recompute getMenuItems when items or name changes', () => {
const { rerender, result } = renderHook(({ items, name }) => useSchemaInitializerMenuItems(items, name), {
initialProps: { items, name: 'InitialName' },
});
const res1 = result.current;
rerender({ items, name: 'NewName' });
const res2 = result.current;
expect(res1).not.toEqual(res2);
});
it('should handle onClick event properly', () => {
const mockOnClick = vitest.fn();
const name = 'TestName';
renderHook(() => useSchemaInitializerMenuItems(items, name, mockOnClick));
expect(mockOnClick).not.toHaveBeenCalled();
act(() => {
items[0].onClick();
items[0].onClick({ domEvent: { stopPropagation: vitest.fn() }, key: items[0].key });
expect(items[0].onClick).toHaveBeenCalled();
expect(mockOnClick).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,101 @@
import React from 'react';
import { Application, SchemaInitializer, useSchemaInitializerRender } from '@nocobase/client';
import { render, waitFor, screen } from '@nocobase/test/client';
describe('useSchemaInitializerRender', () => {
async function createApp(DemoComponent: any) {
const testInitializers = new SchemaInitializer({
name: 'test',
title: 'Test',
});
const app = new Application({
providers: [DemoComponent],
schemaInitializers: [testInitializers],
designable: true,
});
const Root = app.getRootComponent();
render(<Root />);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
}
it('should return exists as false and render as null when name is not provided', async () => {
const Demo = () => {
const { exists, render } = useSchemaInitializerRender(undefined);
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('false');
expect(screen.getByTestId('render').textContent).toBe('');
});
it('should log an error if the initializer is not found', async () => {
const consoleErrorSpy = vitest.spyOn(console, 'error').mockImplementation(() => {});
const Demo = () => {
const { exists, render } = useSchemaInitializerRender('nonexistent');
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('false');
expect(screen.getByTestId('render').textContent).toBe('');
expect(consoleErrorSpy).toHaveBeenCalledWith('[nocobase]: SchemaInitializer "nonexistent" not found');
consoleErrorSpy.mockRestore();
});
it('should render the initializer component with name registered', async () => {
const Demo = () => {
const { exists, render } = useSchemaInitializerRender('test');
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('true');
expect(screen.getByTestId('render').textContent).toBe('Test');
});
it('should render custom options', async () => {
const Demo = () => {
const { render } = useSchemaInitializerRender('test', { componentProps: { className: 'test' } });
return <div data-testid="render">{render()}</div>;
};
await createApp(Demo);
expect(document.querySelector('.test')).toBeInTheDocument();
});
it('should override custom props', async () => {
const Demo = () => {
const { render } = useSchemaInitializerRender('test', { componentProps: { className: 'test' } });
return <div data-testid="render">{render({ componentProps: { className: 'test2' } })}</div>;
};
await createApp(Demo);
expect(document.querySelector('.test2')).toBeInTheDocument();
expect(document.querySelector('.test')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,132 @@
import { screen, userEvent, sleep, act } from '@nocobase/test/client';
import { ISchema } from '@formily/json-schema';
import { uid } from '@formily/shared';
import { createApp } from './fixures/createApp';
describe('withInitializer', () => {
it('renders the component with initializer', async () => {
const user = userEvent.setup();
await createApp();
expect(screen.getByTestId('render')).toHaveTextContent('Test');
expect(screen.queryByText('Item')).not.toBeInTheDocument();
await act(async () => {
await user.hover(screen.getByText('Test'));
await sleep(100);
});
expect(screen.queryByText('Item')).toBeInTheDocument();
await act(async () => {
await user.click(screen.getByText('Item'));
await sleep(100);
});
expect(screen.queryByText('Hello World!')).toBeInTheDocument();
});
it('when app designable is false, the component will not be rendered', async () => {
await createApp({}, { designable: false });
expect(screen.queryByText('Test')).not.toBeInTheDocument();
});
it('when SchemaInitializer config designable is true, but app is false, the component will be rendered', async () => {
await createApp({ designable: true }, { designable: false });
expect(screen.queryByText('Test')).toBeInTheDocument();
});
it('when popover is false, only render button', async () => {
const user = userEvent.setup();
await createApp({ popover: false });
expect(screen.queryByText('Test')).toBeInTheDocument();
expect(screen.queryByText('Item')).not.toBeInTheDocument();
await act(async () => {
await user.hover(screen.getByText('Test'));
await sleep(100);
});
expect(screen.queryByText('Item')).not.toBeInTheDocument();
});
it('wrap', async () => {
const user = userEvent.setup();
await createApp({
wrap: (schema: ISchema) => {
return {
type: 'void',
'x-component': 'WrapDemo',
properties: {
[schema.name || uid()]: schema,
},
};
},
});
await act(async () => {
await user.hover(screen.getByText('Test'));
await sleep(100);
});
await act(async () => {
await user.click(screen.getByText('Item'));
await sleep(100);
});
expect(screen.queryByText('WrapDemo')).toBeInTheDocument();
expect(screen.queryByText('Hello World!')).toBeInTheDocument();
});
it('insert', async () => {
const user = userEvent.setup();
const insert = vitest.fn();
await createApp({
insert,
});
await act(async () => {
await user.hover(screen.getByText('Test'));
await sleep(100);
});
await act(async () => {
await user.click(screen.getByText('Item'));
await sleep(100);
});
expect(insert).toBeCalledWith({
type: 'void',
'x-component': 'div',
'x-content': 'Hello World!',
});
});
it('userInsert', async () => {
const user = userEvent.setup();
const insert = vitest.fn();
await createApp({
useInsert: () => insert,
});
await act(async () => {
await user.hover(screen.getByText('Test'));
await sleep(100);
});
await act(async () => {
await user.click(screen.getByText('Item'));
await sleep(100);
});
expect(insert).toBeCalledWith({
type: 'void',
'x-component': 'div',
'x-content': 'Hello World!',
});
});
});

View File

@ -0,0 +1,94 @@
import { SchemaSettings } from '@nocobase/client';
describe('SchemaSettings', () => {
let schemaSettings: SchemaSettings;
beforeEach(() => {
schemaSettings = new SchemaSettings({ name: 'test', items: [] });
});
test('schemaSettings with default options if none are provided', () => {
expect(schemaSettings).toBeDefined();
expect(schemaSettings.name).toEqual('test');
expect(schemaSettings.items).toEqual([]);
});
test('adds an item', () => {
schemaSettings.add('item1', { type: 'item' });
expect(schemaSettings.items).toEqual([{ name: 'item1', type: 'item' }]);
});
test('updates an item if it already exists', () => {
schemaSettings.add('item1', { type: 'item', componentProps: { title: '123' } });
schemaSettings.add('item1', { type: 'item', componentProps: { title: '456' } });
expect(schemaSettings.items).toEqual([{ name: 'item1', type: 'item', componentProps: { title: '456' } }]);
});
test('adds a nested item', () => {
schemaSettings.add('parent', { type: 'itemGroup' });
schemaSettings.add('parent.item1', { type: 'item' });
expect(schemaSettings.get('parent')).toEqual({
name: 'parent',
type: 'itemGroup',
children: [{ name: 'item1', type: 'item' }],
});
schemaSettings.add('parent.item1', { type: 'item', componentProps: { title: '123' } });
expect(schemaSettings.get('parent')).toEqual({
name: 'parent',
type: 'itemGroup',
children: [{ name: 'item1', type: 'item', componentProps: { title: '123' } }],
});
});
test('gets an item', () => {
schemaSettings.add('item1', { type: 'item' });
expect(schemaSettings.get('item1')).toEqual({ name: 'item1', type: 'item' });
expect(schemaSettings.get('no-exist')).toEqual(undefined);
expect(schemaSettings.get(undefined)).toEqual(undefined);
});
test('gets a nested item', () => {
schemaSettings.add('parent', { type: 'itemGroup' });
schemaSettings.add('parent.item1', { type: 'item' });
expect(schemaSettings.get('parent.item1')).toEqual({
name: 'item1',
type: 'item',
});
expect(schemaSettings.get('parent.item1.not-exist')).toEqual(undefined);
expect(schemaSettings.get('parent.not-exist')).toEqual(undefined);
expect(schemaSettings.get('parent.not-exist.not-exist')).toEqual(undefined);
});
test('returns undefined for a non-existent item', () => {
expect(schemaSettings.get('nonExistent')).toBeUndefined();
});
test('removes an item', () => {
schemaSettings.add('item1', { type: 'item' });
schemaSettings.remove('item1');
expect(schemaSettings.items).toEqual([]);
});
test('removes a nested item', () => {
schemaSettings.add('parent', { type: 'item' });
schemaSettings.add('parent.item1', { type: 'item' });
schemaSettings.remove('parent');
expect(schemaSettings.get('parent')).toEqual(undefined);
expect(schemaSettings.get('parent.item1')).toEqual(undefined);
});
test('removes a nested child item', () => {
schemaSettings.add('parent', { type: 'item' });
schemaSettings.add('parent.item1', { type: 'item' });
schemaSettings.remove('parent.item1');
expect(schemaSettings.get('parent')).toBeDefined();
expect(schemaSettings.get('parent.item1')).toEqual(undefined);
});
test('does nothing when removing a non-existent item', () => {
const initialItems = [...schemaSettings.items];
schemaSettings.remove('nonExistent');
expect(schemaSettings.items).toEqual(initialItems);
});
});

View File

@ -0,0 +1,89 @@
import { SchemaSettings, Application } from '@nocobase/client';
describe('SchemaSettingsManager', () => {
let app: Application;
beforeEach(() => {
app = new Application();
});
it('should add schema initializers correctly', () => {
const demo = new SchemaSettings({ name: 'test', items: [] });
app.schemaSettingsManager.add(demo);
expect(app.schemaSettingsManager.has('test')).toBeTruthy();
});
it('should handle addition of items to non-existent schema initializers', () => {
app.schemaSettingsManager.addItem('test', 'item1', { type: 'item' });
expect(app.schemaSettingsManager.has('test')).toBeFalsy();
const demo = new SchemaSettings({ name: 'test', items: [] });
app.schemaSettingsManager.add(demo);
expect(app.schemaSettingsManager.has('test')).toBeTruthy();
const demoRes = app.schemaSettingsManager.get('test');
expect(demoRes.get('item1')).toBeDefined();
});
it('should add item to existing schema settings', () => {
const mockInitializer = new SchemaSettings({ name: 'test', items: [] });
const fn = vitest.spyOn(mockInitializer, 'add');
app.schemaSettingsManager.add(mockInitializer);
app.schemaSettingsManager.addItem('test', 'item1', { type: 'item' });
expect(fn).toHaveBeenCalledWith('item1', { type: 'item' });
});
it('should return a specific schema settings when it exists', () => {
const mockInitializer = new SchemaSettings({ name: 'test', items: [] });
app.schemaSettingsManager.add(mockInitializer);
const fetchedInitializer = app.schemaSettingsManager.get('test');
expect(fetchedInitializer).toBe(mockInitializer);
});
it('should return undefined when schema settings does not exist', () => {
const fetchedInitializer = app.schemaSettingsManager.get('nonExistent');
expect(fetchedInitializer).toBeUndefined();
});
it('should return all schema initializers', () => {
const mockInitializer1 = new SchemaSettings({ name: 'test1', items: [] });
const mockInitializer2 = new SchemaSettings({ name: 'test2', items: [] });
app.schemaSettingsManager.add(mockInitializer1, mockInitializer2);
const initializers = app.schemaSettingsManager.getAll();
expect(initializers).toEqual({
test1: mockInitializer1,
test2: mockInitializer2,
});
});
it('should correctly check if schema settings exists', () => {
const mockInitializer = new SchemaSettings({ name: 'test', items: [] });
app.schemaSettingsManager.add(mockInitializer);
expect(app.schemaSettingsManager.has('test')).toBeTruthy();
expect(app.schemaSettingsManager.has('nonExistent')).toBeFalsy();
});
it('should remove schema settings', () => {
const mockInitializer = new SchemaSettings({ name: 'test', items: [] });
app.schemaSettingsManager.add(mockInitializer);
app.schemaSettingsManager.remove('test');
expect(app.schemaSettingsManager.has('test')).toBeFalsy();
});
it('should handle removal of items for non-existent schema initializers', () => {
expect(() => app.schemaSettingsManager.removeItem('nonExistent', 'item1')).not.toThrowError();
});
it('should remove item from existing schema settings', () => {
const mockInitializer = new SchemaSettings({ name: 'test', items: [] });
const fn = vitest.spyOn(mockInitializer, 'remove');
app.schemaSettingsManager.add(mockInitializer);
app.schemaSettingsManager.removeItem('test', 'item1');
expect(fn).toHaveBeenCalledWith('item1');
});
});

View File

@ -0,0 +1,386 @@
import React from 'react';
import { screen, userEvent } from '@nocobase/test/client';
import { SchemaInitializerItem, useSchemaSettingsItem } from '@nocobase/client';
import { createAndHover } from './fixtures/createAppAndHover';
describe('SchemaSettingsChildren', () => {
it('basic', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(document.body.querySelector('.ant-dropdown-menu').textContent).toBe('Item1Item2');
});
it('sort', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
sort: 2,
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
sort: 1,
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(document.body.querySelector('.ant-dropdown-menu').textContent).toBe('Item2Item1');
});
it('Component', async () => {
await createAndHover(
[
{
name: 'item1',
componentProps: {
title: 'Item1',
},
Component: () => {
return <div>Item1</div>;
},
},
{
name: 'item2',
Component: 'Item2',
},
{
name: 'item3',
Component: 'not-exists',
},
{
name: 'item4',
} as any,
],
{
components: {
Item2: () => {
return <div>Item2</div>;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
});
it('useVisible()', async () => {
await createAndHover([
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
useVisible() {
return true;
},
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
useVisible() {
return false;
},
},
]);
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).not.toBeInTheDocument();
});
it('children', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
componentProps: {
title: 'A',
},
children: [
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
},
],
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
});
it('useChildren()', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
componentProps: {
title: 'A',
},
useChildren() {
return [
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
},
];
},
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
});
it('should merge `children` and `useChildren()`', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
componentProps: {
title: 'A',
},
useChildren() {
return [
{
name: 'item1',
type: 'item',
componentProps: {
title: 'Item1',
},
},
{
name: 'item2',
type: 'item',
componentProps: {
title: 'Item2',
},
},
];
},
children: [
{
name: 'item3',
type: 'item',
componentProps: {
title: 'Item3',
},
},
],
},
]);
expect(screen.queryByText('A')).toBeInTheDocument();
expect(screen.queryByText('Item1')).toBeInTheDocument();
expect(screen.queryByText('Item2')).toBeInTheDocument();
expect(screen.queryByText('Item3')).toBeInTheDocument();
});
it('hideIfNoChildren', async () => {
await createAndHover([
{
name: 'a',
type: 'itemGroup',
componentProps: {
title: 'A',
},
hideIfNoChildren: true,
children: [],
},
{
name: 'b',
type: 'itemGroup',
componentProps: {
title: 'B',
},
hideIfNoChildren: true,
},
{
name: 'c',
type: 'itemGroup',
componentProps: {
title: 'C',
},
hideIfNoChildren: true,
useChildren() {
return undefined;
},
},
{
name: 'd',
type: 'itemGroup',
componentProps: {
title: 'D',
},
hideIfNoChildren: true,
useChildren() {
return [];
},
},
]);
expect(screen.queryByText('A')).not.toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
expect(screen.queryByText('D')).not.toBeInTheDocument();
});
it('componentProps', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
componentProps: {
title: 'Item1',
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
it('useComponentProps', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
useComponentProps() {
return {
title: 'Item1',
};
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
it('should merge `componentProps` and `useComponentProps()`', async () => {
const onClick = vitest.fn();
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
componentProps: {
title: 'Item1',
},
useComponentProps() {
return {
onClick,
};
},
},
],
{
components: {
CommonDemo: (props) => {
return <SchemaInitializerItem title={props.title} onClick={props.onClick} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
await userEvent.click(screen.getByText('Item1'));
expect(onClick).toBeCalledWith(
expect.objectContaining({
item: expect.objectContaining({
title: 'Item1',
}),
}),
);
});
it('public props', async () => {
await createAndHover(
[
{
name: 'item1',
Component: 'CommonDemo',
title: 'Item1',
} as any,
],
{
components: {
CommonDemo: () => {
const { title } = useSchemaSettingsItem<any>();
return <SchemaInitializerItem title={title} />;
},
},
},
);
expect(screen.queryByText('Item1')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,32 @@
import React from 'react';
import { screen, userEvent, sleep, act, render, waitFor } from '@nocobase/test/client';
import { SchemaSettingsItemType, SchemaSettings, Application, useSchemaSettingsRender } from '@nocobase/client';
export async function createAndHover(items: SchemaSettingsItemType[], appOptions: any = {}) {
const testSettings = new SchemaSettings({
name: 'test',
items,
});
const Demo = () => {
const { render } = useSchemaSettingsRender('test');
return render();
};
const app = new Application({
providers: [Demo],
schemaSettings: [testSettings],
designable: true,
...appOptions,
});
const Root = app.getRootComponent();
render(<Root />);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const user = userEvent.setup();
await user.hover(screen.getByRole('button'));
await waitFor(async () => {
expect(screen.queryByRole('menu')).toBeInTheDocument();
});
}

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Application, SchemaSettings, useSchemaSettingsRender } from '@nocobase/client';
import { render, screen, waitFor } from '@nocobase/test/client';
describe('useSchemaSettingsRender', () => {
async function createApp(DemoComponent: any) {
const testSettings = new SchemaSettings({
name: 'test',
Component: (props) => <div {...props}>Test</div>,
items: [],
});
const app = new Application({
providers: [DemoComponent],
schemaSettings: [testSettings],
designable: true,
});
const Root = app.getRootComponent();
render(<Root />);
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
}
it('should return exists as false and render as null when name is not provided', async () => {
const Demo = () => {
const { exists, render } = useSchemaSettingsRender(undefined);
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('false');
expect(screen.getByTestId('render').textContent).toBe('');
});
it('should log an error if the initializer is not found', async () => {
const consoleErrorSpy = vitest.spyOn(console, 'error').mockImplementation(() => {});
const Demo = () => {
const { exists, render } = useSchemaSettingsRender('nonexistent');
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('false');
expect(screen.getByTestId('render').textContent).toBe('');
expect(consoleErrorSpy).toHaveBeenCalledWith('[nocobase]: SchemaSettings "nonexistent" not found');
consoleErrorSpy.mockRestore();
});
it('should render the settings component with name registered', async () => {
const Demo = () => {
const { exists, render } = useSchemaSettingsRender('test');
return (
<div>
<div data-testid="exists">{JSON.stringify(exists)}</div>
<div data-testid="render">{render()}</div>
</div>
);
};
await createApp(Demo);
expect(screen.getByTestId('exists').textContent).toBe('true');
expect(screen.getByTestId('render').textContent).toBe('Test');
});
it('should render custom options', async () => {
const Demo = () => {
const { render } = useSchemaSettingsRender('test', { componentProps: { className: 'test' } });
return <div data-testid="render">{render()}</div>;
};
await createApp(Demo);
expect(document.querySelector('.test')).toBeInTheDocument();
});
it('should override custom props', async () => {
const Demo = () => {
const { render } = useSchemaSettingsRender('test', { componentProps: { className: 'test' } });
return <div data-testid="render">{render({ componentProps: { className: 'test2' } })}</div>;
};
await createApp(Demo);
expect(document.querySelector('.test2')).toBeInTheDocument();
expect(document.querySelector('.test')).not.toBeInTheDocument();
});
});

View File

@ -9,7 +9,7 @@ export interface AppComponentProps {
} }
export const AppComponent: FC<AppComponentProps> = observer( export const AppComponent: FC<AppComponentProps> = observer(
(props) => { ({ children, ...props }) => {
const { app } = props; const { app } = props;
const handleErrors = useCallback((error: Error, info: { componentStack: string }) => { const handleErrors = useCallback((error: Error, info: { componentStack: string }) => {
console.error(error); console.error(error);
@ -33,7 +33,7 @@ export const AppComponent: FC<AppComponentProps> = observer(
> >
<ApplicationContext.Provider value={app}> <ApplicationContext.Provider value={app}>
{app.maintained && app.maintaining && app.renderComponent('AppMaintainingDialog', { app })} {app.maintained && app.maintaining && app.renderComponent('AppMaintainingDialog', { app })}
{app.renderComponent('AppMain')} {app.renderComponent('AppMain', undefined, children)}
</ApplicationContext.Provider> </ApplicationContext.Provider>
</ErrorBoundary> </ErrorBoundary>
); );

View File

@ -1,9 +1,9 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useApp } from '../hooks'; import { useApp } from '../hooks';
export const MainComponent = React.memo(() => { export const MainComponent = React.memo(({ children }) => {
const app = useApp(); const app = useApp();
const Router = useMemo(() => app.router.getRouterComponent(), [app]); const Router = useMemo(() => app.router.getRouterComponent(children), [app]);
const Providers = useMemo(() => app.getComposeProviders(), [app]); const Providers = useMemo(() => app.getComposeProviders(), [app]);
return <Router BaseLayout={Providers} />; return <Router BaseLayout={Providers} />;
}); });

View File

@ -2,12 +2,15 @@ import React, { FC } from 'react';
import { MainComponent } from './MainComponent'; import { MainComponent } from './MainComponent';
const Loading: FC = () => <div>Loading...</div>; const Loading: FC = () => <div>Loading...</div>;
const AppError: FC<{ error: Error }> = ({ error }) => ( const AppError: FC<{ error: Error }> = ({ error }) => {
return (
<div> <div>
<div>Load Plugin Error</div> <div>Load Plugin Error</div>
{error?.message} {error?.message}
{process.env.__TEST__ && error?.stack}
</div> </div>
); );
};
const AppNotFound: FC = () => <div></div>; const AppNotFound: FC = () => <div></div>;

View File

@ -32,7 +32,8 @@ export class SchemaInitializer<P1 = ButtonProps, P2 = {}> {
if (!parentItem.children) { if (!parentItem.children) {
parentItem.children = []; parentItem.children = [];
} }
const index = parentItem.children.findIndex((item: any) => item.name === name); const childrenName = name.replace(`${parentItem.name}.`, '');
const index = parentItem.children.findIndex((item: any) => item.name === childrenName);
if (index === -1) { if (index === -1) {
parentItem.children.push(data); parentItem.children.push(data);
} else { } else {
@ -42,6 +43,7 @@ export class SchemaInitializer<P1 = ButtonProps, P2 = {}> {
} }
get(nestedName: string): SchemaInitializerItemType | undefined { get(nestedName: string): SchemaInitializerItemType | undefined {
if (!nestedName) return undefined;
const arr = nestedName.split('.'); const arr = nestedName.split('.');
let current: any = this.items; let current: any = this.items;
@ -58,8 +60,6 @@ export class SchemaInitializer<P1 = ButtonProps, P2 = {}> {
return undefined; return undefined;
} }
} }
return current;
} }
remove(nestedName: string) { remove(nestedName: string) {

View File

@ -2,6 +2,7 @@ import { useForm } from '@formily/react';
import React, { FC, useCallback, useMemo } from 'react'; import React, { FC, useCallback, useMemo } from 'react';
import { useActionContext, SchemaComponent } from '../../../schema-component'; import { useActionContext, SchemaComponent } from '../../../schema-component';
import { useSchemaInitializerItem } from '../context'; import { useSchemaInitializerItem } from '../context';
import { SchemaInitializerItem } from './SchemaInitializerItem';
export interface SchemaInitializerActionModalProps { export interface SchemaInitializerActionModalProps {
title: string; title: string;
@ -10,10 +11,24 @@ export interface SchemaInitializerActionModalProps {
onSubmit?: (values: any) => void; onSubmit?: (values: any) => void;
buttonText?: any; buttonText?: any;
component?: any; component?: any;
isItem?: boolean;
} }
export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps> = (props) => {
const { title, schema, buttonText, component, onCancel, onSubmit } = props;
const SchemaInitializerActionModalItemComponent = React.forwardRef((props: any, ref) => {
const { onClick, title, ...others } = props;
return (
<SchemaInitializerItem
ref={ref}
{...others}
onClick={(e) => {
onClick?.(e.event);
}}
></SchemaInitializerItem>
);
});
export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps> = (props) => {
const { title, schema, buttonText, isItem, component, onCancel, onSubmit } = props;
const useCancelAction = useCallback(() => { const useCancelAction = useCallback(() => {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const form = useForm(); const form = useForm();
@ -53,6 +68,11 @@ export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps>
? { ? {
component, component,
} }
: isItem
? {
title: buttonText,
component: SchemaInitializerActionModalItemComponent,
}
: { : {
icon: 'PlusOutlined', icon: 'PlusOutlined',
style: { style: {

View File

@ -21,6 +21,7 @@ const typeComponentMap: Record<string, string> = {
item: 'SchemaInitializerItemInternal', item: 'SchemaInitializerItemInternal',
itemGroup: 'SchemaInitializerItemGroupInternal', itemGroup: 'SchemaInitializerItemGroupInternal',
divider: 'SchemaInitializerDivider', divider: 'SchemaInitializerDivider',
switch: 'SchemaInitializerSwitchInternal',
subMenu: 'SchemaInitializerSubMenuInternal', subMenu: 'SchemaInitializerSubMenuInternal',
actionModal: 'SchemaInitializerActionModalInternal', actionModal: 'SchemaInitializerActionModalInternal',
}; };
@ -70,7 +71,7 @@ export const SchemaInitializerChild: FC<SchemaInitializerItemType> = memo((props
if (!C) { if (!C) {
return null; return null;
} }
if (hideIfNoChildren && Array.isArray(componentChildren) && componentChildren.length === 0) { if (hideIfNoChildren && !componentChildren) {
return null; return null;
} }

View File

@ -76,6 +76,9 @@ export const SchemaInitializerItem = memo(
); );
SchemaInitializerItem.displayName = 'SchemaInitializerItem'; SchemaInitializerItem.displayName = 'SchemaInitializerItem';
/**
* @internal
*/
export const SchemaInitializerItemInternal = () => { export const SchemaInitializerItemInternal = () => {
const itemConfig = useSchemaInitializerItem(); const itemConfig = useSchemaInitializerItem();
return <SchemaInitializerItem {...itemConfig} />; return <SchemaInitializerItem {...itemConfig} />;

View File

@ -10,10 +10,16 @@ import { useSchemaInitializerStyles } from './style';
export interface SchemaInitializerItemGroupProps { export interface SchemaInitializerItemGroupProps {
title: string; title: string;
children?: SchemaInitializerOptions['items']; children?: SchemaInitializerOptions['items'];
items?: SchemaInitializerOptions['items'];
divider?: boolean; divider?: boolean;
} }
export const SchemaInitializerItemGroup: FC<SchemaInitializerItemGroupProps> = ({ children, title, divider }) => { export const SchemaInitializerItemGroup: FC<SchemaInitializerItemGroupProps> = ({
children,
items,
title,
divider,
}) => {
const compile = useCompile(); const compile = useCompile();
const { componentCls } = useSchemaInitializerStyles(); const { componentCls } = useSchemaInitializerStyles();
const { token } = theme.useToken(); const { token } = theme.useToken();
@ -21,11 +27,14 @@ export const SchemaInitializerItemGroup: FC<SchemaInitializerItemGroupProps> = (
<div style={{ marginInline: token.marginXXS }}> <div style={{ marginInline: token.marginXXS }}>
{divider && <SchemaInitializerDivider />} {divider && <SchemaInitializerDivider />}
<div className={`${componentCls}-group-title`}>{compile(title)}</div> <div className={`${componentCls}-group-title`}>{compile(title)}</div>
<SchemaInitializerChildren>{children}</SchemaInitializerChildren> <SchemaInitializerChildren>{items || children}</SchemaInitializerChildren>
</div> </div>
); );
}; };
/**
* @internal
*/
export const SchemaInitializerItemGroupInternal = () => { export const SchemaInitializerItemGroupInternal = () => {
const itemConfig = useSchemaInitializerItem<SchemaInitializerItemGroupProps>(); const itemConfig = useSchemaInitializerItem<SchemaInitializerItemGroupProps>();
return <SchemaInitializerItemGroup {...itemConfig} />; return <SchemaInitializerItemGroup {...itemConfig} />;

View File

@ -55,6 +55,9 @@ export const SchemaInitializerSelect: FC<SchemaInitializerSelectItemProps> = (pr
); );
}; };
/**
* @internal
*/
export const SchemaInitializerSelectInternal = () => { export const SchemaInitializerSelectInternal = () => {
const itemConfig = useSchemaInitializerItem<SchemaInitializerSelectItemProps>(); const itemConfig = useSchemaInitializerItem<SchemaInitializerSelectItemProps>();
return <SchemaInitializerSelect {...itemConfig} />; return <SchemaInitializerSelect {...itemConfig} />;

View File

@ -10,16 +10,20 @@ import { SchemaInitializerOptions } from '../types';
import { useSchemaInitializerStyles } from './style'; import { useSchemaInitializerStyles } from './style';
export interface SchemaInitializerSubMenuProps { export interface SchemaInitializerSubMenuProps {
name: string; name?: string;
title?: string; title?: string;
onClick?: (args: any) => void; onClick?: (args: any) => void;
onOpenChange?: (openKeys: string[]) => void; onOpenChange?: (openKeys: string[]) => void;
icon?: string | ReactNode; icon?: string | ReactNode;
children?: SchemaInitializerOptions['items']; children?: SchemaInitializerOptions['items'];
items?: SchemaInitializerOptions['items'];
} }
const SchemaInitializerSubMenuContext = React.createContext<{ isInMenu?: true }>({}); const SchemaInitializerSubMenuContext = React.createContext<{ isInMenu?: true }>({});
const SchemaInitializerMenuProvider = (props) => { /**
* @internal
*/
export const SchemaInitializerMenuProvider = (props) => {
return ( return (
<SchemaInitializerSubMenuContext.Provider value={{ isInMenu: true }}> <SchemaInitializerSubMenuContext.Provider value={{ isInMenu: true }}>
{props.children} {props.children}
@ -71,25 +75,30 @@ export const SchemaInitializerMenu: FC<MenuProps> = (props) => {
}; };
export const SchemaInitializerSubMenu: FC<SchemaInitializerSubMenuProps> = (props) => { export const SchemaInitializerSubMenu: FC<SchemaInitializerSubMenuProps> = (props) => {
const { children, title, name = uid(), onOpenChange, icon, ...others } = props; const { children, items: propItems, title, name, onOpenChange, icon, ...others } = props;
const compile = useCompile(); const compile = useCompile();
const validChildren = children?.filter((item) => (item.useVisible ? item.useVisible() : true)); const schemaInitializerItem = useSchemaInitializerItem();
const nameValue = useMemo(() => name || schemaInitializerItem?.name || uid(), [name, schemaInitializerItem]);
const validChildren = (propItems || children)?.filter((item) => (item.useVisible ? item.useVisible() : true));
const childrenItems = useSchemaInitializerMenuItems(validChildren, name); const childrenItems = useSchemaInitializerMenuItems(validChildren, name);
const items = useMemo(() => { const items = useMemo(() => {
return [ return [
{ {
...others, ...others,
key: name, key: nameValue,
label: compile(title), label: compile(title),
icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon, icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon,
children: childrenItems, children: childrenItems,
}, },
]; ];
}, [childrenItems, compile, icon, name, others, title]); }, [childrenItems, compile, icon, nameValue, others, title]);
return <SchemaInitializerMenu onOpenChange={onOpenChange} items={items}></SchemaInitializerMenu>; return <SchemaInitializerMenu onOpenChange={onOpenChange} items={items}></SchemaInitializerMenu>;
}; };
/**
* @internal
*/
export const SchemaInitializerSubMenuInternal = () => { export const SchemaInitializerSubMenuInternal = () => {
const itemConfig = useSchemaInitializerItem<SchemaInitializerSubMenuProps>(); const itemConfig = useSchemaInitializerItem<SchemaInitializerSubMenuProps>();
return <SchemaInitializerSubMenu {...itemConfig}></SchemaInitializerSubMenu>; return <SchemaInitializerSubMenu {...itemConfig}></SchemaInitializerSubMenu>;

View File

@ -21,6 +21,9 @@ export const SchemaInitializerSwitch: FC<SchemaInitializerSwitchItemProps> = (pr
); );
}; };
/**
* @internal
*/
export const SchemaInitializerSwitchInternal = () => { export const SchemaInitializerSwitchInternal = () => {
const itemConfig = useSchemaInitializerItem<SchemaInitializerSwitchItemProps>(); const itemConfig = useSchemaInitializerItem<SchemaInitializerSwitchItemProps>();
return <SchemaInitializerSwitch {...itemConfig} />; return <SchemaInitializerSwitch {...itemConfig} />;

View File

@ -1,174 +1,3 @@
import { ButtonProps } from 'antd';
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
import { useCompile } from '../../../schema-component';
import { useApp } from '../../hooks';
import { SchemaInitializerChild, SchemaInitializerItems } from '../components';
import { SchemaInitializerButton } from '../components/SchemaInitializerButton';
import { withInitializer } from '../hoc';
import { SchemaInitializerOptions } from '../types';
export * from './useAriaAttributeOfMenuItem'; export * from './useAriaAttributeOfMenuItem';
export * from './useSchemaInitializerRender';
/** export * from './useGetSchemaInitializerMenuItems';
* @internal
*/
export function useSchemaInitializerMenuItems(items: any[], name?: string, onClick?: (args: any) => void) {
const getMenuItems = useGetSchemaInitializerMenuItems(onClick);
return useMemo(() => getMenuItems(items, name), [getMenuItems, items, name]);
}
/**
* @internal
*/
export function useGetSchemaInitializerMenuItems(onClick?: (args: any) => void) {
const compile = useCompile();
const getMenuItems = useCallback(
(items: any[], parentKey: string) => {
if (!items?.length) {
return [];
}
return items.map((item: any, indexA) => {
const ItemComponent = item.component || item.Component;
let element: ReactNode;
const compiledTitle = item.title || item.label ? compile(item.title || item.label) : undefined;
if (ItemComponent) {
element = React.createElement(SchemaInitializerChild, { ...item, title: compiledTitle });
if (!element) return;
}
if (item.type === 'divider') {
return { type: 'divider', key: item.key || `divider-${indexA}` };
}
if (item.type === 'item' && ItemComponent) {
if (!item.key) {
item.key = `${compiledTitle}-${indexA}`;
}
return item.associationField
? {
key: item.key,
label: element,
associationField: item.associationField,
}
: {
key: item.key,
label: element,
};
}
if (item.type === 'itemGroup') {
const label = typeof compiledTitle === 'string' ? compiledTitle : item.title;
const key = item.key || `${parentKey}-item-group-${indexA}`;
return {
type: 'group',
key,
label,
// className: styles.nbMenuItemGroup,
children: item?.children.length ? getMenuItems(item.children, key) : [],
};
}
if (item.type === 'subMenu') {
const label = compiledTitle;
const key = item.key || item.name || `${parentKey}-sub-menu-${indexA}`;
return {
key,
label,
children: item?.children.length ? getMenuItems(item.children, key) : [],
};
}
if (item.isMenuType) {
const { isMenuType, ...menuData } = item;
return menuData;
}
const label = element || compiledTitle || item.label;
const key = item.key || `${parentKey}-${compiledTitle}-${indexA}`;
const handleClick = (info) => {
info.domEvent.stopPropagation();
if (info.key !== key) return;
if (item.onClick) {
item.onClick({ ...info, item });
} else {
onClick?.({ ...info, item });
}
};
return item.associationField
? {
key,
label,
associationField: item.associationField,
onClick: handleClick,
}
: {
key,
label,
onClick: handleClick,
};
});
},
[compile, onClick],
);
return getMenuItems;
}
const InitializerComponent: FC<SchemaInitializerOptions<any, any>> = React.memo((options) => {
const Component: any = options.Component || SchemaInitializerButton;
const ItemsComponent: any = options.ItemsComponent || SchemaInitializerItems;
const itemsComponentProps: any = {
...options.itemsComponentProps,
options,
items: options.items,
style: options.itemsComponentStyle,
};
const C = useMemo(() => withInitializer(Component), [Component]);
return React.createElement(C, options, React.createElement(ItemsComponent, itemsComponentProps));
});
InitializerComponent.displayName = 'InitializerComponent';
export function useSchemaInitializerRender<P1 = ButtonProps, P2 = {}>(
name: string,
options?: Omit<SchemaInitializerOptions<P1, P2>, 'name'>,
) {
const app = useApp();
const renderCache = React.useRef<Record<string, React.FunctionComponentElement<any>>>({});
const initializer = useMemo(
() => app.schemaInitializerManager.get<P1, P2>(name),
[app.schemaInitializerManager, name],
);
const res = useMemo(() => {
if (!name) {
return {
exists: false,
render: () => null,
};
}
if (!initializer) {
console.error(`[nocobase]: SchemaInitializer "${name}" not found`);
return {
exists: false,
render: () => null,
};
}
return {
exists: true,
render: (props?: Omit<SchemaInitializerOptions<P1, P2>, 'name'>) => {
const key = JSON.stringify(props) || '{}';
if (renderCache.current[key]) {
return renderCache.current[key];
}
return (renderCache.current[key] = React.createElement(InitializerComponent, {
...initializer.options,
...options,
...props,
}));
},
};
}, [initializer, name, options]);
return res;
}

View File

@ -0,0 +1,105 @@
import React, { ReactNode, useCallback, useMemo } from 'react';
import { useCompile } from '../../../schema-component';
import { SchemaInitializerChild } from '../components';
/**
* @internal
*/
export function useSchemaInitializerMenuItems(items: any[], name?: string, onClick?: (args: any) => void) {
const getMenuItems = useGetSchemaInitializerMenuItems(onClick);
return useMemo(() => getMenuItems(items, name), [getMenuItems, items, name]);
}
/**
* @internal
*/
export function useGetSchemaInitializerMenuItems(onClick?: (args: any) => void) {
const compile = useCompile();
const getMenuItems = useCallback(
(items: any[], parentKey: string) => {
if (!items?.length) {
return [];
}
return items.map((item: any, indexA) => {
const ItemComponent = item.component || item.Component;
let element: ReactNode;
const compiledTitle = item.title || item.label ? compile(item.title || item.label) : undefined;
if (ItemComponent) {
element = React.createElement(SchemaInitializerChild, { ...item, title: compiledTitle });
}
if (item.type === 'divider') {
return { type: 'divider', key: item.key || `divider-${indexA}` };
}
if (item.type === 'item' && ItemComponent) {
if (!item.key) {
item.key = `${compiledTitle}-${indexA}`;
}
return item.associationField
? {
key: item.key,
label: element,
associationField: item.associationField,
}
: {
key: item.key,
label: element,
};
}
if (item.type === 'itemGroup') {
const label = typeof compiledTitle === 'string' ? compiledTitle : item.title;
const key = item.key || `${parentKey}-item-group-${indexA}`;
return {
type: 'group',
key,
label,
// className: styles.nbMenuItemGroup,
children: item?.children?.length ? getMenuItems(item.children, key) : [],
};
}
if (item.type === 'subMenu') {
const label = compiledTitle;
const key = item.key || item.name || `${parentKey}-sub-menu-${indexA}`;
return {
key,
label,
children: item?.children?.length ? getMenuItems(item.children, key) : [],
};
}
if (item.isMenuType) {
const { isMenuType, ...menuData } = item;
return menuData;
}
const label = element || compiledTitle;
const key = item.key || `${parentKey}-${compiledTitle}-${indexA}`;
const handleClick = (info) => {
info.domEvent.stopPropagation();
if (info.key !== key) return;
if (item.onClick) {
item.onClick({ ...info, item });
} else {
onClick?.({ ...info, item });
}
};
return item.associationField
? {
key,
label,
associationField: item.associationField,
onClick: handleClick,
}
: {
key,
label,
onClick: handleClick,
};
});
},
[compile, onClick],
);
return getMenuItems;
}

View File

@ -0,0 +1,63 @@
import { ButtonProps } from 'antd';
import React, { FC, useMemo } from 'react';
import { useApp } from '../../hooks';
import { SchemaInitializerItems } from '../components';
import { SchemaInitializerButton } from '../components/SchemaInitializerButton';
import { withInitializer } from '../withInitializer';
import { SchemaInitializerOptions } from '../types';
const InitializerComponent: FC<SchemaInitializerOptions<any, any>> = React.memo((options) => {
const Component: any = options.Component || SchemaInitializerButton;
const ItemsComponent: any = options.ItemsComponent || SchemaInitializerItems;
const itemsComponentProps: any = {
...options.itemsComponentProps,
options,
items: options.items,
style: options.itemsComponentStyle,
};
const C = useMemo(() => withInitializer(Component), [Component]);
return React.createElement(C, options, React.createElement(ItemsComponent, itemsComponentProps));
});
InitializerComponent.displayName = 'InitializerComponent';
export function useSchemaInitializerRender<P1 = ButtonProps, P2 = {}>(
name: string,
options?: Omit<SchemaInitializerOptions<P1, P2>, 'name'>,
) {
const app = useApp();
const initializer = useMemo(
() => app.schemaInitializerManager.get<P1, P2>(name),
[app.schemaInitializerManager, name],
);
const res = useMemo(() => {
if (!name) {
return {
exists: false,
render: () => null,
};
}
if (!initializer) {
console.error(`[nocobase]: SchemaInitializer "${name}" not found`);
return {
exists: false,
render: () => null,
};
}
return {
exists: true,
render: (props?: Omit<SchemaInitializerOptions<P1, P2>, 'name'>) => {
return React.createElement(InitializerComponent, {
...initializer.options,
...options,
...props,
});
},
};
}, [initializer, name, options]);
return res;
}

View File

@ -1,4 +1,4 @@
export * from './hoc'; export * from './withInitializer';
export * from './hooks'; export * from './hooks';
export * from './types'; export * from './types';
export * from './context'; export * from './context';

View File

@ -3,12 +3,12 @@ import { ConfigProvider, Popover, theme } from 'antd';
import React, { ComponentType, useCallback, useMemo, useState } from 'react'; import React, { ComponentType, useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useNiceDropdownMaxHeight } from '../../../common/useNiceDropdownHeight'; import { useNiceDropdownMaxHeight } from '../../common/useNiceDropdownHeight';
import { useFlag } from '../../../flag-provider'; import { useFlag } from '../../flag-provider';
import { useDesignable } from '../../../schema-component'; import { useDesignable } from '../../schema-component';
import { useSchemaInitializerStyles } from '../components/style'; import { useSchemaInitializerStyles } from './components/style';
import { SchemaInitializerContext } from '../context'; import { SchemaInitializerContext } from './context';
import { SchemaInitializerOptions } from '../types'; import { SchemaInitializerOptions } from './types';
const defaultWrap = (s: ISchema) => s; const defaultWrap = (s: ISchema) => s;

View File

@ -32,7 +32,8 @@ export class SchemaSettings<T = {}> {
if (!parentItem.children) { if (!parentItem.children) {
parentItem.children = []; parentItem.children = [];
} }
const index = parentItem.children.findIndex((item: any) => item.name === name); const childrenName = name.replace(`${parentItem.name}.`, '');
const index = parentItem.children.findIndex((item: any) => item.name === childrenName);
if (index === -1) { if (index === -1) {
parentItem.children.push(data); parentItem.children.push(data);
} else { } else {
@ -42,6 +43,7 @@ export class SchemaSettings<T = {}> {
} }
get(nestedName: string): SchemaSettingsItemType | undefined { get(nestedName: string): SchemaSettingsItemType | undefined {
if (!nestedName) return undefined;
const arr = nestedName.split('.'); const arr = nestedName.split('.');
let current: any = this.items; let current: any = this.items;
@ -58,8 +60,6 @@ export class SchemaSettings<T = {}> {
return undefined; return undefined;
} }
} }
return current;
} }
remove(nestedName: string) { remove(nestedName: string) {

View File

@ -78,7 +78,7 @@ export const SchemaSettingsChild: FC<SchemaSettingsItemType> = memo((props) => {
type, type,
Component, Component,
children, children,
hideIfNoChildren = true, hideIfNoChildren,
componentProps, componentProps,
} = props as any; } = props as any;
const useChildrenRes = useChildren(); const useChildrenRes = useChildren();
@ -100,7 +100,7 @@ export const SchemaSettingsChild: FC<SchemaSettingsItemType> = memo((props) => {
if (!C) { if (!C) {
return null; return null;
} }
if (hideIfNoChildren && Array.isArray(componentChildren) && componentChildren.length === 0) { if (hideIfNoChildren && !componentChildren) {
return null; return null;
} }

View File

@ -0,0 +1,9 @@
import { createContext, useContext } from 'react';
import { SchemaSettingsItemType } from '../types';
export const SchemaSettingItemContext = createContext<SchemaSettingsItemType>({} as any);
SchemaSettingItemContext.displayName = 'SchemaSettingItemContext';
export function useSchemaSettingsItem<T = {}>() {
return useContext(SchemaSettingItemContext) as T;
}

View File

@ -1,9 +1 @@
import { createContext, useContext } from 'react'; export * from './SchemaSettingItemContext';
import { SchemaSettingsItemType } from '../types';
export const SchemaSettingItemContext = createContext<SchemaSettingsItemType>({} as any);
SchemaSettingItemContext.displayName = 'SchemaSettingItemContext';
export function useSchemaSettingsItem() {
return useContext(SchemaSettingItemContext) as SchemaSettingsItemType;
}

View File

@ -1,50 +1 @@
import { useMemo } from 'react'; export * from './useSchemaSettingsRender';
import { useApp } from '../../hooks';
import { SchemaSettingOptions } from '../types';
import React from 'react';
import { SchemaSettingsWrapper } from '../components';
import { SchemaSettingsProps } from '../../../schema-settings';
import { Schema } from '@formily/json-schema';
import { GeneralField } from '@formily/core';
import { Designable } from '../../../schema-component';
type UseSchemaSettingsRenderOptions<T = {}> = Omit<SchemaSettingOptions<T>, 'name' | 'items'> &
Omit<SchemaSettingsProps, 'title' | 'children'> & {
fieldSchema?: Schema;
field?: GeneralField;
dn?: Designable;
};
export function useSchemaSettingsRender<T = {}>(name: string, options?: UseSchemaSettingsRenderOptions<T>) {
const app = useApp();
const schemaSetting = useMemo(() => app.schemaSettingsManager.get<T>(name), [app.schemaSettingsManager, name]);
const renderCache = React.useRef<Record<string, React.FunctionComponentElement<any>>>({});
if (!name) {
return {
exists: false,
render: () => null,
};
}
if (!schemaSetting) {
console.error(`[nocobase]: SchemaSettings "${name}" not found`);
return {
exists: false,
render: () => null,
};
}
return {
exists: true,
render: (options2: UseSchemaSettingsRenderOptions = {}) => {
const key = JSON.stringify(options2);
if (key && renderCache.current[key]) {
return renderCache.current[key];
}
return (renderCache.current[key] = React.createElement(SchemaSettingsWrapper, {
...schemaSetting.options,
...options,
...options2,
}));
},
};
}

View File

@ -0,0 +1,45 @@
import { useMemo } from 'react';
import { useApp } from '../../hooks';
import { SchemaSettingOptions } from '../types';
import React from 'react';
import { SchemaSettingsWrapper } from '../components';
import { SchemaSettingsProps } from '../../../schema-settings';
import { Schema } from '@formily/json-schema';
import { GeneralField } from '@formily/core';
import { Designable } from '../../../schema-component';
type UseSchemaSettingsRenderOptions<T = {}> = Omit<SchemaSettingOptions<T>, 'name' | 'items'> &
Omit<SchemaSettingsProps, 'title' | 'children'> & {
fieldSchema?: Schema;
field?: GeneralField;
dn?: Designable;
};
export function useSchemaSettingsRender<T = {}>(name: string, options?: UseSchemaSettingsRenderOptions<T>) {
const app = useApp();
const schemaSetting = useMemo(() => app.schemaSettingsManager.get<T>(name), [app.schemaSettingsManager, name]);
if (!name) {
return {
exists: false,
render: () => null,
};
}
if (!schemaSetting) {
console.error(`[nocobase]: SchemaSettings "${name}" not found`);
return {
exists: false,
render: () => null,
};
}
return {
exists: true,
render: (options2: UseSchemaSettingsRenderOptions = {}) => {
return React.createElement(SchemaSettingsWrapper, {
...schemaSetting.options,
...options,
...options2,
});
},
};
}

View File

@ -94,6 +94,9 @@ describe('AssociationProvider', () => {
}; };
renderApp(Demo, { name: 'users.not-exists' }); renderApp(Demo, { name: 'users.not-exists' });
expect(document.body.innerHTML).toContain('ant-result'); expect(screen.getByText('Delete')).toBeInTheDocument();
expect(
screen.getByText('The collection "users.not-exists" may have been deleted. Please remove this block.'),
).toBeInTheDocument();
}); });
}); });

View File

@ -1,6 +1,5 @@
import { Application, CollectionManager, CollectionTemplate, Collection } from '@nocobase/client'; import { Application, CollectionManager, CollectionTemplate, Collection } from '@nocobase/client';
import collections from '../collections.json'; import collections from '../collections.json';
import { app } from '../../../application/demos/demo3';
describe('CollectionManager', () => { describe('CollectionManager', () => {
let collectionManager: CollectionManager; let collectionManager: CollectionManager;
@ -227,4 +226,71 @@ describe('CollectionManager', () => {
expect(clone.dataSource).toBe(collectionManager.dataSource); expect(clone.dataSource).toBe(collectionManager.dataSource);
}); });
}); });
describe('getSourceKeyByAssociation', () => {
let collectionManager: CollectionManager;
beforeEach(() => {
const app = new Application({
dataSourceManager: {
collections: collections as any,
},
});
collectionManager = app.getCollectionManager();
});
describe('getSourceKeyByAssociation', () => {
it('should return undefined when associationName is not provided', () => {
const result = collectionManager.getSourceKeyByAssociation('');
expect(result).toBeUndefined();
});
it('should return undefined when field is not found', () => {
const result = collectionManager.getSourceKeyByAssociation('not-exist');
expect(result).toBeUndefined();
const result2 = collectionManager.getSourceKeyByAssociation('users.not-exist');
expect(result2).toBeUndefined();
});
it('should return field sourceKey when it exists', () => {
const result = collectionManager.getSourceKeyByAssociation('users.roles');
expect(result).toBe('id');
});
it('should return source collection filterTargetKey when field sourceKey does not exist', () => {
const field = collectionManager.getCollectionField('users.roles');
const users = collectionManager.getCollection('users');
delete field.sourceKey;
const options = users.getOptions();
options.filterTargetKey = 'filterTargetKey';
const result = collectionManager.getSourceKeyByAssociation('users.roles');
expect(result).toBe('filterTargetKey');
});
it('should return source collection primary key when field sourceKey and filterTargetKey do not exist', () => {
const field = collectionManager.getCollectionField('users.roles');
delete field.sourceKey;
const result = collectionManager.getSourceKeyByAssociation('users.roles');
expect(result).toBe('id');
});
it('should return "id" when field sourceKey, filterTargetKey, and primary key do not exist', () => {
const field = collectionManager.getCollectionField('users.roles');
const collection = collectionManager.getCollection('roles');
const roleName = collectionManager.getCollectionField('roles.name');
delete field.sourceKey;
delete roleName.primaryKey;
delete collection.getOptions().filterTargetKey;
const result = collectionManager.getSourceKeyByAssociation('users.roles');
expect(result).toBe('id');
});
});
});
}); });

View File

@ -84,7 +84,7 @@ describe('CollectionManagerProvider', () => {
const Wrapper = () => { const Wrapper = () => {
return ( return (
<CollectionManagerProvider collections={[collections[1] as any]}> <CollectionManagerProvider>
<Demo></Demo> <Demo></Demo>
</CollectionManagerProvider> </CollectionManagerProvider>
); );

View File

@ -80,7 +80,7 @@ describe('CollectionProvider', () => {
renderApp(Demo, { name: 'not-exists', allowNull: false }); renderApp(Demo, { name: 'not-exists', allowNull: false });
expect(document.body.innerHTML).toContain('ant-result'); expect(screen.getByText('Delete')).toBeInTheDocument();
}); });
test('useCollectionFields() support predicate', () => { test('useCollectionFields() support predicate', () => {

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@nocobase/test/client'; import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
import { CollectionDeletedPlaceholder, SchemaComponent, SchemaComponentProvider } from '@nocobase/client'; import { CollectionDeletedPlaceholder, SchemaComponent, SchemaComponentProvider } from '@nocobase/client';
import { App } from 'antd';
function renderApp(name?: any, designable?: boolean) { function renderApp(name?: any, designable?: boolean) {
const schema = { const schema = {
@ -16,31 +17,45 @@ function renderApp(name?: any, designable?: boolean) {
render( render(
<div data-testid="app"> <div data-testid="app">
<App>
<SchemaComponentProvider designable={designable}> <SchemaComponentProvider designable={designable}>
<SchemaComponent schema={schema} components={{ CollectionDeletedPlaceholder }} /> <SchemaComponent schema={schema} components={{ CollectionDeletedPlaceholder }} />
</SchemaComponentProvider> </SchemaComponentProvider>
</App>
</div>, </div>,
); );
} }
describe('CollectionDeletedPlaceholder', () => { describe('CollectionDeletedPlaceholder', () => {
test('name is undefined, render `Result` component', () => { test('name is undefined, render `Result` component', async () => {
renderApp(undefined, true); renderApp(undefined, true);
expect(document.body.innerHTML).toContain('ant-result'); expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Collection name is required')).toBeInTheDocument();
await userEvent.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.queryByText('Are you sure you want to delete it?')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('OK'));
await waitFor(() => {
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
}); });
describe('name exist', () => {
test('designable: true, render `Result` component', () => { test('designable: true, render `Result` component', () => {
renderApp('test', true); renderApp('test', true);
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(document.body.innerHTML).toContain('ant-result'); expect(
screen.getByText('The collection "test" may have been deleted. Please remove this block.'),
).toBeInTheDocument();
}); });
test('designable: false, render nothing', () => { test('designable: false, render nothing', () => {
renderApp('test', false); renderApp('test', false);
expect(screen.getByTestId('app').innerHTML.length).toBe(0); expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
}); });
}); });

View File

@ -1,4 +1,11 @@
import { Application, Collection, DEFAULT_DATA_SOURCE_KEY, LocalDataSource, Plugin } from '@nocobase/client'; import {
Application,
Collection,
DEFAULT_DATA_SOURCE_KEY,
DataSourceOptions,
LocalDataSource,
Plugin,
} from '@nocobase/client';
import collections from '../collections.json'; import collections from '../collections.json';
describe('DataSourceManager', () => { describe('DataSourceManager', () => {
@ -119,6 +126,24 @@ describe('DataSourceManager', () => {
}); });
}); });
describe('getDataSources', () => {
test('filter', () => {
const app = new Application({
dataSourceManager: {
dataSources: [
{
key: 'a',
displayName: 'a',
},
],
},
});
expect(app.dataSourceManager.getDataSources()).toHaveLength(2);
expect(app.dataSourceManager.getDataSources((dataSource) => dataSource.key === 'a')).toHaveLength(1);
});
});
describe('addDataSource', () => { describe('addDataSource', () => {
test('should add a data source', () => { test('should add a data source', () => {
const app = new Application(); const app = new Application();
@ -184,5 +209,32 @@ describe('DataSourceManager', () => {
expect(reloadSpy1).toHaveBeenCalledTimes(1); expect(reloadSpy1).toHaveBeenCalledTimes(1);
expect(reloadSpy2).toHaveBeenCalledTimes(1); expect(reloadSpy2).toHaveBeenCalledTimes(1);
}); });
test('multi data sources', async () => {
const app = new Application();
const dataSourceManager = app.dataSourceManager;
const getThirdDataSource = (): Promise<DataSourceOptions[]> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
key: 'a',
displayName: 'a',
},
{
key: 'b',
displayName: 'b',
},
]);
});
});
};
dataSourceManager.addDataSources(getThirdDataSource, LocalDataSource);
await dataSourceManager.reload();
expect(dataSourceManager.getDataSources()).toHaveLength(3);
});
}); });
}); });

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { DataSourceManagerProvider, useDataSourceManager } from '@nocobase/client'; import { Application, DataSourceManagerProvider, useDataSourceManager } from '@nocobase/client';
describe('DataSourceManagerProvider', () => { describe('DataSourceManagerProvider', () => {
test('should render children', () => { test('should render children', () => {

View File

@ -5,10 +5,9 @@ import {
DataSourceOptions, DataSourceOptions,
DataSource, DataSource,
SchemaComponent, SchemaComponent,
SchemaComponentProvider,
} from '@nocobase/client'; } from '@nocobase/client';
import { DataSourceProvider, useDataSourceKey } from '../../data-source/DataSourceProvider'; import { DataSourceProvider, useDataSourceKey } from '../../data-source/DataSourceProvider';
import { render, screen } from '@nocobase/test/client'; import { render, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react'; import React from 'react';
import { AppSchemaComponentProvider } from '../../../application/AppSchemaComponentProvider'; import { AppSchemaComponentProvider } from '../../../application/AppSchemaComponentProvider';
@ -54,6 +53,8 @@ describe('DataSourceProvider', () => {
</DataSourceManagerProvider> </DataSourceManagerProvider>
</AppSchemaComponentProvider>, </AppSchemaComponentProvider>,
); );
return app.dataSourceManager.getDataSource(dataSource);
} }
it('should render default dataSource', () => { it('should render default dataSource', () => {
renderComponent(); renderComponent();
@ -67,12 +68,34 @@ describe('DataSourceProvider', () => {
it('should render error state when data source is not found', () => { it('should render error state when data source is not found', () => {
renderComponent('non-existent'); renderComponent('non-existent');
expect(document.querySelector('.ant-result')).toBeInTheDocument(); expect(screen.getByText('Delete')).toBeInTheDocument();
expect(
screen.getByText('The data source "non-existent" may have been deleted. Please remove this block.'),
).toBeInTheDocument();
}); });
it('should render loading state when data source is loading', () => { it('should render loading state when data source is loading', async () => {
renderComponent('test', 'loading'); const ds = renderComponent('test', 'loading');
const fn = vitest.spyOn(ds, 'reload');
expect(screen.getByText('Test data source loading...')).toBeInTheDocument(); expect(screen.getByText('Test data source loading...')).toBeInTheDocument();
await userEvent.click(screen.getByText('Refresh'));
await waitFor(() => {
expect(fn).toBeCalled();
});
});
it('should render loading state when data source is loading', async () => {
const ds = renderComponent('test', 'reloading');
const fn = vitest.spyOn(ds, 'reload');
expect(screen.getByText('Test data source loading...')).toBeInTheDocument();
await userEvent.click(screen.getByText('Refresh'));
await waitFor(() => {
expect(fn).toBeCalled();
});
}); });
it('should render error state when data source loading fails', () => { it('should render error state when data source loading fails', () => {

View File

@ -1,4 +1,12 @@
import { CollectionFieldInterface, isTitleField, Application } from '@nocobase/client'; import {
CollectionFieldInterface,
isTitleField,
Application,
useDataSourceHeaders,
DEFAULT_DATA_SOURCE_KEY,
} from '@nocobase/client';
import { renderHook } from '@nocobase/test/client';
import collections from './collections.json'; import collections from './collections.json';
describe('utils', () => { describe('utils', () => {
@ -42,4 +50,50 @@ describe('utils', () => {
expect(isTitleField(dm, field)).toBeTruthy(); expect(isTitleField(dm, field)).toBeTruthy();
}); });
}); });
describe('useDataSourceHeaders', () => {
test('should return undefined when dataSource is not provided', () => {
const { result } = renderHook(() => useDataSourceHeaders());
expect(result.current).toBeUndefined();
});
test('should return undefined when dataSource is the default key', () => {
const { result } = renderHook(() => useDataSourceHeaders(DEFAULT_DATA_SOURCE_KEY));
expect(result.current).toBeUndefined();
});
test('should return correct headers when dataSource is provided and not the default key', () => {
const dataSource = 'customDataSource';
const { result } = renderHook(() => useDataSourceHeaders(dataSource));
expect(result.current).toEqual({ 'x-data-source': dataSource });
});
test('should return undefined when dataSource is an empty string', () => {
const { result } = renderHook(() => useDataSourceHeaders(''));
expect(result.current).toBeUndefined();
});
test('should return undefined when dataSource is null', () => {
// This test will always pass in TypeScript since the type doesn't allow null, but included for comprehensive testing if the dataSource type changes.
const { result } = renderHook(() => useDataSourceHeaders(null as unknown as string));
expect(result.current).toBeUndefined();
});
test('should handle dataSource change', () => {
const { result, rerender } = renderHook(({ dataSource }) => useDataSourceHeaders(dataSource), {
initialProps: { dataSource: 'initialDataSource' },
});
expect(result.current).toEqual({ 'x-data-source': 'initialDataSource' });
// Change the dataSource prop
rerender({ dataSource: 'updatedDataSource' });
expect(result.current).toEqual({ 'x-data-source': 'updatedDataSource' });
});
});
}); });

View File

@ -37,7 +37,6 @@ export class CollectionFieldInterfaceManager {
if (item.notSupportDataSourceType) { if (item.notSupportDataSourceType) {
return !item.notSupportDataSourceType?.includes(dataSourceType); return !item.notSupportDataSourceType?.includes(dataSourceType);
} }
return true;
}); });
} }

View File

@ -46,7 +46,6 @@ export class CollectionTemplateManager {
if (item.notSupportDataSourceType) { if (item.notSupportDataSourceType) {
return !item.notSupportDataSourceType?.includes(dataSourceType); return !item.notSupportDataSourceType?.includes(dataSourceType);
} }
return true;
}) })
.sort((a, b) => (a.order || 0) - (b.order || 0)); .sort((a, b) => (a.order || 0) - (b.order || 0));
} }

View File

@ -131,7 +131,7 @@ export class CollectionManager {
return this.getCollection(collectionName)?.getFields(predicate) || []; return this.getCollection(collectionName)?.getFields(predicate) || [];
} }
getSourceKeyByAssocation(associationName: string) { getSourceKeyByAssociation(associationName: string) {
if (!associationName) { if (!associationName) {
return; return;
} }

View File

@ -8,7 +8,7 @@ import { DEFAULT_DATA_SOURCE_KEY } from '../../data-source/data-source/DataSourc
import { useCollection } from '../collection'; import { useCollection } from '../collection';
export interface CollectionDeletedPlaceholderProps { export interface CollectionDeletedPlaceholderProps {
type: 'Collection' | 'Field' | 'DataSource'; type: 'Collection' | 'Field' | 'Data Source' | 'Block template';
name?: string | number; name?: string | number;
message?: string; message?: string;
} }
@ -60,7 +60,7 @@ export const CollectionDeletedPlaceholder: FC<CollectionDeletedPlaceholderProps>
return t(`The {{type}} "{{name}}" may have been deleted. Please remove this {{blockType}}.`, { return t(`The {{type}} "{{name}}" may have been deleted. Please remove this {{blockType}}.`, {
type: t(type).toLocaleLowerCase(), type: t(type).toLocaleLowerCase(),
name: nameValue, name: nameValue,
blockType: t(blockType), blockType: t(blockType).toLocaleLowerCase(),
}).replaceAll('&gt;', '>'); }).replaceAll('&gt;', '>');
}, [message, nameValue, type, t, blockType]); }, [message, nameValue, type, t, blockType]);

View File

@ -23,7 +23,7 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr
return sourceId; return sourceId;
} }
if (association && parentRecord) { if (association && parentRecord) {
const sourceKey = cm.getSourceKeyByAssocation(association); const sourceKey = cm.getSourceKeyByAssociation(association);
const parentRecordData = parentRecord instanceof CollectionRecord ? parentRecord.data : parentRecord; const parentRecordData = parentRecord instanceof CollectionRecord ? parentRecord.data : parentRecord;
return parentRecordData[sourceKey]; return parentRecordData[sourceKey];
} }

View File

@ -56,9 +56,9 @@ export class DataSourceManager {
this.getDataSources().forEach((dataSource) => dataSource.collectionManager.reAddCollections()); this.getDataSources().forEach((dataSource) => dataSource.collectionManager.reAddCollections());
} }
getDataSources(filterCollection?: (dataSource: DataSource) => boolean) { getDataSources(filterDataSource?: (dataSource: DataSource) => boolean) {
const allDataSources = Object.values(this.dataSourceInstancesMap); const allDataSources = Object.values(this.dataSourceInstancesMap);
return filterCollection ? _.filter(allDataSources, filterCollection) : allDataSources; return filterDataSource ? _.filter(allDataSources, filterDataSource) : allDataSources;
} }
getDataSource(key?: string) { getDataSource(key?: string) {

View File

@ -500,6 +500,7 @@
"Save as template": "Save as template", "Save as template": "Save as template",
"Save as block template": "Save as block template", "Save as block template": "Save as block template",
"Block templates": "Block templates", "Block templates": "Block templates",
"Block template": "Block template",
"Convert reference to duplicate": "Convert reference to duplicate", "Convert reference to duplicate": "Convert reference to duplicate",
"Template name": "Template name", "Template name": "Template name",
"Block type": "Block type", "Block type": "Block type",
@ -801,6 +802,7 @@
"loading": "loading", "loading": "loading",
"name is required": "name is required", "name is required": "name is required",
"data source": "data source", "data source": "data source",
"Data source": "Data source",
"DataSource": "DataSource", "DataSource": "DataSource",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.",
"Preset fields": "Preset fields", "Preset fields": "Preset fields",

View File

@ -471,6 +471,7 @@
"Save as template": "Guardar como plantilla", "Save as template": "Guardar como plantilla",
"Save as block template": "Guardar como plantilla de bloque", "Save as block template": "Guardar como plantilla de bloque",
"Block templates": "Bloquear plantillas", "Block templates": "Bloquear plantillas",
"Block template": "Plantilla de bloque",
"Convert reference to duplicate": "Convertir referencia a duplicado", "Convert reference to duplicate": "Convertir referencia a duplicado",
"Template name": "Nombre de plantilla", "Template name": "Nombre de plantilla",
"Block type": "Tipo de bloque", "Block type": "Tipo de bloque",
@ -749,6 +750,7 @@
"name is required": "el nombre es obligatorio", "name is required": "el nombre es obligatorio",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "El {{type}} \"{{name}}\" puede haber sido eliminado. Por favor, elimine este {{blockType}}.", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "El {{type}} \"{{name}}\" puede haber sido eliminado. Por favor, elimine este {{blockType}}.",
"data source": "fuente de datos", "data source": "fuente de datos",
"Data source": "fuente de datos",
"DataSource": "Fuente de datos", "DataSource": "Fuente de datos",
"Home page": "Página de inicio", "Home page": "Página de inicio",
"Handbook": "Manual de usuario", "Handbook": "Manual de usuario",

View File

@ -486,6 +486,7 @@
"Save as template": "Enregistrer en tant que modèle", "Save as template": "Enregistrer en tant que modèle",
"Save as block template": "Enregistrer en tant que modèle de bloc", "Save as block template": "Enregistrer en tant que modèle de bloc",
"Block templates": "Modèles de bloc", "Block templates": "Modèles de bloc",
"Block template": "Modèle de bloc",
"Convert reference to duplicate": "Convertir la référence en doublon", "Convert reference to duplicate": "Convertir la référence en doublon",
"Template name": "Nom du modèle", "Template name": "Nom du modèle",
"Block type": "Type de bloc", "Block type": "Type de bloc",
@ -767,6 +768,7 @@
"loading": "chargement", "loading": "chargement",
"name is required": "le nom est requis", "name is required": "le nom est requis",
"data source": "source de données", "data source": "source de données",
"Data source": "source de données",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "Le {{type}} \"{{name}}\" a peut-être été supprimé. Veuillez supprimer ce {{blockType}}.", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "Le {{type}} \"{{name}}\" a peut-être été supprimé. Veuillez supprimer ce {{blockType}}.",
"DataSource": "Source de données", "DataSource": "Source de données",
"Allow selection of existing records":"Permet de sélectionner des données existantes", "Allow selection of existing records":"Permet de sélectionner des données existantes",

View File

@ -398,6 +398,7 @@
"Save as template": "テンプレートとして保存", "Save as template": "テンプレートとして保存",
"Save as block template": "ブロックテンプレートとして保存", "Save as block template": "ブロックテンプレートとして保存",
"Block templates": "ブロックテンプレート", "Block templates": "ブロックテンプレート",
"Block template": "ブロックテンプレート",
"Convert reference to duplicate": "参照を複製に変換", "Convert reference to duplicate": "参照を複製に変換",
"Template name": "テンプレート名", "Template name": "テンプレート名",
"Block type": "ブロックタイプ", "Block type": "ブロックタイプ",
@ -687,8 +688,9 @@
"loading": "ロード中", "loading": "ロード中",
"name is required": "名前は必須です", "name is required": "名前は必須です",
"data source": "データソース", "data source": "データソース",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" は削除されている可能性があります。この {{blockType}} を削除してください。", "Data source": "データソース",
"DataSource": "データソース", "DataSource": "データソース",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" は削除されている可能性があります。この {{blockType}} を削除してください。",
"Home page": "ホームページ", "Home page": "ホームページ",
"Handbook": "ユーザーマニュアル", "Handbook": "ユーザーマニュアル",
"License": "ライセンス" "License": "ライセンス"

View File

@ -518,6 +518,7 @@
"Save as template": "템플릿으로 저장", "Save as template": "템플릿으로 저장",
"Save as block template": "블록 템플릿으로 저장", "Save as block template": "블록 템플릿으로 저장",
"Block templates": "블록 템플릿", "Block templates": "블록 템플릿",
"Block template": "블록 템플릿",
"Convert reference to duplicate": "참조를 복제로 변환", "Convert reference to duplicate": "참조를 복제로 변환",
"Template name": "템플릿 이름", "Template name": "템플릿 이름",
"Block type": "블록 타입", "Block type": "블록 타입",
@ -859,8 +860,9 @@
"loading": "로드 중", "loading": "로드 중",
"name is required": "이름은 필수입니다", "name is required": "이름은 필수입니다",
"data source": "데이터 소스", "data source": "데이터 소스",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\"이(가) 삭제되었을 수 있습니다. 이 {{blockType}}을(를) 제거하십시오.", "Data source": "데이터 소스",
"DataSource": "데이터 소스", "DataSource": "데이터 소스",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\"이(가) 삭제되었을 수 있습니다. 이 {{blockType}}을(를) 제거하십시오.",
"Home page": "홈페이지", "Home page": "홈페이지",
"Handbook": "사용자 매뉴얼", "Handbook": "사용자 매뉴얼",
"License": "라이선스" "License": "라이선스"

View File

@ -434,6 +434,7 @@
"Save as template": "Salvar como modelo", "Save as template": "Salvar como modelo",
"Save as block template": "Salvar como modelo de bloco", "Save as block template": "Salvar como modelo de bloco",
"Block templates": "Modelos de bloco", "Block templates": "Modelos de bloco",
"Block template": "Modelo de bloco",
"Convert reference to duplicate": "Converter referência em duplicado", "Convert reference to duplicate": "Converter referência em duplicado",
"Template name": "Nome do modelo", "Template name": "Nome do modelo",
"Block type": "Tipo de bloco", "Block type": "Tipo de bloco",
@ -726,8 +727,9 @@
"loading": "carregando", "loading": "carregando",
"name is required": "nome é obrigatório", "name is required": "nome é obrigatório",
"data source": "fonte de dados", "data source": "fonte de dados",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "O {{type}} \"{{name}}\" pode ter sido excluído. Por favor, remova este {{blockType}}.", "Data source": "fonte de dados",
"DataSource": "Fonte de dados", "DataSource": "Fonte de dados",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "O {{type}} \"{{name}}\" pode ter sido excluído. Por favor, remova este {{blockType}}.",
"Allow selection of existing records":"Permitir a selecção dos registos existentes", "Allow selection of existing records":"Permitir a selecção dos registos existentes",
"Home page": "Página inicial", "Home page": "Página inicial",
"Handbook": "Manual do usuário", "Handbook": "Manual do usuário",

View File

@ -337,7 +337,7 @@
"Other records": "Другие записи", "Other records": "Другие записи",
"Save as template": "Сохранить как шаблон", "Save as template": "Сохранить как шаблон",
"Save as block template": "Сохранить как шаблон Блока", "Save as block template": "Сохранить как шаблон Блока",
"Block templates": "Шаблоны Блока", "Block templates": "Шаблоны блоков",
"Convert reference to duplicate": "Преобразовать ссылку в дубликат", "Convert reference to duplicate": "Преобразовать ссылку в дубликат",
"Template name": "Имя Шаблона", "Template name": "Имя Шаблона",
"Block type": "Тип Блока", "Block type": "Тип Блока",
@ -563,6 +563,7 @@
"loading": "загрузка", "loading": "загрузка",
"name is required": "имя обязательно", "name is required": "имя обязательно",
"data source": "источник данных", "data source": "источник данных",
"Data source": "источник данных",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" возможно был удален. Пожалуйста, удалите этот {{blockType}}.", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" возможно был удален. Пожалуйста, удалите этот {{blockType}}.",
"DataSource": "Источник данных", "DataSource": "Источник данных",
"Home page": "Домашняя страница", "Home page": "Домашняя страница",

View File

@ -337,6 +337,7 @@
"Save as template": "Şablon olarak kaydet", "Save as template": "Şablon olarak kaydet",
"Save as block template": "Blok şablonu olarak kaydet", "Save as block template": "Blok şablonu olarak kaydet",
"Block templates": "Blok şablonları", "Block templates": "Blok şablonları",
"Block template": "Blok şablonu",
"Convert reference to duplicate": "Referansı kopyaya dönüştür", "Convert reference to duplicate": "Referansı kopyaya dönüştür",
"Template name": "Şablon adı", "Template name": "Şablon adı",
"Block type": "Blok türü", "Block type": "Blok türü",
@ -560,8 +561,9 @@
"loading": "yükleniyor", "loading": "yükleniyor",
"name is required": "ad gereklidir", "name is required": "ad gereklidir",
"data source": "veri kaynağı", "data source": "veri kaynağı",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" silinmiş olabilir. Lütfen bu {{blockType}}'yi kaldırın.", "Data source": "veri kaynağı",
"DataSource": "Veri Kaynağı", "DataSource": "Veri Kaynağı",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" silinmiş olabilir. Lütfen bu {{blockType}}'yi kaldırın.",
"Home page": "Anasayfa", "Home page": "Anasayfa",
"Handbook": "Kullanıcı kılavuzu", "Handbook": "Kullanıcı kılavuzu",
"License": "Lisans" "License": "Lisans"

View File

@ -488,6 +488,7 @@
"Save as template": "Зберегти як шаблон", "Save as template": "Зберегти як шаблон",
"Save as block template": "Зберегти як шаблон блока", "Save as block template": "Зберегти як шаблон блока",
"Block templates": "Шаблони блоків", "Block templates": "Шаблони блоків",
"Block template": "Шаблон блока",
"Convert reference to duplicate": "Конвертувати посилання на дублікат", "Convert reference to duplicate": "Конвертувати посилання на дублікат",
"Template name": "Назва шаблону", "Template name": "Назва шаблону",
"Block type": "Тип блока", "Block type": "Тип блока",
@ -768,8 +769,9 @@
"loading": "завантаження", "loading": "завантаження",
"name is required": "ім'я обов'язкове", "name is required": "ім'я обов'язкове",
"data source": "джерело даних", "data source": "джерело даних",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" може бути видалено. Будь ласка, видаліть цей {{blockType}}.", "Data source": "джерело даних",
"DataSource": "Джерело даних", "DataSource": "Джерело даних",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" може бути видалено. Будь ласка, видаліть цей {{blockType}}.",
"Home page": "Домашня сторінка", "Home page": "Домашня сторінка",
"Handbook": "Посібник користувача", "Handbook": "Посібник користувача",
"License": "Ліцензія" "License": "Ліцензія"

View File

@ -521,6 +521,7 @@
"Save as template": "保存为模板", "Save as template": "保存为模板",
"Save as block template": "保存为区块模板", "Save as block template": "保存为区块模板",
"Block templates": "区块模板", "Block templates": "区块模板",
"Block template": "区块模板",
"Convert reference to duplicate": "模板引用转为复制", "Convert reference to duplicate": "模板引用转为复制",
"Template name": "模板名称", "Template name": "模板名称",
"Block type": "区块类型", "Block type": "区块类型",
@ -864,6 +865,7 @@
"loading": "加载中", "loading": "加载中",
"name is required": "名称不能为空", "name is required": "名称不能为空",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被删除。请删除当前{{blockType}}.", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被删除。请删除当前{{blockType}}.",
"data source": "数据源",
"Data source": "数据源", "Data source": "数据源",
"DataSource": "数据源", "DataSource": "数据源",
"Data model": "数据模型", "Data model": "数据模型",

View File

@ -518,6 +518,7 @@
"Save as template": "儲存為模板", "Save as template": "儲存為模板",
"Save as block template": "儲存為區塊模板", "Save as block template": "儲存為區塊模板",
"Block templates": "區塊模板", "Block templates": "區塊模板",
"Block template": "區塊模板",
"Convert reference to duplicate": "模板引用轉為複製", "Convert reference to duplicate": "模板引用轉為複製",
"Template name": "模板名稱", "Template name": "模板名稱",
"Block type": "區塊型別", "Block type": "區塊型別",
@ -856,6 +857,9 @@
"Permission denied": "沒有權限", "Permission denied": "沒有權限",
"Allow add new":"允許新增", "Allow add new":"允許新增",
"The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被刪除,請移除這個 {{blockType}}。", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被刪除,請移除這個 {{blockType}}。",
"data source": "數據源",
"Data source": "數據源",
"DataSource": "數據源",
"Allow selection of existing records":"允許選擇已有資料", "Allow selection of existing records":"允許選擇已有資料",
"Home page": "主頁", "Home page": "主頁",
"Handbook": "使用手冊", "Handbook": "使用手冊",

View File

@ -7,5 +7,5 @@ import { useCollectionManager } from '../../data-source/collection/CollectionMan
*/ */
export const useSourceKey = (association: string) => { export const useSourceKey = (association: string) => {
const cm = useCollectionManager(); const cm = useCollectionManager();
return cm.getSourceKeyByAssocation(association); return cm.getSourceKeyByAssociation(association);
}; };

View File

@ -1,2 +1,2 @@
// TODO: 因他们之间功能相同,所以先直接复用,后续有需要再拆分 // TODO: 因他们之间功能相同,所以先直接复用,后续有需要再拆分
export { TableBlockProvider as AssociationFilterProvider } from '../../../block-provider'; export { TableBlockProvider as AssociationFilterProvider } from '../../../block-provider/TableBlockProvider';

View File

@ -1,7 +1,8 @@
import { render, screen, userEvent } from '@nocobase/test/client'; import { render, renderReadPrettyApp, screen, userEvent } from '@nocobase/test/client';
import React from 'react'; import React from 'react';
import App1 from '../demos/checkbox'; import App1 from '../demos/checkbox';
import App2 from '../demos/checkbox.group'; import App2 from '../demos/checkbox.group';
import { Checkbox } from '@nocobase/client';
describe('Checkbox', () => { describe('Checkbox', () => {
it('should display the title', () => { it('should display the title', () => {
@ -37,6 +38,67 @@ describe('Checkbox', () => {
await userEvent.click(input); await userEvent.click(input);
expect(container.querySelector('svg')).toBeNull(); expect(container.querySelector('svg')).toBeNull();
}); });
describe('read pretty', () => {
it('true', async () => {
const { container } = await renderReadPrettyApp({
Component: Checkbox,
value: true,
});
expect(container.querySelector('svg')).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>
`);
});
it('false', async () => {
const { container } = await renderReadPrettyApp({
Component: Checkbox,
value: false,
});
expect(container.querySelector('svg')).toBeFalsy();
});
it('false and showUnchecked', async () => {
const { container } = await renderReadPrettyApp({
Component: Checkbox,
value: false,
props: {
showUnchecked: true,
},
});
expect(container.querySelector('svg')).toMatchInlineSnapshot(`
<svg
aria-hidden="true"
data-icon="close"
fill="currentColor"
fill-rule="evenodd"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M799.86 166.31c.02 0 .04.02.08.06l57.69 57.7c.04.03.05.05.06.08a.12.12 0 010 .06c0 .03-.02.05-.06.09L569.93 512l287.7 287.7c.04.04.05.06.06.09a.12.12 0 010 .07c0 .02-.02.04-.06.08l-57.7 57.69c-.03.04-.05.05-.07.06a.12.12 0 01-.07 0c-.03 0-.05-.02-.09-.06L512 569.93l-287.7 287.7c-.04.04-.06.05-.09.06a.12.12 0 01-.07 0c-.02 0-.04-.02-.08-.06l-57.69-57.7c-.04-.03-.05-.05-.06-.07a.12.12 0 010-.07c0-.03.02-.05.06-.09L454.07 512l-287.7-287.7c-.04-.04-.05-.06-.06-.09a.12.12 0 010-.07c0-.02.02-.04.06-.08l57.7-57.69c.03-.04.05-.05.07-.06a.12.12 0 01.07 0c.03 0 .05.02.09.06L512 454.07l287.7-287.7c.04-.04.06-.05.09-.06a.12.12 0 01.07 0z"
/>
</svg>
`);
});
});
}); });
describe('Checkbox.Group', () => { describe('Checkbox.Group', () => {

View File

@ -1,22 +1,255 @@
import { render, screen, userEvent } from '@nocobase/test/client'; import { renderReadPrettyApp, renderApp, screen, userEvent, waitFor } from '@nocobase/test/client';
import React from 'react'; import { FormItem, CollectionSelect } from '@nocobase/client';
import App1 from '../demos/demo1';
describe.skip('CollectionSelect', () => { describe('CollectionSelect', () => {
it('should works', async () => { it('should works', async () => {
render(<App1 />); const { container } = await renderApp({
schema: {
type: 'object',
properties: {
test: {
type: 'string',
title: 'demo title',
'x-decorator': FormItem,
'x-component': CollectionSelect,
},
},
},
});
// 标题 expect(screen.getByText('demo title')).toBeInTheDocument();
expect(screen.getByText('Collection')).toBeInTheDocument();
const selector = document.querySelector('.ant-select-selector'); await userEvent.click(document.querySelector('.ant-select-selector'));
expect(selector).toBeInTheDocument(); await waitFor(() => {
expect(screen.queryByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(selector); expect(screen.queryByText('Users')).toBeInTheDocument();
expect(screen.queryByText('Roles')).toBeInTheDocument();
await userEvent.click(screen.getByText('Users'));
await waitFor(() => {
expect(document.body.querySelector('.ant-select-selection-item')).toHaveTextContent('Users');
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
>
<div
aria-label="block-item-demo title"
class="nb-sortable-designer nb-block-item nb-form-item css-1hvilub"
role="button"
>
<div
class="css-1nrq807 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1mw46su"
>
<div
class="ant-formily-item-label"
>
<div
class="ant-formily-item-label-content"
>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
>
<div
class="ant-formily-item-control-content-component"
>
<div
class="ant-select css-dev-only-do-not-override-1mw46su ant-select-focused ant-select-single ant-select-show-arrow ant-select-show-search"
data-testid="select-collection"
role="button"
>
<span
aria-live="polite"
style="width: 0px; height: 0px; position: absolute; overflow: hidden; opacity: 0;"
>
Users
</span>
<div
class="ant-select-selector"
>
<span
class="ant-select-selection-search"
>
<input
aria-autocomplete="list"
aria-controls="rc_select_TEST_OR_SSR_list"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="rc_select_TEST_OR_SSR_list"
autocomplete="off"
class="ant-select-selection-search-input"
id="rc_select_TEST_OR_SSR"
role="button"
type="search"
value=""
/>
</span>
<span
class="ant-select-selection-item"
title="Users"
>
Users
</span>
</div>
<span
aria-hidden="true"
class="ant-select-arrow"
style="user-select: none;"
unselectable="on"
>
<span
aria-label="down"
class="anticon anticon-down ant-select-suffix"
role="img"
>
<svg
aria-hidden="true"
data-icon="down"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M884 256h-75c-5.1 0-9.9 2.5-12.9 6.6L512 654.2 227.9 262.6c-3-4.1-7.8-6.6-12.9-6.6h-75c-6.5 0-10.3 7.4-6.5 12.7l352.6 486.1c12.8 17.6 39 17.6 51.7 0l352.6-486.1c3.9-5.3.1-12.7-6.4-12.7z"
/>
</svg>
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`);
});
it('read pretty', async () => {
const { container } = await renderReadPrettyApp({
value: {
test: 'users',
},
schema: {
type: 'object',
properties: {
test: {
type: 'string',
title: 'demo title',
'x-decorator': FormItem,
'x-component': CollectionSelect,
},
},
},
});
expect(screen.getByText('demo title')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
>
<div
aria-label="block-item-demo title"
class="nb-sortable-designer nb-block-item nb-form-item css-1hvilub"
role="button"
>
<div
class="css-1nrq807 ant-formily-item ant-formily-item-layout-horizontal ant-formily-item-feedback-layout-loose ant-formily-item-label-align-right ant-formily-item-control-align-left css-dev-only-do-not-override-1mw46su"
>
<div
class="ant-formily-item-label"
>
<div
class="ant-formily-item-label-content"
>
<span>
<label>
demo title
</label>
</span>
</div>
<span
class="ant-formily-item-colon"
>
:
</span>
</div>
<div
class="ant-formily-item-control"
>
<div
class="ant-formily-item-control-content"
>
<div
class="ant-formily-item-control-content-component"
>
<div>
<span
class="ant-tag css-dev-only-do-not-override-1mw46su"
>
Users
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`);
});
it('read pretty: multiple', async () => {
await renderReadPrettyApp({
value: {
test: ['users', 'roles'],
},
schema: {
type: 'object',
properties: {
test: {
type: 'string',
title: 'demo title',
'x-decorator': FormItem,
'x-component': CollectionSelect,
'x-component-props': {
mode: 'multiple',
},
},
},
},
});
// 下拉框内容
expect(screen.getByText('Users')).toBeInTheDocument(); expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Roles')).toBeInTheDocument(); expect(screen.getByText('Roles')).toBeInTheDocument();
expect(screen.getByText('测试表')).toBeInTheDocument();
}); });
}); });

View File

@ -0,0 +1,109 @@
import { ColorPicker } from '@nocobase/client';
import { renderApp, screen, renderReadPrettyApp, userEvent, waitFor } from '@nocobase/test/client';
describe('ColorPicker', () => {
test('basic', async () => {
const { container } = await renderApp({
Component: ColorPicker,
value: 'rgb(139, 187, 17)',
});
await waitFor(() => {
expect(container.querySelector('.ant-color-picker-color-block-inner')).toHaveAttribute(
'style',
'background: rgb(139, 187, 17);',
);
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
>
<div
aria-label="color-picker-normal"
role="button"
style="display: inline-block;"
>
<div
class="ant-color-picker-trigger css-dev-only-do-not-override-1mw46su"
>
<div
class="ant-color-picker-color-block"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
</div>
</div>
</div>
</div>
</div>
`);
});
test('change', async () => {
const { container } = await renderApp({
Component: ColorPicker,
value: 'rgb(139, 187, 17)',
});
await userEvent.hover(screen.getByRole('button').querySelector('.ant-color-picker-trigger'));
await waitFor(() => {
expect(document.querySelector('.ant-color-picker-input')).toBeInTheDocument();
});
const input = document.querySelector('.ant-color-picker-input').querySelector('input');
await userEvent.clear(input);
await userEvent.type(input, '123123'); // 对应的 rgb(18, 49, 35)
await waitFor(() => {
expect(container.querySelector('.ant-color-picker-color-block-inner')).toHaveAttribute(
'style',
'background: rgb(18, 49, 35);',
);
});
});
test('read pretty', async () => {
const { container } = await renderReadPrettyApp({
Component: ColorPicker,
value: 'rgb(139, 187, 17)',
});
await waitFor(() => {
expect(container.querySelector('.ant-color-picker-color-block-inner')).toHaveAttribute(
'style',
'background: rgb(139, 187, 17);',
);
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
>
<div
aria-label="color-picker-read-pretty"
class="ant-description-color-picker css-gy8kge"
role="button"
>
<div
class="ant-color-picker-trigger ant-color-picker-sm css-dev-only-do-not-override-1mw46su ant-color-picker-trigger-disabled"
>
<div
class="ant-color-picker-color-block"
>
<div
class="ant-color-picker-color-block-inner"
style="background: rgb(139, 187, 17);"
/>
</div>
</div>
</div>
</div>
</div>
`);
});
});

View File

@ -960,8 +960,11 @@ function useFormItemCollectionField() {
const { getCollectionJoinField } = useCollectionManager_deprecated(); const { getCollectionJoinField } = useCollectionManager_deprecated();
const { getField } = useCollection_deprecated(); const { getField } = useCollection_deprecated();
const fieldSchema = useFieldSchema(); const fieldSchema = useFieldSchema();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const { collectionField: columnCollectionField } = useColumnSchema(); const { collectionField: columnCollectionField } = useColumnSchema();
const collectionField = fieldSchema
? getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field'])
: null;
if (!fieldSchema) return null;
return collectionField || columnCollectionField; return collectionField || columnCollectionField;
} }

View File

@ -18,8 +18,8 @@ describe('FormV2', () => {
await userEvent.type(input, '李四'); await userEvent.type(input, '李四');
await waitFor(async () => {
await userEvent.click(screen.getByText('Submit')); await userEvent.click(screen.getByText('Submit'));
await waitFor(async () => {
// notification 的内容 // notification 的内容
expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument(); expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument();
}); });

View File

@ -0,0 +1,106 @@
import React, { useEffect, useRef } from 'react';
import { render, sleep, userEvent, waitFor, screen, act } from '@nocobase/test/client';
import { EllipsisWithTooltip } from '@nocobase/client';
describe('EllipsisWithTooltip Component', () => {
const text = 'Text';
const Demo = (props) => {
const ref = useRef<any>();
useEffect(() => {
ref.current.setPopoverVisible(true);
}, []);
return (
<EllipsisWithTooltip ref={ref} {...props}>
{text}
</EllipsisWithTooltip>
);
};
function setHasEllipsis() {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, value: 200 });
}
function setNoEllipsis() {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, value: 400 });
}
function resetEllipsis() {
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, value: 0 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 0 });
}
async function noPopoverCheck() {
expect(screen.queryByText(text)).toBeInTheDocument();
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await act(async () => {
await userEvent.hover(screen.getByText(text));
});
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
}
async function hasPopoverCheck(popoverContent?: string) {
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
await act(async () => {
await userEvent.hover(screen.getByText(text));
});
await sleep(300);
await waitFor(async () => {
expect(screen.queryByRole('tooltip')).toHaveTextContent(popoverContent || text);
});
}
const originalCreateRange: any = document.createRange;
beforeAll(() => {
const mockRange = {
selectNodeContents: vitest.fn(),
getBoundingClientRect: vitest.fn(() => ({
width: 100,
})),
};
document.createRange = vitest.fn(() => mockRange) as any;
});
afterAll(() => {
document.createRange = originalCreateRange;
});
beforeEach(() => {
resetEllipsis();
});
it('renders children without ellipsis by default', async () => {
render(<Demo />);
await noPopoverCheck();
});
it('shows Popover when ellipsis is true and content overflows', async () => {
setHasEllipsis();
render(<Demo ellipsis />);
await hasPopoverCheck();
});
it('does not show Popover when content does not overflow', async () => {
setNoEllipsis();
render(<Demo ellipsis />);
await noPopoverCheck();
});
it('uses popoverContent when provided', async () => {
setHasEllipsis();
const popoverContent = 'Custom Popover Content';
render(<Demo ellipsis popoverContent={popoverContent} />);
await hasPopoverCheck(popoverContent);
});
});

View File

@ -0,0 +1,83 @@
import { Schema } from '@formily/json-schema';
import { findMenuItem } from '../util';
describe('findMenuItem', () => {
test('should return null for invalid schema', () => {
const result = findMenuItem(null);
expect(result).toBeNull();
});
test('should return null if no Menu.Item schema found', () => {
const schema = new Schema({
properties: {
prop1: {
type: 'string',
},
prop2: {
type: 'number',
},
},
});
const result = findMenuItem(schema);
expect(result).toBeNull();
});
test('should return the first Menu.Item schema found', () => {
const schema = new Schema({
properties: {
prop1: {
type: 'string',
},
prop2: {
type: 'number',
},
menuItem: {
type: 'object',
'x-uid': 'test',
'x-component': 'Menu.Item',
},
},
});
const result = findMenuItem(schema);
expect(result).toEqual(
expect.objectContaining({
type: 'object',
'x-uid': 'test',
'x-component': 'Menu.Item',
}),
);
});
test('should return the first Menu.Item schema found recursively', () => {
const schema = new Schema({
properties: {
prop1: {
type: 'string',
},
prop2: {
type: 'number',
},
menuItem: {
type: 'object',
'x-component': 'test',
properties: {
nestedMenuItem: {
type: 'object',
'x-uid': 'test',
'x-component': 'Menu.Item',
},
},
},
},
});
const result = findMenuItem(schema);
expect(result).toEqual(
expect.objectContaining({
type: 'object',
'x-uid': 'test',
'x-component': 'Menu.Item',
}),
);
});
});

View File

@ -1,6 +1,8 @@
import { render, screen, waitFor } from '@nocobase/test/client'; import { render, screen, waitFor, renderApp, userEvent } from '@nocobase/test/client';
import React from 'react'; import React from 'react';
import App1 from '../demos/demo1'; import App1 from '../demos/demo1';
import { Page } from '../Page';
import { DocumentTitleProvider, Form, FormItem, Grid, IconPicker, Input } from '@nocobase/client';
describe('Page', () => { describe('Page', () => {
it('should render correctly', async () => { it('should render correctly', async () => {
@ -16,4 +18,129 @@ describe('Page', () => {
expect(document.title).toBe('Page Title - NocoBase'); expect(document.title).toBe('Page Title - NocoBase');
}); });
}); });
describe('Page Component', () => {
const title = 'Test Title';
test('schema title', async () => {
await renderApp({
schema: {
type: 'void',
title,
'x-component': Page,
},
});
expect(screen.getByText(title)).toBeInTheDocument();
});
test('hide title', async () => {
await renderApp({
schema: {
type: 'void',
title,
'x-component': Page,
'x-component-props': {
hidePageTitle: true,
},
},
});
expect(screen.queryByText('Test Title')).not.toBeInTheDocument();
});
test('should request remote schema when no title', async () => {
await renderApp({
schema: {
type: 'void',
'x-uid': 'test',
'x-component': Page,
'x-decorator': DocumentTitleProvider,
},
apis: {
'/uiSchemas:getParentJsonSchema/test': {
data: {
title: 'remote title',
},
},
},
});
await waitFor(() => {
expect(screen.getByText('remote title')).toBeInTheDocument();
});
});
test('enablePageTabs', async () => {
await renderApp({
schema: {
type: 'void',
title,
'x-decorator': DocumentTitleProvider,
'x-component': Page,
'x-component-props': {
enablePageTabs: true,
},
properties: {
tab1: {
type: 'void',
title: 'tab1 title',
'x-component': 'div',
'x-content': 'tab1 content',
},
tab2: {
type: 'void',
'x-component': 'div',
'x-content': 'tab2 content',
},
},
},
});
expect(screen.getByRole('tablist')).toBeInTheDocument();
expect(screen.getByText('tab1 title')).toBeInTheDocument();
expect(screen.getByText('tab1 content')).toBeInTheDocument();
// 没有 title 的时候会使用 Unnamed
expect(screen.getByText('Unnamed')).toBeInTheDocument();
});
test('add tab', async () => {
await renderApp({
schema: {
type: 'void',
title,
'x-decorator': DocumentTitleProvider,
'x-component': Page,
'x-component-props': {
enablePageTabs: true,
},
},
appOptions: {
designable: true,
components: {
Input,
Form,
FormItem,
IconPicker,
Grid,
},
},
});
await userEvent.click(screen.getByText('Add tab'));
await waitFor(() => {
expect(screen.queryByText('Tab name')).toBeInTheDocument();
});
await userEvent.type(screen.queryByRole('textbox'), 'tab1');
await userEvent.click(screen.getByText('OK'));
await waitFor(() => {
expect(screen.getByText('tab1')).toBeInTheDocument();
});
});
});
}); });

View File

@ -0,0 +1,131 @@
import { renderApp } from '@nocobase/test/client';
import { Pagination } from '@nocobase/client';
describe('Pagination', () => {
it('renders without errors', async () => {
const { container } = await renderApp({
Component: Pagination,
props: {
total: 20,
},
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
>
<div>
<ul
class="ant-pagination css-dev-only-do-not-override-1mw46su"
>
<li
aria-disabled="true"
class="ant-pagination-prev ant-pagination-disabled"
title="Previous Page"
>
<button
class="ant-pagination-item-link"
disabled=""
tabindex="-1"
type="button"
>
<span
aria-label="left"
class="anticon anticon-left"
role="img"
>
<svg
aria-hidden="true"
data-icon="left"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M724 218.3V141c0-6.7-7.7-10.4-12.9-6.3L260.3 486.8a31.86 31.86 0 000 50.3l450.8 352.1c5.3 4.1 12.9.4 12.9-6.3v-77.3c0-4.9-2.3-9.6-6.1-12.6l-360-281 360-281.1c3.8-3 6.1-7.7 6.1-12.6z"
/>
</svg>
</span>
</button>
</li>
<li
class="ant-pagination-item ant-pagination-item-1 ant-pagination-item-active"
tabindex="0"
title="1"
>
<a
rel="nofollow"
>
1
</a>
</li>
<li
class="ant-pagination-item ant-pagination-item-2"
tabindex="0"
title="2"
>
<a
rel="nofollow"
>
2
</a>
</li>
<li
aria-disabled="false"
class="ant-pagination-next"
tabindex="0"
title="Next Page"
>
<button
class="ant-pagination-item-link"
tabindex="-1"
type="button"
>
<span
aria-label="right"
class="anticon anticon-right"
role="img"
>
<svg
aria-hidden="true"
data-icon="right"
fill="currentColor"
focusable="false"
height="1em"
viewBox="64 64 896 896"
width="1em"
>
<path
d="M765.7 486.8L314.9 134.7A7.97 7.97 0 00302 141v77.3c0 4.9 2.3 9.6 6.1 12.6l360 281.1-360 281.1c-3.9 3-6.1 7.7-6.1 12.6V883c0 6.7 7.7 10.4 12.9 6.3l450.8-352.1a31.96 31.96 0 000-50.4z"
/>
</svg>
</span>
</button>
</li>
</ul>
</div>
</div>
</div>
`);
});
it('hides when hidden prop is true', async () => {
const { container } = await renderApp({
Component: Pagination,
props: {
hidden: true,
},
});
expect(container).toMatchInlineSnapshot(`
<div>
<div
class="css-dev-only-do-not-override-1mw46su ant-app"
style="height: 100%;"
/>
</div>
`);
});
});

View File

@ -0,0 +1,26 @@
import React from 'react';
import { render } from '@testing-library/react';
import { PasswordStrength } from '../PasswordStrength';
describe('PasswordStrength', () => {
it('renders children with strength value', () => {
const strengthValue = 20;
const childrenMock = vitest.fn().mockReturnValue(<div>Strength: {strengthValue}</div>);
const { getByText } = render(<PasswordStrength value="password">{childrenMock}</PasswordStrength>);
expect(childrenMock).toHaveBeenCalledWith(strengthValue);
expect(getByText(`Strength: ${strengthValue}`)).toBeInTheDocument();
});
it('renders children without strength value', () => {
const childrenMock = vitest.fn().mockReturnValue(<div>No strength value</div>);
const { getByText } = render(<PasswordStrength>{childrenMock}</PasswordStrength>);
expect(childrenMock).toHaveBeenCalledWith(0);
expect(getByText('No strength value')).toBeInTheDocument();
});
it('renders children without function', () => {
const childrenMock = <div>Children without function</div>;
const { getByText } = render(<PasswordStrength>{childrenMock}</PasswordStrength>);
expect(getByText('Children without function')).toBeInTheDocument();
});
});

View File

@ -24,4 +24,60 @@ describe('getStrength', () => {
it('should return 100', () => { it('should return 100', () => {
expect(getStrength('z1234567890')).toBe(100); expect(getStrength('z1234567890')).toBe(100);
}); });
it('should return 0 for empty value', () => {
expect(getStrength('')).toBe(0);
});
it('should return 20 for a single digit', () => {
expect(getStrength('1')).toBe(20);
});
it('should return 20 for a single lowercase letter', () => {
expect(getStrength('a')).toBe(20);
});
it('should return 20 for a single uppercase letter', () => {
expect(getStrength('A')).toBe(20);
});
it('should return 20 for a single symbol', () => {
expect(getStrength('@')).toBe(20);
});
it('should return 60 for a combination of lowercase letters and digits', () => {
expect(getStrength('a1b2c3')).toBe(60);
});
it('should return 60 for a combination of uppercase letters and digits', () => {
expect(getStrength('A1B2C3')).toBe(60);
});
it('should return 80 for a combination of lowercase letters and symbols', () => {
expect(getStrength('a@b#c$d%')).toBe(80);
});
it('should return 80 for a combination of uppercase letters and symbols', () => {
expect(getStrength('A@B#C$D%')).toBe(80);
});
it('should return 100 for a combination of lowercase letters, uppercase letters, and digits', () => {
expect(getStrength('aA1bB2cC3')).toBe(100);
});
it('should return 100 for a combination of lowercase letters, uppercase letters, and symbols', () => {
expect(getStrength('aA@bB#cC$dD%')).toBe(100);
});
it('should return 100 for a combination of lowercase letters, digits, and symbols', () => {
expect(getStrength('a1@b2#c3$d4%')).toBe(100);
});
it('should return 100 for a combination of uppercase letters, digits, and symbols', () => {
expect(getStrength('A1@B2#C3$D4%')).toBe(100);
});
it('should return 100 for a strong password with a combination of lowercase letters, uppercase letters, digits, and symbols', () => {
expect(getStrength('aA1@bB2#cC3$dD4%')).toBe(100);
});
}); });

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