feat: plugin mobile v2 (#4777)

* feat: init

* fix: mobile layout

* feat: more code

* feat: improve navigate bar

* fix: mobile title

* feat: improve code

* fix: add settings and initailzer

* fix: settings

* fix: tabbar items settings

* feat: tabbar initializer

* fix: api

* fix: styles

* feat: navbar

* feat: navigate bar tabs initializer

* feat: navigate bar tab settings

* feat: navigation bar actions

* fix: bug

* fix: bug

* fix: bug

* fix: tabbar active

* fix: bug

* fix: mobile login and layout

* fix: update version

* fix: build error

* feat: plugin settings support link

* fix: add mobile meta

* fix: desktop mode

* fix: remove old code and change collection name and mobile path

* fix: tabbar and tabs initialer layout

* fix: initializer style

* fix: adjust schema position

* fix: mobile style

* fix: delete relation resource and home page bug

* fix: support multi app

* fix: not found page

* fix: js bridge

* fix: bug

* fix: navigation bar schema flat

* fix: navigation bar action style

* fix: change version

* fix: mobile meta and real mobile test

* refactor: folder and name

* fix: navigation bar sticky and zIndex

* fix: full mobile schema

* fix: mobile readme and package.json

* fix: e2e bug

* fix: bug

* fix: tabbar style on productino

* fix: bug

* fix: rename MobileTabBar.Page

* fix: support tabbar sort

* fix: support  page tabs sort

* fix: i18n

* fix: settings utils import bug

* docs: api doc

* fix: qrcode refresh

* test: unit tests

* fix: bug

* fix: unit test

* fix: build bug

* fix: e2e test

* fix: overflow scroll

* fix: bug

* fix: scroll and overflow

* fix: bug

* fix: e2e expect await

* fix: e2e bug

* fix: bug

* fix: change name

* fix: add more e2e

* fix: page header

* fix: tab support icon

* fix: bug

* fix: bug

* fix: docs

* fix(T-4811): scroll bar too long

* fix(T-4810): desktop mode

* fix: e2e

* fix(T-4812): title empty

* fix: unit test

* feat: hide Open mode option in mobile mode

* feat: change default value of Open mode on mobile

* feat: add OpenModeProvider

* feat: support page mode

* fix: fix build

* test: update unit tests

* chore: remove pro-plugins

* fix: bug

* fix(T-4812): title is required

* fix: bug

* fix: bug

* fix: bug

* fix: bug

* refactor: remove z-index

* refactor: make better for subpages

* fix: drag bug

* fix: bug

* fix: theme bug

* fix(T-4859): create tab bar title empty

* fix(T-4857): action too long

* fix: e2e bug

* fix: remove comment

* fix: bug

* fix: theme bug

* fix: should provider modal component

* fix: bug

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
Co-authored-by: Zeke Zhang <958414905@qq.com>
This commit is contained in:
jack zhang 2024-07-22 14:06:36 +08:00 committed by GitHub
parent 01477986ee
commit 61e9dd5cc1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
246 changed files with 10854 additions and 200 deletions

1
.gitignore vendored
View File

@ -28,6 +28,7 @@ storage/backups/*
**/.dumi/tmp-production
packages/core/client/docs/contributing.md
packages/core/app/client/src/.plugins
packages/pro-plugins/
storage/plugins
storage/tar
storage/tmp

View File

@ -59,7 +59,11 @@ export function AntdConfigProvider(props) {
},
);
if (loading) {
return <Spin />;
return (
<div style={{ textAlign: 'center', marginTop: 20 }}>
<Spin />
</div>
);
}
return (
<AppLangContext.Provider value={data?.data}>

View File

@ -39,6 +39,7 @@ import { DataSourceApplicationProvider } from '../data-source/components/DataSou
import { DataBlockProvider } from '../data-source/data-block/DataBlockProvider';
import { DataSourceManager, type DataSourceManagerOptions } from '../data-source/data-source/DataSourceManager';
import { OpenModeProvider } from '../modules/popup/OpenModeProvider';
import { AppSchemaComponentProvider } from './AppSchemaComponentProvider';
import type { Plugin } from './Plugin';
import type { RequireJS } from './utils/requirejs';
@ -158,6 +159,7 @@ export class Application {
});
this.use(AntdAppProvider);
this.use(DataSourceApplicationProvider, { dataSourceManager: this.dataSourceManager });
this.use(OpenModeProvider);
}
private addReactRouterComponents() {

View File

@ -12,7 +12,7 @@ import type { Application } from './Application';
export class Plugin<T = any> {
constructor(
protected options: T,
public options: T,
protected app: Application,
) {
this.options = options;

View File

@ -12,7 +12,7 @@ import type { Plugin } from './Plugin';
import { getPlugins } from './utils/remotePlugins';
export type PluginOptions<T = any> = { name?: string; packageName?: string; config?: T };
export type PluginType<Opts = any> = typeof Plugin | [typeof Plugin, PluginOptions<Opts>];
export type PluginType<Opts = any> = typeof Plugin | [typeof Plugin<Opts>, PluginOptions<Opts>];
export type PluginData = {
name: string;
packageName: string;

View File

@ -32,12 +32,14 @@ export interface PluginSettingOptions {
*/
sort?: number;
aclSnippet?: string;
link?: string;
[index: string]: any;
}
export interface PluginSettingsPageType {
label?: string | React.ReactElement;
title: string | React.ReactElement;
link?: string;
key: string;
icon: any;
path: string;

View File

@ -13,6 +13,7 @@ import MockAdapter from 'axios-mock-adapter';
import React, { Component } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { describe } from 'vitest';
import { OpenModeProvider } from '../../modules/popup/OpenModeProvider';
import { Application } from '../Application';
import { Plugin } from '../Plugin';
import { useApp } from '../hooks';
@ -211,6 +212,7 @@ describe('Application', () => {
it('initial', () => {
const app = new Application({ router, providers: [Hello, [World, { name: 'aaa' }]] });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[OpenModeProvider, undefined],
[Hello, undefined],
[World, { name: 'aaa' }],
]);
@ -220,6 +222,7 @@ describe('Application', () => {
const app = new Application({ router, providers: [Hello] });
app.addProviders([[World, { name: 'aaa' }], Foo]);
expect(app.providers.slice(initialProvidersLength)).toEqual([
[OpenModeProvider, undefined],
[Hello, undefined],
[World, { name: 'aaa' }],
[Foo, undefined],
@ -230,6 +233,7 @@ describe('Application', () => {
const app = new Application({ router, providers: [Hello] });
app.addProvider(World, { name: 'aaa' });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[OpenModeProvider, undefined],
[Hello, undefined],
[World, { name: 'aaa' }],
]);
@ -239,6 +243,7 @@ describe('Application', () => {
const app = new Application({ router, providers: [Hello] });
app.use(World, { name: 'aaa' });
expect(app.providers.slice(initialProvidersLength)).toEqual([
[OpenModeProvider, undefined],
[Hello, undefined],
[World, { name: 'aaa' }],
]);

View File

@ -10,7 +10,7 @@
import { Plugin } from '../Plugin';
import { useApp } from './useApp';
export function usePlugin<T extends typeof Plugin>(plugin: T): InstanceType<T>;
export function usePlugin<T extends typeof Plugin = any>(plugin: T): InstanceType<T>;
export function usePlugin<T extends {}>(name: string): T;
export function usePlugin(name: any) {
const app = useApp();

View File

@ -18,10 +18,8 @@ export * from './globalType';
export * from './hooks';
export * from './schema-initializer';
export * from './schema-settings';
export * from './schema-settings/utils';
export * from './schema-settings/context/SchemaSettingItemContext';
export * from './schema-settings/hooks/useSchemaSettingsRender';
export * from './schema-settings/utils/createModalSettingsItem';
export * from './schema-settings/utils/createSelectSettingsItem';
export * from './schema-settings/utils/createSwitchSettingsItem';
export * from './schema-toolbar';
export * from './utils';

View File

@ -10,7 +10,7 @@
import { useForm } from '@formily/react';
import React, { FC, useCallback, useMemo } from 'react';
import { useActionContext, SchemaComponent } from '../../../schema-component';
import { useSchemaInitializerItem } from '../context';
import { useSchemaInitializer, useSchemaInitializerItem } from '../context';
import { SchemaInitializerItem } from './SchemaInitializerItem';
import { uid } from '@formily/shared';
@ -19,10 +19,12 @@ export interface SchemaInitializerActionModalProps {
icon?: string | React.ReactNode;
schema: any;
onCancel?: () => void;
onSubmit?: (values: any) => void;
onSubmit?: (values: any) => Promise<any> | void;
buttonText?: any;
component?: any;
isItem?: boolean;
width?: string;
btnStyles?: React.CSSProperties;
}
const SchemaInitializerActionModalItemComponent = React.forwardRef((props: any, ref: any) => {
@ -31,7 +33,8 @@ const SchemaInitializerActionModalItemComponent = React.forwardRef((props: any,
});
export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps> = (props) => {
const { title, icon, schema, buttonText, isItem, component, onCancel, onSubmit } = props;
const { title, icon, width, schema, buttonText, btnStyles, isItem, component, onCancel, onSubmit } = props;
const { setVisible: initializerSetVisible } = useSchemaInitializer();
const useCancelAction = useCallback(() => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const form = useForm();
@ -54,9 +57,13 @@ export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps>
return {
async run() {
await form.validate();
await onSubmit?.(form.values);
ctx.setVisible(false);
void form.reset();
try {
await onSubmit?.(form.values);
ctx.setVisible(false);
void form.reset();
} catch (err) {
console.error(err);
}
},
};
}, [onSubmit]);
@ -92,6 +99,7 @@ export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps>
style: {
borderColor: 'var(--colorSettings)',
color: 'var(--colorSettings)',
...(btnStyles || {}),
},
title: buttonText,
type: 'dashed',
@ -101,10 +109,14 @@ export const SchemaInitializerActionModal: FC<SchemaInitializerActionModalProps>
'x-decorator': 'Form',
'x-component': 'Action.Modal',
'x-component-props': {
width: width,
style: {
maxWidth: '520px',
maxWidth: width ? width : '520px',
width: '100%',
},
afterOpenChange: () => {
initializerSetVisible(false);
},
},
type: 'void',
title,

View File

@ -31,23 +31,26 @@ export const SchemaInitializerButton: FC<SchemaInitializerButtonProps> = React.m
style={{
borderColor: 'var(--colorSettings)',
color: 'var(--colorSettings)',
flex: 'none',
...style,
}}
icon={typeof options.icon === 'string' ? <Icon type={options.icon as string} /> : options.icon}
{...others}
>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
width: options.icon ? 'calc(100% - 14px)' : '100%',
verticalAlign: 'bottom',
}}
>
{compile(options.title)}
</span>
{options.title && (
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'inline-block',
width: options.icon ? 'calc(100% - 14px)' : '100%',
verticalAlign: 'bottom',
}}
>
{compile(options.title)}
</span>
)}
</Button>
);
});

View File

@ -21,7 +21,7 @@ import type {
export type InsertType = (s: ISchema) => void;
type SchemaInitializerItemBuiltInType<T = {}> = T & {
type SchemaInitializerItemBuiltInType<T = {}> = Partial<T> & {
name: string;
sort?: number;
componentProps?: Omit<T, 'children'>;
@ -110,6 +110,7 @@ export interface SchemaInitializerOptions<P1 = ButtonProps, P2 = {}> {
insertPosition?: 'beforeBegin' | 'afterBegin' | 'beforeEnd' | 'afterEnd';
designable?: boolean;
wrap?: (s: ISchema, options?: any) => ISchema;
useWrap?: () => ((s: ISchema, options?: any) => ISchema);
onSuccess?: (data: any) => void;
insert?: InsertType;
useInsert?: () => InsertType;

View File

@ -21,6 +21,7 @@ import { SchemaInitializerOptions } from './types';
import { ErrorBoundary } from 'react-error-boundary';
const defaultWrap = (s: ISchema) => s;
const useWrapDefault = (wrap = defaultWrap) => wrap;
export function withInitializer<T>(C: ComponentType<T>) {
const WithInitializer = observer(
@ -30,6 +31,7 @@ export function withInitializer<T>(C: ComponentType<T>) {
const {
insert,
useInsert,
useWrap = useWrapDefault,
wrap = defaultWrap,
insertPosition = 'beforeEnd',
onSuccess,
@ -43,15 +45,16 @@ export function withInitializer<T>(C: ComponentType<T>) {
// 插入 schema 的能力
const insertCallback = useInsert ? useInsert() : insert;
const wrapCallback = useWrap(wrap);
const insertSchema = useCallback(
(schema) => {
if (insertCallback) {
insertCallback(wrap(schema, { isInSubTable }));
insertCallback(wrapCallback(schema, { isInSubTable }));
} else {
insertAdjacent(insertPosition, wrap(schema, { isInSubTable }), { onSuccess });
insertAdjacent(insertPosition, wrapCallback(schema, { isInSubTable }), { onSuccess });
}
},
[insertCallback, wrap, insertAdjacent, insertPosition, onSuccess],
[insertCallback, wrapCallback, insertAdjacent, insertPosition, onSuccess],
);
const { wrapSSR, hashId, componentCls } = useSchemaInitializerStyles();
@ -105,7 +108,7 @@ export function withInitializer<T>(C: ComponentType<T>) {
{...popoverProps}
arrow={false}
overlayClassName={overlayClassName}
open={visible}
open={visible}
onOpenChange={setVisible}
content={wrapSSR(
<div

View File

@ -7,22 +7,26 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaSettingsItemType, useCompile, useDesignable } from '@nocobase/client';
import { ISchema, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import { ISchema, useFieldSchema } from '@formily/react';
import { TFunction, useTranslation } from 'react-i18next';
import { SchemaSettingsItemType } from '../types';
import { getNewSchema, useHookDefault } from './util';
import { useCompile } from '../../../schema-component/hooks/useCompile';
import { useDesignable } from '../../../schema-component/hooks/useDesignable';
export interface CreateModalSchemaSettingsItemProps {
name: string;
title: string | ((t: TFunction<'translation', undefined>) => string);
parentSchemaKey: string;
parentSchemaKey?: string;
defaultValue?: any;
useDefaultValue?: () => any;
schema: (defaultValue: any) => ISchema;
valueKeys?: string[];
useVisible?: () => boolean;
width?: number | string;
useSubmit?: () => (values: any) => void;
}
/**
@ -38,9 +42,11 @@ export function createModalSettingsItem(options: CreateModalSchemaSettingsItemPr
valueKeys,
schema,
title,
useSubmit = useHookDefault,
useVisible,
defaultValue: propsDefaultValue,
useDefaultValue = useHookDefault,
width,
} = options;
return {
name,
@ -50,15 +56,18 @@ export function createModalSettingsItem(options: CreateModalSchemaSettingsItemPr
const fieldSchema = useFieldSchema();
const { deepMerge } = useDesignable();
const defaultValue = useDefaultValue(propsDefaultValue);
const values = _.get(fieldSchema, parentSchemaKey);
const values = parentSchemaKey ? _.get(fieldSchema, parentSchemaKey) : undefined;
const compile = useCompile();
const { t } = useTranslation();
const onSubmit = useSubmit();
return {
title: typeof title === 'function' ? title(t) : compile(title),
width,
schema: schema({ ...defaultValue, ...values }),
onSubmit(values) {
deepMerge(getNewSchema({ fieldSchema, schemaKey: parentSchemaKey, value: values, valueKeys }));
deepMerge(getNewSchema({ fieldSchema, parentSchemaKey, value: values, valueKeys }));
return onSubmit?.(values);
},
};
},

View File

@ -9,10 +9,14 @@
import _ from 'lodash';
import { useFieldSchema } from '@formily/react';
import { SchemaSettingsItemType, SelectProps, useCompile, useDesignable } from '@nocobase/client';
import { getNewSchema, useHookDefault } from './util';
import { TFunction, useTranslation } from 'react-i18next';
import { SchemaSettingsItemType } from '../types';
import { getNewSchema, useHookDefault } from './util';
import { SelectProps } from '../../../schema-component/antd/select';
import { useCompile } from '../../../schema-component/hooks/useCompile';
import { useDesignable } from '../../../schema-component/hooks/useDesignable';
interface CreateSelectSchemaSettingsItemProps {
name: string;
title: string | ((t: TFunction<'translation', undefined>) => string);

View File

@ -7,13 +7,15 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { SchemaSettingsItemType, useCompile, useDesignable } from '@nocobase/client';
import { useFieldSchema } from '@formily/react';
import _ from 'lodash';
import { getNewSchema, useHookDefault } from './util';
import { useFieldSchema } from '@formily/react';
import { TFunction, useTranslation } from 'react-i18next';
import { SchemaSettingsItemType } from '../types';
import { getNewSchema, useHookDefault } from './util';
import { useCompile } from '../../../schema-component/hooks/useCompile';
import { useDesignable } from '../../../schema-component/hooks/useDesignable';
export interface CreateSwitchSchemaSettingsItemProps {
name: string;
title: string | ((t: TFunction<'translation', undefined>) => string);

View File

@ -0,0 +1,39 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { TFunction, useTranslation } from 'react-i18next';
import { useHookDefault } from './util';
import { SchemaSettingsItemType } from '../types';
import { useCompile } from '../../../schema-component/hooks/useCompile';
export interface CreateTextSchemaSettingsItemProps {
name: string;
useVisible?: () => boolean;
title: string | ((t: TFunction<'translation', undefined>) => string);
useTextClick: () => void;
}
export function createTextSettingsItem(options: CreateTextSchemaSettingsItemProps): SchemaSettingsItemType {
const { name, useVisible, title, useTextClick = useHookDefault } = options;
return {
name,
type: 'item',
useVisible,
useComponentProps() {
const compile = useCompile();
const { t } = useTranslation();
const onClick = useTextClick();
return {
title: typeof title === 'function' ? title(t) : compile(title),
onClick,
};
},
};
}

View File

@ -0,0 +1,13 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export * from './createModalSettingsItem';
export * from './createSelectSettingsItem';
export * from './createSwitchSettingsItem';
export * from './createTextSettingsItem';

View File

@ -12,28 +12,24 @@ import _ from 'lodash';
type IGetNewSchema = {
fieldSchema: ISchema;
schemaKey: string;
schemaKey?: string;
parentSchemaKey?: string;
value: any;
valueKeys?: string[];
};
export function getNewSchema(options: IGetNewSchema) {
const { fieldSchema, schemaKey, value, valueKeys } = options as any;
const schemaKeyArr = schemaKey.split('.');
const clonedSchema = _.cloneDeep(fieldSchema[schemaKeyArr[0]]);
const { fieldSchema, schemaKey, value, parentSchemaKey, valueKeys } = options;
if (value != undefined && typeof value === 'object') {
Object.keys(value).forEach((key) => {
if (valueKeys && !valueKeys.includes(key)) return;
_.set(clonedSchema, `${schemaKeyArr.slice(1)}.${key}`, value[key]);
_.set(fieldSchema, `${parentSchemaKey}.${key}`, value[key]);
});
} else {
_.set(clonedSchema, schemaKeyArr.slice(1), value);
_.set(fieldSchema, schemaKey, value);
}
return {
'x-uid': fieldSchema['x-uid'],
[schemaKeyArr[0]]: clonedSchema,
};
return fieldSchema;
}
export const useHookDefault = (defaultValues?: any) => defaultValues;

View File

@ -7,6 +7,7 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { css } from '@emotion/css';
import { Field, Form } from '@formily/core';
import { SchemaExpressionScopeContext, useField, useFieldSchema, useForm } from '@formily/react';
import { untracked } from '@formily/reactive';
@ -21,7 +22,6 @@ import { ChangeEvent, useCallback, useContext, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next';
import { NavigateFunction } from 'react-router-dom';
import { useReactToPrint } from 'react-to-print';
import { css } from '@emotion/css';
import {
AssociationFilter,
useCollection,
@ -1582,12 +1582,13 @@ export const useParseURLAndParams = () => {
return { parseURLAndParams };
};
export function useLinkActionProps() {
export function useLinkActionProps(componentProps?: any) {
const navigate = useNavigateNoUpdate();
const fieldSchema = useFieldSchema();
const componentPropsValue = fieldSchema?.['x-component-props'] || componentProps;
const { t } = useTranslation();
const url = fieldSchema?.['x-component-props']?.['url'];
const searchParams = fieldSchema?.['x-component-props']?.['params'] || [];
const url = componentPropsValue?.['url'];
const searchParams = componentPropsValue?.['params'] || [];
const openInNewWindow = fieldSchema?.['x-component-props']?.['openInNewWindow'];
const { parseURLAndParams } = useParseURLAndParams();

View File

@ -17,3 +17,4 @@ export * from './TableFieldProvider';
export * from './TableSelectorProvider';
export * from './DetailsBlockProvider';
export * from './hooks';
export { useLinkActionProps } from './hooks/index';

View File

@ -9,7 +9,7 @@
import { ConfigProvider, theme as antdTheme } from 'antd';
import _ from 'lodash';
import React, { createContext, useCallback, useMemo, useRef } from 'react';
import React, { createContext, FC, useCallback, useMemo, useRef } from 'react';
import compatOldTheme from './compatOldTheme';
import { addCustomAlgorithmToTheme } from './customAlgorithm';
import defaultTheme from './defaultTheme';
@ -41,7 +41,11 @@ export const useGlobalTheme = () => {
return React.useContext(GlobalThemeContext) || ({ theme: {}, isDarkTheme: false } as GlobalThemeContextProps);
};
export const GlobalThemeProvider = ({ children, theme: themeFromProps }) => {
interface GlobalThemeProviderProps {
theme?: ThemeConfig;
}
export const GlobalThemeProvider: FC<GlobalThemeProviderProps> = ({ children, theme: themeFromProps }) => {
const [theme, setTheme] = React.useState<ThemeConfig>(themeFromProps || defaultTheme);
const currentSettingThemeRef = useRef<ThemeConfig>(null);
const currentEditingThemeRef = useRef<ThemeItem>(null);

View File

@ -70,5 +70,6 @@ export * from './modules/blocks/data-blocks/table';
export * from './modules/blocks/data-blocks/table-selector';
export * from './modules/blocks/index';
export * from './modules/blocks/useParentRecordCommon';
export { OpenModeProvider, useOpenModeContext } from './modules/popup/OpenModeProvider';
export { VariablePopupRecordProvider } from './modules/variable/variablesProvider/VariablePopupRecordProvider';

View File

@ -11,7 +11,9 @@ import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const CreateChildInitializer = (props) => {
const { defaultOpenMode } = useOpenModeContext();
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
@ -22,7 +24,7 @@ export const CreateChildInitializer = (props) => {
'x-component': 'Action',
'x-visible': '{{treeTable}}',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
type: 'link',
addChild: true,
style: { height: 'auto', lineHeight: 'normal' },

View File

@ -7,14 +7,14 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { useFieldSchema } from '@formily/react';
import { useMemo } from 'react';
import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { useCollection_deprecated, useCollectionManager_deprecated } from '../../../collection-manager';
import { ButtonEditor } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsLinkageRules, SchemaSettingsEnableChildCollections } from '../../../schema-settings';
import { SchemaSettingsEnableChildCollections, SchemaSettingsLinkageRules } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const addChildActionSettings = new SchemaSettings({
name: 'actionSettings:addChild',
@ -42,9 +42,12 @@ export const addChildActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -12,8 +12,10 @@ import { useSchemaInitializerItem } from '../../../application';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const CreateActionInitializer = () => {
const { defaultOpenMode } = useOpenModeContext();
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
@ -25,7 +27,7 @@ export const CreateActionInitializer = () => {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
type: 'primary',
component: 'CreateRecordAction',
icon: 'PlusOutlined',

View File

@ -14,6 +14,7 @@ import { useCollection_deprecated, useCollectionManager_deprecated } from '../..
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsEnableChildCollections } from '../../../schema-settings/SchemaSettings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const addNewActionSettings = new SchemaSettings({
name: 'actionSettings:addNew',
@ -29,9 +30,12 @@ export const addNewActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -11,6 +11,7 @@ import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const customizeAddRecordActionSettings = new SchemaSettings({
name: 'actionSettings:addRecord',
@ -26,9 +27,12 @@ export const customizeAddRecordActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -9,7 +9,7 @@
import { ArrayItems } from '@formily/antd-v5';
import { useField, useFieldSchema } from '@formily/react';
import _ from 'lodash';
import React from 'react';
import React, { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { useCollectionRecord, useDesignable } from '../../../';
import { useSchemaToolbar } from '../../../application';
@ -19,7 +19,11 @@ import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/actio
import { SchemaSettingsLinkageRules, SchemaSettingsModalItem } from '../../../schema-settings';
import { useURLAndHTMLSchema } from './useURLAndHTMLSchema';
export function SchemaSettingsActionLinkItem() {
interface SchemaSettingsActionLinkItemProps {
afterSubmit?: () => void;
}
export const SchemaSettingsActionLinkItem: FC<SchemaSettingsActionLinkItemProps> = ({ afterSubmit }) => {
const field = useField();
const fieldSchema = useFieldSchema();
const { dn } = useDesignable();
@ -66,11 +70,12 @@ export function SchemaSettingsActionLinkItem() {
},
});
dn.refresh();
afterSubmit?.();
}}
initialValues={initialValues}
/>
);
}
};
export const customizeLinkActionSettings = new SchemaSettings({
name: 'actionSettings:link',

View File

@ -12,8 +12,10 @@ import { useSchemaInitializerItem } from '../../../application';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { BlockInitializer } from '../../../schema-initializer/items';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const PopupActionInitializer = (props) => {
const { defaultOpenMode } = useOpenModeContext();
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
@ -23,7 +25,7 @@ export const PopupActionInitializer = (props) => {
'x-settings': 'actionSettings:popup',
'x-component': props?.['x-component'] || 'Action.Link',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
refreshDataBlockRequest: true,
},
properties: {

View File

@ -11,8 +11,10 @@ import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const UpdateActionInitializer = (props) => {
const { defaultOpenMode } = useOpenModeContext();
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
@ -22,7 +24,7 @@ export const UpdateActionInitializer = (props) => {
'x-settings': 'actionSettings:edit',
'x-component': 'Action',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
icon: 'EditOutlined',
},
properties: {

View File

@ -11,8 +11,10 @@ import React from 'react';
import { usePagePopup } from '../../../schema-component/antd/page/pagePopupUtils';
import { CONTEXT_SCHEMA_KEY } from '../../../schema-component/antd/page/usePopupContextInActionOrAssociationField';
import { ActionInitializerItem } from '../../../schema-initializer/items/ActionInitializerItem';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const ViewActionInitializer = (props) => {
const { defaultOpenMode } = useOpenModeContext();
const { getPopupContext } = usePagePopup();
const schema = {
type: 'void',
@ -22,7 +24,7 @@ export const ViewActionInitializer = (props) => {
'x-settings': 'actionSettings:view',
'x-component': 'Action',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
},
properties: {
drawer: {

View File

@ -10,13 +10,10 @@
import { useSchemaToolbar } from '../../../application';
import { SchemaSettings } from '../../../application/schema-settings/SchemaSettings';
import { useCollection_deprecated } from '../../../collection-manager';
import {
ButtonEditor,
RemoveButton,
RefreshDataBlockRequest,
} from '../../../schema-component/antd/action/Action.Designer';
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const customizePopupActionSettings = new SchemaSettings({
name: 'actionSettings:popup',
@ -44,9 +41,12 @@ export const customizePopupActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -13,6 +13,7 @@ import { useCollection_deprecated } from '../../../collection-manager';
import { ButtonEditor } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const editActionSettings = new SchemaSettings({
name: 'actionSettings:edit',
@ -40,9 +41,12 @@ export const editActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -13,6 +13,7 @@ import { useCollection_deprecated } from '../../../collection-manager';
import { ButtonEditor, RemoveButton } from '../../../schema-component/antd/action/Action.Designer';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { SchemaSettingsLinkageRules } from '../../../schema-settings';
import { useOpenModeContext } from '../../popup/OpenModeProvider';
export const viewActionSettings = new SchemaSettings({
name: 'actionSettings:view',
@ -40,9 +41,12 @@ export const viewActionSettings = new SchemaSettings({
{
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
componentProps: {
openMode: true,
openSize: true,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
{

View File

@ -33,6 +33,7 @@ import { SchemaSettingsDataScope } from '../../../../schema-settings/SchemaSetti
import { SchemaSettingsSortingRule } from '../../../../schema-settings/SchemaSettingsSortingRule';
import { useIsShowMultipleSwitch } from '../../../../schema-settings/hooks/useIsShowMultipleSwitch';
import { useLocalVariables, useVariables } from '../../../../variables';
import { useOpenModeContext } from '../../../popup/OpenModeProvider';
const enableLink = {
name: 'enableLink',
@ -162,6 +163,7 @@ const quickCreate: any = {
name: 'quickCreate',
type: 'select',
useComponentProps() {
const { defaultOpenMode } = useOpenModeContext();
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
@ -194,7 +196,7 @@ const quickCreate: any = {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
type: 'default',
component: 'CreateRecordAction',
},

View File

@ -0,0 +1,99 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React, { FC, useCallback, useMemo } from 'react';
import ActionDrawer from '../../schema-component/antd/action/Action.Drawer';
import ActionModal from '../../schema-component/antd/action/Action.Modal';
import ActionPage from '../../schema-component/antd/action/Action.Page';
type OpenMode = 'drawer' | 'page' | 'modal';
interface OpenModeProviderProps {
/**
* @default 'drawer'
* open mode
*/
defaultOpenMode?: OpenMode;
/**
* @default { drawer: ActionDrawer, page: ActionPage, modal: ActionModal }
* open mode
*/
openModeToComponent?: Partial<Record<OpenMode, any>>;
/**
* @default false
* open mode
*/
hideOpenMode?: boolean;
}
const defaultContext: OpenModeProviderProps = {
defaultOpenMode: 'drawer',
openModeToComponent: {
drawer: ActionDrawer,
page: ActionPage,
modal: ActionModal,
},
hideOpenMode: false,
};
const OpenModeContext = React.createContext<{
defaultOpenMode: OpenModeProviderProps['defaultOpenMode'];
hideOpenMode: boolean;
getComponentByOpenMode: (openMode: OpenMode) => any;
}>(null);
/**
* Open mode
* @param props
* @returns
*/
export const OpenModeProvider: FC<OpenModeProviderProps> = (props) => {
const context = useMemo(() => {
const result = { ...defaultContext };
if (props.defaultOpenMode !== undefined) {
result.defaultOpenMode = props.defaultOpenMode;
}
if (props.openModeToComponent !== undefined) {
result.openModeToComponent = props.openModeToComponent;
}
if (props.hideOpenMode !== undefined) {
result.hideOpenMode = props.hideOpenMode;
}
return result;
}, [props.defaultOpenMode, props.openModeToComponent, props.hideOpenMode]);
const getComponentByOpenMode = useCallback(
(openMode: OpenMode) => {
const result = context.openModeToComponent[openMode];
if (!result) {
console.error(`OpenModeProvider: openModeToComponent[${openMode}] is not defined`);
}
return result;
},
[context],
);
const value = useMemo(() => {
return {
defaultOpenMode: context.defaultOpenMode,
hideOpenMode: context.hideOpenMode,
getComponentByOpenMode,
};
}, [context.defaultOpenMode, context.hideOpenMode, getComponentByOpenMode]);
return <OpenModeContext.Provider value={value}>{props.children}</OpenModeContext.Provider>;
};
export const useOpenModeContext = () => {
return React.useContext(OpenModeContext);
};

View File

@ -47,7 +47,8 @@ export const SettingsCenterDropdown = () => {
return {
key: setting.name,
icon: setting.icon,
label: <Link to={setting.path}>{compile(setting.title)}</Link>,
label: setting.link ? <div onClick={() => window.open(setting.link)}>{compile(setting.title)}</div> :
<Link to={setting.path}>{compile(setting.title)}</Link>
};
});
}, [app, t]);

View File

@ -144,6 +144,12 @@ export const AdminSettingsLayout = () => {
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
onClick={({ key }) => {
const plugin = settings.find((item) => item.name === key);
if (plugin.link) {
window.open(plugin.link, '_blank');
return;
}
if (plugin.children?.length) {
return navigate(getFirstDeepChildPath(plugin.children));
} else {

View File

@ -10,21 +10,19 @@
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import React from 'react';
import { useActionContext } from '.';
import { ActionDrawer } from './Action.Drawer';
import { ActionModal } from './Action.Modal';
import { ActionPage } from './Action.Page';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
import { useCurrentPopupContext } from '../page/PagePopups';
import { ComposedActionDrawer } from './types';
export const ActionContainer: ComposedActionDrawer = observer(
(props: any) => {
const { openMode } = useActionContext();
if (openMode === 'drawer') {
return <ActionDrawer footerNodeName={'Action.Container.Footer'} {...props} />;
}
if (openMode === 'modal') {
return <ActionModal footerNodeName={'Action.Container.Footer'} {...props} />;
}
return <ActionPage footerNodeName={'Action.Container.Footer'} {...props} />;
const { getComponentByOpenMode } = useOpenModeContext();
const { currentLevel } = useCurrentPopupContext();
const Component = getComponentByOpenMode(openMode);
return <Component footerNodeName={'Action.Container.Footer'} level={currentLevel} {...props} />;
},
{ displayName: 'ActionContainer' },
);

View File

@ -28,6 +28,7 @@ import {
import { DataSourceProvider, useDataSourceKey } from '../../../data-source';
import { FlagProvider } from '../../../flag-provider';
import { SaveMode } from '../../../modules/actions/submit/createSubmitActionSettings';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
import { SchemaSettingOpenModeSchemaItems } from '../../../schema-items';
import { GeneralSchemaDesigner } from '../../../schema-settings/GeneralSchemaDesigner';
import {
@ -656,6 +657,7 @@ export const actionSettingsItems: SchemaSettingOptions['items'] = [
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
const fieldSchema = useFieldSchema();
const isPopupAction = [
'create',
@ -667,8 +669,8 @@ export const actionSettingsItems: SchemaSettingOptions['items'] = [
].includes(fieldSchema['x-action'] || '');
return {
openMode: isPopupAction,
openSize: isPopupAction,
openMode: isPopupAction && !hideOpenMode,
openSize: isPopupAction && !hideOpenMode,
};
},
},

View File

@ -11,25 +11,26 @@ import { RecursionField, observer, useFieldSchema } from '@formily/react';
import React, { useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useActionContext } from '.';
import { useCurrentPopupContext } from '../page/PagePopups';
import { BackButtonUsedInSubPage } from '../page/BackButtonUsedInSubPage';
import { TabsContextProvider, useTabsContext } from '../tabs/context';
import { useActionPageStyle } from './Action.Page.style';
import { usePopupOrSubpagesContainerDOM } from './hooks/usePopupSlotDOM';
import { ComposedActionDrawer } from './types';
export const ActionPage: ComposedActionDrawer = observer(
() => {
({ level }) => {
const filedSchema = useFieldSchema();
const ctx = useActionContext();
const { getContainerDOM } = usePopupOrSubpagesContainerDOM();
const { styles } = useActionPageStyle();
const { currentLevel } = useCurrentPopupContext();
const tabContext = useTabsContext();
const style = useMemo(() => {
return {
// 20 is the z-index value of the main page
zIndex: 20 + currentLevel,
zIndex: 20 + level,
};
}, [currentLevel]);
}, [level]);
if (!ctx.visible) {
return null;
@ -37,11 +38,19 @@ export const ActionPage: ComposedActionDrawer = observer(
const actionPageNode = (
<div className={styles.container} style={style}>
<RecursionField schema={filedSchema} onlyRenderProperties />
<TabsContextProvider {...tabContext} tabBarExtraContent={<BackButtonUsedInSubPage />}>
<RecursionField schema={filedSchema} onlyRenderProperties />
</TabsContextProvider>
</div>
);
return createPortal(actionPageNode, getContainerDOM());
const container = getContainerDOM();
if (container) {
return createPortal(actionPageNode, container);
}
return actionPageNode;
},
{ displayName: 'ActionPage' },
);

View File

@ -79,7 +79,7 @@ export const ActionBar = withDynamicSchemaProps(
>
{props.children && (
<div>
<Space {...spaceProps} style={{ flexWrap: 'wrap' }}>
<Space {...spaceProps} style={{ flexWrap: 'wrap', ...(spaceProps?.style || {}) }}>
{fieldSchema.mapProperties((schema, key) => {
return <RecursionField key={key} name={key} schema={schema} />;
})}
@ -129,7 +129,7 @@ export const ActionBar = withDynamicSchemaProps(
return <RecursionField key={key} name={key} schema={schema} />;
})}
</Space>
<Space {...spaceProps} style={{ flexWrap: 'wrap' }}>
<Space {...spaceProps} style={{ flexWrap: 'wrap', ...(spaceProps?.style || {}) }}>
{fieldSchema.mapProperties((schema, key) => {
if (schema['x-align'] === 'left') {
return null;

View File

@ -87,7 +87,11 @@ export type ComposedAction = React.FC<ActionProps> & {
[key: string]: any;
};
export type ActionDrawerProps<T = DrawerProps> = T & { footerNodeName?: string };
export type ActionDrawerProps<T = DrawerProps> = T & {
footerNodeName?: string;
/** 当前弹窗嵌套的层级 */
level?: number;
};
export type ComposedActionDrawer<T = DrawerProps> = React.FC<ActionDrawerProps<T>> & {
Footer?: React.FC;

View File

@ -13,6 +13,7 @@ import React, { FC, Fragment, useRef, useState } from 'react';
import { useDesignable } from '../../';
import { WithoutTableFieldResource } from '../../../block-provider';
import { useCollectionManager, useCollectionRecordData } from '../../../data-source';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
import { VariablePopupRecordProvider } from '../../../modules/variable/variablesProvider/VariablePopupRecordProvider';
import { useCompile } from '../../hooks';
import { ActionContextProvider, useActionContext } from '../action';
@ -139,6 +140,7 @@ export const ReadPrettyInternalViewer: React.FC = observer(
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const { visibleWithURL, setVisibleWithURL } = usePagePopup();
const [btnHover, setBtnHover] = useState(!!visibleWithURL);
const { defaultOpenMode } = useOpenModeContext();
const btnElement = (
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
@ -175,7 +177,7 @@ export const ReadPrettyInternalViewer: React.FC = observer(
setVisible?.(value);
setVisibleWithURL?.(value);
},
openMode: 'drawer',
openMode: defaultOpenMode,
snapshot: collectionField?.interface === 'snapshot',
fieldSchema: fieldSchema,
}}

View File

@ -73,13 +73,14 @@ const useStyles = createStyles(({ css, token }: CustomCreateStylesUtils) => {
export interface BlockItemProps {
name?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
}
export const BlockItem: React.FC<BlockItemProps> = withDynamicSchemaProps(
(props) => {
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { className, children } = useProps(props);
const { className, children, style } = useProps(props);
const { styles: blockItemCss } = useStyles();
const fieldSchema = useFieldSchema();
const { render } = useSchemaToolbarRender(fieldSchema);
@ -87,7 +88,12 @@ export const BlockItem: React.FC<BlockItemProps> = withDynamicSchemaProps(
const label = useMemo(() => getAriaLabel(), [getAriaLabel]);
return (
<SortableItem role="button" aria-label={label} className={cls('nb-block-item', className, blockItemCss)}>
<SortableItem
role="button"
aria-label={label}
className={cls('nb-block-item', className, blockItemCss)}
style={style}
>
{render()}
<ErrorBoundary FallbackComponent={ErrorFallback} onError={(err) => console.log(err)}>
{children}

View File

@ -13,7 +13,7 @@ import { Select, SelectProps, Tag } from 'antd';
import React from 'react';
import { useCompile } from '../../hooks/useCompile';
const colors = {
const defaultColors = {
red: '{{t("Red")}}',
magenta: '{{t("Magenta")}}',
volcano: '{{t("Volcano")}}',
@ -30,13 +30,15 @@ const colors = {
export interface ColorSelectProps extends SelectProps {
suffix?: React.ReactNode;
colors?: Record<string, string>;
}
export const ColorSelect = connect(
(props: ColorSelectProps) => {
const compile = useCompile();
const { colors = defaultColors, ...selectProps } = props;
return (
<Select {...props}>
<Select {...selectProps}>
{Object.keys(colors).map((color) => (
<Select.Option key={color} value={color}>
<Tag color={color}>{compile(colors[color] || colors.default)}</Tag>
@ -53,7 +55,7 @@ export const ColorSelect = connect(
}),
mapReadPretty((props) => {
const compile = useCompile();
const { value } = props;
const { value, colors = defaultColors } = props;
if (!colors[value]) {
return null;
}

View File

@ -23,6 +23,7 @@ import {
} from '../../../collection-manager';
import { useCollectionManager } from '../../../data-source';
import { useFlag } from '../../../flag-provider';
import { useOpenModeContext } from '../../../modules/popup/OpenModeProvider';
import { useRecord } from '../../../record-provider';
import { useColumnSchema } from '../../../schema-component/antd/table-v2/Table.Column.Decorator';
import { generalSettingsItems } from '../../../schema-items/GeneralSettings';
@ -54,6 +55,7 @@ export const allowAddNew: SchemaSettingsItemType = {
return !flag?.isInSubTable && !readPretty && isAssociationField && ['Picker'].includes(fieldMode);
},
useComponentProps() {
const { defaultOpenMode } = useOpenModeContext();
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
@ -83,7 +85,7 @@ export const allowAddNew: SchemaSettingsItemType = {
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
type: 'default',
component: 'CreateRecordAction',
},
@ -540,6 +542,7 @@ export const formItemSettings = new SchemaSettings({
return !readPretty && isAssociationField && ['Select'].includes(fieldMode);
},
useComponentProps() {
const { defaultOpenMode } = useOpenModeContext();
const { t } = useTranslation();
const field = useField<Field>();
const fieldSchema = useFieldSchema();
@ -572,7 +575,7 @@ export const formItemSettings = new SchemaSettings({
'x-component': 'Action',
'x-decorator': 'ACLActionProvider',
'x-component-props': {
openMode: 'drawer',
openMode: defaultOpenMode,
type: 'default',
component: 'CreateRecordAction',
},

View File

@ -14,14 +14,25 @@ import { useToken } from '../../../style';
import { useCurrentPopupContext } from './PagePopups';
import { usePagePopup } from './pagePopupUtils';
export const useBackButton = () => {
const { params } = useCurrentPopupContext();
const { closePopup } = usePagePopup();
const goBack = useCallback(() => {
closePopup(params?.popupuid);
}, [closePopup, params?.popupuid]);
return {
goBack,
};
};
/**
* Used for the back button in subpages
* @returns
*/
export const BackButtonUsedInSubPage = () => {
const { params } = useCurrentPopupContext();
const { closePopup } = usePagePopup();
const { token } = useToken();
const { goBack } = useBackButton();
// tab item gutter, this is fixed value in antd
const horizontalItemGutter = 32;
@ -35,17 +46,7 @@ export const BackButtonUsedInSubPage = () => {
};
}, [token.paddingXS]);
const handleClick = useCallback(() => {
closePopup(params.popupuid);
}, [params.popupuid]);
return (
<Button
aria-label="back-button"
type="text"
icon={<ArrowLeftOutlined />}
style={resetStyle}
onClick={handleClick}
/>
<Button aria-label="back-button" type="text" icon={<ArrowLeftOutlined />} style={resetStyle} onClick={goBack} />
);
};

View File

@ -110,7 +110,7 @@ export const Page = (props) => {
marginLeft: token.paddingPageHorizontal - token.paddingLG,
marginRight: token.paddingPageHorizontal - token.paddingLG,
}}
onTabClick={(activeKey) => {
onChange={(activeKey) => {
setLoading(true);
navigate(`/admin/${pageUid}/tabs/${activeKey}`, { replace: true });
setTimeout(() => {

View File

@ -19,7 +19,6 @@ import { DataBlockProvider } from '../../../data-source/data-block/DataBlockProv
import { BlockRequestContext } from '../../../data-source/data-block/DataBlockRequestProvider';
import { SchemaComponent } from '../../core';
import { TabsContextProvider } from '../tabs/context';
import { BackButtonUsedInSubPage } from './BackButtonUsedInSubPage';
import { usePopupSettings } from './PopupSettingsProvider';
import { deleteRandomNestedSchemaKey, getRandomNestedSchemaKey } from './nestedSchemaKeyStorage';
import { PopupParams, getPopupParamsFromPath, getStoredPopupContext, usePagePopup } from './pagePopupUtils';
@ -87,22 +86,20 @@ const PopupParamsProvider: FC<Omit<PopupProps, 'hidden'>> = (props) => {
const PopupTabsPropsProvider: FC<{ params: PopupParams }> = ({ children, params }) => {
const { changeTab } = usePagePopup();
const onTabClick = useCallback(
const onChange = useCallback(
(key: string) => {
changeTab(key);
},
[changeTab],
);
const { isPopupVisibleControlledByURL } = usePopupSettings();
const { isSubPage } = useCurrentPopupContext();
const tabBarExtraContent = useMemo(() => (isSubPage ? <BackButtonUsedInSubPage /> : null), [isSubPage]);
if (!isPopupVisibleControlledByURL()) {
return <>{children}</>;
}
return (
<TabsContextProvider activeKey={params.tab} onTabClick={onTabClick} tabBarExtraContent={tabBarExtraContent}>
<TabsContextProvider activeKey={params.tab} onChange={onChange}>
{children}
</TabsContextProvider>
);
@ -276,7 +273,7 @@ export const PagePopups = (props: { paramsList?: PopupParams[] }) => {
context={popupPropsRef.current[0].context}
currentLevel={1}
>
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />;
<SchemaComponent components={components} schema={rootSchema} onlyRenderProperties />
</PagePopupsItemProvider>
</AllPopupsPropsProviderContext.Provider>
);
@ -351,6 +348,7 @@ function get404Schema() {
'x-component': 'Action.Container',
'x-component-props': {
className: 'nb-action-popup',
level: 99, // 确保在最上层
},
properties: {
tabs: {

View File

@ -17,8 +17,15 @@ export const usePopupSettings = () => {
const isPopupVisibleControlledByURL = useCallback(() => {
const pathname = window.location.pathname;
const hash = window.location.hash;
return pathname?.includes('/admin/') && !hash?.includes('/mobile');
const isOldMobileMode = pathname?.includes('/mobile/') || hash?.includes('/mobile/');
const isNewMobileMode = pathname?.includes('/m/');
const isPCMode = pathname?.includes('/admin/');
return (isPCMode || isNewMobileMode) && !isOldMobileMode;
}, []);
return { isPopupVisibleControlledByURL };
return {
/** 弹窗窗口的显隐是否由 URL 控制 */
isPopupVisibleControlledByURL,
};
};

View File

@ -7,8 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { BackButtonUsedInSubPage, useBackButton } from './BackButtonUsedInSubPage';
export * from './FixedBlock';
export * from './FixedBlockDesignerItem';
export * from './Page';
export * from './Page.Settings';
export { PagePopups } from './PagePopups';
export * from './PageTab.Settings';

View File

@ -129,7 +129,7 @@ export const usePagePopup = () => {
(_parentRecordData || parentRecord?.data)?.[cm.getSourceKeyByAssociation(association)],
[parentRecord, association],
);
const currentPopupUidWithoutOpened = fieldSchema['x-uid'];
const currentPopupUidWithoutOpened = fieldSchema?.['x-uid'];
const getNewPathname = useCallback(
({

View File

@ -22,6 +22,8 @@ export const DesignableSwitch = () => {
const style = {};
if (designable) {
style['backgroundColor'] = 'var(--colorSettings)';
} else {
style['backgroundColor'] = 'transparent';
}
// 快捷键切换编辑状态

View File

@ -11,7 +11,7 @@ import { createForm } from '@formily/core';
import { Schema } from '@formily/react';
import { Spin } from 'antd';
import React, { memo, useMemo } from 'react';
import { useSchemaComponentContext } from '../hooks';
import { useComponent, useSchemaComponentContext } from '../hooks';
import { FormProvider } from './FormProvider';
import { SchemaComponent } from './SchemaComponent';
import { useRequestSchema } from './useRequestSchema';
@ -26,6 +26,12 @@ export interface RemoteSchemaComponentProps {
hidden?: any;
onlyRenderProperties?: boolean;
noForm?: boolean;
/**
* @default true
*/
memoized?: boolean;
NotFoundPage?: React.ComponentType | string;
onPageNotFind?: () => void;
}
const defaultTransform = (s: Schema) => s;
@ -37,9 +43,12 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
hidden,
scope,
uid,
memoized = true,
components,
onSuccess,
NotFoundPage,
schemaTransform = defaultTransform,
onPageNotFind,
} = props;
const { reset } = useSchemaComponentContext();
const type = onlyRenderProperties ? 'getProperties' : 'getJsonSchema';
@ -55,12 +64,20 @@ const RequestSchemaComponent: React.FC<RemoteSchemaComponentProps> = (props) =>
reset && reset();
},
});
if (loading) {
return <Spin />;
const NotFoundComponent = useComponent(NotFoundPage);
if (loading || hidden) {
return (
<div style={{ textAlign: 'center', marginTop: 20 }}>
<Spin />
</div>
);
}
if (hidden) {
return <Spin />;
if (!schema || Object.keys(schema).length === 0) {
onPageNotFind && onPageNotFind();
return NotFoundComponent ? <NotFoundComponent /> : null;
}
return noForm ? (
<SchemaComponent components={components} scope={scope} schema={schemaTransform(schema || {})} />
) : (

View File

@ -189,6 +189,12 @@ export interface SchemaToolbarProps {
*/
showBorder?: boolean;
showBackground?: boolean;
toolbarClassName?: string;
toolbarStyle?: React.CSSProperties;
spaceWrapperClassName?: string;
spaceWrapperStyle?: React.CSSProperties;
spaceClassName?: string;
spaceStyle?: React.CSSProperties;
}
const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
@ -198,11 +204,17 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
initializer,
settings,
showBackground,
spaceWrapperClassName,
spaceWrapperStyle,
showBorder = true,
draggable = true,
spaceClassName,
spaceStyle,
toolbarClassName,
toolbarStyle = {},
} = {
...props,
...(fieldSchema['x-toolbar-props'] || {}),
...(fieldSchema?.['x-toolbar-props'] || {}),
} as SchemaToolbarProps;
const { designable } = useDesignable();
const compile = useCompile();
@ -220,12 +232,12 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
if (Array.isArray(title)) return title.map((item) => compile(item));
}, [compile, title]);
const { render: schemaSettingsRender, exists: schemaSettingsExists } = useSchemaSettingsRender(
settings || fieldSchema['x-settings'],
fieldSchema['x-settings-props'],
settings || fieldSchema?.['x-settings'],
fieldSchema?.['x-settings-props'],
);
const { render: schemaInitializerRender, exists: schemaInitializerExists } = useSchemaInitializerRender(
initializer || fieldSchema['x-initializer'],
fieldSchema['x-initializer-props'],
initializer || fieldSchema?.['x-initializer'],
fieldSchema?.['x-initializer-props'],
);
const rowCtx = useGridRowContext();
const gridContext = useGridContext();
@ -316,8 +328,8 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{ border: showBorder ? 'auto' : 0, background: showBackground ? 'auto' : 0 }}
className={classNames(styles.toolbar, toolbarClassName, 'schema-toolbar')}
style={{ border: showBorder ? 'auto' : 0, background: showBackground ? 'auto' : 0, ...toolbarStyle }}
>
{titleArr && (
<div className={styles.toolbarTitle}>
@ -333,8 +345,8 @@ const InternalSchemaToolbar: FC<SchemaToolbarProps> = (props) => {
</Space>
</div>
)}
<div className={styles.toolbarIcons}>
<Space size={3} align={'center'}>
<div className={classNames(styles.toolbarIcons, spaceWrapperClassName)} style={spaceWrapperStyle}>
<Space size={3} align={'center'} className={spaceClassName} style={spaceStyle}>
{dragElement}
{initializerElement}
{settingsElement}

View File

@ -447,15 +447,17 @@ export const SchemaSettingsDivider = function Divider() {
export interface SchemaSettingsRemoveProps {
disabled?: boolean;
title?: string;
confirm?: ModalFuncProps;
removeParentsIfNoChildren?: boolean;
breakRemoveOn?: ISchema | ((s: ISchema) => boolean);
}
export const SchemaSettingsRemove: FC<SchemaSettingsRemoveProps> = (props) => {
const { disabled, confirm, removeParentsIfNoChildren, breakRemoveOn } = props;
const { disabled, confirm, title, removeParentsIfNoChildren, breakRemoveOn } = props;
const { dn, template } = useSchemaSettings();
const { t } = useTranslation();
const field = useField<Field>();
const compile = useCompile();
const fieldSchema = useFieldSchema();
const ctx = useBlockTemplateContext();
const form = useForm();
@ -470,7 +472,7 @@ export const SchemaSettingsRemove: FC<SchemaSettingsRemoveProps> = (props) => {
eventKey="remove"
onClick={() => {
modal.confirm({
title: t('Delete block'),
title: title ? compile(title) : t('Delete block'),
content: t('Are you sure you want to delete it?'),
...confirm,
async onOk() {
@ -602,10 +604,22 @@ export interface SchemaSettingsActionModalItemProps
schema?: ISchema;
beforeOpen?: () => void;
maskClosable?: boolean;
width?: string | number;
}
export const SchemaSettingsActionModalItem: FC<SchemaSettingsActionModalItemProps> = React.memo((props) => {
const { title, onSubmit, initialValues, beforeOpen, initialSchema, schema, modalTip, components, scope, ...others } =
props;
const {
title,
onSubmit,
width = '50%',
initialValues,
beforeOpen,
initialSchema,
schema,
modalTip,
components,
scope,
...others
} = props;
const [visible, setVisible] = useState(false);
const [schemaUid, setSchemaUid] = useState<string>(props.uid);
const { t } = useTranslation();
@ -636,8 +650,12 @@ export const SchemaSettingsActionModalItem: FC<SchemaSettingsActionModalItemProp
const submitHandler = useCallback(async () => {
await form.submit();
onSubmit?.(cloneDeep(form.values));
setVisible(false);
try {
await onSubmit?.(cloneDeep(form.values));
setVisible(false);
} catch (err) {
console.error(err);
}
}, [form, onSubmit]);
const openAssignedFieldValueHandler = useCallback(async () => {
@ -667,7 +685,7 @@ export const SchemaSettingsActionModalItem: FC<SchemaSettingsActionModalItemProp
</SchemaSettingsItem>
{createPortal(
<Modal
width={'50%'}
width={width}
title={compile(title)}
{...others}
destroyOnClose
@ -704,7 +722,7 @@ SchemaSettingsActionModalItem.displayName = 'SchemaSettingsActionModalItem';
export interface SchemaSettingsModalItemProps {
title: string;
onSubmit: (values: any) => void;
onSubmit: (values: any) => Promise<any> | void;
initialValues?: any;
schema?: ISchema | (() => ISchema);
modalTip?: string;

View File

@ -183,6 +183,15 @@ export interface PageConfig {
keepUid?: boolean;
}
export interface MobilePageConfig extends Omit<PageConfig, 'type'> {
type?: 'page' | 'link';
/**
*
* @default '/m/'
*/
basePath?: string;
}
interface CreatePageOptions {
type?: PageConfig['type'];
url?: PageConfig['url'];
@ -192,6 +201,10 @@ interface CreatePageOptions {
keepUid?: boolean;
}
interface CreateMobilePageOptions extends Omit<CreatePageOptions, 'type'> {
type?: Omit<PageConfig['type'], 'group'>;
}
interface ExtendUtils {
page?: Page;
/**
@ -200,6 +213,12 @@ interface ExtendUtils {
* @returns
*/
mockPage: (pageConfig?: PageConfig) => NocoPage;
/**
* NocoBase
* @param pageConfig
* @returns
*/
mockMobilePage: (pageConfig?: MobilePageConfig) => NocoMobilePage;
/**
* NocoPage
* @param pageConfig
@ -299,14 +318,14 @@ const PORT = process.env.APP_PORT || 20000;
const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${PORT}`;
export class NocoPage {
private url: string;
private uid: string | undefined;
private collectionsName: string[] | undefined;
private _waitForInit: Promise<void>;
protected url: string;
protected uid: string | undefined;
protected collectionsName: string[] | undefined;
protected _waitForInit: Promise<void>;
constructor(
private options?: PageConfig,
private page?: Page,
protected options?: PageConfig,
protected page?: Page,
) {
this._waitForInit = this.init();
}
@ -374,6 +393,57 @@ export class NocoPage {
}
}
export class NocoMobilePage extends NocoPage {
protected routeId: number;
protected title: string;
constructor(
protected options?: MobilePageConfig,
protected page?: Page,
) {
super(options, page);
}
getTitle() {
return this.title;
}
async init() {
const waitList = [];
if (this.options?.collections?.length) {
const collections: any = omitSomeFields(this.options.collections);
this.collectionsName = collections.map((item) => item.name);
waitList.push(createCollections(collections));
}
waitList.push(createMobilePage(this.options));
const result = await Promise.all(waitList);
const { url, pageSchemaUid, routeId, title } = result[result.length - 1];
this.title = title;
this.routeId = routeId;
this.uid = pageSchemaUid;
if (this.options?.type == 'link') {
// 内部 URL 和外部 URL
if (url?.startsWith('/')) {
this.url = `${this.options?.basePath || '/m'}${url}`;
} else {
this.url = url;
}
} else {
this.url = `${this.options?.basePath || '/m'}${url}`;
}
}
async mobileDestroy() {
// 移除 mobile routes
await deleteMobileRoutes(this.routeId);
// 移除 schema
await this.destroy();
}
}
let _page: Page;
const getPage = async (browser: Browser) => {
if (!_page) {
@ -412,6 +482,33 @@ const _test = base.extend<ExtendUtils>({
await Promise.all(waitList);
},
mockMobilePage: async ({ browser }, use) => {
// 保证每个测试运行时 faker 的随机值都是一样的
// faker.seed(1);
const page = await getPage(browser);
const nocoPages: NocoMobilePage[] = [];
const mockPage = (config?: MobilePageConfig) => {
const nocoPage = new NocoMobilePage(config, page);
nocoPages.push(nocoPage);
return nocoPage;
};
await use(mockPage);
const waitList = [];
// 测试运行完自动销毁页面
for (const nocoPage of nocoPages) {
// 这里之所以不加入 waitList 是因为会导致 acl 的测试报错
await nocoPage.mobileDestroy();
}
waitList.push(setDefaultRole('root'));
// 删除掉 id 不是 1 的 users 和 name 不是 root admin member 的 roles
waitList.push(removeRedundantUserAndRoles());
await Promise.all(waitList);
},
mockManualDestroyPage: async ({ browser }, use) => {
const mockManualDestroyPage = (config?: PageConfig) => {
const nocoPage = new NocoPage(config);
@ -684,6 +781,196 @@ const createPage = async (options?: CreatePageOptions) => {
return pageUid;
};
/**
* NocoBase
*/
const createMobilePage = async (options?: CreateMobilePageOptions) => {
const { type = 'page', url, name, pageSchema, keepUid } = options || {};
function randomStr() {
return Math.random().toString(36).substring(2);
}
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const pageSchemaUid = name || uid();
const schemaUrl = `/page/${pageSchemaUid}`;
const firstTabUid = uid();
const title = name || randomStr();
// 创建路由
const routerResponse: any = await api.post(`/api/mobileRoutes:create`, {
headers,
data: {
type: type,
schemaUid: pageSchemaUid,
title: title,
icon: 'appstoreoutlined',
options: {
url,
},
},
});
const responseData = await routerResponse.json();
const routeId = responseData.data.id;
if (!routerResponse.ok()) {
throw new Error(await routerResponse.text());
}
if (type === 'link') return { url, routeId, title };
// 创建空页面
const createSchemaResult = await api.post(`/api/uiSchemas:insertAdjacent?resourceIndex=mobile&position=beforeEnd`, {
headers,
data: {
schema: {
type: 'void',
name: pageSchemaUid,
'x-uid': pageSchemaUid,
'x-component': 'MobilePageProvider',
'x-settings': 'mobile:page',
'x-decorator': 'BlockItem',
'x-toolbar-props': {
draggable: false,
spaceWrapperStyle: {
right: -15,
top: -15,
},
spaceClassName: 'css-m1q7xw',
toolbarStyle: {
overflowX: 'hidden',
},
},
properties: {
header: {
type: 'void',
'x-component': 'MobilePageHeader',
properties: {
pageNavigationBar: {
type: 'void',
'x-component': 'MobilePageNavigationBar',
properties: {
actionBar: {
type: 'void',
'x-component': 'MobileNavigationActionBar',
'x-initializer': 'mobile:navigation-bar:actions',
'x-component-props': {
spaceProps: {
style: {
flexWrap: 'nowrap',
},
},
},
name: 'actionBar',
},
},
name: 'pageNavigationBar',
},
pageTabs: {
type: 'void',
'x-component': 'MobilePageTabs',
name: 'pageTabs',
},
},
name: 'header',
},
content: {
type: 'void',
'x-component': 'MobilePageContent',
properties: {
[firstTabUid]: {
...((keepUid ? pageSchema : updateUidOfPageSchema(pageSchema)) || {
type: 'void',
'x-uid': firstTabUid,
'x-async': true,
'x-component': 'Grid',
'x-initializer': 'mobile:addBlock',
}),
name: firstTabUid,
'x-uid': firstTabUid,
},
},
},
},
},
},
});
if (!createSchemaResult.ok()) {
throw new Error(await createSchemaResult.text());
}
// 创建第一个 tab
const createTabResponse = await api.post(`/api/mobileRoutes:create`, {
headers,
data: {
parentId: routeId,
type: 'tabs',
title: 'Unnamed',
schemaUid: firstTabUid,
},
});
if (!createTabResponse.ok()) {
throw new Error(await createTabResponse.text());
}
return { url: schemaUrl, pageSchemaUid, routeId, title };
};
export const removeAllMobileRoutes = async () => {
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(
`/api/mobileRoutes:destroy?filter=%7B%22%24and%22%3A%5B%7B%22id%22%3A%7B%22%24ne%22%3A0%7D%7D%5D%7D`,
{
headers,
},
);
if (!result.ok()) {
throw new Error(await result.text());
}
};
/**
* id Mobile Routes
*/
const deleteMobileRoutes = async (mobileRouteId: number) => {
if (!mobileRouteId) return;
const api = await request.newContext({
storageState: process.env.PLAYWRIGHT_AUTH_FILE,
});
const state = await api.storageState();
const headers = getHeaders(state);
const result = await api.post(`/api/mobileRoutes:destroy?filterByTk=${mobileRouteId}`, {
headers,
});
if (!result.ok()) {
throw new Error(await result.text());
}
const result2 = await api.post(
`/api/mobileRoutes:destroy?filter=${encodeURIComponent(JSON.stringify({ parentId: mobileRouteId }))}`,
{
headers,
},
);
if (!result2.ok()) {
throw new Error(await result2.text());
}
};
/**
* uid NocoBase
*/

View File

@ -16,6 +16,7 @@ import {
SchemaSettingsRemove,
SchemaSettingsSelectItem,
useDesignable,
useOpenModeContext,
useSchemaToolbar,
} from '@nocobase/client';
import { ModalProps } from 'antd';
@ -137,14 +138,15 @@ export const bulkEditActionSettings = new SchemaSettings({
name: 'openMode',
Component: SchemaInitializerOpenModeSchemaItems,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
const fieldSchema = useFieldSchema();
const isPopupAction = ['create', 'update', 'view', 'customize:popup', 'duplicate', 'customize:create'].includes(
fieldSchema['x-action'] || '',
);
return {
openMode: isPopupAction,
openSize: isPopupAction,
openMode: isPopupAction && !hideOpenMode,
openSize: isPopupAction && !hideOpenMode,
};
},
},

View File

@ -19,6 +19,7 @@ import {
useCollectionState,
useCollection_deprecated,
useDesignable,
useOpenModeContext,
useRecord,
useSchemaToolbar,
useSyncFromForm,
@ -357,6 +358,7 @@ const schemaSettingsItems: SchemaSettingsItemType[] = [
name: 'openMode',
Component: SchemaSettingOpenModeSchemaItems,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
const { t } = useTranslation();
const modeOptions = useMemo(() => {
@ -367,8 +369,8 @@ const schemaSettingsItems: SchemaSettingsItemType[] = [
}, [t]);
return {
openMode: true,
openSize: true,
openMode: !hideOpenMode,
openSize: !hideOpenMode,
modeOptions,
};
},

View File

@ -30,7 +30,11 @@ export const AuthenticatorsContextProvider: FC<{ children: React.ReactNode }> =
);
if (loading) {
return <Spin />;
return (
<div style={{ textAlign: 'center', marginTop: 20 }}>
<Spin />
</div>
);
}
if (error) {

View File

@ -44,6 +44,11 @@ export class PluginBlockIframeClient extends Plugin {
title: '{{t("Iframe")}}',
Component: 'IframeBlockInitializer',
});
this.app.schemaInitializerManager.addItem('mobile:addBlock', 'otherBlocks.iframe', {
title: '{{t("Iframe")}}',
Component: 'IframeBlockInitializer',
});
}
}

View File

@ -42,6 +42,11 @@ export class PluginBlockWorkbenchClient extends Plugin {
`otherBlocks.${workbenchBlockInitializerItem.name}`,
workbenchBlockInitializerItem,
);
this.app.schemaInitializerManager.addItem(
'mobile:addBlock',
`otherBlocks.${workbenchBlockInitializerItem.name}`,
workbenchBlockInitializerItem,
);
// link 操作
this.app.schemaSettingsManager.add(workbenchActionSettingsLink);

View File

@ -34,6 +34,10 @@ export class PluginCalendarClient extends Plugin {
title: generateNTemplate('Calendar'),
Component: 'CalendarBlockInitializer',
});
this.app.schemaInitializerManager.addItem('mobile:addBlock', 'dataBlocks.calendar', {
title: generateNTemplate('Calendar'),
Component: 'CalendarBlockInitializer',
});
this.app.schemaInitializerManager.addItem('popup:common:addBlock', 'dataBlocks.calendar', {
title: generateNTemplate('Calendar'),
Component: 'CalendarBlockInitializer',

View File

@ -50,6 +50,10 @@ class PluginDataVisualiztionClient extends Plugin {
title: lang('Charts'),
Component: 'ChartV2BlockInitializer',
});
this.app.schemaInitializerManager.addItem('mobile:addBlock', 'dataBlocks.chartV2', {
title: lang('Charts'),
Component: 'ChartV2BlockInitializer',
});
}
}

View File

@ -7,12 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { ActionInitializerItem, useCollection_deprecated } from '@nocobase/client';
import { ActionInitializerItem } from '@nocobase/client';
import React from 'react';
export const UploadActionInitializer = (props) => {
const collection = useCollection_deprecated();
const schema = {
type: 'void',
'x-action': 'create',

View File

@ -19,6 +19,7 @@ import {
useDesignable,
useFormItemInitializerFields,
useGetAriaLabelOfDesigner,
useOpenModeContext,
useSchemaInitializerRender,
} from '@nocobase/client';
import { Space } from 'antd';
@ -137,6 +138,13 @@ const commonOptions = {
{
name: 'openMode',
Component: SchemaInitializerOpenModeSchemaItems,
useComponentProps() {
const { hideOpenMode } = useOpenModeContext();
return {
openMode: !hideOpenMode,
openSize: !hideOpenMode,
};
},
},
],
};

View File

@ -98,7 +98,7 @@ const components = {
google: GoogleMapConfiguration,
};
const tabList = MapTypes.map((item) => {
const routeList = MapTypes.map((item) => {
return {
...item,
component: components[item.value],
@ -112,7 +112,7 @@ export const Configuration = () => {
return (
<Card bordered>
<Tabs type="card" defaultActiveKey={search.get('tab')}>
{tabList.map((tab) => {
{routeList.map((tab) => {
return (
<Tabs.TabPane key={tab.value} tab={compile(tab.label)}>
<tab.component type={tab.value} />

View File

@ -47,7 +47,10 @@ export class PluginMapClient extends Plugin {
title: generateNTemplate('Map'),
Component: 'MapBlockInitializer',
});
this.app.schemaInitializerManager.addItem('mobile:addBlock', 'dataBlocks.map', {
title: generateNTemplate('Map'),
Component: 'MapBlockInitializer',
});
this.app.pluginSettingsManager.add(NAMESPACE, {
title: `{{t("Map Manager", { ns: "${NAMESPACE}" })}}`,
icon: 'EnvironmentOutlined',

View File

@ -0,0 +1,30 @@
import { defineConfig } from 'dumi';
import { getUmiConfig } from '@nocobase/devtools/umiConfig';
const umiConfig = getUmiConfig();
export default defineConfig({
hash: true,
fastRefresh: false,
mfsu: false,
cacheDirectoryPath: `node_modules/.docs-mobile-cache`,
alias: {
...umiConfig.alias
},
resolve: {
atomDirs: [
{ type: 'component', dir: 'src/client' },
{ type: 'component', dir: 'src/server' },
],
},
styles: [`
.dumi-mobile-demo-layout { padding: 0 !important; }
.dumi-default-previewer-sources{ flex: 0 !important; margin-top: 50px; }
`],
metas: [
{
name: 'viewport',
content:
'width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover',
},
],
});

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1,15 @@
# Mobile
English | [中文](./README.zh-CN.md)
多应用管理插件。
## 安装激活
```bash
yarn pm enable @nocobase/plugin-mobile
```
## 文档
[使用文档](https://docs.nocobase.com/handbook/mobile)

View File

@ -0,0 +1,15 @@
# Mobile
[English](./README.md) | 中文
多应用管理插件。
## 安装激活
```bash
yarn pm enable @nocobase/plugin-mobile
```
## 文档
[使用文档](https://docs.nocobase.com/handbook/mobile)

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,28 @@
{
"name": "@nocobase/plugin-mobile",
"version": "1.3.0-alpha",
"main": "dist/server/index.js",
"homepage": "https://docs.nocobase.com/handbook/mobile",
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/mobile",
"license": "AGPL-3.0",
"displayName": "Mobile",
"displayName.zh-CN": "移动端",
"description": "Provides the ability to configure mobile pages.",
"description.zh-CN": "提供移动端页面配置的能力。",
"peerDependencies": {
"@nocobase/client": "1.x",
"@nocobase/server": "1.x",
"@nocobase/test": "1.x"
},
"devDependencies": {
"@types/react": "17.x",
"@types/react-dom": "17.x",
"@ant-design/icons": "5.x",
"@formily/antd-v5": "1.x",
"@formily/react": "2.x",
"@formily/shared": "2.x",
"antd-mobile": "^5.36.1",
"react-device-detect": "2.2.3",
"re-resizable": "6.6.0"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,52 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expect, test } from '@nocobase/test/e2e';
test.describe('desktop-mode', () => {
test.beforeAll(async ({ page }) => {
await page.goto('/m');
});
test('desktop should have back link to admin', async ({ page }) => {
await page.getByRole('link', { name: 'Back' }).click();
// 跳转到 /admin
expect(page.url()).toContain('/admin');
await page.goto('/m');
});
test('ui editor should work', async ({ page }) => {
// 默认 designer 开启
await expect(page.getByTestId('schema-initializer-MobileTabBar')).toBeVisible();
// 再次点击应该隐藏
await page.getByTestId('ui-editor-button').click();
await expect(page.getByTestId('schema-initializer-MobileTabBar')).not.toBeVisible();
await page.getByTestId('ui-editor-button').click();
await expect(page.getByTestId('schema-initializer-MobileTabBar')).toBeVisible();
});
test('change mobile size', async ({ page }) => {
await page.getByTestId('desktop-mode-size-pad').click();
await expect(page.getByTestId('desktop-mode-resizable')).toHaveCSS('width', '768px');
await expect(page.getByTestId('desktop-mode-resizable')).toHaveCSS('height', '667px');
await page.getByTestId('desktop-mode-size-mobile').click();
await expect(page.getByTestId('desktop-mode-resizable')).toHaveCSS('width', '375px');
await expect(page.getByTestId('desktop-mode-resizable')).toHaveCSS('height', '667px');
});
test('show qrcode', async ({ page }) => {
await expect(page.getByRole('button', { name: 'qrcode' })).toBeVisible();
await page.getByRole('button', { name: 'qrcode' }).click();
await expect(page.getByRole('tooltip')).toBeVisible();
});
});

View File

@ -0,0 +1,263 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expect, test } from '@nocobase/test/e2e';
test.describe('PageHeader', () => {
test.describe('PageHeader', () => {
test('Display page header', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
// 默认有 header
await page.getByLabel('block-item-MobilePageProvider').click();
await expect(page.getByTestId('mobile-page-header')).toBeVisible();
await expect(page.getByTestId('mobile-page-header')).toContainText(nocoPage.getTitle());
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
// 四个选项都显示
await expect(page.getByRole('menuitem', { name: 'Display page header' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display navigation bar' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display page title' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display tabs' })).toBeVisible();
// 启用 tabs
await page.getByRole('menuitem', { name: 'Display tabs', exact: true }).click();
await expect(page.getByTestId('mobile-page-header')).toContainText('Unnamed');
// 点击后隐藏 tabs 和 title
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display page header', exact: true }).click();
await expect(page.getByLabel('block-item-MobilePageProvider')).not.toContainText(nocoPage.getTitle());
await expect(page.getByLabel('block-item-MobilePageProvider')).not.toContainText('Unnamed');
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
// 仅有 Display page header 显示
await expect(page.getByRole('menuitem', { name: 'Display page header' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display navigation bar' })).not.toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display page title' })).not.toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display tabs' })).not.toBeVisible();
// 再次点击显示
await page.getByRole('menuitem', { name: 'Display page header', exact: true }).click();
await expect(page.getByTestId('mobile-page-header')).toContainText(nocoPage.getTitle());
await expect(page.getByTestId('mobile-page-header')).toContainText('Unnamed');
});
});
test.describe('PageNavigationBar', () => {
test('Display navigation bar', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
// 默认有 navigation bar
await page.getByLabel('block-item-MobilePageProvider').click();
await expect(page.getByTestId('mobile-page-navigation-bar')).toBeVisible();
await expect(page.getByTestId('mobile-page-navigation-bar')).toContainText(nocoPage.getTitle());
// 点击后隐藏
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
// 显示 Display navigation bar 和 Display page title
await expect(page.getByRole('menuitem', { name: 'Display page title' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display navigation bar' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Display navigation bar', exact: true }).click();
await expect(page.getByTestId('mobile-page-navigation-bar')).not.toBeVisible();
// 再次点击显示
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
// Display navigation bar 为 false 时Display page title 应该不显示
await expect(page.getByRole('menuitem', { name: 'Display navigation bar' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Display page title' })).not.toBeVisible();
await page.getByRole('menuitem', { name: 'Display navigation bar' }).click();
await expect(page.getByTestId('mobile-page-navigation-bar')).toBeVisible();
await expect(page.getByTestId('mobile-page-navigation-bar')).toContainText(nocoPage.getTitle());
});
test('Display page title', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
// 默认有 title
await page.getByLabel('block-item-MobilePageProvider').click();
await expect(page.getByTestId('mobile-page-navigation-bar')).toContainText(nocoPage.getTitle());
// 点击后隐藏
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display page title' }).click();
await expect(page.getByTestId('mobile-page-navigation-bar')).not.toContainText(nocoPage.getTitle());
// 再次点击显示
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display page title' }).click();
await expect(page.getByTestId('mobile-page-navigation-bar')).toContainText(nocoPage.getTitle());
});
});
test.describe('tabs', () => {
test.beforeEach(async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display tabs' }).click();
await expect(page.getByTestId('mobile-page-tabs')).toBeVisible();
await expect(page.getByTestId('mobile-page-tabs')).toContainText('Unnamed');
await page.getByTestId('mobile-page-tabs').click();
await expect(page.getByRole('menuitem', { name: 'Display tabs' })).not.toBeVisible();
});
test('Display tabs', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
// 默认没有 tabs
await page.getByLabel('block-item-MobilePageProvider').click();
await expect(page.getByTestId('mobile-page-tabs')).not.toBeVisible();
// 点击后现实
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display tabs' }).click();
await expect(page.getByTestId('mobile-page-tabs')).toBeVisible();
await expect(page.getByTestId('mobile-page-tabs')).toContainText('Unnamed');
// 再次点击隐藏
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display tabs' }).click();
await expect(page.getByTestId('mobile-page-tabs')).not.toBeVisible();
});
test('Itemsettings', async ({ page }) => {
await page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-Unnamed`).click();
await page.getByTestId('mobile-page-tabs').getByLabel('designer-schema-settings-MobilePageTabs').click();
await expect(page.getByRole('menuitem', { name: 'Edit', exact: true })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Remove' })).not.toBeVisible(); // 仅有一项的时候不显示删除
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
const newTitle = Math.random().toString(36).substring(2);
await page.getByRole('textbox').fill(newTitle);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-${newTitle}`)).toHaveText(
newTitle,
);
await page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-${newTitle}`).click();
await page.getByTestId('mobile-page-tabs').getByLabel('designer-schema-settings-MobilePageTabs').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('textbox')).toHaveValue(newTitle);
});
test('Itemadd and remove', async ({ page }) => {
// 添加页面内容
await page.getByLabel('schema-initializer-Grid-mobile:addBlock').click();
await page.getByRole('menuitem', { name: 'form Markdown' }).click();
await expect(page.getByLabel('block-item-Markdown.Void-')).toBeVisible();
await page.getByLabel('action-Action-undefined').click();
const title = Math.random().toString(36).substring(2);
await page.getByRole('textbox').fill(title);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-${title}`)).toHaveText(title);
// 第一项也显示删除了
await page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-Unnamed`).click();
await page.getByTestId('mobile-page-tabs-Unnamed').getByLabel('designer-schema-settings-MobilePageTabs').click();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Edit', exact: true })).toBeVisible();
// 新增显示删除和编辑
await page.getByTestId('mobile-page-tabs').getByTestId(`mobile-page-tabs-${title}`).click();
await page.getByTestId(`mobile-page-tabs-${title}`).getByLabel('designer-schema-settings-MobilePageTabs').click();
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Edit', exact: true })).toBeVisible();
// 切换页面,第一个 tab 的内容不显示
await expect(page.getByLabel('block-item-Markdown.Void-')).not.toBeVisible();
// 删除
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByTestId('mobile-page-tabs')).not.toContainText(title);
// 等待删除完成
await page.waitForTimeout(500);
// 删除后第一个 tab 的内容显示
await expect(page.getByLabel('block-item-Markdown.Void-')).toBeVisible();
});
});
test.describe('actions', () => {
test('link', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
await expect(page.getByTestId('mobile-page-navigation-bar')).toBeVisible();
async function doPosition(position: 'left' | 'right') {
// 添加左侧 Action
await expect(page.getByTestId(`mobile-navigation-action-bar-${position}`)).toBeVisible();
const navigationBarPositionElement = page.getByTestId(`mobile-navigation-action-bar-${position}`);
await navigationBarPositionElement
.getByLabel('schema-initializer-MobileNavigationActionBar-mobile:navigation-bar:actions')
.click();
await page.getByRole('menuitem', { name: 'Link' }).click();
await page.getByRole('textbox').fill('Test________');
await page.getByLabel('action-Action-Submit').click();
await expect(navigationBarPositionElement).toContainText('Test________');
// 编辑
await navigationBarPositionElement.getByRole('button', { name: 'Test________' }).hover();
await navigationBarPositionElement
.getByLabel('designer-schema-settings-Action-mobile:navigation-bar:actions:link')
.hover();
await page.getByRole('menuitem', { name: 'Edit button' }).click();
await page.getByRole('textbox').fill('Test_changed');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(navigationBarPositionElement).toContainText('Test_changed');
// 编辑 URL
await navigationBarPositionElement.getByText('Test_changed').hover();
await navigationBarPositionElement
.getByLabel('designer-schema-settings-Action-mobile:navigation-bar:actions:link')
.hover();
await page.getByRole('menuitem', { name: 'Edit link' }).click();
await page.getByLabel('block-item-URL').getByLabel('textbox').fill('https://github.com');
await page.getByRole('button', { name: 'OK' }).click();
// 删除
await navigationBarPositionElement.getByText('Test_changed').hover();
await navigationBarPositionElement
.getByLabel('designer-schema-settings-Action-mobile:navigation-bar:actions:link')
.hover();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByText('Delete action').click();
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(navigationBarPositionElement).not.toContainText('Test_changed');
}
await doPosition('left');
await doPosition('right');
});
});
});

View File

@ -0,0 +1,147 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { expect, removeAllMobileRoutes, test } from '@nocobase/test/e2e';
function randomStr() {
return Math.random().toString(36).substring(2);
}
test.describe('TabBar', () => {
test('schema page & settings', async ({ page }) => {
await removeAllMobileRoutes();
await page.goto('/m');
// hover initializer
await expect(page.getByTestId('schema-initializer-MobileTabBar')).toBeVisible();
await page.getByTestId('schema-initializer-MobileTabBar').click();
// 添加页面
const Title = randomStr();
await page.getByRole('menuitem', { name: 'Page' }).click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill(Title);
await page.getByRole('button', { name: 'Select icon' }).click();
await page.getByRole('tooltip').getByLabel('account-book').locator('svg').click();
await page.getByLabel('action-Action-Submit').click();
await expect(page.getByTestId('modal-Action.Modal-Add page')).not.toBeVisible();
// 确认添加成功 data-testid="mobile-tab-bar-r5fb94wgkra"
await page.getByTestId(`mobile-tab-bar-${Title}`).click();
const count = await page.locator(`text=${Title}`).count(); // title and tabBar
await expect(count).toBe(2);
await expect(page.getByLabel('block-item-MobilePageProvider')).toBeVisible();
// 编辑
await page.getByTestId(`mobile-tab-bar-${Title}`).click();
await page.getByLabel('designer-schema-settings-MobileTabBar.Page-mobile:tab-bar:page').click();
await page.getByRole('menuitem', { name: 'Edit button' }).click();
await page.getByRole('textbox').fill(`${Title}_change`);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByTestId('modal-Action.Modal-Add page')).not.toBeVisible();
await page.getByLabel('block-item-MobileTabBar.Page').click();
const count_changed = await page.locator(`text=${Title}_change`).count();
await expect(count_changed).toBe(2);
// 删除
await page.getByTestId(`mobile-tab-bar-${Title}_change`).click();
await page.getByLabel('designer-schema-settings-MobileTabBar.Page-mobile:tab-bar:page').click();
await page.getByText('Delete').click();
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Delete action')).not.toBeVisible();
// 确认删除成功
await page.waitForTimeout(1000);
await expect(page.getByText(`${Title}_change`)).not.toBeVisible();
});
test('add link page & settings', async ({ page }) => {
await page.goto('/m');
await removeAllMobileRoutes();
// hover initializer
await expect(page.getByTestId('schema-initializer-MobileTabBar')).toBeVisible();
await page.getByTestId('schema-initializer-MobileTabBar').hover();
// 添加页面
const Title = randomStr();
await page.getByRole('menuitem', { name: 'Link' }).click();
await page.getByRole('textbox').click();
await page.getByRole('textbox').fill(Title);
await page.getByRole('button', { name: 'Select icon' }).click();
await page.getByRole('tooltip').getByLabel('account-book').locator('svg').click();
await page.getByLabel('action-Action-Submit').click();
await expect(page.getByTestId('modal-Action.Modal-Add page')).not.toBeVisible();
// 确认添加成功
await page.getByTestId(`mobile-tab-bar-${Title}`).click();
await expect(page.getByText('Please configure the URL')).toBeVisible();
const count = await page.locator(`text=${Title}`).count();
await expect(count).toBe(1);
// 编辑
await page.getByTestId(`mobile-tab-bar-${Title}`).hover();
await page.getByLabel('designer-schema-settings-MobileTabBar.Link-mobile:tab-bar:link').click();
await page.getByRole('menuitem', { name: 'Edit button' }).click();
await page.getByRole('textbox').fill(`${Title}_change`);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByTestId('modal-Action.Modal-Add page')).not.toBeVisible();
await page.getByTestId(`mobile-tab-bar-${Title}_change`).click();
const count_changed = await page.locator(`text=${Title}_change`).count();
expect(count_changed).toBe(1);
// 编辑 URL
await page.getByTestId(`mobile-tab-bar-${Title}_change`).hover();
// 如果有多个,点击获取 display 不为 none 的元素
await page.getByLabel('designer-schema-settings-MobileTabBar.Link-mobile:tab-bar:link').hover();
await page.getByRole('menuitem', { name: 'Edit link' }).click();
console.log('page.url()', page.url());
await page.getByLabel('block-item-URL').getByLabel('textbox').fill(page.url().replace('/m', '/admin'));
await page.getByRole('button', { name: 'OK' }).click();
const page2Promise = page.waitForEvent('popup');
await page.getByTestId(`mobile-tab-bar-${Title}_change`).click();
const page2 = await page2Promise;
expect(page2.url()).toBe(page.url().replace('/m', '/admin'));
await page2.close();
// 删除
await page.getByTestId(`mobile-tab-bar-${Title}_change`).hover();
await page.getByLabel('designer-schema-settings-MobileTabBar.Link-mobile:tab-bar:link').click();
await page.getByText('Delete').click();
await page.getByRole('button', { name: 'OK' }).click();
await expect(page.getByText('Delete action')).not.toBeVisible();
// 确认删除成功
await page.waitForTimeout(1000);
await expect(page.getByText(`${Title}_change`)).not.toBeVisible();
});
test('TabBar settings', async ({ page, mockMobilePage }) => {
const nocoPage = mockMobilePage();
await nocoPage.goto();
// 默认有 title
await page.getByLabel('block-item-MobilePageProvider').click();
await expect(page.getByTestId('mobile-tab-bar')).toBeVisible();
// 点击后隐藏
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display tab bar' }).click();
await expect(page.getByTestId('mobile-tab-bar')).not.toBeVisible();
// 再次点击显示
await page.getByLabel('block-item-MobilePageProvider').click();
await page.getByLabel('designer-schema-settings-MobilePageProvider-mobile:page').click();
await page.getByRole('menuitem', { name: 'Display tab bar' }).click();
await expect(page.getByTestId('mobile-tab-bar')).toBeVisible();
});
});

View File

@ -0,0 +1,53 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import App from '../demos/DesktopMode-basic';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
describe('DesktopMode', () => {
it('basic', async () => {
render(<App />);
await waitForApp();
// back
expect(screen.queryByRole('link')).toHaveAttribute('href', '/admin');
// ui-editor
expect(screen.queryByTestId('ui-editor-button')).toBeInTheDocument();
// size
await act(async () => {
await userEvent.click(screen.queryByTestId('desktop-mode-size-pad'));
});
await waitFor(() => {
expect(screen.queryByTestId('desktop-mode-resizable').style.width).toBe('768px');
expect(screen.queryByTestId('desktop-mode-resizable').style.height).toBe('667px');
});
await act(async () => {
await userEvent.click(screen.queryByTestId('desktop-mode-size-mobile'));
});
await waitFor(() => {
expect(screen.queryByTestId('desktop-mode-resizable').style.width).toBe('375px');
expect(screen.queryByTestId('desktop-mode-resizable').style.height).toBe('667px');
});
// qrcode
expect(screen.queryByTestId('desktop-mode-qrcode')).toBeInTheDocument();
await act(async () => {
await userEvent.hover(screen.queryByTestId('desktop-mode-qrcode'));
});
await waitFor(() => {
expect(document.querySelector('canvas')).toBeInTheDocument();
});
// content
expect(screen.queryByText('demo content')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,114 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import Basic from '../../demos/pages-dynamic-page-basic';
import NotFound from '../../demos/pages-dynamic-page-404';
import Schema from '../../demos/pages-dynamic-page-schema';
// import Settings from '../../demos/pages-dynamic-page-settings'
describe('MobilePage', () => {
it('basic', async () => {
render(<Basic />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Schema Test Page')).toBeInTheDocument();
});
});
it('not found', async () => {
render(<NotFound />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('404')).toBeInTheDocument();
});
await act(async () => {
await userEvent.click(screen.getByText('Back Home'));
});
await waitFor(() => {
expect(screen.queryByText('404')).not.toBeInTheDocument();
});
});
it('schema', async () => {
render(<Schema />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Tab1 Content')).toBeInTheDocument();
expect(screen.queryAllByRole('button')).toHaveLength(4);
});
});
// it('settings', async () => {
// render(<Settings />);
// await waitForApp();
// await waitFor(() => {
// expect(screen.queryByText('Settings')).toBeInTheDocument();
// });
// await act(async () => {
// await userEvent.hover(screen.getByText('Settings'));
// });
// await waitFor(() => {
// expect(document.querySelector('span[aria-label="designer-schema-settings-div-mobile:page"]')).toBeInTheDocument();
// })
// await act(async () => {
// await userEvent.hover(document.querySelector('span[aria-label="designer-schema-settings-div-mobile:page"]'));
// });
// await waitFor(() => {
// expect(screen.queryByText('Display page header')).toBeInTheDocument();
// expect(screen.queryByText('Display page title')).not.toBeInTheDocument();
// expect(screen.queryByText('Display tabs')).not.toBeInTheDocument();
// });
// await act(async () => {
// await userEvent.click(screen.getByText('Display page header'));
// });
// await waitFor(() => {
// expect(screen.queryByText('Display page title')).toBeInTheDocument();
// expect(screen.queryByText('Display tabs')).toBeInTheDocument();
// expect(screen.queryByTestId('schema-json')).toHaveTextContent(JSON.stringify({
// "displayNavigationBar": true
// }));
// });
// await act(async () => {
// await userEvent.click(screen.getByText('Display page title'));
// });
// await waitFor(() => {
// expect(screen.queryByTestId('schema-json')).toHaveTextContent(JSON.stringify({
// "displayNavigationBar": true,
// "displayPageTitle": false
// }));
// });
// await act(async () => {
// await userEvent.click(screen.getByText('Display tabs'));
// });
// await waitFor(() => {
// expect(screen.queryByTestId('schema-json')).toHaveTextContent(JSON.stringify({
// "displayNavigationBar": true,
// "displayPageTitle": false,
// "displayTabs": true
// }));
// });
// });
});

View File

@ -0,0 +1,51 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import Basic from '../../demos/pages-page-content-basic';
import FirstRoute from '../../demos/pages-page-content-first-route';
import NotFound from '../../demos/pages-page-content-404';
describe('MobilePageContent', () => {
it('basic', async () => {
render(<Basic />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Schema Test Page')).toBeInTheDocument();
});
});
it('render first route', async () => {
render(<FirstRoute />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('First Route Content')).toBeInTheDocument();
});
});
it('not found', async () => {
render(<NotFound />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('404')).toBeInTheDocument();
});
await act(async () => {
await userEvent.click(screen.getByText('Back Home'));
});
await waitFor(() => {
expect(screen.queryByText('404')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,64 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import Basic from '../../demos/pages-navigation-bar-basic';
import NavFalse from '../../demos/pages-navigation-bar-false';
import NavTitleFalse from '../../demos/pages-navigation-bar-title-false';
import NavTabs from '../../demos/pages-page-tabs';
describe('MobilePageNavigationBar', () => {
it('basic', async () => {
render(<Basic />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Title')).toBeInTheDocument();
});
});
it('displayNavigationBar: false', async () => {
render(<NavFalse />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Title')).not.toBeInTheDocument();
});
});
it('displayPageTitle: false', async () => {
render(<NavTitleFalse />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Title')).not.toBeInTheDocument();
});
});
it('displayTabs: true', async () => {
render(<NavTabs />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Tab1')).toBeInTheDocument();
expect(screen.queryByText('Tab2')).toBeInTheDocument();
expect(screen.queryByText('/page/page1/tabs/tab1')).toBeInTheDocument();
});
await act(async () => {
await userEvent.click(screen.getByText('Tab2'));
});
await waitFor(() => {
expect(screen.queryByText('Tab1')).toBeInTheDocument();
expect(screen.queryByText('Tab2')).toBeInTheDocument();
expect(screen.queryByText('/page/page1/tabs/tab2')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,29 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render, screen, waitFor, waitForApp } from '@nocobase/test/client';
import Basic from '../../demos/pages-navigation-bar-actions';
describe('MobilePage', () => {
it('basic', async () => {
render(<Basic />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Title')).toBeInTheDocument();
expect(document.querySelector('.adm-nav-bar-left')).toHaveTextContent('Left');
expect(document.querySelector('.adm-nav-bar-right')).toHaveTextContent('Right1');
expect(document.querySelector('.adm-nav-bar-right')).toHaveTextContent('Right2');
expect(screen.queryByText('Bottom')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,38 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import App from '../demos/Mobile-basic';
describe('Mobile', () => {
test('desktop mode', async () => {
render(<App />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByTestId('ui-editor-button')).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.queryAllByText('Test1')).toHaveLength(2);
expect(screen.queryByText('Test2')).toBeInTheDocument();
expect(screen.queryByText('Tab1')).toBeInTheDocument();
expect(screen.queryByText('Tab2')).toBeInTheDocument();
expect(screen.queryByText('Tab1 Content')).toBeInTheDocument();
});
await act(async () => {
await userEvent.click(screen.queryByText('Tab2'));
});
await waitFor(() => {
expect(screen.queryByText('Tab2 Content')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,44 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render, screen, waitFor, waitForApp } from '@nocobase/test/client';
import BasicApp from '../demos/MobileTabBar-basic';
import FalseApp from '../demos/MobileTabBar-false';
import InnerPageApp from '../demos/MobileTabBar-inner-page';
describe('MobileTabBar', () => {
test('basic', async () => {
render(<BasicApp />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Test1')).toBeInTheDocument(); // title
expect(screen.queryByText('Test2')).toBeInTheDocument(); // title
});
});
test('enableTabBar: false', async () => {
render(<FalseApp />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Test1')).not.toBeInTheDocument(); // title
expect(screen.queryByText('Test2')).not.toBeInTheDocument(); // title
});
});
test('inner page', async () => {
render(<InnerPageApp />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('inner page')).toBeInTheDocument(); // custom page content
expect(screen.queryByText('Test1')).not.toBeInTheDocument();
expect(screen.queryByText('Test2')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,38 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import MobileTitleProviderApp from '../demos/MobileTitleProvider-basic';
import MobileRoutesProviderApp from '../demos/MobileRoutesProvider-basic';
describe('MobileProviders', () => {
test('MobileTitleProvider', async () => {
render(<MobileTitleProviderApp />);
await waitFor(() => {
expect(screen.queryByText('Set Title')).toBeInTheDocument();
});
await act(async () => {
await userEvent.click(screen.queryByText('Set Title'));
});
await waitFor(() => {
expect(screen.queryByText('Hello World')).toBeInTheDocument();
});
});
test('MobileRoutesProvider', async () => {
render(<MobileRoutesProviderApp />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Test1')).toBeInTheDocument();
expect(screen.queryByText('2')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,58 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent } from '@nocobase/test/client';
import BasicApp from '../demos/MobileTabBar.Item-basic';
import OnClickApp from '../demos/MobileTabBar.Item-on-click';
import SelectedApp from '../demos/MobileTabBar.Item-selected';
import SelectedIconApp from '../demos/MobileTabBar.Item-selected-icon';
import WithIconApp from '../demos/MobileTabBar.Item-with-icon';
import WithIconReactNodeApp from '../demos/MobileTabBar.Item-with-icon-node';
describe('MobileTabBar.Item', () => {
test('Basic', () => {
render(<BasicApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
});
test('With Icon: string', () => {
render(<WithIconApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
});
test('With Icon: React.Node', () => {
render(<WithIconReactNodeApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
});
test('Selected', () => {
render(<SelectedApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(document.querySelector('.adm-tab-bar-item-active')).toBeInTheDocument();
});
test('Selected Icon', () => {
render(<SelectedIconApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
expect(document.querySelector('.adm-tab-bar-item-active')).toBeInTheDocument();
expect(screen.queryByRole('img')).toHaveAttribute('aria-label', 'appstore-add');
});
test('onClick', async () => {
render(<OnClickApp />);
expect(screen.getByText('Test')).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByText('Test'));
});
expect(screen.getByText('Clicked')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,88 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import InnerApp from '../demos/MobileTabBar.Link-inner';
import OuterApp from '../demos/MobileTabBar.Link-outer';
import SelectedApp from '../demos/MobileTabBar.Link-selected';
import SchemaApp from '../demos/MobileTabBar.Link-schema';
describe('MobileTabBar.Item', () => {
test('Inner Link', async () => {
render(<InnerApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
await userEvent.click(screen.getByText('Test'));
await waitFor(() => {
expect(screen.getByText('Test Page')).toBeInTheDocument();
});
});
test('Outer Link', async () => {
render(<OuterApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
const originOpen = window.open;
const origin = vitest.fn();
window.open = origin;
await userEvent.click(screen.getByText('Test'));
await waitFor(() => {
expect(origin).toBeCalled();
});
window.open = originOpen;
});
test('Selected', async () => {
render(<SelectedApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(document.querySelector('.adm-tab-bar-item-active')).toBeInTheDocument();
});
test('Schema', async () => {
render(<SchemaApp />);
await waitForApp();
expect(screen.getByText('Link')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
expect(screen.queryByTestId('schema-json')).toMatchInlineSnapshot(`
<pre
data-testid="schema-json"
>
{
"_isJSONSchemaObject": true,
"version": "2.0",
"name": "schema",
"type": "void",
"x-decorator": "BlockItem",
"x-settings": "mobile:tab-bar:link",
"x-component": "MobileTabBar.Link",
"x-toolbar-props": {
"showBorder": false,
"showBackground": true
},
"x-component-props": {
"title": "Link",
"icon": "AppstoreOutlined",
"url": "https://github.com"
}
}
</pre>
`);
});
});

View File

@ -0,0 +1,68 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/test/client';
import BasicApp from '../demos/MobileTabBar.Page-basic';
import SelectedApp from '../demos/MobileTabBar.Page-selected';
import SchemaApp from '../demos/MobileTabBar.Page-schema';
describe('MobileTabBar.Item', () => {
test('Basic', async () => {
render(<BasicApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
await userEvent.click(screen.getByText('Test'));
await waitFor(() => {
expect(screen.getByText('Schema Test Page')).toBeInTheDocument();
});
});
test('Selected', async () => {
render(<SelectedApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(document.querySelector('.adm-tab-bar-item-active')).toBeInTheDocument();
});
test('Schema', async () => {
render(<SchemaApp />);
await waitForApp();
expect(screen.getByText('Test')).toBeInTheDocument();
expect(screen.queryByRole('img')).toBeInTheDocument();
expect(screen.queryByTestId('schema-json')).toMatchInlineSnapshot(`
<pre
data-testid="schema-json"
>
{
"_isJSONSchemaObject": true,
"version": "2.0",
"name": "schema",
"type": "void",
"x-decorator": "BlockItem",
"x-settings": "mobile:tab-bar:page",
"x-component": "MobileTabBar.Page",
"x-toolbar-props": {
"showBorder": false,
"showBackground": true
},
"x-component-props": {
"title": "Test",
"icon": "AppstoreOutlined",
"schemaUid": "test"
}
}
</pre>
`);
});
});

View File

@ -0,0 +1,46 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { invoke, isJSBridge } from '../js-bridge';
describe('invoke function', () => {
beforeAll(() => {
(window as any).JsBridge = { invoke: vitest.fn() };
});
it('should invoke scan correctly', () => {
const cb = vitest.fn();
invoke({ action: 'scan' }, cb);
expect((window as any).JsBridge.invoke).toHaveBeenCalledWith({ action: 'scan' }, cb);
});
it('should invoke moveTaskToBack correctly', () => {
invoke({ action: 'moveTaskToBack' });
expect((window as any).JsBridge.invoke).toHaveBeenCalledWith({ action: 'moveTaskToBack' }, undefined);
});
it('should handle callbacks on moveTaskToBack action', () => {
const cb = vitest.fn();
invoke({ action: 'moveTaskToBack' }, cb);
expect((window as any).JsBridge.invoke).toHaveBeenCalledWith({ action: 'moveTaskToBack' }, cb);
});
});
describe('isJSBridge function', () => {
it('should return true if JsBridge is available', () => {
expect(isJSBridge()).toBe(true);
});
it('should return false if JsBridge is not defined', () => {
const originalJsBridge = (window as any).JsBridge;
delete (window as any).JsBridge;
expect(isJSBridge()).toBe(false);
(window as any).JsBridge = originalJsBridge; // Restore original state
});
});

View File

@ -0,0 +1,42 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render, waitForApp, screen, waitFor } from '@nocobase/test/client';
import BasicApp from '../../demos/pages-home-basic';
import CustomApp from '../../demos/pages-home-custom';
import NullApp from '../../demos/pages-home-null';
describe('Home page', () => {
it('rewrite to first page', async () => {
render(<BasicApp />);
await waitForApp();
await waitFor(() => {
expect(screen.queryByText('Test Page')).toBeInTheDocument();
});
});
it('if custom home page, not rewrite', async () => {
render(<CustomApp />);
await waitFor(() => {
expect(screen.queryByTestId('mobile-loading')).not.toBeInTheDocument();
expect(screen.queryByText('Custom Home Page')).toBeInTheDocument();
});
});
it('no routes render null', async () => {
render(<NullApp />);
await waitForApp();
await waitFor(() => {
expect(document.querySelector('.ant-app').innerHTML).toHaveLength(0);
});
});
});

View File

@ -0,0 +1,29 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render, waitForApp, screen, userEvent, waitFor, act } from '@nocobase/test/client';
import App from '../../demos/pages-not-found';
describe('NotFound page', () => {
it('basic page', async () => {
render(<App />);
await waitForApp();
expect(screen.queryByText('404')).toBeInTheDocument();
await act(async () => {
await userEvent.click(screen.getByText('Back Home'));
});
await waitFor(() => {
expect(screen.queryByText('Home Page')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,259 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// CSS modules
type CSSModuleClasses = { readonly [key: string]: string };
declare module 'demos' {
import type { FC } from 'react'
export const DemoBlock: FC<{
title: string
padding?: string
background?: string
children?: ReactNode
}>;
}
declare module '*.module.css' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.scss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sass' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.less' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.styl' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.stylus' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.pcss' {
const classes: CSSModuleClasses;
export default classes;
}
declare module '*.module.sss' {
const classes: CSSModuleClasses;
export default classes;
}
// CSS
declare module '*.css' { }
declare module '*.scss' { }
declare module '*.sass' { }
declare module '*.less' { }
declare module '*.styl' { }
declare module '*.stylus' { }
declare module '*.pcss' { }
declare module '*.sss' { }
// Built-in asset types
// see `src/node/constants.ts`
// images
declare module '*.apng' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.jfif' {
const src: string;
export default src;
}
declare module '*.pjpeg' {
const src: string;
export default src;
}
declare module '*.pjp' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.ico' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.avif' {
const src: string;
export default src;
}
// media
declare module '*.mp4' {
const src: string;
export default src;
}
declare module '*.webm' {
const src: string;
export default src;
}
declare module '*.ogg' {
const src: string;
export default src;
}
declare module '*.mp3' {
const src: string;
export default src;
}
declare module '*.wav' {
const src: string;
export default src;
}
declare module '*.flac' {
const src: string;
export default src;
}
declare module '*.aac' {
const src: string;
export default src;
}
declare module '*.opus' {
const src: string;
export default src;
}
declare module '*.mov' {
const src: string;
export default src;
}
declare module '*.m4a' {
const src: string;
export default src;
}
declare module '*.vtt' {
const src: string;
export default src;
}
// fonts
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.eot' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}
// other
declare module '*.webmanifest' {
const src: string;
export default src;
}
declare module '*.pdf' {
const src: string;
export default src;
}
declare module '*.txt' {
const src: string;
export default src;
}
// wasm?init
declare module '*.wasm?init' {
const initWasm: (options?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
export default initWasm;
}
// web worker
declare module '*?worker' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&inline' {
const workerConstructor: {
new(options?: { name?: string }): Worker;
};
export default workerConstructor;
}
declare module '*?worker&url' {
const src: string;
export default src;
}
declare module '*?sharedworker' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&inline' {
const sharedWorkerConstructor: {
new(options?: { name?: string }): SharedWorker;
};
export default sharedWorkerConstructor;
}
declare module '*?sharedworker&url' {
const src: string;
export default src;
}
declare module '*?raw' {
const src: string;
export default src;
}
declare module '*?url' {
const src: string;
export default src;
}
declare module '*?inline' {
const src: string;
export default src;
}

View File

@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export const PluginName = 'mobile';
export const NavigationBarHeight = 50;
export const PageBackgroundColor = '#f5f5f5';

View File

@ -0,0 +1,19 @@
import { Plugin } from '@nocobase/client';
import { DesktopMode } from '@nocobase/plugin-mobile/client';
import { mockApp } from '@nocobase/client/demo-utils';
import React from 'react';
const Demo = () => {
return <DesktopMode>demo content</DesktopMode>;
};
class DemoPlugin extends Plugin {
async load() {
this.app.router.add('root', { path: '/', Component: Demo });
}
}
const app = mockApp({ plugins: [DemoPlugin] });
export default app.getRootComponent();

View File

@ -0,0 +1,290 @@
import { Plugin } from '@nocobase/client';
import PluginMobileClient, { Mobile } from '@nocobase/plugin-mobile/client';
import { mockApp } from '@nocobase/client/demo-utils';
class DemoPlugin extends Plugin {
async beforeLoad(): Promise<void> {
await this.app.pluginManager.add(PluginMobileClient, {
config: {
router: {
type: 'memory',
basename: '/m',
initialEntries: ['/m'],
},
skipLogin: true,
},
});
}
async load() {
this.app.router.add('root', { path: '/m', Component: Mobile });
}
}
const app = mockApp({
router: {
type: 'memory',
initialEntries: ['/m'],
},
plugins: [DemoPlugin],
apis: {
'mobileRoutes:list': {
data: [
{
id: 10,
createdAt: '2024-07-08T13:22:33.763Z',
updatedAt: '2024-07-08T13:22:33.763Z',
parentId: null,
title: 'Test1',
icon: 'AppstoreOutlined',
schemaUid: 'd4o6esth2ik',
type: 'page',
options: null,
sort: 1,
createdById: 1,
updatedById: 1,
children: [
{
id: 11,
createdAt: '2024-07-08T13:22:33.800Z',
updatedAt: '2024-07-08T13:22:45.084Z',
parentId: 10,
title: 'Tab1',
icon: null,
schemaUid: 'pm65m9y0o2y',
type: 'tabs',
options: null,
sort: 2,
createdById: 1,
updatedById: 1,
__index: '0.children.0',
},
{
id: 12,
createdAt: '2024-07-08T13:22:48.564Z',
updatedAt: '2024-07-08T13:22:48.564Z',
parentId: 10,
title: 'Tab2',
icon: null,
schemaUid: '1mcth1tfcb6',
type: 'tabs',
options: null,
sort: 3,
createdById: 1,
updatedById: 1,
__index: '0.children.1',
},
],
__index: '0',
},
{
id: 13,
createdAt: '2024-07-08T13:23:01.929Z',
updatedAt: '2024-07-08T13:23:12.433Z',
parentId: null,
title: 'Test2',
icon: 'aliwangwangoutlined',
schemaUid: null,
type: 'link',
options: {
schemaUid: null,
url: 'https://github.com',
params: [{}],
},
sort: 4,
createdById: 1,
updatedById: 1,
__index: '1',
},
],
},
'applicationPlugins:update': {
data: {},
},
'uiSchemas:getJsonSchema/d4o6esth2ik': {
data: {
'x-uid': 'd4o6esth2ik',
name: 'd4o6esth2ik',
type: 'void',
'x-component': 'MobilePageProvider',
'x-settings': 'mobile:page',
'x-decorator': 'BlockItem',
'x-decorator-props': {
style: {
height: '100%',
},
},
'x-toolbar-props': {
draggable: false,
spaceWrapperStyle: {
right: -15,
top: -15,
},
spaceClassName: 'css-m1q7xw',
toolbarStyle: {
overflowX: 'hidden',
},
},
_isJSONSchemaObject: true,
version: '2.0',
'x-async': false,
'x-component-props': {
displayPageTitle: true,
displayTabs: true,
},
properties: {
header: {
type: 'void',
'x-component': 'MobilePageHeader',
properties: {
pageNavigationBar: {
type: 'void',
'x-component': 'MobilePageNavigationBar',
properties: {
actionBar: {
type: 'void',
'x-component': 'MobileNavigationActionBar',
'x-initializer': 'mobile:navigation-bar:actions',
'x-component-props': {
spaceProps: {
style: {
flexWrap: 'nowrap',
},
},
},
name: 'actionBar',
},
},
name: 'pageNavigationBar',
},
pageTabs: {
type: 'void',
'x-component': 'MobilePageTabs',
name: 'pageTabs',
},
},
name: 'header',
},
content: {
type: 'void',
'x-component': 'MobilePageContent',
'x-uid': 'ooteekvdis8',
'x-async': false,
'x-index': 2,
},
},
},
},
'uiSchemas:getJsonSchema/1mcth1tfcb6': {
data: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid',
'x-initializer': 'mobile:addBlock',
'x-app-version': '1.2.12-alpha',
properties: {
mbds3xuxm48: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.12-alpha',
properties: {
jfe4z693cji: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.12-alpha',
properties: {
'01rowxmritv': {
'x-uid': 'pj9gi5yfpza',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-settings': 'blockSettings:markdown',
'x-decorator': 'CardItem',
'x-decorator-props': {
name: 'markdown',
},
'x-component': 'div',
'x-content': 'Tab2 Content',
'x-app-version': '1.2.12-alpha',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '4twpusksaod',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ktou2snt890',
'x-async': false,
'x-index': 1,
},
},
name: 'nuh60rxntix',
'x-uid': '1mcth1tfcb6',
'x-async': true,
'x-index': 2,
},
},
'uiSchemas:getJsonSchema/pm65m9y0o2y': {
data: {
type: 'void',
'x-component': 'Grid',
'x-initializer': 'mobile:addBlock',
properties: {
lxtx5t4hh2x: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Row',
'x-app-version': '1.2.12-alpha',
properties: {
yn6ojyount2: {
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-component': 'Grid.Col',
'x-app-version': '1.2.12-alpha',
properties: {
mgsz7z1ibu0: {
'x-uid': '1dpiddwlasg',
_isJSONSchemaObject: true,
version: '2.0',
type: 'void',
'x-settings': 'blockSettings:markdown',
'x-decorator': 'CardItem',
'x-decorator-props': {
name: 'markdown',
},
'x-component': 'div',
'x-content': 'Tab1 Content',
'x-app-version': '1.2.12-alpha',
'x-async': false,
'x-index': 1,
},
},
'x-uid': 'ip7l9yu8v37',
'x-async': false,
'x-index': 1,
},
},
'x-uid': '48ilv5bcdz1',
'x-async': false,
'x-index': 1,
},
},
name: 'pm65m9y0o2y',
'x-uid': 'pm65m9y0o2y',
'x-async': true,
'x-index': 1,
},
},
},
});
export default app.getRootComponent();

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