From 91254bdf55f87cedff7685f1ad1c028b0d20bed4 Mon Sep 17 00:00:00 2001 From: jack zhang <1098626505@qq.com> Date: Fri, 12 Apr 2024 21:55:37 +0800 Subject: [PATCH] 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 --- packages/core/build/src/buildEsm.ts | 16 +- ...-initializer-components-action-modal-1.tsx | 69 ++++ ...-initializer-components-action-modal-2.tsx | 76 ++++ .../schema-initializer-components-group.tsx | 7 +- .../schema-initializer-components-menu.tsx | 21 +- .../core/ui-schema/schema-initializer.md | 12 + ...-initializer-components-action-modal-1.tsx | 69 ++++ ...-initializer-components-action-modal-2.tsx | 76 ++++ .../schema-initializer-components-group.tsx | 7 +- .../schema-initializer-components-menu.tsx | 21 +- .../core/ui-schema/schema-initializer.md | 12 + .../__tests__/CurrentAppInfoProvider.test.tsx | 29 ++ .../client/src/application/Application.tsx | 12 +- .../client/src/application/RouterManager.tsx | 3 +- .../__tests__/Application.test.tsx | 49 ++- .../src/application/__tests__/Plugin.test.ts | 17 + .../__tests__/PluginSettingsManager.test.ts | 35 ++ .../__tests__/RouterManager.test.tsx | 1 + .../hoc/withDynamicSchemaProps.test.tsx | 39 +- .../SchemaInitializer.test.tsx | 98 +++++ .../SchemaInitializerManager.test.ts | 89 ++++ .../SchemaInitializerActionModal.test.tsx | 118 ++++++ .../SchemaInitializerChildren.test.tsx | 353 ++++++++++++++++ .../SchemaInitializerDivider.test.tsx | 28 ++ .../components/SchemaInitializerItem.test.tsx | 80 ++++ .../SchemaInitializerItemGroup.test.tsx | 83 ++++ .../SchemaInitializerSubMenu.test.tsx | 149 +++++++ .../SchemaInitializerSwitch.test.tsx | 103 +++++ .../components/fixtures/createAppAndHover.tsx | 13 + .../schema-initializer/fixures/createApp.tsx | 85 ++++ .../hooks/useAriaAttributeOfMenuItem.test.tsx | 16 + .../useGetSchemaInitializerMenuItems.test.tsx | 319 +++++++++++++++ .../hooks/useSchemaInitializerRender.test.tsx | 101 +++++ .../withInitializer.test.tsx | 132 ++++++ .../schema-settings/SchemaSettings.test.tsx | 94 +++++ .../SchemaSettingsManager.test.ts | 89 ++++ .../SchemaSettingsChildren.test.tsx | 386 ++++++++++++++++++ .../components/fixtures/createAppAndHover.tsx | 32 ++ .../hooks/useSchemaSettingsRender.test.tsx | 102 +++++ .../application/components/AppComponent.tsx | 4 +- .../application/components/MainComponent.tsx | 4 +- .../components/defaultComponents.tsx | 15 +- .../schema-initializer/SchemaInitializer.tsx | 6 +- .../SchemaInitializerActionModal.tsx | 40 +- .../components/SchemaInitializerChildren.tsx | 3 +- .../components/SchemaInitializerItem.tsx | 3 + .../components/SchemaInitializerItemGroup.tsx | 13 +- .../components/SchemaInitializerSelect.tsx | 3 + .../components/SchemaInitializerSubMenu.tsx | 21 +- .../components/SchemaInitializerSwitch.tsx | 3 + .../schema-initializer/hooks/index.tsx | 175 +------- .../useGetSchemaInitializerMenuItems.tsx | 105 +++++ .../hooks/useSchemaInitializerRender.tsx | 63 +++ .../application/schema-initializer/index.ts | 2 +- .../{hoc/index.tsx => withInitializer.tsx} | 12 +- .../schema-settings/SchemaSettings.tsx | 6 +- .../components/SchemaSettingsChildren.tsx | 4 +- .../context/SchemaSettingItemContext.ts | 9 + .../schema-settings/context/index.ts | 10 +- .../schema-settings/hooks/index.tsx | 51 +-- .../hooks/useSchemaSettingsRender.tsx | 45 ++ .../collection/AssociationProvider.test.tsx | 5 +- .../collection/CollectionManager.test.tsx | 68 ++- .../CollectionManagerProvider.test.tsx | 2 +- .../collection/CollectionProvider.test.tsx | 2 +- .../CollectionDeletedPlaceholder.test.tsx | 47 ++- .../data-source/DataSourceManager.test.ts | 54 ++- .../DataSourceManagerProvider.test.tsx | 2 +- .../data-source/DataSourceProvider.test.tsx | 35 +- .../src/data-source/__tests__/utils.test.ts | 56 ++- .../CollectionFieldInterfaceManager.ts | 1 - .../CollectionTemplateManager.ts | 1 - .../collection/CollectionManager.ts | 2 +- .../CollectionDeletedPlaceholder.tsx | 6 +- .../data-block/DataBlockResourceProvider.tsx | 2 +- .../data-source/DataSourceManager.ts | 4 +- .../data-source/DataSourceProvider.tsx | 4 +- packages/core/client/src/locale/en_US.json | 2 + packages/core/client/src/locale/es_ES.json | 2 + packages/core/client/src/locale/fr_FR.json | 2 + packages/core/client/src/locale/ja_JP.json | 4 +- packages/core/client/src/locale/ko_KR.json | 4 +- packages/core/client/src/locale/pt_BR.json | 4 +- packages/core/client/src/locale/ru_RU.json | 3 +- packages/core/client/src/locale/tr_TR.json | 4 +- packages/core/client/src/locale/uk_UA.json | 4 +- packages/core/client/src/locale/zh-CN.json | 2 + packages/core/client/src/locale/zh-TW.json | 4 + .../client/src/modules/blocks/useSourceKey.ts | 2 +- .../AssociationFilterProvider.tsx | 2 +- .../antd/checkbox/__tests__/checkbox.test.tsx | 64 ++- .../__tests__/collection-select.test.tsx | 257 +++++++++++- .../__tests__/ColorPicker.test.tsx | 109 +++++ .../antd/form-item/FormItem.Settings.tsx | 5 +- .../antd/form-v2/__tests__/form-v2.test.tsx | 2 +- .../__tests__/EllipsisWithTooltip.test.tsx | 106 +++++ .../antd/menu/__tests__/utils.test.ts | 83 ++++ .../antd/page/__tests__/page.test.tsx | 129 +++++- .../pagination/__tests__/pagination.test.tsx | 131 ++++++ .../__tests__/PasswordStrength.test.tsx | 26 ++ .../antd/password/__tests__/utils.test.ts | 56 +++ .../antd/space/__tests__/space.test.tsx | 15 + .../antd/table-v2/Table.Column.Decorator.tsx | 2 +- .../__tests__/UnixTimestamp.test.tsx | 138 +++++++ .../core/client/src/schema-component/types.ts | 1 + .../client/src/schema-initializer/utils.ts | 3 +- .../src/schema-settings/SchemaSettings.tsx | 1 - .../src/schema-templates/BlockTemplate.tsx | 6 +- .../schemas/uiSchemaTemplates.ts | 5 + packages/core/test/package.json | 11 + packages/core/test/src/client/index.ts | 21 - packages/core/test/src/client/index.tsx | 69 ++++ packages/core/test/src/web/dataSource2.json | 139 +++++++ .../src/web/dataSourceMainCollections.json | 223 ++++++++++ packages/core/test/src/web/index.tsx | 138 +++++++ packages/core/test/src/web/usersListData.json | 30 ++ packages/core/test/vitest.mjs | 49 ++- packages/core/test/web.d.ts | 1 + packages/core/test/web.js | 1 + .../src/client/KanbanBlockProvider.tsx | 4 +- yarn.lock | 180 +++++++- 121 files changed, 5557 insertions(+), 416 deletions(-) create mode 100644 packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx create mode 100644 packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx create mode 100644 packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx create mode 100644 packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx create mode 100644 packages/core/client/src/appInfo/__tests__/CurrentAppInfoProvider.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializer.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializerManager.test.ts create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerActionModal.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerChildren.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerDivider.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItem.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItemGroup.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSubMenu.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSwitch.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/components/fixtures/createAppAndHover.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/fixures/createApp.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/hooks/useAriaAttributeOfMenuItem.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/hooks/useGetSchemaInitializerMenuItems.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/hooks/useSchemaInitializerRender.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-initializer/withInitializer.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-settings/SchemaSettings.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-settings/SchemaSettingsManager.test.ts create mode 100644 packages/core/client/src/application/__tests__/schema-settings/components/SchemaSettingsChildren.test.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-settings/components/fixtures/createAppAndHover.tsx create mode 100644 packages/core/client/src/application/__tests__/schema-settings/hooks/useSchemaSettingsRender.test.tsx create mode 100644 packages/core/client/src/application/schema-initializer/hooks/useGetSchemaInitializerMenuItems.tsx create mode 100644 packages/core/client/src/application/schema-initializer/hooks/useSchemaInitializerRender.tsx rename packages/core/client/src/application/schema-initializer/{hoc/index.tsx => withInitializer.tsx} (91%) create mode 100644 packages/core/client/src/application/schema-settings/context/SchemaSettingItemContext.ts create mode 100644 packages/core/client/src/application/schema-settings/hooks/useSchemaSettingsRender.tsx create mode 100644 packages/core/client/src/schema-component/antd/color-picker/__tests__/ColorPicker.test.tsx create mode 100644 packages/core/client/src/schema-component/antd/input/__tests__/EllipsisWithTooltip.test.tsx create mode 100644 packages/core/client/src/schema-component/antd/menu/__tests__/utils.test.ts create mode 100644 packages/core/client/src/schema-component/antd/pagination/__tests__/pagination.test.tsx create mode 100644 packages/core/client/src/schema-component/antd/password/__tests__/PasswordStrength.test.tsx create mode 100644 packages/core/client/src/schema-component/antd/space/__tests__/space.test.tsx create mode 100644 packages/core/client/src/schema-component/antd/unixTimestamp/__tests__/UnixTimestamp.test.tsx delete mode 100644 packages/core/test/src/client/index.ts create mode 100644 packages/core/test/src/client/index.tsx create mode 100644 packages/core/test/src/web/dataSource2.json create mode 100644 packages/core/test/src/web/dataSourceMainCollections.json create mode 100644 packages/core/test/src/web/index.tsx create mode 100644 packages/core/test/src/web/usersListData.json create mode 100644 packages/core/test/web.d.ts create mode 100644 packages/core/test/web.js diff --git a/packages/core/build/src/buildEsm.ts b/packages/core/build/src/buildEsm.ts index 8515555498..9daaa83f7c 100644 --- a/packages/core/build/src/buildEsm.ts +++ b/packages/core/build/src/buildEsm.ts @@ -4,15 +4,21 @@ import { PkgLog, UserConfig } from './utils'; import { build as viteBuild } from 'vite'; 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) { 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'); 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'); if (clientEntry) { 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')); 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'); 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); } } diff --git a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx new file mode 100644 index 0000000000..93159ed197 --- /dev/null +++ b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx @@ -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 ( + { + insert({ + type: 'void', + title, + 'x-decorator': 'CardItem', + 'x-component': 'Hello', + }); + }} + schema={{ + title: { + type: 'string', + title: 'Title', + required: true, + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + }} + > + ); + }, +}); + +const Hello = () => { + const schema = useFieldSchema(); + return

Hello, world! {schema.title}

; +}; + +const app = new Application({ + ...appOptions, + components: { + FormItem, + Action, + Input, + Form, + Hello, + CardItem, + }, + schemaInitializers: [myInitializer], +}); + +export default app.getRootComponent(); diff --git a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx new file mode 100644 index 0000000000..1727d805e6 --- /dev/null +++ b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx @@ -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 ( + { + insert({ + type: 'void', + title, + 'x-decorator': 'CardItem', + 'x-component': 'Hello', + }); + }} + schema={{ + title: { + type: 'string', + title: 'Title', + required: true, + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + }} + > + ); + }, + }, + ], +}); + +const Hello = () => { + const schema = useFieldSchema(); + return

Hello, world! {schema.title}

; +}; + +const app = new Application({ + ...appOptions, + components: { + FormItem, + Action, + Input, + Form, + Hello, + CardItem, + }, + schemaInitializers: [myInitializer], +}); + +export default app.getRootComponent(); diff --git a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-group.tsx b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-group.tsx index d691ff9259..63188a7d98 100644 --- a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-group.tsx +++ b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-group.tsx @@ -48,15 +48,16 @@ const myInitializer = new SchemaInitializer({ name: 'c', Component: () => { return ( - - {[ + + > ); }, }, diff --git a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-menu.tsx b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-menu.tsx index f795b02715..7ae2f71176 100644 --- a/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-menu.tsx +++ b/packages/core/client/docs/en-US/core/ui-schema/demos/schema-initializer-components-menu.tsx @@ -1,8 +1,9 @@ /** * defaultShowCode: true */ -import { Application, SchemaInitializer } from '@nocobase/client'; +import { Application, SchemaInitializer, SchemaInitializerSubMenu } from '@nocobase/client'; import { appOptions } from './schema-initializer-common'; +import React from 'react'; const myInitializer = new SchemaInitializer({ name: 'MyInitializer', @@ -42,6 +43,24 @@ const myInitializer = new SchemaInitializer({ }, ], }, + { + name: 'c', + Component: () => { + return ( + + ); + }, + }, ], }); diff --git a/packages/core/client/docs/en-US/core/ui-schema/schema-initializer.md b/packages/core/client/docs/en-US/core/ui-schema/schema-initializer.md index 2637b0a80b..bebd3cbcef 100644 --- a/packages/core/client/docs/en-US/core/ui-schema/schema-initializer.md +++ b/packages/core/client/docs/en-US/core/ui-schema/schema-initializer.md @@ -657,6 +657,18 @@ interface SchemaInitializerSwitchItemProps extends SchemaInitializerItemProps { +### `type: 'actionModal'` & SchemaInitializerActionModal + +#### Component Mode + + + +#### Item Mode + +`SchemaInitializerActionModal` 需要加上 `isItem` 属性 + + + ## 渲染组件 ### SchemaInitializerChildren diff --git a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx new file mode 100644 index 0000000000..93159ed197 --- /dev/null +++ b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-1.tsx @@ -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 ( + { + insert({ + type: 'void', + title, + 'x-decorator': 'CardItem', + 'x-component': 'Hello', + }); + }} + schema={{ + title: { + type: 'string', + title: 'Title', + required: true, + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + }} + > + ); + }, +}); + +const Hello = () => { + const schema = useFieldSchema(); + return

Hello, world! {schema.title}

; +}; + +const app = new Application({ + ...appOptions, + components: { + FormItem, + Action, + Input, + Form, + Hello, + CardItem, + }, + schemaInitializers: [myInitializer], +}); + +export default app.getRootComponent(); diff --git a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx new file mode 100644 index 0000000000..1727d805e6 --- /dev/null +++ b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-action-modal-2.tsx @@ -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 ( + { + insert({ + type: 'void', + title, + 'x-decorator': 'CardItem', + 'x-component': 'Hello', + }); + }} + schema={{ + title: { + type: 'string', + title: 'Title', + required: true, + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + }} + > + ); + }, + }, + ], +}); + +const Hello = () => { + const schema = useFieldSchema(); + return

Hello, world! {schema.title}

; +}; + +const app = new Application({ + ...appOptions, + components: { + FormItem, + Action, + Input, + Form, + Hello, + CardItem, + }, + schemaInitializers: [myInitializer], +}); + +export default app.getRootComponent(); diff --git a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-group.tsx b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-group.tsx index d691ff9259..63188a7d98 100644 --- a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-group.tsx +++ b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-group.tsx @@ -48,15 +48,16 @@ const myInitializer = new SchemaInitializer({ name: 'c', Component: () => { return ( - - {[ + + > ); }, }, diff --git a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-menu.tsx b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-menu.tsx index f795b02715..7ae2f71176 100644 --- a/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-menu.tsx +++ b/packages/core/client/docs/zh-CN/core/ui-schema/demos/schema-initializer-components-menu.tsx @@ -1,8 +1,9 @@ /** * defaultShowCode: true */ -import { Application, SchemaInitializer } from '@nocobase/client'; +import { Application, SchemaInitializer, SchemaInitializerSubMenu } from '@nocobase/client'; import { appOptions } from './schema-initializer-common'; +import React from 'react'; const myInitializer = new SchemaInitializer({ name: 'MyInitializer', @@ -42,6 +43,24 @@ const myInitializer = new SchemaInitializer({ }, ], }, + { + name: 'c', + Component: () => { + return ( + + ); + }, + }, ], }); diff --git a/packages/core/client/docs/zh-CN/core/ui-schema/schema-initializer.md b/packages/core/client/docs/zh-CN/core/ui-schema/schema-initializer.md index 2637b0a80b..bebd3cbcef 100644 --- a/packages/core/client/docs/zh-CN/core/ui-schema/schema-initializer.md +++ b/packages/core/client/docs/zh-CN/core/ui-schema/schema-initializer.md @@ -657,6 +657,18 @@ interface SchemaInitializerSwitchItemProps extends SchemaInitializerItemProps { +### `type: 'actionModal'` & SchemaInitializerActionModal + +#### Component Mode + + + +#### Item Mode + +`SchemaInitializerActionModal` 需要加上 `isItem` 属性 + + + ## 渲染组件 ### SchemaInitializerChildren diff --git a/packages/core/client/src/appInfo/__tests__/CurrentAppInfoProvider.test.tsx b/packages/core/client/src/appInfo/__tests__/CurrentAppInfoProvider.test.tsx new file mode 100644 index 0000000000..da37d9cfa9 --- /dev/null +++ b/packages/core/client/src/appInfo/__tests__/CurrentAppInfoProvider.test.tsx @@ -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', + }); + }); + }); +}); diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index 27cf7d959e..351ef737ac 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -4,7 +4,7 @@ import { i18n as i18next } from 'i18next'; import get from 'lodash/get'; import merge from 'lodash/merge'; 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 { I18nextProvider } from 'react-i18next'; import { Link, NavLink, Navigate } from 'react-router-dom'; @@ -177,7 +177,7 @@ export class Application { } getRouteUrl(pathname: string) { - return this.options.publicPath.replace(/\/$/g, '') + pathname; + return this.getPublicPath().replace(/\/$/g, '') + pathname; } getCollectionManager(dataSource?: string) { @@ -288,8 +288,8 @@ export class Application { return; } - renderComponent(Component: ComponentTypeAndString, props?: T): ReactElement { - return React.createElement(this.getComponent(Component), props); + renderComponent(Component: ComponentTypeAndString, props?: T, children?: ReactNode): ReactElement { + return React.createElement(this.getComponent(Component), props, children); } /** @@ -315,7 +315,9 @@ export class Application { } getRootComponent() { - const Root: FC = () => ; + const Root: FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); return Root; } diff --git a/packages/core/client/src/application/RouterManager.tsx b/packages/core/client/src/application/RouterManager.tsx index 09d90a2fa5..cf16954dde 100644 --- a/packages/core/client/src/application/RouterManager.tsx +++ b/packages/core/client/src/application/RouterManager.tsx @@ -112,7 +112,7 @@ export class RouterManager { /** * @internal */ - getRouterComponent() { + getRouterComponent(children?: React.ReactNode) { const { type = 'browser', ...opts } = this.options; const Routers = { hash: HashRouter, @@ -134,6 +134,7 @@ export class RouterManager { + {children} diff --git a/packages/core/client/src/application/__tests__/Application.test.tsx b/packages/core/client/src/application/__tests__/Application.test.tsx index 2d39cb0047..c087236692 100644 --- a/packages/core/client/src/application/__tests__/Application.test.tsx +++ b/packages/core/client/src/application/__tests__/Application.test.tsx @@ -6,6 +6,7 @@ import { Link, Outlet } from 'react-router-dom'; import { describe } from 'vitest'; import { Application } from '../Application'; import { Plugin } from '../Plugin'; +import { useApp } from '../hooks'; describe('Application', () => { beforeAll(() => { @@ -18,7 +19,9 @@ describe('Application', () => { const router: any = { type: 'memory', initialEntries: ['/'] }; const initialProvidersLength = 7; 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.apiClient).toBeDefined(); expect(app.components).toBeDefined(); @@ -30,6 +33,26 @@ describe('Application', () => { 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', () => { const Hello = () =>
Hello
; Hello.displayName = 'Hello'; @@ -242,6 +265,30 @@ describe('Application', () => { expect(screen.getByText('AboutComponent')).toBeInTheDocument(); }); + it('Root with children', async () => { + const app = new Application({ name: 'test' }); + + const Demo = () => { + const app = useApp(); + return
{app.name}
; + }; + + const Root = app.getRootComponent(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('test')).toBeInTheDocument(); + }); + }); + it('mount', async () => { const Hello = () =>
Hello
; const app = new Application({ diff --git a/packages/core/client/src/application/__tests__/Plugin.test.ts b/packages/core/client/src/application/__tests__/Plugin.test.ts index 836a4ff78c..e870a323cc 100644 --- a/packages/core/client/src/application/__tests__/Plugin.test.ts +++ b/packages/core/client/src/application/__tests__/Plugin.test.ts @@ -159,4 +159,21 @@ describe('PluginManager', () => { await app.load(); 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(); + }); }); diff --git a/packages/core/client/src/application/__tests__/PluginSettingsManager.test.ts b/packages/core/client/src/application/__tests__/PluginSettingsManager.test.ts index f001237d80..3c846e07d3 100644 --- a/packages/core/client/src/application/__tests__/PluginSettingsManager.test.ts +++ b/packages/core/client/src/application/__tests__/PluginSettingsManager.test.ts @@ -86,6 +86,32 @@ describe('PluginSettingsManager', () => { 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', () => { app.pluginSettingsManager.add('test1', test1); 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()', () => { app.pluginSettingsManager.add('test1', test1); app.pluginSettingsManager.add('test2', { diff --git a/packages/core/client/src/application/__tests__/RouterManager.test.tsx b/packages/core/client/src/application/__tests__/RouterManager.test.tsx index b711fa4b23..542dd40ca5 100644 --- a/packages/core/client/src/application/__tests__/RouterManager.test.tsx +++ b/packages/core/client/src/application/__tests__/RouterManager.test.tsx @@ -188,6 +188,7 @@ describe('Router', () => { const RouterComponent = router.getRouterComponent(); render(); expect(screen.queryByTestId('content')).not.toBeInTheDocument(); + expect(router.getBasename()).toBe('/admin'); window.location.hash = '#/admin'; diff --git a/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx b/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx index 1fa07abdfb..2503da7e94 100644 --- a/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx +++ b/packages/core/client/src/application/__tests__/hoc/withDynamicSchemaProps.test.tsx @@ -1,6 +1,6 @@ +import { render } from '@nocobase/test/client'; import React from 'react'; import { SchemaComponent, SchemaComponentProvider } from '../../../schema-component'; -import { render } from '@nocobase/test/client'; import { withDynamicSchemaProps } from '../../hoc'; const HelloComponent = withDynamicSchemaProps((props: any) => ( @@ -213,4 +213,41 @@ describe('withDynamicSchemaProps', () => { const { getByTestId } = render(); 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 ( + + + + ); + }; + const { getByTestId } = render(); + expect(getByTestId('decorator')).toHaveTextContent(JSON.stringify({ c: 'c' })); + }); }); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializer.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializer.test.tsx new file mode 100644 index 0000000000..751e4c3e94 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializer.test.tsx @@ -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); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializerManager.test.ts b/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializerManager.test.ts new file mode 100644 index 0000000000..af464a2c1b --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/SchemaInitializerManager.test.ts @@ -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'); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerActionModal.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerActionModal.test.tsx new file mode 100644 index 0000000000..60154ca194 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerActionModal.test.tsx @@ -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 ( + + ); + }; + 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 ( + + ); + }; + 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(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerChildren.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerChildren.test.tsx new file mode 100644 index 0000000000..d3dc2288b6 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerChildren.test.tsx @@ -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
Item1
; + }, + }, + { + name: 'item2', + type: 'item', + title: 'Item2', + // 大写 + Component: () => { + return
Item2
; + }, + }, + { + name: 'item3', + Component: 'Item3', + }, + { + name: 'item4', + Component: 'not-exists', + }, + ], + { + components: { + Item3: () => { + return
Item3
; + }, + }, + }, + ); + 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 ; + }, + }, + }, + ); + + expect(screen.queryByText('Item1')).toBeInTheDocument(); + }); + + it('useComponentProps', async () => { + await createAndHover( + [ + { + name: 'item1', + Component: 'CommonDemo', + useComponentProps() { + return { + title: 'Item1', + }; + }, + }, + ], + { + components: { + CommonDemo: (props) => { + return ; + }, + }, + }, + ); + + 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 ; + }, + }, + }, + ); + + 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 ; + }, + }, + }, + ); + + expect(screen.queryByText('Item1')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerDivider.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerDivider.test.tsx new file mode 100644 index 0000000000..6d57fd724c --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerDivider.test.tsx @@ -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(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItem.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItem.test.tsx new file mode 100644 index 0000000000..6d8ec926a7 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItem.test.tsx @@ -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 ; + }, + }, + { + 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', + }, + }), + ); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItemGroup.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItemGroup.test.tsx new file mode 100644 index 0000000000..dcb1390ab5 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerItemGroup.test.tsx @@ -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 ( + + {[ + { + name: 'c1', + type: 'item', + title: 'C1', + }, + ]} + + ); + }, + }, + { + name: 'd', + Component: () => { + return ( + + ); + }, + }, + ]); + 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); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSubMenu.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSubMenu.test.tsx new file mode 100644 index 0000000000..f22e1de00e --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSubMenu.test.tsx @@ -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 ( + + ); + }; + await createAndHover( + [ + { + name: 'a', + Component: 'TestDemo', + }, + ], + { + components: { + TestDemo, + }, + }, + ); + + await valid(onClick); + }); + test('component children mode', async () => { + const onClick = vitest.fn(); + const TestDemo = () => { + return ( + + {[ + { + name: 'a1', + type: 'item', + title: 'A1', + onClick: onClick, + useVisible() { + return true; + }, + }, + { + name: 'a2', + type: 'item', + title: 'A2', + useVisible() { + return false; + }, + }, + ]} + + ); + }; + await createAndHover( + [ + { + name: 'a', + Component: 'TestDemo', + }, + ], + { + components: { + TestDemo, + }, + }, + ); + await valid(onClick); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSwitch.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSwitch.test.tsx new file mode 100644 index 0000000000..d6c93b329f --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/SchemaInitializerSwitch.test.tsx @@ -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 ( + { + // 如果已插入,则移除 + 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(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/components/fixtures/createAppAndHover.tsx b/packages/core/client/src/application/__tests__/schema-initializer/components/fixtures/createAppAndHover.tsx new file mode 100644 index 0000000000..0a8095fb94 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/components/fixtures/createAppAndHover.tsx @@ -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(); + }); +} diff --git a/packages/core/client/src/application/__tests__/schema-initializer/fixures/createApp.tsx b/packages/core/client/src/application/__tests__/schema-initializer/fixures/createApp.tsx new file mode 100644 index 0000000000..feae708353 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/fixures/createApp.tsx @@ -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 ; + }, + }, + ], + ...options, + }); + + const AddBlockButton = observer(() => { + const { render } = useSchemaInitializerRender('test'); + + return
{render()}
; + }); + + const WrapDemo = ({ children }) => { + return ( +
+

WrapDemo

+ {children} +
+ ); + }; + + const Page = observer( + (props) => { + return ( +
+ {props.children} + +
+ ); + }, + { displayName: 'Page' }, + ); + + const Root = () => { + return ( + + ); + }; + await renderApp({ + appOptions: { + providers: [Root], + schemaInitializers: [testInitializers], + designable: true, + ...appOptions, + }, + }); +} diff --git a/packages/core/client/src/application/__tests__/schema-initializer/hooks/useAriaAttributeOfMenuItem.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useAriaAttributeOfMenuItem.test.tsx new file mode 100644 index 0000000000..dbc5712ad7 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useAriaAttributeOfMenuItem.test.tsx @@ -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({}); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/hooks/useGetSchemaInitializerMenuItems.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useGetSchemaInitializerMenuItems.test.tsx new file mode 100644 index 0000000000..bc7ed48a44 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useGetSchemaInitializerMenuItems.test.tsx @@ -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: () =>
item4
, + }, + { + type: 'item', + label: 'item5', + component: () =>
item5
, + }, + ]; + + 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": , + }, + { + "key": "item5-4", + "label": , + }, + ] + `); + 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: () =>
123
, + 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(); + }); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/hooks/useSchemaInitializerRender.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useSchemaInitializerRender.test.tsx new file mode 100644 index 0000000000..e9aa449b6d --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/hooks/useSchemaInitializerRender.test.tsx @@ -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(); + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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
{render()}
; + }; + await createApp(Demo); + + expect(document.querySelector('.test')).toBeInTheDocument(); + }); + + it('should override custom props', async () => { + const Demo = () => { + const { render } = useSchemaInitializerRender('test', { componentProps: { className: 'test' } }); + + return
{render({ componentProps: { className: 'test2' } })}
; + }; + await createApp(Demo); + + expect(document.querySelector('.test2')).toBeInTheDocument(); + expect(document.querySelector('.test')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-initializer/withInitializer.test.tsx b/packages/core/client/src/application/__tests__/schema-initializer/withInitializer.test.tsx new file mode 100644 index 0000000000..6716fc0716 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-initializer/withInitializer.test.tsx @@ -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!', + }); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-settings/SchemaSettings.test.tsx b/packages/core/client/src/application/__tests__/schema-settings/SchemaSettings.test.tsx new file mode 100644 index 0000000000..af1c9fdaa6 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-settings/SchemaSettings.test.tsx @@ -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); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-settings/SchemaSettingsManager.test.ts b/packages/core/client/src/application/__tests__/schema-settings/SchemaSettingsManager.test.ts new file mode 100644 index 0000000000..f94efd8345 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-settings/SchemaSettingsManager.test.ts @@ -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'); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-settings/components/SchemaSettingsChildren.test.tsx b/packages/core/client/src/application/__tests__/schema-settings/components/SchemaSettingsChildren.test.tsx new file mode 100644 index 0000000000..5f5735bdb9 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-settings/components/SchemaSettingsChildren.test.tsx @@ -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
Item1
; + }, + }, + { + name: 'item2', + Component: 'Item2', + }, + { + name: 'item3', + Component: 'not-exists', + }, + { + name: 'item4', + } as any, + ], + { + components: { + Item2: () => { + return
Item2
; + }, + }, + }, + ); + 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 ; + }, + }, + }, + ); + + expect(screen.queryByText('Item1')).toBeInTheDocument(); + }); + + it('useComponentProps', async () => { + await createAndHover( + [ + { + name: 'item1', + Component: 'CommonDemo', + useComponentProps() { + return { + title: 'Item1', + }; + }, + }, + ], + { + components: { + CommonDemo: (props) => { + return ; + }, + }, + }, + ); + + 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 ; + }, + }, + }, + ); + + 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(); + return ; + }, + }, + }, + ); + + expect(screen.queryByText('Item1')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/client/src/application/__tests__/schema-settings/components/fixtures/createAppAndHover.tsx b/packages/core/client/src/application/__tests__/schema-settings/components/fixtures/createAppAndHover.tsx new file mode 100644 index 0000000000..c44cc4e2a9 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-settings/components/fixtures/createAppAndHover.tsx @@ -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(); + 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(); + }); +} diff --git a/packages/core/client/src/application/__tests__/schema-settings/hooks/useSchemaSettingsRender.test.tsx b/packages/core/client/src/application/__tests__/schema-settings/hooks/useSchemaSettingsRender.test.tsx new file mode 100644 index 0000000000..89e00fccf6 --- /dev/null +++ b/packages/core/client/src/application/__tests__/schema-settings/hooks/useSchemaSettingsRender.test.tsx @@ -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) =>
Test
, + items: [], + }); + + const app = new Application({ + providers: [DemoComponent], + schemaSettings: [testSettings], + designable: true, + }); + const Root = app.getRootComponent(); + render(); + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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 ( +
+
{JSON.stringify(exists)}
+
{render()}
+
+ ); + }; + 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
{render()}
; + }; + await createApp(Demo); + + expect(document.querySelector('.test')).toBeInTheDocument(); + }); + + it('should override custom props', async () => { + const Demo = () => { + const { render } = useSchemaSettingsRender('test', { componentProps: { className: 'test' } }); + + return
{render({ componentProps: { className: 'test2' } })}
; + }; + await createApp(Demo); + + expect(document.querySelector('.test2')).toBeInTheDocument(); + expect(document.querySelector('.test')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/core/client/src/application/components/AppComponent.tsx b/packages/core/client/src/application/components/AppComponent.tsx index 686c0213a6..09c21123b5 100644 --- a/packages/core/client/src/application/components/AppComponent.tsx +++ b/packages/core/client/src/application/components/AppComponent.tsx @@ -9,7 +9,7 @@ export interface AppComponentProps { } export const AppComponent: FC = observer( - (props) => { + ({ children, ...props }) => { const { app } = props; const handleErrors = useCallback((error: Error, info: { componentStack: string }) => { console.error(error); @@ -33,7 +33,7 @@ export const AppComponent: FC = observer( > {app.maintained && app.maintaining && app.renderComponent('AppMaintainingDialog', { app })} - {app.renderComponent('AppMain')} + {app.renderComponent('AppMain', undefined, children)} ); diff --git a/packages/core/client/src/application/components/MainComponent.tsx b/packages/core/client/src/application/components/MainComponent.tsx index e64ab0b114..0749c31fdd 100644 --- a/packages/core/client/src/application/components/MainComponent.tsx +++ b/packages/core/client/src/application/components/MainComponent.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; import { useApp } from '../hooks'; -export const MainComponent = React.memo(() => { +export const MainComponent = React.memo(({ children }) => { const app = useApp(); - const Router = useMemo(() => app.router.getRouterComponent(), [app]); + const Router = useMemo(() => app.router.getRouterComponent(children), [app]); const Providers = useMemo(() => app.getComposeProviders(), [app]); return ; }); diff --git a/packages/core/client/src/application/components/defaultComponents.tsx b/packages/core/client/src/application/components/defaultComponents.tsx index 7ddcb8d98b..9be8c2a488 100644 --- a/packages/core/client/src/application/components/defaultComponents.tsx +++ b/packages/core/client/src/application/components/defaultComponents.tsx @@ -2,12 +2,15 @@ import React, { FC } from 'react'; import { MainComponent } from './MainComponent'; const Loading: FC = () =>
Loading...
; -const AppError: FC<{ error: Error }> = ({ error }) => ( -
-
Load Plugin Error
- {error?.message} -
-); +const AppError: FC<{ error: Error }> = ({ error }) => { + return ( +
+
Load Plugin Error
+ {error?.message} + {process.env.__TEST__ && error?.stack} +
+ ); +}; const AppNotFound: FC = () =>
; diff --git a/packages/core/client/src/application/schema-initializer/SchemaInitializer.tsx b/packages/core/client/src/application/schema-initializer/SchemaInitializer.tsx index 5850aa48a4..87ea0b72bc 100644 --- a/packages/core/client/src/application/schema-initializer/SchemaInitializer.tsx +++ b/packages/core/client/src/application/schema-initializer/SchemaInitializer.tsx @@ -32,7 +32,8 @@ export class SchemaInitializer { if (!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) { parentItem.children.push(data); } else { @@ -42,6 +43,7 @@ export class SchemaInitializer { } get(nestedName: string): SchemaInitializerItemType | undefined { + if (!nestedName) return undefined; const arr = nestedName.split('.'); let current: any = this.items; @@ -58,8 +60,6 @@ export class SchemaInitializer { return undefined; } } - - return current; } remove(nestedName: string) { diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerActionModal.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerActionModal.tsx index 1b2f0210bf..7a483f739c 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerActionModal.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerActionModal.tsx @@ -2,6 +2,7 @@ import { useForm } from '@formily/react'; import React, { FC, useCallback, useMemo } from 'react'; import { useActionContext, SchemaComponent } from '../../../schema-component'; import { useSchemaInitializerItem } from '../context'; +import { SchemaInitializerItem } from './SchemaInitializerItem'; export interface SchemaInitializerActionModalProps { title: string; @@ -10,10 +11,24 @@ export interface SchemaInitializerActionModalProps { onSubmit?: (values: any) => void; buttonText?: any; component?: any; + isItem?: boolean; } -export const SchemaInitializerActionModal: FC = (props) => { - const { title, schema, buttonText, component, onCancel, onSubmit } = props; +const SchemaInitializerActionModalItemComponent = React.forwardRef((props: any, ref) => { + const { onClick, title, ...others } = props; + return ( + { + onClick?.(e.event); + }} + > + ); +}); + +export const SchemaInitializerActionModal: FC = (props) => { + const { title, schema, buttonText, isItem, component, onCancel, onSubmit } = props; const useCancelAction = useCallback(() => { // eslint-disable-next-line react-hooks/rules-of-hooks const form = useForm(); @@ -53,15 +68,20 @@ export const SchemaInitializerActionModal: FC ? { component, } - : { - icon: 'PlusOutlined', - style: { - borderColor: 'var(--colorSettings)', - color: 'var(--colorSettings)', + : isItem + ? { + title: buttonText, + component: SchemaInitializerActionModalItemComponent, + } + : { + icon: 'PlusOutlined', + style: { + borderColor: 'var(--colorSettings)', + color: 'var(--colorSettings)', + }, + title: buttonText, + type: 'dashed', }, - title: buttonText, - type: 'dashed', - }, properties: { drawer1: { 'x-decorator': 'Form', diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerChildren.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerChildren.tsx index 8a0642af48..70698787fb 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerChildren.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerChildren.tsx @@ -21,6 +21,7 @@ const typeComponentMap: Record = { item: 'SchemaInitializerItemInternal', itemGroup: 'SchemaInitializerItemGroupInternal', divider: 'SchemaInitializerDivider', + switch: 'SchemaInitializerSwitchInternal', subMenu: 'SchemaInitializerSubMenuInternal', actionModal: 'SchemaInitializerActionModalInternal', }; @@ -70,7 +71,7 @@ export const SchemaInitializerChild: FC = memo((props if (!C) { return null; } - if (hideIfNoChildren && Array.isArray(componentChildren) && componentChildren.length === 0) { + if (hideIfNoChildren && !componentChildren) { return null; } diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx index 51aec36412..3624e5de27 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItem.tsx @@ -76,6 +76,9 @@ export const SchemaInitializerItem = memo( ); SchemaInitializerItem.displayName = 'SchemaInitializerItem'; +/** + * @internal + */ export const SchemaInitializerItemInternal = () => { const itemConfig = useSchemaInitializerItem(); return ; diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItemGroup.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItemGroup.tsx index 728f3501eb..ce032e826e 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItemGroup.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerItemGroup.tsx @@ -10,10 +10,16 @@ import { useSchemaInitializerStyles } from './style'; export interface SchemaInitializerItemGroupProps { title: string; children?: SchemaInitializerOptions['items']; + items?: SchemaInitializerOptions['items']; divider?: boolean; } -export const SchemaInitializerItemGroup: FC = ({ children, title, divider }) => { +export const SchemaInitializerItemGroup: FC = ({ + children, + items, + title, + divider, +}) => { const compile = useCompile(); const { componentCls } = useSchemaInitializerStyles(); const { token } = theme.useToken(); @@ -21,11 +27,14 @@ export const SchemaInitializerItemGroup: FC = (
{divider && }
{compile(title)}
- {children} + {items || children}
); }; +/** + * @internal + */ export const SchemaInitializerItemGroupInternal = () => { const itemConfig = useSchemaInitializerItem(); return ; diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSelect.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSelect.tsx index f220bcdd16..4d57c30213 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSelect.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSelect.tsx @@ -55,6 +55,9 @@ export const SchemaInitializerSelect: FC = (pr ); }; +/** + * @internal + */ export const SchemaInitializerSelectInternal = () => { const itemConfig = useSchemaInitializerItem(); return ; diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSubMenu.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSubMenu.tsx index 606f0169b2..5954dba9b4 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSubMenu.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSubMenu.tsx @@ -10,16 +10,20 @@ import { SchemaInitializerOptions } from '../types'; import { useSchemaInitializerStyles } from './style'; export interface SchemaInitializerSubMenuProps { - name: string; + name?: string; title?: string; onClick?: (args: any) => void; onOpenChange?: (openKeys: string[]) => void; icon?: string | ReactNode; children?: SchemaInitializerOptions['items']; + items?: SchemaInitializerOptions['items']; } const SchemaInitializerSubMenuContext = React.createContext<{ isInMenu?: true }>({}); -const SchemaInitializerMenuProvider = (props) => { +/** + * @internal + */ +export const SchemaInitializerMenuProvider = (props) => { return ( {props.children} @@ -71,25 +75,30 @@ export const SchemaInitializerMenu: FC = (props) => { }; export const SchemaInitializerSubMenu: FC = (props) => { - const { children, title, name = uid(), onOpenChange, icon, ...others } = props; + const { children, items: propItems, title, name, onOpenChange, icon, ...others } = props; 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 items = useMemo(() => { return [ { ...others, - key: name, + key: nameValue, label: compile(title), icon: typeof icon === 'string' ? : icon, children: childrenItems, }, ]; - }, [childrenItems, compile, icon, name, others, title]); + }, [childrenItems, compile, icon, nameValue, others, title]); return ; }; +/** + * @internal + */ export const SchemaInitializerSubMenuInternal = () => { const itemConfig = useSchemaInitializerItem(); return ; diff --git a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSwitch.tsx b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSwitch.tsx index 164242c6d8..fcf49a3713 100644 --- a/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSwitch.tsx +++ b/packages/core/client/src/application/schema-initializer/components/SchemaInitializerSwitch.tsx @@ -21,6 +21,9 @@ export const SchemaInitializerSwitch: FC = (pr ); }; +/** + * @internal + */ export const SchemaInitializerSwitchInternal = () => { const itemConfig = useSchemaInitializerItem(); return ; diff --git a/packages/core/client/src/application/schema-initializer/hooks/index.tsx b/packages/core/client/src/application/schema-initializer/hooks/index.tsx index da3cad0e77..83fda65c99 100644 --- a/packages/core/client/src/application/schema-initializer/hooks/index.tsx +++ b/packages/core/client/src/application/schema-initializer/hooks/index.tsx @@ -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'; - -/** - * @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> = 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( - name: string, - options?: Omit, 'name'>, -) { - const app = useApp(); - const renderCache = React.useRef>>({}); - const initializer = useMemo( - () => app.schemaInitializerManager.get(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, '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; -} +export * from './useSchemaInitializerRender'; +export * from './useGetSchemaInitializerMenuItems'; diff --git a/packages/core/client/src/application/schema-initializer/hooks/useGetSchemaInitializerMenuItems.tsx b/packages/core/client/src/application/schema-initializer/hooks/useGetSchemaInitializerMenuItems.tsx new file mode 100644 index 0000000000..69c2684c6b --- /dev/null +++ b/packages/core/client/src/application/schema-initializer/hooks/useGetSchemaInitializerMenuItems.tsx @@ -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; +} diff --git a/packages/core/client/src/application/schema-initializer/hooks/useSchemaInitializerRender.tsx b/packages/core/client/src/application/schema-initializer/hooks/useSchemaInitializerRender.tsx new file mode 100644 index 0000000000..3de839a01e --- /dev/null +++ b/packages/core/client/src/application/schema-initializer/hooks/useSchemaInitializerRender.tsx @@ -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> = 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( + name: string, + options?: Omit, 'name'>, +) { + const app = useApp(); + const initializer = useMemo( + () => app.schemaInitializerManager.get(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, 'name'>) => { + return React.createElement(InitializerComponent, { + ...initializer.options, + ...options, + ...props, + }); + }, + }; + }, [initializer, name, options]); + + return res; +} diff --git a/packages/core/client/src/application/schema-initializer/index.ts b/packages/core/client/src/application/schema-initializer/index.ts index 9bb2efd08f..5b1cc1b4a5 100644 --- a/packages/core/client/src/application/schema-initializer/index.ts +++ b/packages/core/client/src/application/schema-initializer/index.ts @@ -1,4 +1,4 @@ -export * from './hoc'; +export * from './withInitializer'; export * from './hooks'; export * from './types'; export * from './context'; diff --git a/packages/core/client/src/application/schema-initializer/hoc/index.tsx b/packages/core/client/src/application/schema-initializer/withInitializer.tsx similarity index 91% rename from packages/core/client/src/application/schema-initializer/hoc/index.tsx rename to packages/core/client/src/application/schema-initializer/withInitializer.tsx index 5ad695dfb3..bb6c65d73c 100644 --- a/packages/core/client/src/application/schema-initializer/hoc/index.tsx +++ b/packages/core/client/src/application/schema-initializer/withInitializer.tsx @@ -3,12 +3,12 @@ import { ConfigProvider, Popover, theme } from 'antd'; import React, { ComponentType, useCallback, useMemo, useState } from 'react'; import { css } from '@emotion/css'; -import { useNiceDropdownMaxHeight } from '../../../common/useNiceDropdownHeight'; -import { useFlag } from '../../../flag-provider'; -import { useDesignable } from '../../../schema-component'; -import { useSchemaInitializerStyles } from '../components/style'; -import { SchemaInitializerContext } from '../context'; -import { SchemaInitializerOptions } from '../types'; +import { useNiceDropdownMaxHeight } from '../../common/useNiceDropdownHeight'; +import { useFlag } from '../../flag-provider'; +import { useDesignable } from '../../schema-component'; +import { useSchemaInitializerStyles } from './components/style'; +import { SchemaInitializerContext } from './context'; +import { SchemaInitializerOptions } from './types'; const defaultWrap = (s: ISchema) => s; diff --git a/packages/core/client/src/application/schema-settings/SchemaSettings.tsx b/packages/core/client/src/application/schema-settings/SchemaSettings.tsx index d56aa81fec..2a1a4d4687 100644 --- a/packages/core/client/src/application/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/application/schema-settings/SchemaSettings.tsx @@ -32,7 +32,8 @@ export class SchemaSettings { if (!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) { parentItem.children.push(data); } else { @@ -42,6 +43,7 @@ export class SchemaSettings { } get(nestedName: string): SchemaSettingsItemType | undefined { + if (!nestedName) return undefined; const arr = nestedName.split('.'); let current: any = this.items; @@ -58,8 +60,6 @@ export class SchemaSettings { return undefined; } } - - return current; } remove(nestedName: string) { diff --git a/packages/core/client/src/application/schema-settings/components/SchemaSettingsChildren.tsx b/packages/core/client/src/application/schema-settings/components/SchemaSettingsChildren.tsx index bbf4a58598..c735d0a28c 100644 --- a/packages/core/client/src/application/schema-settings/components/SchemaSettingsChildren.tsx +++ b/packages/core/client/src/application/schema-settings/components/SchemaSettingsChildren.tsx @@ -78,7 +78,7 @@ export const SchemaSettingsChild: FC = memo((props) => { type, Component, children, - hideIfNoChildren = true, + hideIfNoChildren, componentProps, } = props as any; const useChildrenRes = useChildren(); @@ -100,7 +100,7 @@ export const SchemaSettingsChild: FC = memo((props) => { if (!C) { return null; } - if (hideIfNoChildren && Array.isArray(componentChildren) && componentChildren.length === 0) { + if (hideIfNoChildren && !componentChildren) { return null; } diff --git a/packages/core/client/src/application/schema-settings/context/SchemaSettingItemContext.ts b/packages/core/client/src/application/schema-settings/context/SchemaSettingItemContext.ts new file mode 100644 index 0000000000..55e358ed1d --- /dev/null +++ b/packages/core/client/src/application/schema-settings/context/SchemaSettingItemContext.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react'; +import { SchemaSettingsItemType } from '../types'; + +export const SchemaSettingItemContext = createContext({} as any); +SchemaSettingItemContext.displayName = 'SchemaSettingItemContext'; + +export function useSchemaSettingsItem() { + return useContext(SchemaSettingItemContext) as T; +} diff --git a/packages/core/client/src/application/schema-settings/context/index.ts b/packages/core/client/src/application/schema-settings/context/index.ts index 5b197f7a6a..829c6c41ad 100644 --- a/packages/core/client/src/application/schema-settings/context/index.ts +++ b/packages/core/client/src/application/schema-settings/context/index.ts @@ -1,9 +1 @@ -import { createContext, useContext } from 'react'; -import { SchemaSettingsItemType } from '../types'; - -export const SchemaSettingItemContext = createContext({} as any); -SchemaSettingItemContext.displayName = 'SchemaSettingItemContext'; - -export function useSchemaSettingsItem() { - return useContext(SchemaSettingItemContext) as SchemaSettingsItemType; -} +export * from './SchemaSettingItemContext'; diff --git a/packages/core/client/src/application/schema-settings/hooks/index.tsx b/packages/core/client/src/application/schema-settings/hooks/index.tsx index e5643ebf76..a52a2fbebc 100644 --- a/packages/core/client/src/application/schema-settings/hooks/index.tsx +++ b/packages/core/client/src/application/schema-settings/hooks/index.tsx @@ -1,50 +1 @@ -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 = Omit, 'name' | 'items'> & - Omit & { - fieldSchema?: Schema; - field?: GeneralField; - dn?: Designable; - }; - -export function useSchemaSettingsRender(name: string, options?: UseSchemaSettingsRenderOptions) { - const app = useApp(); - const schemaSetting = useMemo(() => app.schemaSettingsManager.get(name), [app.schemaSettingsManager, name]); - const renderCache = React.useRef>>({}); - 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, - })); - }, - }; -} +export * from './useSchemaSettingsRender'; diff --git a/packages/core/client/src/application/schema-settings/hooks/useSchemaSettingsRender.tsx b/packages/core/client/src/application/schema-settings/hooks/useSchemaSettingsRender.tsx new file mode 100644 index 0000000000..d881c842e0 --- /dev/null +++ b/packages/core/client/src/application/schema-settings/hooks/useSchemaSettingsRender.tsx @@ -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 = Omit, 'name' | 'items'> & + Omit & { + fieldSchema?: Schema; + field?: GeneralField; + dn?: Designable; + }; + +export function useSchemaSettingsRender(name: string, options?: UseSchemaSettingsRenderOptions) { + const app = useApp(); + const schemaSetting = useMemo(() => app.schemaSettingsManager.get(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, + }); + }, + }; +} diff --git a/packages/core/client/src/data-source/__tests__/collection/AssociationProvider.test.tsx b/packages/core/client/src/data-source/__tests__/collection/AssociationProvider.test.tsx index afe2f1b18f..d125f7fab3 100644 --- a/packages/core/client/src/data-source/__tests__/collection/AssociationProvider.test.tsx +++ b/packages/core/client/src/data-source/__tests__/collection/AssociationProvider.test.tsx @@ -94,6 +94,9 @@ describe('AssociationProvider', () => { }; 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(); }); }); diff --git a/packages/core/client/src/data-source/__tests__/collection/CollectionManager.test.tsx b/packages/core/client/src/data-source/__tests__/collection/CollectionManager.test.tsx index ddfaf80c2d..d24eab8241 100644 --- a/packages/core/client/src/data-source/__tests__/collection/CollectionManager.test.tsx +++ b/packages/core/client/src/data-source/__tests__/collection/CollectionManager.test.tsx @@ -1,6 +1,5 @@ import { Application, CollectionManager, CollectionTemplate, Collection } from '@nocobase/client'; import collections from '../collections.json'; -import { app } from '../../../application/demos/demo3'; describe('CollectionManager', () => { let collectionManager: CollectionManager; @@ -227,4 +226,71 @@ describe('CollectionManager', () => { 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'); + }); + }); + }); }); diff --git a/packages/core/client/src/data-source/__tests__/collection/CollectionManagerProvider.test.tsx b/packages/core/client/src/data-source/__tests__/collection/CollectionManagerProvider.test.tsx index abe01efb83..f512aaa953 100644 --- a/packages/core/client/src/data-source/__tests__/collection/CollectionManagerProvider.test.tsx +++ b/packages/core/client/src/data-source/__tests__/collection/CollectionManagerProvider.test.tsx @@ -84,7 +84,7 @@ describe('CollectionManagerProvider', () => { const Wrapper = () => { return ( - + ); diff --git a/packages/core/client/src/data-source/__tests__/collection/CollectionProvider.test.tsx b/packages/core/client/src/data-source/__tests__/collection/CollectionProvider.test.tsx index 639e336bb1..ee6ebaafe6 100644 --- a/packages/core/client/src/data-source/__tests__/collection/CollectionProvider.test.tsx +++ b/packages/core/client/src/data-source/__tests__/collection/CollectionProvider.test.tsx @@ -80,7 +80,7 @@ describe('CollectionProvider', () => { renderApp(Demo, { name: 'not-exists', allowNull: false }); - expect(document.body.innerHTML).toContain('ant-result'); + expect(screen.getByText('Delete')).toBeInTheDocument(); }); test('useCollectionFields() support predicate', () => { diff --git a/packages/core/client/src/data-source/__tests__/components/CollectionDeletedPlaceholder.test.tsx b/packages/core/client/src/data-source/__tests__/components/CollectionDeletedPlaceholder.test.tsx index d716197d02..1dadfc9921 100644 --- a/packages/core/client/src/data-source/__tests__/components/CollectionDeletedPlaceholder.test.tsx +++ b/packages/core/client/src/data-source/__tests__/components/CollectionDeletedPlaceholder.test.tsx @@ -1,7 +1,8 @@ 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 { App } from 'antd'; function renderApp(name?: any, designable?: boolean) { const schema = { @@ -16,31 +17,45 @@ function renderApp(name?: any, designable?: boolean) { render(
- - - + + + + +
, ); } describe('CollectionDeletedPlaceholder', () => { - test('name is undefined, render `Result` component', () => { + test('name is undefined, render `Result` component', async () => { 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', () => { - renderApp('test', true); + test('designable: true, render `Result` component', () => { + renderApp('test', true); + expect(screen.getByText('Delete')).toBeInTheDocument(); + expect( + screen.getByText('The collection "test" may have been deleted. Please remove this block.'), + ).toBeInTheDocument(); + }); - expect(document.body.innerHTML).toContain('ant-result'); - }); + test('designable: false, render nothing', () => { + renderApp('test', false); - test('designable: false, render nothing', () => { - renderApp('test', false); - - expect(screen.getByTestId('app').innerHTML.length).toBe(0); - }); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); }); }); diff --git a/packages/core/client/src/data-source/__tests__/data-source/DataSourceManager.test.ts b/packages/core/client/src/data-source/__tests__/data-source/DataSourceManager.test.ts index 72aaeace17..7933735342 100644 --- a/packages/core/client/src/data-source/__tests__/data-source/DataSourceManager.test.ts +++ b/packages/core/client/src/data-source/__tests__/data-source/DataSourceManager.test.ts @@ -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'; 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', () => { test('should add a data source', () => { const app = new Application(); @@ -184,5 +209,32 @@ describe('DataSourceManager', () => { expect(reloadSpy1).toHaveBeenCalledTimes(1); expect(reloadSpy2).toHaveBeenCalledTimes(1); }); + + test('multi data sources', async () => { + const app = new Application(); + const dataSourceManager = app.dataSourceManager; + const getThirdDataSource = (): Promise => { + 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); + }); }); }); diff --git a/packages/core/client/src/data-source/__tests__/data-source/DataSourceManagerProvider.test.tsx b/packages/core/client/src/data-source/__tests__/data-source/DataSourceManagerProvider.test.tsx index 2f5649add0..f76a341837 100644 --- a/packages/core/client/src/data-source/__tests__/data-source/DataSourceManagerProvider.test.tsx +++ b/packages/core/client/src/data-source/__tests__/data-source/DataSourceManagerProvider.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { DataSourceManagerProvider, useDataSourceManager } from '@nocobase/client'; +import { Application, DataSourceManagerProvider, useDataSourceManager } from '@nocobase/client'; describe('DataSourceManagerProvider', () => { test('should render children', () => { diff --git a/packages/core/client/src/data-source/__tests__/data-source/DataSourceProvider.test.tsx b/packages/core/client/src/data-source/__tests__/data-source/DataSourceProvider.test.tsx index 8088514c38..b9fa71a452 100644 --- a/packages/core/client/src/data-source/__tests__/data-source/DataSourceProvider.test.tsx +++ b/packages/core/client/src/data-source/__tests__/data-source/DataSourceProvider.test.tsx @@ -5,10 +5,9 @@ import { DataSourceOptions, DataSource, SchemaComponent, - SchemaComponentProvider, } from '@nocobase/client'; 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 { AppSchemaComponentProvider } from '../../../application/AppSchemaComponentProvider'; @@ -54,6 +53,8 @@ describe('DataSourceProvider', () => { , ); + + return app.dataSourceManager.getDataSource(dataSource); } it('should render default dataSource', () => { renderComponent(); @@ -67,16 +68,38 @@ describe('DataSourceProvider', () => { it('should render error state when data source is not found', () => { 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', () => { - renderComponent('test', 'loading'); + it('should render loading state when data source is loading', async () => { + const ds = renderComponent('test', 'loading'); + 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 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', () => { renderComponent('test', 'loading-failed'); - expect(screen.getByText('DataSource "Test" loading failed')).toBeInTheDocument(); + expect(screen.getByText('Data Source "Test" loading failed')).toBeInTheDocument(); }); }); diff --git a/packages/core/client/src/data-source/__tests__/utils.test.ts b/packages/core/client/src/data-source/__tests__/utils.test.ts index d097966c26..167caec3f0 100644 --- a/packages/core/client/src/data-source/__tests__/utils.test.ts +++ b/packages/core/client/src/data-source/__tests__/utils.test.ts @@ -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'; describe('utils', () => { @@ -42,4 +50,50 @@ describe('utils', () => { 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' }); + }); + }); }); diff --git a/packages/core/client/src/data-source/collection-field-interface/CollectionFieldInterfaceManager.ts b/packages/core/client/src/data-source/collection-field-interface/CollectionFieldInterfaceManager.ts index 10089c4498..5f7a4b5b6e 100644 --- a/packages/core/client/src/data-source/collection-field-interface/CollectionFieldInterfaceManager.ts +++ b/packages/core/client/src/data-source/collection-field-interface/CollectionFieldInterfaceManager.ts @@ -37,7 +37,6 @@ export class CollectionFieldInterfaceManager { if (item.notSupportDataSourceType) { return !item.notSupportDataSourceType?.includes(dataSourceType); } - return true; }); } diff --git a/packages/core/client/src/data-source/collection-template/CollectionTemplateManager.ts b/packages/core/client/src/data-source/collection-template/CollectionTemplateManager.ts index 7a52ece171..add83b3f61 100644 --- a/packages/core/client/src/data-source/collection-template/CollectionTemplateManager.ts +++ b/packages/core/client/src/data-source/collection-template/CollectionTemplateManager.ts @@ -46,7 +46,6 @@ export class CollectionTemplateManager { if (item.notSupportDataSourceType) { return !item.notSupportDataSourceType?.includes(dataSourceType); } - return true; }) .sort((a, b) => (a.order || 0) - (b.order || 0)); } diff --git a/packages/core/client/src/data-source/collection/CollectionManager.ts b/packages/core/client/src/data-source/collection/CollectionManager.ts index e85a6487ed..5e5d5ac328 100644 --- a/packages/core/client/src/data-source/collection/CollectionManager.ts +++ b/packages/core/client/src/data-source/collection/CollectionManager.ts @@ -131,7 +131,7 @@ export class CollectionManager { return this.getCollection(collectionName)?.getFields(predicate) || []; } - getSourceKeyByAssocation(associationName: string) { + getSourceKeyByAssociation(associationName: string) { if (!associationName) { return; } diff --git a/packages/core/client/src/data-source/components/CollectionDeletedPlaceholder.tsx b/packages/core/client/src/data-source/components/CollectionDeletedPlaceholder.tsx index 33c3d8aec7..13470f53e2 100644 --- a/packages/core/client/src/data-source/components/CollectionDeletedPlaceholder.tsx +++ b/packages/core/client/src/data-source/components/CollectionDeletedPlaceholder.tsx @@ -8,7 +8,7 @@ import { DEFAULT_DATA_SOURCE_KEY } from '../../data-source/data-source/DataSourc import { useCollection } from '../collection'; export interface CollectionDeletedPlaceholderProps { - type: 'Collection' | 'Field' | 'DataSource'; + type: 'Collection' | 'Field' | 'Data Source' | 'Block template'; name?: string | number; message?: string; } @@ -27,7 +27,7 @@ export const CollectionDeletedPlaceholder: FC const collection = useCollection(); const dataSourceManager = useDataSourceManager(); const nameValue = useMemo(() => { - if (type === 'DataSource') { + if (type === 'Data Source') { return name; } const dataSourcePrefix = @@ -60,7 +60,7 @@ export const CollectionDeletedPlaceholder: FC return t(`The {{type}} "{{name}}" may have been deleted. Please remove this {{blockType}}.`, { type: t(type).toLocaleLowerCase(), name: nameValue, - blockType: t(blockType), + blockType: t(blockType).toLocaleLowerCase(), }).replaceAll('>', '>'); }, [message, nameValue, type, t, blockType]); diff --git a/packages/core/client/src/data-source/data-block/DataBlockResourceProvider.tsx b/packages/core/client/src/data-source/data-block/DataBlockResourceProvider.tsx index 6dba28bd99..124582af84 100644 --- a/packages/core/client/src/data-source/data-block/DataBlockResourceProvider.tsx +++ b/packages/core/client/src/data-source/data-block/DataBlockResourceProvider.tsx @@ -23,7 +23,7 @@ export const DataBlockResourceProvider: FC<{ children?: ReactNode }> = ({ childr return sourceId; } if (association && parentRecord) { - const sourceKey = cm.getSourceKeyByAssocation(association); + const sourceKey = cm.getSourceKeyByAssociation(association); const parentRecordData = parentRecord instanceof CollectionRecord ? parentRecord.data : parentRecord; return parentRecordData[sourceKey]; } diff --git a/packages/core/client/src/data-source/data-source/DataSourceManager.ts b/packages/core/client/src/data-source/data-source/DataSourceManager.ts index 14b732d12c..96c78a4960 100644 --- a/packages/core/client/src/data-source/data-source/DataSourceManager.ts +++ b/packages/core/client/src/data-source/data-source/DataSourceManager.ts @@ -56,9 +56,9 @@ export class DataSourceManager { this.getDataSources().forEach((dataSource) => dataSource.collectionManager.reAddCollections()); } - getDataSources(filterCollection?: (dataSource: DataSource) => boolean) { + getDataSources(filterDataSource?: (dataSource: DataSource) => boolean) { const allDataSources = Object.values(this.dataSourceInstancesMap); - return filterCollection ? _.filter(allDataSources, filterCollection) : allDataSources; + return filterDataSource ? _.filter(allDataSources, filterDataSource) : allDataSources; } getDataSource(key?: string) { diff --git a/packages/core/client/src/data-source/data-source/DataSourceProvider.tsx b/packages/core/client/src/data-source/data-source/DataSourceProvider.tsx index 273886b761..ef4e4202da 100644 --- a/packages/core/client/src/data-source/data-source/DataSourceProvider.tsx +++ b/packages/core/client/src/data-source/data-source/DataSourceProvider.tsx @@ -23,13 +23,13 @@ export const DataSourceProvider: FC = ({ children, data const dataSourceValue = dataSourceManager.getDataSource(dataSource); if (!dataSourceValue) { - return ; + return ; } if (dataSourceValue.status === 'loading-failed') { return ( diff --git a/packages/core/client/src/locale/en_US.json b/packages/core/client/src/locale/en_US.json index aab05b7785..15ed588803 100644 --- a/packages/core/client/src/locale/en_US.json +++ b/packages/core/client/src/locale/en_US.json @@ -500,6 +500,7 @@ "Save as template": "Save as template", "Save as block template": "Save as block template", "Block templates": "Block templates", + "Block template": "Block template", "Convert reference to duplicate": "Convert reference to duplicate", "Template name": "Template name", "Block type": "Block type", @@ -801,6 +802,7 @@ "loading": "loading", "name is required": "name is required", "data source": "data source", + "Data source": "Data source", "DataSource": "DataSource", "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", diff --git a/packages/core/client/src/locale/es_ES.json b/packages/core/client/src/locale/es_ES.json index 148ed25971..44c8126d06 100644 --- a/packages/core/client/src/locale/es_ES.json +++ b/packages/core/client/src/locale/es_ES.json @@ -471,6 +471,7 @@ "Save as template": "Guardar como plantilla", "Save as block template": "Guardar como plantilla de bloque", "Block templates": "Bloquear plantillas", + "Block template": "Plantilla de bloque", "Convert reference to duplicate": "Convertir referencia a duplicado", "Template name": "Nombre de plantilla", "Block type": "Tipo de bloque", @@ -749,6 +750,7 @@ "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}}.", "data source": "fuente de datos", + "Data source": "fuente de datos", "DataSource": "Fuente de datos", "Home page": "Página de inicio", "Handbook": "Manual de usuario", diff --git a/packages/core/client/src/locale/fr_FR.json b/packages/core/client/src/locale/fr_FR.json index 96844e314d..f43b3948cd 100644 --- a/packages/core/client/src/locale/fr_FR.json +++ b/packages/core/client/src/locale/fr_FR.json @@ -486,6 +486,7 @@ "Save as template": "Enregistrer en tant que modèle", "Save as block template": "Enregistrer en tant que modèle 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", "Template name": "Nom du modèle", "Block type": "Type de bloc", @@ -767,6 +768,7 @@ "loading": "chargement", "name is required": "le nom est requis", "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}}.", "DataSource": "Source de données", "Allow selection of existing records":"Permet de sélectionner des données existantes", diff --git a/packages/core/client/src/locale/ja_JP.json b/packages/core/client/src/locale/ja_JP.json index bd7602c985..4a69fecbee 100644 --- a/packages/core/client/src/locale/ja_JP.json +++ b/packages/core/client/src/locale/ja_JP.json @@ -398,6 +398,7 @@ "Save as template": "テンプレートとして保存", "Save as block template": "ブロックテンプレートとして保存", "Block templates": "ブロックテンプレート", + "Block template": "ブロックテンプレート", "Convert reference to duplicate": "参照を複製に変換", "Template name": "テンプレート名", "Block type": "ブロックタイプ", @@ -687,8 +688,9 @@ "loading": "ロード中", "name is required": "名前は必須です", "data source": "データソース", - "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" は削除されている可能性があります。この {{blockType}} を削除してください。", + "Data source": "データソース", "DataSource": "データソース", + "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" は削除されている可能性があります。この {{blockType}} を削除してください。", "Home page": "ホームページ", "Handbook": "ユーザーマニュアル", "License": "ライセンス" diff --git a/packages/core/client/src/locale/ko_KR.json b/packages/core/client/src/locale/ko_KR.json index b6851be465..f474e41a31 100644 --- a/packages/core/client/src/locale/ko_KR.json +++ b/packages/core/client/src/locale/ko_KR.json @@ -518,6 +518,7 @@ "Save as template": "템플릿으로 저장", "Save as block template": "블록 템플릿으로 저장", "Block templates": "블록 템플릿", + "Block template": "블록 템플릿", "Convert reference to duplicate": "참조를 복제로 변환", "Template name": "템플릿 이름", "Block type": "블록 타입", @@ -859,8 +860,9 @@ "loading": "로드 중", "name is required": "이름은 필수입니다", "data source": "데이터 소스", - "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\"이(가) 삭제되었을 수 있습니다. 이 {{blockType}}을(를) 제거하십시오.", + "Data source": "데이터 소스", "DataSource": "데이터 소스", + "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\"이(가) 삭제되었을 수 있습니다. 이 {{blockType}}을(를) 제거하십시오.", "Home page": "홈페이지", "Handbook": "사용자 매뉴얼", "License": "라이선스" diff --git a/packages/core/client/src/locale/pt_BR.json b/packages/core/client/src/locale/pt_BR.json index acf18447f8..a24cc333fa 100644 --- a/packages/core/client/src/locale/pt_BR.json +++ b/packages/core/client/src/locale/pt_BR.json @@ -434,6 +434,7 @@ "Save as template": "Salvar como modelo", "Save as block template": "Salvar como modelo de bloco", "Block templates": "Modelos de bloco", + "Block template": "Modelo de bloco", "Convert reference to duplicate": "Converter referência em duplicado", "Template name": "Nome do modelo", "Block type": "Tipo de bloco", @@ -726,8 +727,9 @@ "loading": "carregando", "name is required": "nome é obrigatório", "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", + "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", "Home page": "Página inicial", "Handbook": "Manual do usuário", diff --git a/packages/core/client/src/locale/ru_RU.json b/packages/core/client/src/locale/ru_RU.json index 31f1a6a24b..4f643ed7e1 100644 --- a/packages/core/client/src/locale/ru_RU.json +++ b/packages/core/client/src/locale/ru_RU.json @@ -337,7 +337,7 @@ "Other records": "Другие записи", "Save as template": "Сохранить как шаблон", "Save as block template": "Сохранить как шаблон Блока", - "Block templates": "Шаблоны Блока", + "Block templates": "Шаблоны блоков", "Convert reference to duplicate": "Преобразовать ссылку в дубликат", "Template name": "Имя Шаблона", "Block type": "Тип Блока", @@ -563,6 +563,7 @@ "loading": "загрузка", "name is required": "имя обязательно", "data source": "источник данных", + "Data source": "источник данных", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" возможно был удален. Пожалуйста, удалите этот {{blockType}}.", "DataSource": "Источник данных", "Home page": "Домашняя страница", diff --git a/packages/core/client/src/locale/tr_TR.json b/packages/core/client/src/locale/tr_TR.json index 1fecdf9dec..bb411c3baf 100644 --- a/packages/core/client/src/locale/tr_TR.json +++ b/packages/core/client/src/locale/tr_TR.json @@ -337,6 +337,7 @@ "Save as template": "Şablon olarak kaydet", "Save as block template": "Blok şablonu olarak kaydet", "Block templates": "Blok şablonları", + "Block template": "Blok şablonu", "Convert reference to duplicate": "Referansı kopyaya dönüştür", "Template name": "Şablon adı", "Block type": "Blok türü", @@ -560,8 +561,9 @@ "loading": "yükleniyor", "name is required": "ad gereklidir", "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ğı", + "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", "Handbook": "Kullanıcı kılavuzu", "License": "Lisans" diff --git a/packages/core/client/src/locale/uk_UA.json b/packages/core/client/src/locale/uk_UA.json index 1b7d9e74e5..66d8088499 100644 --- a/packages/core/client/src/locale/uk_UA.json +++ b/packages/core/client/src/locale/uk_UA.json @@ -488,6 +488,7 @@ "Save as template": "Зберегти як шаблон", "Save as block template": "Зберегти як шаблон блока", "Block templates": "Шаблони блоків", + "Block template": "Шаблон блока", "Convert reference to duplicate": "Конвертувати посилання на дублікат", "Template name": "Назва шаблону", "Block type": "Тип блока", @@ -768,8 +769,9 @@ "loading": "завантаження", "name is required": "ім'я обов'язкове", "data source": "джерело даних", - "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" може бути видалено. Будь ласка, видаліть цей {{blockType}}.", + "Data source": "джерело даних", "DataSource": "Джерело даних", + "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" може бути видалено. Будь ласка, видаліть цей {{blockType}}.", "Home page": "Домашня сторінка", "Handbook": "Посібник користувача", "License": "Ліцензія" diff --git a/packages/core/client/src/locale/zh-CN.json b/packages/core/client/src/locale/zh-CN.json index 85e11884b3..e3caf20fe0 100644 --- a/packages/core/client/src/locale/zh-CN.json +++ b/packages/core/client/src/locale/zh-CN.json @@ -521,6 +521,7 @@ "Save as template": "保存为模板", "Save as block template": "保存为区块模板", "Block templates": "区块模板", + "Block template": "区块模板", "Convert reference to duplicate": "模板引用转为复制", "Template name": "模板名称", "Block type": "区块类型", @@ -864,6 +865,7 @@ "loading": "加载中", "name is required": "名称不能为空", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被删除。请删除当前{{blockType}}.", + "data source": "数据源", "Data source": "数据源", "DataSource": "数据源", "Data model": "数据模型", diff --git a/packages/core/client/src/locale/zh-TW.json b/packages/core/client/src/locale/zh-TW.json index 5abc14367f..b62ae0a014 100644 --- a/packages/core/client/src/locale/zh-TW.json +++ b/packages/core/client/src/locale/zh-TW.json @@ -518,6 +518,7 @@ "Save as template": "儲存為模板", "Save as block template": "儲存為區塊模板", "Block templates": "區塊模板", + "Block template": "區塊模板", "Convert reference to duplicate": "模板引用轉為複製", "Template name": "模板名稱", "Block type": "區塊型別", @@ -856,6 +857,9 @@ "Permission denied": "沒有權限", "Allow add new":"允許新增", "The {{type}} \"{{name}}\" may have been deleted. Please remove this {{blockType}}.": "{{type}} \"{{name}}\" 可能已被刪除,請移除這個 {{blockType}}。", + "data source": "數據源", + "Data source": "數據源", + "DataSource": "數據源", "Allow selection of existing records":"允許選擇已有資料", "Home page": "主頁", "Handbook": "使用手冊", diff --git a/packages/core/client/src/modules/blocks/useSourceKey.ts b/packages/core/client/src/modules/blocks/useSourceKey.ts index b42c47c3ee..5bcb804138 100644 --- a/packages/core/client/src/modules/blocks/useSourceKey.ts +++ b/packages/core/client/src/modules/blocks/useSourceKey.ts @@ -7,5 +7,5 @@ import { useCollectionManager } from '../../data-source/collection/CollectionMan */ export const useSourceKey = (association: string) => { const cm = useCollectionManager(); - return cm.getSourceKeyByAssocation(association); + return cm.getSourceKeyByAssociation(association); }; diff --git a/packages/core/client/src/schema-component/antd/association-filter/AssociationFilterProvider.tsx b/packages/core/client/src/schema-component/antd/association-filter/AssociationFilterProvider.tsx index cf22c69fcc..472ae251ea 100644 --- a/packages/core/client/src/schema-component/antd/association-filter/AssociationFilterProvider.tsx +++ b/packages/core/client/src/schema-component/antd/association-filter/AssociationFilterProvider.tsx @@ -1,2 +1,2 @@ // TODO: 因他们之间功能相同,所以先直接复用,后续有需要再拆分 -export { TableBlockProvider as AssociationFilterProvider } from '../../../block-provider'; +export { TableBlockProvider as AssociationFilterProvider } from '../../../block-provider/TableBlockProvider'; diff --git a/packages/core/client/src/schema-component/antd/checkbox/__tests__/checkbox.test.tsx b/packages/core/client/src/schema-component/antd/checkbox/__tests__/checkbox.test.tsx index 4be0abc9c7..e772edd8fd 100644 --- a/packages/core/client/src/schema-component/antd/checkbox/__tests__/checkbox.test.tsx +++ b/packages/core/client/src/schema-component/antd/checkbox/__tests__/checkbox.test.tsx @@ -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 App1 from '../demos/checkbox'; import App2 from '../demos/checkbox.group'; +import { Checkbox } from '@nocobase/client'; describe('Checkbox', () => { it('should display the title', () => { @@ -37,6 +38,67 @@ describe('Checkbox', () => { await userEvent.click(input); expect(container.querySelector('svg')).toBeNull(); }); + + describe('read pretty', () => { + it('true', async () => { + const { container } = await renderReadPrettyApp({ + Component: Checkbox, + value: true, + }); + + expect(container.querySelector('svg')).toMatchInlineSnapshot(` + + `); + }); + + 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(` + + `); + }); + }); }); describe('Checkbox.Group', () => { diff --git a/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx b/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx index 29652ca80a..38b7242f22 100644 --- a/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx +++ b/packages/core/client/src/schema-component/antd/collection-select/__tests__/collection-select.test.tsx @@ -1,22 +1,255 @@ -import { render, screen, userEvent } from '@nocobase/test/client'; -import React from 'react'; -import App1 from '../demos/demo1'; +import { renderReadPrettyApp, renderApp, screen, userEvent, waitFor } from '@nocobase/test/client'; +import { FormItem, CollectionSelect } from '@nocobase/client'; -describe.skip('CollectionSelect', () => { +describe('CollectionSelect', () => { it('should works', async () => { - render(); + const { container } = await renderApp({ + schema: { + type: 'object', + properties: { + test: { + type: 'string', + title: 'demo title', + 'x-decorator': FormItem, + 'x-component': CollectionSelect, + }, + }, + }, + }); - // 标题 - expect(screen.getByText('Collection')).toBeInTheDocument(); + expect(screen.getByText('demo title')).toBeInTheDocument(); - const selector = document.querySelector('.ant-select-selector'); - expect(selector).toBeInTheDocument(); + await userEvent.click(document.querySelector('.ant-select-selector')); + 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(` +
+
+
+ +
+
+
+ `); + }); + + 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(` +
+
+
+ +
+
+
+ `); + }); + + 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('Roles')).toBeInTheDocument(); - expect(screen.getByText('测试表')).toBeInTheDocument(); }); }); diff --git a/packages/core/client/src/schema-component/antd/color-picker/__tests__/ColorPicker.test.tsx b/packages/core/client/src/schema-component/antd/color-picker/__tests__/ColorPicker.test.tsx new file mode 100644 index 0000000000..aca163fe64 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/color-picker/__tests__/ColorPicker.test.tsx @@ -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(` +
+
+
+
+
+
+
+
+
+
+
+ `); + }); + + 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(` +
+
+
+
+
+
+
+
+
+
+
+ `); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx b/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx index 50929d7a78..83e53eb71c 100644 --- a/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx +++ b/packages/core/client/src/schema-component/antd/form-item/FormItem.Settings.tsx @@ -960,8 +960,11 @@ function useFormItemCollectionField() { const { getCollectionJoinField } = useCollectionManager_deprecated(); const { getField } = useCollection_deprecated(); const fieldSchema = useFieldSchema(); - const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']); const { collectionField: columnCollectionField } = useColumnSchema(); + const collectionField = fieldSchema + ? getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']) + : null; + if (!fieldSchema) return null; return collectionField || columnCollectionField; } diff --git a/packages/core/client/src/schema-component/antd/form-v2/__tests__/form-v2.test.tsx b/packages/core/client/src/schema-component/antd/form-v2/__tests__/form-v2.test.tsx index 0bf275a5eb..f8bf7d8af3 100644 --- a/packages/core/client/src/schema-component/antd/form-v2/__tests__/form-v2.test.tsx +++ b/packages/core/client/src/schema-component/antd/form-v2/__tests__/form-v2.test.tsx @@ -18,8 +18,8 @@ describe('FormV2', () => { await userEvent.type(input, '李四'); + await userEvent.click(screen.getByText('Submit')); await waitFor(async () => { - await userEvent.click(screen.getByText('Submit')); // notification 的内容 expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument(); }); diff --git a/packages/core/client/src/schema-component/antd/input/__tests__/EllipsisWithTooltip.test.tsx b/packages/core/client/src/schema-component/antd/input/__tests__/EllipsisWithTooltip.test.tsx new file mode 100644 index 0000000000..2b6689095e --- /dev/null +++ b/packages/core/client/src/schema-component/antd/input/__tests__/EllipsisWithTooltip.test.tsx @@ -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(); + + useEffect(() => { + ref.current.setPopoverVisible(true); + }, []); + + return ( + + {text} + + ); + }; + + 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(); + await noPopoverCheck(); + }); + + it('shows Popover when ellipsis is true and content overflows', async () => { + setHasEllipsis(); + + render(); + + await hasPopoverCheck(); + }); + + it('does not show Popover when content does not overflow', async () => { + setNoEllipsis(); + + render(); + await noPopoverCheck(); + }); + + it('uses popoverContent when provided', async () => { + setHasEllipsis(); + + const popoverContent = 'Custom Popover Content'; + + render(); + await hasPopoverCheck(popoverContent); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/menu/__tests__/utils.test.ts b/packages/core/client/src/schema-component/antd/menu/__tests__/utils.test.ts new file mode 100644 index 0000000000..a7085c2b4f --- /dev/null +++ b/packages/core/client/src/schema-component/antd/menu/__tests__/utils.test.ts @@ -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', + }), + ); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx b/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx index f2518865ed..64142fba0b 100644 --- a/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx +++ b/packages/core/client/src/schema-component/antd/page/__tests__/page.test.tsx @@ -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 App1 from '../demos/demo1'; +import { Page } from '../Page'; +import { DocumentTitleProvider, Form, FormItem, Grid, IconPicker, Input } from '@nocobase/client'; describe('Page', () => { it('should render correctly', async () => { @@ -16,4 +18,129 @@ describe('Page', () => { 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(); + }); + }); + }); }); diff --git a/packages/core/client/src/schema-component/antd/pagination/__tests__/pagination.test.tsx b/packages/core/client/src/schema-component/antd/pagination/__tests__/pagination.test.tsx new file mode 100644 index 0000000000..6f1c928cc4 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/pagination/__tests__/pagination.test.tsx @@ -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(` +
+
+
+ +
+
+
+ `); + }); + + it('hides when hidden prop is true', async () => { + const { container } = await renderApp({ + Component: Pagination, + props: { + hidden: true, + }, + }); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/password/__tests__/PasswordStrength.test.tsx b/packages/core/client/src/schema-component/antd/password/__tests__/PasswordStrength.test.tsx new file mode 100644 index 0000000000..efbafb07c5 --- /dev/null +++ b/packages/core/client/src/schema-component/antd/password/__tests__/PasswordStrength.test.tsx @@ -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(
Strength: {strengthValue}
); + const { getByText } = render({childrenMock}); + expect(childrenMock).toHaveBeenCalledWith(strengthValue); + expect(getByText(`Strength: ${strengthValue}`)).toBeInTheDocument(); + }); + + it('renders children without strength value', () => { + const childrenMock = vitest.fn().mockReturnValue(
No strength value
); + const { getByText } = render({childrenMock}); + expect(childrenMock).toHaveBeenCalledWith(0); + expect(getByText('No strength value')).toBeInTheDocument(); + }); + + it('renders children without function', () => { + const childrenMock =
Children without function
; + const { getByText } = render({childrenMock}); + expect(getByText('Children without function')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/password/__tests__/utils.test.ts b/packages/core/client/src/schema-component/antd/password/__tests__/utils.test.ts index 0aceb2a11a..8b0201498b 100644 --- a/packages/core/client/src/schema-component/antd/password/__tests__/utils.test.ts +++ b/packages/core/client/src/schema-component/antd/password/__tests__/utils.test.ts @@ -24,4 +24,60 @@ describe('getStrength', () => { it('should return 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); + }); }); diff --git a/packages/core/client/src/schema-component/antd/space/__tests__/space.test.tsx b/packages/core/client/src/schema-component/antd/space/__tests__/space.test.tsx new file mode 100644 index 0000000000..b06db746fd --- /dev/null +++ b/packages/core/client/src/schema-component/antd/space/__tests__/space.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { Space } from '../index'; + +describe('Space', () => { + it('renders without error', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); + + it('renders with custom split', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(`
`); + }); +}); diff --git a/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Decorator.tsx b/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Decorator.tsx index e26bed37c9..ef35a6ae4c 100644 --- a/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Decorator.tsx +++ b/packages/core/client/src/schema-component/antd/table-v2/Table.Column.Decorator.tsx @@ -17,7 +17,7 @@ export const useColumnSchema = () => { const compile = useCompile(); const columnSchema = useFieldSchema(); const { getCollectionJoinField } = useCollectionManager_deprecated(); - const fieldSchema = columnSchema.reduceProperties((buf, s) => { + const fieldSchema = columnSchema?.reduceProperties((buf, s) => { if (isCollectionFieldComponent(s)) { return s; } diff --git a/packages/core/client/src/schema-component/antd/unixTimestamp/__tests__/UnixTimestamp.test.tsx b/packages/core/client/src/schema-component/antd/unixTimestamp/__tests__/UnixTimestamp.test.tsx new file mode 100644 index 0000000000..9e4f4bbd6a --- /dev/null +++ b/packages/core/client/src/schema-component/antd/unixTimestamp/__tests__/UnixTimestamp.test.tsx @@ -0,0 +1,138 @@ +import { screen, renderApp, userEvent, waitFor, renderReadPrettyApp } from '@nocobase/test/client'; +import { UnixTimestamp } from '@nocobase/client'; + +describe('UnixTimestamp', () => { + it('renders without errors', async () => { + const { container } = await renderApp({ + Component: UnixTimestamp, + value: 0, + }); + expect(container).toMatchInlineSnapshot(` +
+
+
+
+ + + + + + +
+
+
+
+ `); + }); + + it('millisecond', async () => { + await renderApp({ + Component: UnixTimestamp, + value: 1712819630000, + }); + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); + }); + }); + + it('second', async () => { + await renderApp({ + Component: UnixTimestamp, + value: 1712819630, + props: { + accuracy: 'second', + }, + }); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); + }); + }); + + it('string', async () => { + await renderApp({ + Component: UnixTimestamp, + value: '2024-04-11', + }); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('2024-04-11'); + }); + }); + + it('change', async () => { + const onChange = vitest.fn(); + await renderApp({ + Component: UnixTimestamp, + value: '2024-04-11', + onChange, + }); + await userEvent.click(screen.getByRole('textbox')); + + await waitFor(() => { + expect(screen.queryByRole('table')).toBeInTheDocument(); + }); + + await userEvent.click(document.querySelector('td[title="2024-04-12"]')); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toHaveValue('2024-04-12'); + }); + expect(onChange).toBeCalledWith(1712880000000); + }); + + it('read pretty', async () => { + const { container } = await renderReadPrettyApp({ + Component: UnixTimestamp, + value: '2024-04-11', + }); + + expect(screen.getByText('2024-04-11')).toBeInTheDocument(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ 2024-04-11 +
+
+
+ `); + }); +}); diff --git a/packages/core/client/src/schema-component/types.ts b/packages/core/client/src/schema-component/types.ts index c8a01ee8b3..54b2d152bc 100644 --- a/packages/core/client/src/schema-component/types.ts +++ b/packages/core/client/src/schema-component/types.ts @@ -19,6 +19,7 @@ export interface ISchemaComponentProvider { form?: Form; scope?: any; components?: SchemaReactComponents; + children?: React.ReactNode; } export interface IRecursionComponentProps extends IRecursionFieldProps { diff --git a/packages/core/client/src/schema-initializer/utils.ts b/packages/core/client/src/schema-initializer/utils.ts index b206af9855..34e6a713de 100644 --- a/packages/core/client/src/schema-initializer/utils.ts +++ b/packages/core/client/src/schema-initializer/utils.ts @@ -11,7 +11,6 @@ import { useCollection, useCollectionManager, useDataSourceKey, - useFormActiveFields, useFormBlockContext, } from '../'; import { FieldOptions, useCollectionManager_deprecated, useCollection_deprecated } from '../collection-manager'; @@ -20,7 +19,7 @@ import { useDataSourceManager } from '../data-source/data-source/DataSourceManag import { isAssocField } from '../filter-provider/utils'; import { useActionContext, useCompile, useDesignable } from '../schema-component'; import { useSchemaTemplateManager } from '../schema-templates'; - +import { useFormActiveFields } from '../block-provider/hooks/useFormActiveFields'; export const itemsMerge = (items1) => { return items1; }; diff --git a/packages/core/client/src/schema-settings/SchemaSettings.tsx b/packages/core/client/src/schema-settings/SchemaSettings.tsx index 68266d0dd3..a5869f83e9 100644 --- a/packages/core/client/src/schema-settings/SchemaSettings.tsx +++ b/packages/core/client/src/schema-settings/SchemaSettings.tsx @@ -460,7 +460,6 @@ export const SchemaSettingsItem: FC = (props) => { const { pushMenuItem } = useCollectMenuItems(); const { collectMenuItem } = useCollectMenuItem(); const { eventKey, title } = props; - const { name } = useSchemaSettingsItem(); if (process.env.NODE_ENV !== 'production' && !title) { throw new Error('SchemaSettingsItem must have a title'); diff --git a/packages/core/client/src/schema-templates/BlockTemplate.tsx b/packages/core/client/src/schema-templates/BlockTemplate.tsx index 9edb013463..d606a0791f 100644 --- a/packages/core/client/src/schema-templates/BlockTemplate.tsx +++ b/packages/core/client/src/schema-templates/BlockTemplate.tsx @@ -1,6 +1,6 @@ import { observer, useField, useFieldSchema } from '@formily/react'; import React, { createContext, useContext, useMemo } from 'react'; -import { RemoteSchemaComponent, useDesignable } from '..'; +import { CollectionDeletedPlaceholder, RemoteSchemaComponent, useDesignable } from '..'; import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider'; import { useTemplateBlockContext } from '../block-provider/TemplateBlockProvider'; @@ -30,7 +30,9 @@ export const BlockTemplate = observer( - ) : null; + ) : ( + + ); }, { displayName: 'BlockTemplate' }, ); diff --git a/packages/core/client/src/schema-templates/schemas/uiSchemaTemplates.ts b/packages/core/client/src/schema-templates/schemas/uiSchemaTemplates.ts index d6a21af5d9..e865f2d7f5 100644 --- a/packages/core/client/src/schema-templates/schemas/uiSchemaTemplates.ts +++ b/packages/core/client/src/schema-templates/schemas/uiSchemaTemplates.ts @@ -4,6 +4,7 @@ import { useBulkDestroyActionProps, useDestroyActionProps, useUpdateActionProps import { uiSchemaTemplatesCollection } from '../collections/uiSchemaTemplates'; import { CollectionTitle } from './CollectionTitle'; import { useBlockRequestContext } from '../../block-provider'; +import { useSchemaTemplateManager } from '../SchemaTemplateManagerProvider'; const useUpdateSchemaTemplateActionProps = () => { const props = useUpdateActionProps(); @@ -18,11 +19,13 @@ const useUpdateSchemaTemplateActionProps = () => { const useBulkDestroyTemplateProps = () => { const props = useBulkDestroyActionProps(); + const bm = useSchemaTemplateManager(); const { service } = useBlockRequestContext(); return { async onClick() { await props.onClick(); + await bm.refresh(); service?.refresh?.(); }, }; @@ -31,9 +34,11 @@ const useBulkDestroyTemplateProps = () => { const useDestroyTemplateProps = () => { const props = useDestroyActionProps(); const { service } = useBlockRequestContext(); + const bm = useSchemaTemplateManager(); return { async onClick() { await props.onClick(); + await bm.refresh(); service?.refresh?.(); }, }; diff --git a/packages/core/test/package.json b/packages/core/test/package.json index de84b3db07..8e9349f5ee 100644 --- a/packages/core/test/package.json +++ b/packages/core/test/package.json @@ -26,6 +26,16 @@ "default": "./es/client/index.mjs" } }, + "./web": { + "require": { + "types": "./lib/web/index.d.ts", + "default": "./lib/web/index.js" + }, + "import": { + "types": "./es/web/index.d.ts", + "default": "./es/web/index.mjs" + } + }, "./e2e": { "require": { "types": "./lib/e2e/index.d.ts", @@ -40,6 +50,7 @@ "./vitest.mjs": "./vitest.mjs" }, "dependencies": { + "axios-mock-adapter": "1.22.0", "@faker-js/faker": "8.1.0", "@nocobase/server": "0.21.0-alpha.6", "@playwright/test": "^1.42.1", diff --git a/packages/core/test/src/client/index.ts b/packages/core/test/src/client/index.ts deleted file mode 100644 index 0327ed2549..0000000000 --- a/packages/core/test/src/client/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { render } from '@testing-library/react'; -export { renderHook } from '@testing-library/react-hooks'; - -function customRender(ui: React.ReactElement, options = {}) { - return render(ui, { - // wrap provider(s) here if needed - wrapper: ({ children }) => children, - ...options, - }); -} - -export * from '@testing-library/react'; -export { default as userEvent } from '@testing-library/user-event'; -// override render export -export { customRender as render }; - -export const sleep = async (timeout = 0) => { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }); -}; diff --git a/packages/core/test/src/client/index.tsx b/packages/core/test/src/client/index.tsx new file mode 100644 index 0000000000..a6e26d286a --- /dev/null +++ b/packages/core/test/src/client/index.tsx @@ -0,0 +1,69 @@ +import { expect } from 'vitest'; +import React, { FC, Fragment } from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { GetAppComponentOptions, GetAppOptions, getApp, getAppComponent } from '../web'; + +export { renderHook } from '@testing-library/react-hooks'; + +function customRender(ui: React.ReactElement, options = {}) { + return render(ui, { + // wrap provider(s) here if needed + wrapper: ({ children }) => children, + ...options, + }); +} + +export * from '@testing-library/react'; +export { default as userEvent } from '@testing-library/user-event'; +// override render export +export { customRender as render }; + +export const sleep = async (timeout = 0) => { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }); +}; + +export const WaitApp = async () => { + await waitFor(() => { + // @ts-ignore + expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + }); +}; + +interface RenderHookOptions extends Omit { + hook: () => any; + props?: any; + Wrapper?: FC<{ children: React.ReactNode }>; +} + +export const renderHookWithApp = async (options: RenderHookOptions) => { + const { hook: useHook, props, Wrapper = Fragment, ...otherOptions } = options; + const { App } = getApp(otherOptions); + const WrapperValue: FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + + ); + + const res = renderHook(() => useHook(), { wrapper: WrapperValue, initialProps: props }); + + await WaitApp(); + + return res; +}; + +export const renderApp = async (options: GetAppComponentOptions) => { + const App = getAppComponent(options); + + const res = render(); + + await WaitApp(); + + return res; +}; + +export const renderReadPrettyApp = (options: GetAppComponentOptions) => { + return renderApp({ ...options, schema: { ...(options.schema || {}), 'x-read-pretty': true } }); +}; diff --git a/packages/core/test/src/web/dataSource2.json b/packages/core/test/src/web/dataSource2.json new file mode 100644 index 0000000000..fc70600ed6 --- /dev/null +++ b/packages/core/test/src/web/dataSource2.json @@ -0,0 +1,139 @@ +{ + "key": "data-source2", + "displayName": "Data Source 2", + "status": "loaded", + "type": "postgres", + "isDBInstance": true, + "collections": [ + { + "name": "test", + "title": "test", + "tableName": "test", + "timestamps": false, + "autoGenId": false, + "filterTargetKey": "id", + "fields": [ + { + "name": "id", + "type": "integer", + "allowNull": false, + "primaryKey": false, + "unique": false, + "autoIncrement": true, + "possibleTypes": ["integer", "sort"], + "rawType": "INTEGER", + "interface": "integer", + "uiSchema": { + "type": "number", + "x-component": "InputNumber", + "x-component-props": { + "stringMode": true, + "step": "1" + }, + "x-validator": "integer", + "title": "id" + } + }, + { + "name": "title", + "type": "string", + "allowNull": false, + "primaryKey": false, + "unique": false, + "possibleTypes": ["string", "uuid", "nanoid"], + "rawType": "CHARACTER VARYING(255)", + "interface": "input", + "uiSchema": { + "x-component": "Input", + "x-component-props": { + "style": { + "width": "100%" + } + }, + "title": "title" + } + }, + { + "name": "content", + "type": "text", + "allowNull": true, + "primaryKey": false, + "unique": false, + "rawType": "TEXT", + "interface": "textarea", + "uiSchema": { + "type": "string", + "x-component": "Input.TextArea", + "title": "content" + } + } + ], + "introspected": true + }, + { + "name": "test2", + "title": "test2", + "tableName": "test2", + "timestamps": false, + "autoGenId": false, + "filterTargetKey": "id", + "fields": [ + { + "name": "id", + "type": "integer", + "allowNull": false, + "primaryKey": true, + "unique": false, + "autoIncrement": true, + "possibleTypes": ["integer", "sort"], + "rawType": "INTEGER", + "interface": "integer", + "uiSchema": { + "type": "number", + "x-component": "InputNumber", + "x-component-props": { + "stringMode": true, + "step": "1" + }, + "x-validator": "integer", + "title": "id" + } + }, + { + "name": "title", + "type": "string", + "allowNull": true, + "primaryKey": false, + "unique": false, + "possibleTypes": ["string", "uuid", "nanoid"], + "rawType": "CHARACTER VARYING(255)", + "interface": "input", + "uiSchema": { + "x-component": "Input", + "x-component-props": { + "style": { + "width": "100%" + } + }, + "title": "title" + } + }, + { + "name": "content", + "type": "text", + "allowNull": true, + "primaryKey": false, + "unique": false, + "rawType": "TEXT", + "interface": "textarea", + "uiSchema": { + "type": "string", + "x-component": "Input.TextArea", + "title": "content" + } + } + ], + "introspected": true + } + ] +} diff --git a/packages/core/test/src/web/dataSourceMainCollections.json b/packages/core/test/src/web/dataSourceMainCollections.json new file mode 100644 index 0000000000..9d8fc5f6b5 --- /dev/null +++ b/packages/core/test/src/web/dataSourceMainCollections.json @@ -0,0 +1,223 @@ +[ + { + "key": "h7b9i8khc3q", + "name": "users", + "inherit": false, + "hidden": false, + "description": null, + "category": [], + "namespace": "users.users", + "duplicator": { + "dumpable": "optional", + "with": "rolesUsers" + }, + "sortable": "sort", + "model": "UserModel", + "createdBy": true, + "updatedBy": true, + "logging": true, + "from": "db2cm", + "title": "{{t(\"Users\")}}", + "rawTitle": "{{t(\"Users\")}}", + "fields": [ + { + "uiSchema": { + "type": "number", + "title": "{{t(\"ID\")}}", + "x-component": "InputNumber", + "x-read-pretty": true, + "rawTitle": "{{t(\"ID\")}}" + }, + "key": "ffp1f2sula0", + "name": "id", + "type": "bigInt", + "interface": "id", + "description": null, + "collectionName": "users", + "parentKey": null, + "reverseKey": null, + "autoIncrement": true, + "primaryKey": true, + "allowNull": false + }, + { + "uiSchema": { + "type": "string", + "title": "{{t(\"Nickname\")}}", + "x-component": "Input", + "rawTitle": "{{t(\"Nickname\")}}" + }, + "key": "vrv7yjue90g", + "name": "nickname", + "type": "string", + "interface": "input", + "description": null, + "collectionName": "users", + "parentKey": null, + "reverseKey": null + }, + { + "uiSchema": { + "type": "string", + "title": "{{t(\"Username\")}}", + "x-component": "Input", + "x-validator": { + "username": true + }, + "required": true, + "rawTitle": "{{t(\"Username\")}}" + }, + "key": "2ccs6evyrub", + "name": "username", + "type": "string", + "interface": "input", + "description": null, + "collectionName": "users", + "parentKey": null, + "reverseKey": null, + "unique": true + }, + { + "uiSchema": { + "type": "string", + "title": "{{t(\"Email\")}}", + "x-component": "Input", + "x-validator": "email", + "required": true, + "rawTitle": "{{t(\"Email\")}}" + }, + "key": "rrskwjl5wt1", + "name": "email", + "type": "string", + "interface": "email", + "description": null, + "collectionName": "users", + "parentKey": null, + "reverseKey": null, + "unique": true + }, + { + "key": "t09bauwm0wb", + "name": "roles", + "type": "belongsToMany", + "interface": "m2m", + "description": null, + "collectionName": "users", + "parentKey": null, + "reverseKey": null, + "target": "roles", + "foreignKey": "userId", + "otherKey": "roleName", + "onDelete": "CASCADE", + "sourceKey": "id", + "targetKey": "name", + "through": "rolesUsers", + "uiSchema": { + "type": "array", + "title": "{{t(\"Roles\")}}", + "x-component": "AssociationField", + "x-component-props": { + "multiple": true, + "fieldNames": { + "label": "title", + "value": "name" + } + } + } + }, + { + "key": "1pz0art9mt7", + "name": "f_n2fu6hvprct", + "type": "string", + "interface": "select", + "description": null, + "collectionName": "t_vwpds9fs4xs", + "parentKey": null, + "reverseKey": null, + "uiSchema": { + "enum": [ + { + "value": "test1", + "label": "test1" + }, + { + "value": "test2", + "label": "test2" + } + ], + "type": "string", + "x-component": "Select", + "title": "test" + } + } + ] + }, + { + "key": "pqnenvqrzxr", + "name": "roles", + "inherit": false, + "hidden": false, + "description": null, + "category": [], + "namespace": "acl.acl", + "duplicator": { + "dumpable": "required", + "with": "uiSchemas" + }, + "autoGenId": false, + "model": "RoleModel", + "filterTargetKey": "name", + "sortable": true, + "from": "db2cm", + "title": "{{t(\"Roles\")}}", + "rawTitle": "{{t(\"Roles\")}}", + "fields": [ + { + "uiSchema": { + "type": "string", + "title": "{{t(\"Role UID\")}}", + "x-component": "Input", + "rawTitle": "{{t(\"Role UID\")}}" + }, + "key": "jbz9m80bxmp", + "name": "name", + "type": "uid", + "interface": "input", + "description": null, + "collectionName": "roles", + "parentKey": null, + "reverseKey": null, + "prefix": "r_", + "primaryKey": true + }, + { + "uiSchema": { + "type": "string", + "title": "{{t(\"Role name\")}}", + "x-component": "Input", + "rawTitle": "{{t(\"Role name\")}}" + }, + "key": "faywtz4sf3u", + "name": "title", + "type": "string", + "interface": "input", + "description": null, + "collectionName": "roles", + "parentKey": null, + "reverseKey": null, + "unique": true, + "translation": true + }, + { + "key": "1enkovm9sye", + "name": "description", + "type": "string", + "interface": null, + "description": null, + "collectionName": "roles", + "parentKey": null, + "reverseKey": null + } + ] + } +] diff --git a/packages/core/test/src/web/index.tsx b/packages/core/test/src/web/index.tsx new file mode 100644 index 0000000000..5370347fe2 --- /dev/null +++ b/packages/core/test/src/web/index.tsx @@ -0,0 +1,138 @@ +import React, { ComponentType } from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { AxiosInstance } from 'axios'; + +// @ts-ignore +import { Application, ApplicationOptions, DataBlockProvider, LocalDataSource, SchemaComponent } from '@nocobase/client'; + +import dataSourceMainCollections from './dataSourceMainCollections.json'; +import dataSource2 from './dataSource2.json'; +import usersListData from './usersListData.json'; + +type URL = string; +type ResponseData = any; + +type MockApis = Record; +type AppOrOptions = Application | ApplicationOptions; + +export const mockApi = (axiosInstance: AxiosInstance, apis: MockApis = {}) => { + const mock = new MockAdapter(axiosInstance); + Object.keys(apis).forEach((key) => { + mock.onAny(key).reply(200, apis[key]); + }); + + return (apis: MockApis = {}) => { + Object.keys(apis).forEach((key) => { + mock.onAny(key).reply(200, apis[key]); + }); + }; +}; + +export const mockAppApi = (app: Application, apis: MockApis = {}) => { + const mock = mockApi(app.apiClient.axios, apis); + return mock; +}; + +export interface GetAppOptions { + appOptions?: AppOrOptions; + providers?: (ComponentType | [ComponentType, any])[]; + apis?: MockApis; + enableUserListDataBlock?: boolean; + enableMultipleDataSource?: boolean; +} + +export const getApp = (options: GetAppOptions) => { + const { appOptions, enableUserListDataBlock, providers, apis, enableMultipleDataSource } = options; + const app = appOptions instanceof Application ? appOptions : new Application(appOptions); + if (providers) { + app.addProviders(providers); + } + + app.getCollectionManager().addCollections(dataSourceMainCollections as any); + + if (enableUserListDataBlock && !apis['users:list']) { + apis['users:list'] = usersListData; + } + + if (enableMultipleDataSource) { + app.dataSourceManager.addDataSource(LocalDataSource, dataSource2 as any); + } + + mockAppApi(app, apis); + + const App = app.getRootComponent(); + return { + App, + app, + }; +}; + +export interface GetAppComponentOptions { + schema?: any; + appOptions?: AppOrOptions; + apis?: MockApis; + Component?: ComponentType; + value?: V; + props?: Props; + onChange?: (value: V) => void; + enableUserListDataBlock?: boolean; + enableMultipleDataSource?: boolean; +} + +export const getAppComponent = (options: GetAppComponentOptions) => { + const { + Component, + enableUserListDataBlock, + enableMultipleDataSource, + value, + props, + appOptions, + apis, + onChange, + schema: optionsSchema = {}, + } = options; + const schema = { + type: 'object', + name: 'test', + default: value, + 'x-component': Component, + 'x-component-props': { + onChange, + ...props, + }, + ...optionsSchema, + }; + + if (!schema.name) { + schema.name = 'test'; + } + + if (!schema.type) { + schema.type = 'void'; + } + + const TestDemo = () => { + if (!enableUserListDataBlock) { + return ; + } + return ( + + + + ); + }; + + const { App } = getApp({ + appOptions, + apis, + providers: [TestDemo], + enableMultipleDataSource, + enableUserListDataBlock, + }); + + return App; +}; + +export const getReadPrettyAppComponent = (options: GetAppComponentOptions) => { + return getAppComponent({ ...options, schema: { ...(options.schema || {}), 'x-read-pretty': true } }); +}; diff --git a/packages/core/test/src/web/usersListData.json b/packages/core/test/src/web/usersListData.json new file mode 100644 index 0000000000..3fedab4345 --- /dev/null +++ b/packages/core/test/src/web/usersListData.json @@ -0,0 +1,30 @@ +{ + "data": [ + { + "f_o3y6p9gf1gx": null, + "createdAt": "2023-03-30T07:53:10.941Z", + "updatedAt": "2024-04-12T03:27:45.748Z", + "appLang": "zh-CN", + "createdById": null, + "email": "admin@nocobase.com", + "f_2ytvt3phlp2": null, + "f_3jl554hv7lt": null, + "f_51qityssoq1": null, + "f_dybwctlb233": null, + "f_hbegrnglpv2": null, + "f_ndkyrfvh9il": null, + "f_o33xmbd62fj": null, + "f_t52vqdtfv4h": null, + "f_vak0o8efq4v": [], + "id": 1, + "nickname": "Super Admin", + "phone": null, + "systemSettings": { + "theme": "compact", + "themeId": 1 + }, + "updatedById": 1, + "username": "nocobase" + } + ] +} diff --git a/packages/core/test/vitest.mjs b/packages/core/test/vitest.mjs index a9a5e09ce8..5db72435b4 100644 --- a/packages/core/test/vitest.mjs +++ b/packages/core/test/vitest.mjs @@ -86,6 +86,7 @@ const defineCommonConfig = () => { provider: 'istanbul', include: ['packages/**/src/**/*.{ts,tsx}'], exclude: [ + '**/requirejs.ts', '**/demos/**', '**/swagger/**', '**/.dumi/**', @@ -94,6 +95,7 @@ const defineCommonConfig = () => { '**/lib/**', '**/__tests__/**', '**/e2e/**', + '**/__e2e__/**', '**/client.js', '**/server.js', '**/*.d.ts', @@ -149,12 +151,15 @@ const defineClientConfig = () => { export const getFilterInclude = (isServer, isCoverage) => { let filterFileOrDir = process.argv.slice(2).find((arg) => !arg.startsWith('-')); - if (!filterFileOrDir) return; + if (!filterFileOrDir) return {}; const absPath = path.join(process.cwd(), filterFileOrDir); const isDir = fs.existsSync(absPath) && fs.statSync(absPath).isDirectory(); // 如果是文件,则只测试当前文件 if (!isDir) { - return [filterFileOrDir]; + return { + isFile: true, + include: [filterFileOrDir], + }; } const suffix = isCoverage ? `**/*.{ts,tsx}` : `**/__tests__/**/*.{test,spec}.{ts,tsx}`; @@ -162,25 +167,32 @@ export const getFilterInclude = (isServer, isCoverage) => { // 判断是否为包目录,如果不是包目录,则只测试当前目录 const isPackage = fs.existsSync(path.join(absPath, 'package.json')); if (!isPackage) { - return [`${filterFileOrDir}/${suffix}`]; + return { + include: [`${filterFileOrDir}/${suffix}`], + }; } // 判断是否为 core 包目录,不分 client 和 server const isCore = absPath.includes('packages/core'); if (isCore) { - return [`${filterFileOrDir}/src/${suffix}`]; + return { + include: [`${filterFileOrDir}/src/${suffix}`], + }; } // 插件目录,区分 client 和 server - return [`${filterFileOrDir}/src/${isServer ? 'server' : 'client'}/${suffix}`]; + return { + include: [`${filterFileOrDir}/src/${isServer ? 'server' : 'client'}/${suffix}`], + }; }; export const getReportsDirectory = (isServer) => { let filterFileOrDir = process.argv.slice(2).find((arg) => !arg.startsWith('-')); if (!filterFileOrDir) return; + const basePath = `./storage/coverage/`; const isPackage = fs.existsSync(path.join(process.cwd(), filterFileOrDir, 'package.json')); if (isPackage) { - let reportsDirectory = `./storage/coverage/${filterFileOrDir.replace('packages/', '')}`; + let reportsDirectory = `${basePath}${filterFileOrDir.replace('packages/', '')}`; const isCore = filterFileOrDir.includes('packages/core'); @@ -189,6 +201,8 @@ export const getReportsDirectory = (isServer) => { } return reportsDirectory; + } else { + return basePath; } }; @@ -198,17 +212,30 @@ export const defineConfig = () => { mergeConfig(defineCommonConfig(), isServer ? defineServerConfig() : defineClientConfig()), ); + const { isFile, include: filterInclude } = getFilterInclude(isServer); + if (filterInclude) { + config.test.include = filterInclude; + if (isFile) { + // 减少收集的文件 + config.test.root = path.dirname(filterInclude[0]); + config.test.exclude = []; + config.test.coverage = { + enabled: false, + }; + + return config; + } + } + const isCoverage = process.argv.includes('--coverage'); if (!isCoverage) { return config; } - const filterInclude = getFilterInclude(isServer); - if (filterInclude) { - config.test.include = getFilterInclude(isServer); + const { include: coverageInclude } = getFilterInclude(isServer, true); + if (coverageInclude) { + config.test.coverage.include = coverageInclude; } - - config.test.coverage.include = getFilterInclude(isServer, true); const reportsDirectory = getReportsDirectory(isServer); if (reportsDirectory) { config.test.coverage.reportsDirectory = reportsDirectory; diff --git a/packages/core/test/web.d.ts b/packages/core/test/web.d.ts new file mode 100644 index 0000000000..6ee2ab6c74 --- /dev/null +++ b/packages/core/test/web.d.ts @@ -0,0 +1 @@ +export * from './lib/web'; diff --git a/packages/core/test/web.js b/packages/core/test/web.js new file mode 100644 index 0000000000..29a135f81c --- /dev/null +++ b/packages/core/test/web.js @@ -0,0 +1 @@ +module.exports = require('./lib/web'); diff --git a/packages/plugins/@nocobase/plugin-kanban/src/client/KanbanBlockProvider.tsx b/packages/plugins/@nocobase/plugin-kanban/src/client/KanbanBlockProvider.tsx index 76f83119da..22c891b18e 100644 --- a/packages/plugins/@nocobase/plugin-kanban/src/client/KanbanBlockProvider.tsx +++ b/packages/plugins/@nocobase/plugin-kanban/src/client/KanbanBlockProvider.tsx @@ -144,11 +144,11 @@ export const useKanbanBlockProps = () => { useEffect(() => { const data = toColumns(ctx.groupField, ctx?.service?.data?.data, primaryKey); - if (isEqual(field.value, data)) { + if (isEqual(field.value, data) && dataSource === field.value) { return; } field.value = data; - setDataSource(data); + setDataSource(field.value); }, [ctx?.service?.loading]); const disableCardDrag = useDisableCardDrag(); diff --git a/yarn.lock b/yarn.lock index 06b5023a0a..a5e4b4c923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3647,14 +3647,6 @@ bessel "^1.0.2" jstat "^1.9.6" -"@formulajs/formulajs@^4.2.0": - version "4.3.4" - resolved "https://registry.npmmirror.com/@formulajs/formulajs/-/formulajs-4.3.4.tgz#b2a48ab9dd8ad60901ae6b3ca76cfc5416bd27e0" - integrity sha512-iETOopDEUmKaeEUofUfazebhihErh+klyRQbEemDnnBj0hcwNu3xF0oAPHVL0vU/E01rVjy/ZC+1UBMOVkXrvQ== - dependencies: - bessel "^1.0.2" - jstat "^1.9.6" - "@gar/promisify@^1.0.1": version "1.1.3" resolved "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -4873,6 +4865,15 @@ dependencies: "@opentelemetry/semantic-conventions" "1.19.0" +"@opentelemetry/exporter-prometheus@^0.46.0": + version "0.46.0" + resolved "https://registry.npmmirror.com/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.46.0.tgz#c411a1e8a5266f9f3ddc44a088a538c3c1ee4830" + integrity sha512-AXcoCHG31K2PLGizlJJWcfQqZsGfUZkT7ik6C8VJu7U2Cenk0Xhvd3rO+vVNSSjP1+LHkP4MQtqEXpIZttw5cw== + dependencies: + "@opentelemetry/core" "1.19.0" + "@opentelemetry/resources" "1.19.0" + "@opentelemetry/sdk-metrics" "1.19.0" + "@opentelemetry/instrumentation@^0.46.0": version "0.46.0" resolved "https://registry.npmmirror.com/@opentelemetry/instrumentation/-/instrumentation-0.46.0.tgz#a8a252306f82e2eace489312798592a14eb9830e" @@ -4906,7 +4907,7 @@ "@opentelemetry/core" "1.19.0" "@opentelemetry/semantic-conventions" "1.19.0" -"@opentelemetry/sdk-metrics@^1.19.0": +"@opentelemetry/sdk-metrics@1.19.0", "@opentelemetry/sdk-metrics@^1.19.0": version "1.19.0" resolved "https://registry.npmmirror.com/@opentelemetry/sdk-metrics/-/sdk-metrics-1.19.0.tgz#fe8029af29402563eb8dba75a85fc02006ea92c4" integrity sha512-FiMii40zr0Fmys4F1i8gmuCvbinBnBsDeGBr4FQemOf0iPCLytYQm5AZJ/nn4xSc71IgKBQwTFQRAGJI7JvZ4Q== @@ -6471,6 +6472,11 @@ resolved "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.13.tgz#e6e77ea9ecf36564980a861e24e62a095988775e" integrity sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ== +"@types/geojson@^7946.0.14": + version "7946.0.14" + resolved "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" + integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== + "@types/glob-stream@*": version "8.0.2" resolved "https://registry.npmmirror.com/@types/glob-stream/-/glob-stream-8.0.2.tgz#56234435cd20f9b7b08c993be9267d661f9b914d" @@ -6784,6 +6790,13 @@ resolved "https://registry.npmmirror.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^20.11.17": + version "20.12.2" + resolved "https://registry.npmmirror.com/@types/node/-/node-20.12.2.tgz#9facdd11102f38b21b4ebedd9d7999663343d72e" + integrity sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ== + dependencies: + undici-types "~5.26.4" + "@types/nodemailer@6.4.4": version "6.4.4" resolved "https://registry.npmmirror.com/@types/nodemailer/-/nodemailer-6.4.4.tgz#c265f7e7a51df587597b3a49a023acaf0c741f4b" @@ -6818,6 +6831,15 @@ dependencies: "@types/express" "*" +"@types/pg@^8.10.9": + version "8.11.4" + resolved "https://registry.npmmirror.com/@types/pg/-/pg-8.11.4.tgz#befbe4dc0c14aa31acf86bbce83641b73ff838a7" + integrity sha512-yw3Bwbda6vO+NvI1Ue/YKOwtl31AYvvd/e73O3V4ZkNzuGpTDndLSyc0dQRB2xrQqDePd20pEGIfqSp/GH3pRw== + dependencies: + "@types/node" "*" + pg-protocol "*" + pg-types "^4.0.1" + "@types/picomatch@*": version "2.3.3" resolved "https://registry.npmmirror.com/@types/picomatch/-/picomatch-2.3.3.tgz#be60498568c19e989e43fb39aa84be1ed3655e92" @@ -8774,7 +8796,7 @@ aws4@^1.8.0: resolved "https://registry.npmmirror.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== -axios-mock-adapter@^1.20.0: +axios-mock-adapter@1.22.0, axios-mock-adapter@^1.20.0: version "1.22.0" resolved "https://registry.npmmirror.com/axios-mock-adapter/-/axios-mock-adapter-1.22.0.tgz#0f3e6be0fc9b55baab06f2d49c0b71157e7c053d" integrity sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw== @@ -8998,7 +9020,7 @@ bessel@^1.0.2: resolved "https://registry.npmmirror.com/bessel/-/bessel-1.0.2.tgz#828812291e0b62e94959cdea43fac186e8a7202d" integrity sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw== -big-integer@^1.6.44: +big-integer@^1.6.44, big-integer@^1.6.48: version "1.6.52" resolved "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85" integrity sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg== @@ -9008,7 +9030,7 @@ big.js@^5.2.2: resolved "https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== -bignumber.js@^9.0.0: +bignumber.js@^9.0.0, bignumber.js@^9.1.2: version "9.1.2" resolved "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c" integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug== @@ -11829,7 +11851,7 @@ delegates@^1.0.0: resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== -denque@^2.0.1: +denque@^2.0.1, denque@^2.1.0: version "2.1.0" resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== @@ -16424,6 +16446,11 @@ jest-worker@^29.7.0: merge-stream "^2.0.0" supports-color "^8.0.0" +jmespath@^0.16.0: + version "0.16.0" + resolved "https://registry.npmmirror.com/jmespath/-/jmespath-0.16.0.tgz#b15b0a85dfd4d930d43e69ed605943c802785076" + integrity sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw== + jose@^4.15.1: version "4.15.4" resolved "https://registry.npmmirror.com/jose/-/jose-4.15.4.tgz#02a9a763803e3872cf55f29ecef0dfdcc218cc03" @@ -16618,6 +16645,11 @@ json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: resolved "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonata@^2.0.3: + version "2.0.4" + resolved "https://registry.npmmirror.com/jsonata/-/jsonata-2.0.4.tgz#d8575d4c7695a2086162a2baebce0f0ee4e84fdb" + integrity sha512-vfavX4/G/yrYxE+UrmT/oUJ3ph7KqUrb0R7b0LVRcntQwxw+Z5kA1pNUIQzX5hF04Oe1eKxyoIPsmXtc2LgJTQ== + jsonc-parser@^3.2.0: version "3.2.0" resolved "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" @@ -16651,6 +16683,11 @@ jsonparse@^1.2.0, jsonparse@^1.3.1: resolved "https://registry.npmmirror.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jsonpath-plus@^7.2.0: + version "7.2.0" + resolved "https://registry.npmmirror.com/jsonpath-plus/-/jsonpath-plus-7.2.0.tgz#7ad94e147b3ed42f7939c315d2b9ce490c5a3899" + integrity sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA== + jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -17422,7 +17459,7 @@ long@^4.0.0: resolved "https://registry.npmmirror.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.2.0: +long@^5.2.0, long@^5.2.1: version "5.2.3" resolved "https://registry.npmmirror.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1" integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q== @@ -17468,7 +17505,7 @@ lowercase-keys@^2.0.0: resolved "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru-cache@8.0.5: +lru-cache@8.0.5, lru-cache@^8.0.0: version "8.0.5" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e" integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA== @@ -17478,6 +17515,11 @@ lru-cache@^10.0.2: resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484" integrity sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag== +lru-cache@^10.2.0: + version "10.2.0" + resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lru-cache@^4.0.1, lru-cache@^4.1.1: version "4.1.5" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -17689,6 +17731,17 @@ mariadb@^2.5.6: moment-timezone "^0.5.34" please-upgrade-node "^3.2.0" +mariadb@^3.3.0: + version "3.3.0" + resolved "https://registry.npmmirror.com/mariadb/-/mariadb-3.3.0.tgz#e9c844965d616a1842fa05221206e8678842c09e" + integrity sha512-sAL4bJgbfCAtXcE8bXI+NAMzVaPNkIU8hRZUXYfgNFoWB9U57G3XQiMeCx/A6IrS6y7kGwBLylrwgsZQ8kUYlw== + dependencies: + "@types/geojson" "^7946.0.14" + "@types/node" "^20.11.17" + denque "^2.1.0" + iconv-lite "^0.6.3" + lru-cache "^10.2.0" + markdown-it-highlightjs@3.3.1: version "3.3.1" resolved "https://registry.npmmirror.com/markdown-it-highlightjs/-/markdown-it-highlightjs-3.3.1.tgz#38403610487292b8a1ae2d1acc7bb66e4ede6be8" @@ -18858,6 +18911,20 @@ mysql2@^2.3.3: seq-queue "^0.0.5" sqlstring "^2.3.2" +mysql2@^3.9.1: + version "3.9.3" + resolved "https://registry.npmmirror.com/mysql2/-/mysql2-3.9.3.tgz#72a5e0c90d78ec2d8f9846e83727067c0cc8c25e" + integrity sha512-+ZaoF0llESUy7BffccHG+urErHcWPZ/WuzYAA9TEeLaDYyke3/3D+VQDzK9xzRnXpd0eMtRf0WNOeo4Q1Baung== + dependencies: + denque "^2.1.0" + generate-function "^2.3.1" + iconv-lite "^0.6.3" + long "^5.2.1" + lru-cache "^8.0.0" + named-placeholders "^1.1.3" + seq-queue "^0.0.5" + sqlstring "^2.3.2" + mz@^2.7.0: version "2.7.0" resolved "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -18867,7 +18934,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -named-placeholders@^1.1.2: +named-placeholders@^1.1.2, named-placeholders@^1.1.3: version "1.1.3" resolved "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.3.tgz#df595799a36654da55dda6152ba7a137ad1d9351" integrity sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w== @@ -19099,6 +19166,13 @@ node-releases@^2.0.14: resolved "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== +node-sql-parser@^4.11.0: + version "4.18.0" + resolved "https://registry.npmmirror.com/node-sql-parser/-/node-sql-parser-4.18.0.tgz#516b6e633c55c5abbba1ca588ab372db81ae9318" + integrity sha512-2YEOR5qlI1zUFbGMLKNfsrR5JUvFg9LxIRVE+xJe962pfVLH0rnItqLzv96XVs1Y1UIR8FxsXAuvX/lYAWZ2BQ== + dependencies: + big-integer "^1.6.48" + node-xlsx@^0.16.1: version "0.16.2" resolved "https://registry.npmmirror.com/node-xlsx/-/node-xlsx-0.16.2.tgz#40f580187eae0e032cac96e958e97cb6ceca09f6" @@ -19562,7 +19636,7 @@ object.values@^1.1.6, object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -obuf@^1.0.0, obuf@^1.1.2: +obuf@^1.0.0, obuf@^1.1.2, obuf@~1.1.2: version "1.1.2" resolved "https://registry.npmmirror.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== @@ -20391,11 +20465,21 @@ pg-int8@1.0.1: resolved "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== +pg-numeric@1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/pg-numeric/-/pg-numeric-1.0.2.tgz#816d9a44026086ae8ae74839acd6a09b0636aa3a" + integrity sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw== + pg-pool@^3.6.1: version "3.6.1" resolved "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.6.1.tgz#5a902eda79a8d7e3c928b77abf776b3cb7d351f7" integrity sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og== +pg-protocol@*: + version "1.6.1" + resolved "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.6.1.tgz#21333e6d83b01faaebfe7a33a7ad6bfd9ed38cb3" + integrity sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg== + pg-protocol@^1.6.0: version "1.6.0" resolved "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.6.0.tgz#4c91613c0315349363af2084608db843502f8833" @@ -20412,6 +20496,19 @@ pg-types@^2.1.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" +pg-types@^4.0.1: + version "4.0.2" + resolved "https://registry.npmmirror.com/pg-types/-/pg-types-4.0.2.tgz#399209a57c326f162461faa870145bb0f918b76d" + integrity sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng== + dependencies: + pg-int8 "1.0.1" + pg-numeric "1.0.2" + postgres-array "~3.0.1" + postgres-bytea "~3.0.0" + postgres-date "~2.1.0" + postgres-interval "^3.0.0" + postgres-range "^1.1.1" + pg@^8.11.3, pg@^8.7.3: version "8.11.3" resolved "https://registry.npmmirror.com/pg/-/pg-8.11.3.tgz#d7db6e3fe268fcedd65b8e4599cda0b8b4bf76cb" @@ -21010,16 +21107,33 @@ postgres-array@~2.0.0: resolved "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== +postgres-array@~3.0.1: + version "3.0.2" + resolved "https://registry.npmmirror.com/postgres-array/-/postgres-array-3.0.2.tgz#68d6182cb0f7f152a7e60dc6a6889ed74b0a5f98" + integrity sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog== + postgres-bytea@~1.0.0: version "1.0.0" resolved "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" integrity sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w== +postgres-bytea@~3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-3.0.0.tgz#9048dc461ac7ba70a6a42d109221619ecd1cb089" + integrity sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw== + dependencies: + obuf "~1.1.2" + postgres-date@~1.0.4: version "1.0.7" resolved "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz#51bc086006005e5061c591cee727f2531bf641a8" integrity sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q== +postgres-date@~2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/postgres-date/-/postgres-date-2.1.0.tgz#b85d3c1fb6fb3c6c8db1e9942a13a3bf625189d0" + integrity sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA== + postgres-interval@^1.1.0: version "1.2.0" resolved "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" @@ -21027,6 +21141,16 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" +postgres-interval@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-3.0.0.tgz#baf7a8b3ebab19b7f38f07566c7aab0962f0c86a" + integrity sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw== + +postgres-range@^1.1.1: + version "1.1.4" + resolved "https://registry.npmmirror.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" + integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -23321,6 +23445,28 @@ sequelize@^6.26.0: validator "^13.9.0" wkx "^0.5.0" +sequelize@^6.35.0: + version "6.37.2" + resolved "https://registry.npmmirror.com/sequelize/-/sequelize-6.37.2.tgz#f98052f81c40c26ba85382fcb35e7346308542f4" + integrity sha512-bnb7swGANONXCTrVyebpOOZssLwQrVkYX2tcC6qOIvH+P+OhsoMBi7c3GXI5bC+Z4b4tOl+kQy6yeqLCZ1YQAQ== + dependencies: + "@types/debug" "^4.1.8" + "@types/validator" "^13.7.17" + debug "^4.3.4" + dottie "^2.0.6" + inflection "^1.13.4" + lodash "^4.17.21" + moment "^2.29.4" + moment-timezone "^0.5.43" + pg-connection-string "^2.6.1" + retry-as-promised "^7.0.4" + semver "^7.5.4" + sequelize-pool "^7.1.0" + toposort-class "^1.0.1" + uuid "^8.3.2" + validator "^13.9.0" + wkx "^0.5.0" + serve-handler@6.1.3: version "6.1.3" resolved "https://registry.npmmirror.com/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8"