feat: adapt styles for mobile devices (#6393)

* feat(layout): integrate ConfigProvider for theme management in admin layout

* feat(layout): add mobile layout context and transform utility for schema

* feat(toolbar): disable dragging in mobile layout for BlockSchemaToolbar

* test: add unit tests for transformMultiColumnToSingleColumn function

* feat: add mobile layout support to form schema transformation

* feat: support mobile layout in Tabs component schema transformation

* feat: integrate mobile layout support into designable components

* feat: refactor mobile layout hooks and integrate MobileComponentsProvider

* feat: update mobile layout styles and integrate global theme in ActionDrawer

* feat: update Mobile component to use CommonDrawer for popup handling in mobile layout

* feat: adjust padding in ActionDrawer style for improved layout on mobile

* feat: optimize scope usage in InternalPicker for improved performance

* feat: update Action.Page style to improve overflow handling

* style: remove minHeight from ActionDrawer style

* feat: add mobile layout support and transform schema for multi-column fields

* feat: support mobile components

* fix(mobile): issue with mobile flash/flicker

* feat: hide Plugin manager and Settings center

* feat: hide scroll bar in mobile

* feat(todos): support to collapse sider menu

* feat: enhance mobile layout theme support with dark mode
This commit is contained in:
Zeke Zhang 2025-03-12 07:51:11 +08:00 committed by GitHub
parent 37f8936781
commit c2786c1bee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 600 additions and 102 deletions

View File

@ -14,6 +14,7 @@ import { useCollection } from '../../data-source/collection/CollectionProvider';
import { useCompile } from '../../schema-component';
import { SchemaToolbar } from '../../schema-settings/GeneralSchemaDesigner';
import { useSchemaTemplate } from '../../schema-templates';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
export const BlockSchemaToolbar = (props) => {
const { t } = useTranslation();
@ -22,6 +23,7 @@ export const BlockSchemaToolbar = (props) => {
const template = useSchemaTemplate();
const { association, collection } = useDataBlockProps() || {};
const compile = useCompile();
const { isMobileLayout } = useMobileLayout();
if (association) {
const [collectionName] = association.split('.');
@ -51,7 +53,7 @@ export const BlockSchemaToolbar = (props) => {
].filter(Boolean);
}, [currentCollectionTitle, currentCollectionName, associationField, associationCollection, compile, templateName]);
return <SchemaToolbar title={toolbarTitle} {...props} />;
return <SchemaToolbar title={toolbarTitle} {...props} draggable={!isMobileLayout} />;
};
export function getCollectionTitle(arg: {

View File

@ -13,6 +13,7 @@ import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useApp, useNavigateNoUpdate } from '../application';
import { useMobileLayout } from '../route-switch/antd/admin-layout';
import { useCompile } from '../schema-component';
import { useToken } from '../style';
@ -20,6 +21,12 @@ export const PluginManagerLink = () => {
const { t } = useTranslation();
const navigate = useNavigateNoUpdate();
const { token } = useToken();
const { isMobileLayout } = useMobileLayout();
if (isMobileLayout) {
return null;
}
return (
<Tooltip title={t('Plugin manager')}>
<Button
@ -62,6 +69,12 @@ export const SettingsCenterDropdown = () => {
};
}, [app.pluginSettingsManager]);
const { isMobileLayout } = useMobileLayout();
if (isMobileLayout) {
return null;
}
return (
<Dropdown
menu={{

View File

@ -11,7 +11,7 @@ import { EllipsisOutlined } from '@ant-design/icons';
import ProLayout, { RouteContext, RouteContextType } from '@ant-design/pro-layout';
import { HeaderViewProps } from '@ant-design/pro-layout/es/components/Header';
import { css } from '@emotion/css';
import { Popover, Tooltip } from 'antd';
import { theme as antdTheme, ConfigProvider, Popover, Tooltip } from 'antd';
import React, { createContext, FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import { Link, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
@ -29,6 +29,7 @@ import {
RemoteSchemaTemplateManagerProvider,
SortableItem,
useDesignable,
useGlobalTheme,
useMenuDragEnd,
useParseURLAndParams,
useRequest,
@ -73,7 +74,7 @@ const AllAccessDesktopRoutesContext = createContext<{
refresh: () => void;
}>({
allAccessRoutes: emptyArray,
refresh: () => {},
refresh: () => { },
});
AllAccessDesktopRoutesContext.displayName = 'AllAccessDesktopRoutesContext';
@ -495,22 +496,44 @@ const headerRender = (props: HeaderViewProps, defaultDom: React.ReactNode) => {
return <headerContext.Provider value={headerContextValue}>{defaultDom}</headerContext.Provider>;
};
const IsMobileLayoutContext = React.createContext<{
isMobileLayout: boolean;
setIsMobileLayout: React.Dispatch<React.SetStateAction<boolean>>;
}>({
isMobileLayout: false,
setIsMobileLayout: () => { },
});
const MobileLayoutProvider: FC = (props) => {
const [isMobileLayout, setIsMobileLayout] = useState(false);
const value = useMemo(() => ({ isMobileLayout, setIsMobileLayout }), [isMobileLayout]);
return <IsMobileLayoutContext.Provider value={value}>{props.children}</IsMobileLayoutContext.Provider>;
};
export const useMobileLayout = () => {
const { isMobileLayout, setIsMobileLayout } = useContext(IsMobileLayoutContext);
return { isMobileLayout, setIsMobileLayout };
};
export const InternalAdminLayout = () => {
const { allAccessRoutes } = useAllAccessDesktopRoutes();
const { designable } = useDesignable();
const { designable: _designable } = useDesignable();
const location = useLocation();
const { onDragEnd } = useMenuDragEnd();
const { token } = useToken();
const [isMobile, setIsMobile] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const { isMobileLayout, setIsMobileLayout } = useMobileLayout();
const [collapsed, setCollapsed] = useState(isMobileLayout);
const doNotChangeCollapsedRef = useRef(false);
const { t } = useMenuTranslation();
const designable = isMobileLayout ? false : _designable;
const route = useMemo(() => {
return {
path: '/',
children: convertRoutesToLayout(allAccessRoutes, { designable, isMobile, t }),
children: convertRoutesToLayout(allAccessRoutes, { designable, isMobile: isMobileLayout, t }),
};
}, [allAccessRoutes, designable, isMobile, t]);
}, [allAccessRoutes, designable, isMobileLayout, t]);
const layoutToken = useMemo(() => {
return {
header: {
@ -534,6 +557,21 @@ export const InternalAdminLayout = () => {
bgLayout: token.colorBgLayout,
};
}, [token]);
const { theme, isDarkTheme } = useGlobalTheme();
const mobileTheme = useMemo(() => {
return {
...theme,
token: {
...theme.token,
paddingPageHorizontal: 8, // Horizontal page padding
paddingPageVertical: 8, // Vertical page padding
marginBlock: 12, // Spacing between blocks
borderRadiusBlock: 8, // Block border radius
fontSize: 14, // Font size
},
algorithm: isDarkTheme ? [antdTheme.compactAlgorithm, antdTheme.darkAlgorithm] : antdTheme.compactAlgorithm, // Set mobile mode to always use compact algorithm
};
}, [theme, isDarkTheme]);
const onCollapse = useCallback((collapsed: boolean) => {
if (doNotChangeCollapsedRef.current) {
@ -575,11 +613,15 @@ export const InternalAdminLayout = () => {
{(value: RouteContextType) => {
const { isMobile: _isMobile } = value;
if (_isMobile !== isMobile) {
setIsMobile(_isMobile);
if (_isMobile !== isMobileLayout) {
setIsMobileLayout(_isMobile);
}
return <LayoutContent />;
return (
<ConfigProvider theme={_isMobile ? mobileTheme : theme}>
<LayoutContent />
</ConfigProvider>
);
}}
</RouteContext.Consumer>
</ProLayout>
@ -696,6 +738,7 @@ export class AdminLayoutPlugin extends Plugin {
async load() {
this.app.schemaSettingsManager.add(userCenterSettings);
this.app.addComponents({ AdminLayout, AdminDynamicPage });
this.app.use(MobileLayoutProvider);
}
}
@ -717,36 +760,6 @@ export function findRouteBySchemaUid(schemaUid: string, treeArray: any[]) {
return null;
}
const MenuItemIcon: FC<{ icon: string; title: string }> = (props) => {
const { inHeader } = useContext(headerContext);
return (
<RouteContext.Consumer>
{(value: RouteContextType) => {
const { collapsed } = value;
if (collapsed && !inHeader) {
return props.icon ? (
<Icon type={props.icon} />
) : (
<span
style={{
display: 'inline-block',
width: '100%',
textAlign: 'center',
}}
>
{props.title.charAt(0)}
</span>
);
}
return props.icon ? <Icon type={props.icon} /> : null;
}}
</RouteContext.Consumer>
);
};
const MenuDesignerButton: FC<{ testId: string }> = (props) => {
const { render: renderInitializer } = useSchemaInitializerRender(menuItemInitializer);
@ -766,7 +779,7 @@ const MenuTitleWithIcon: FC<{ icon: any; title: string }> = (props) => {
);
}
return props.title;
return <>{props.title}</>;
};
function convertRoutesToLayout(

View File

@ -20,7 +20,8 @@ export const useActionPageStyle = genStyleHook('nb-action-page', (token) => {
right: 0,
bottom: 0,
backgroundColor: token.colorBgLayout,
overflow: 'auto',
overflowX: 'hidden',
overflowY: 'auto',
'.ant-tabs-nav': {
background: token.colorBgContainer,

View File

@ -13,6 +13,7 @@ import { differenceBy, unionBy } from 'lodash';
import React, { useContext, useMemo, useState } from 'react';
import {
FormProvider,
PopupSettingsProvider,
RecordPickerContext,
RecordPickerProvider,
SchemaComponentOptions,
@ -24,6 +25,7 @@ import {
NocoBaseRecursionField,
RecordProvider,
useCollectionRecordData,
useMobileLayout,
} from '../../..';
import { useFormBlockContext } from '../../../block-provider/FormBlockProvider';
import {
@ -35,6 +37,7 @@ import { ActionContextProvider } from '../action';
import { useAssociationFieldContext, useFieldNames, useInsertSchema } from './hooks';
import schema from './schema';
import { flatData, getLabelFormatValue, useLabelUiSchema } from './util';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export const useTableSelectorProps = () => {
const field: any = useField();
@ -117,6 +120,7 @@ export const InternalPicker = observer(
collectionField,
currentFormCollection: collectionName,
};
const { isMobileLayout } = useMobileLayout();
const getValue = () => {
if (multiple == null) return null;
@ -147,8 +151,16 @@ export const InternalPicker = observer(
},
};
};
const scope = useMemo(
() => ({
usePickActionProps,
useTableSelectorProps,
}),
[],
);
return (
<>
<PopupSettingsProvider enableURL={false}>
<Space.Compact style={{ display: 'flex', lineHeight: '32px' }}>
<div style={{ width: '100%' }}>
<Select
@ -213,16 +225,11 @@ export const InternalPicker = observer(
<CollectionProvider_deprecated name={collectionField?.target}>
<FormProvider>
<TableSelectorParamsProvider params={{ filter: getFilter() }}>
<SchemaComponentOptions
scope={{
usePickActionProps,
useTableSelectorProps,
}}
>
<SchemaComponentOptions scope={scope}>
<NocoBaseRecursionField
onlyRenderProperties
basePath={field.address}
schema={fieldSchema}
schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
filterProperties={(s) => {
return s['x-component'] === 'AssociationField.Selector';
}}
@ -233,7 +240,7 @@ export const InternalPicker = observer(
</CollectionProvider_deprecated>
</RecordPickerProvider>
</ActionContextProvider>
</>
</PopupSettingsProvider>
);
},
{ displayName: 'InternalPicker' },

View File

@ -28,6 +28,8 @@ import { useLocalVariables, useVariables } from '../../../variables';
import { useProps } from '../../hooks/useProps';
import { useFormBlockHeight } from './hook';
import { useApp } from '../../../application';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export interface FormProps extends IFormLayoutProps {
form?: FormilyForm;
@ -49,6 +51,8 @@ const FormComponent: React.FC<FormProps> = (props) => {
labelWidth = 120,
labelWrap = true,
} = cardItemSchema?.['x-component-props'] || {};
const { isMobileLayout } = useMobileLayout();
return (
<FieldContext.Provider value={undefined}>
<FormContext.Provider value={form}>
@ -76,7 +80,12 @@ const FormComponent: React.FC<FormProps> = (props) => {
}
`}
>
<NocoBaseRecursionField basePath={f.address} schema={fieldSchema} onlyRenderProperties isUseFormilyField />
<NocoBaseRecursionField
basePath={f.address}
schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
onlyRenderProperties
isUseFormilyField
/>
</div>
</FormLayout>
</FormContext.Provider>
@ -93,6 +102,8 @@ const FormDecorator: React.FC<FormProps> = (props) => {
// TODO: component 里 useField 会与当前 field 存在偏差
const f = useAttach(form.createVoidField({ ...field.props, basePath: '' }));
const Component = useComponent(fieldSchema['x-component'], Def);
const { isMobileLayout } = useMobileLayout();
return (
<FieldContext.Provider value={undefined}>
<FormContext.Provider value={form}>
@ -101,7 +112,7 @@ const FormDecorator: React.FC<FormProps> = (props) => {
<Component {...field.componentProps}>
<NocoBaseRecursionField
basePath={f.address}
schema={fieldSchema}
schema={isMobileLayout ? transformMultiColumnToSingleColumn(fieldSchema) : fieldSchema}
onlyRenderProperties
isUseFormilyField
/>
@ -250,7 +261,7 @@ export const Form: React.FC<FormProps> & {
const theme: any = useMemo(() => {
return {
token: {
// 这里是为了防止区块内部也到 marginBlock 的影响marginBlock区块之间的间距
// 这里是为了防止区块内部也到 marginBlock 的影响marginBlock区块之间的间距
// @ts-ignore
marginBlock: token.marginLG,
},

View File

@ -25,6 +25,8 @@ import { ListDesigner } from './List.Designer';
import { ListItem } from './List.Item';
import useStyles from './List.style';
import { useListActionBarProps, useListBlockHeight } from './hooks';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
const InternalList = withSkeletonComponent(
(props) => {
@ -121,6 +123,8 @@ const InternalList = withSkeletonComponent(
};
};
const paginationProps = usePagination();
const { isMobileLayout } = useMobileLayout();
return wrapSSR(
<SchemaComponentOptions
scope={{
@ -165,7 +169,9 @@ const InternalList = withSkeletonComponent(
key={index}
name={index}
onlyRenderProperties
schema={getSchema(index)}
schema={
isMobileLayout ? transformMultiColumnToSingleColumn(getSchema(index)) : getSchema(index)
}
></NocoBaseRecursionField>
);
})

View File

@ -37,6 +37,7 @@ import {
NocoBaseDesktopRouteType,
NocoBaseRouteContext,
useCurrentRoute,
useMobileLayout,
} from '../../../route-switch/antd/admin-layout';
import { KeepAliveProvider, useKeepAlive } from '../../../route-switch/antd/admin-layout/KeepAlive';
import { useGetAriaLabelOfSchemaInitializer } from '../../../schema-initializer/hooks/useGetAriaLabelOfSchemaInitializer';
@ -50,6 +51,7 @@ import { useMenuDragEnd, useNocoBaseRoutes } from '../menu/Menu';
import { useStyles } from './Page.style';
import { PageDesigner, PageTabDesigner } from './PageTabDesigner';
import { PopupRouteContextResetter } from './PopupRouteContextResetter';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
interface PageProps {
currentTabUid: string;
@ -174,6 +176,7 @@ const displayNone = {
const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid: string }) => {
const mountedRef = useRef(false);
const { active: pageActive } = useKeepAlive();
const { isMobileLayout } = useMobileLayout();
if (tabActive && !mountedRef.current) {
mountedRef.current = true;
@ -186,7 +189,10 @@ const TabPane = React.memo(({ active: tabActive, uid }: { active: boolean; uid:
return (
<div style={tabActive ? displayBlock : displayNone}>
<KeepAliveProvider active={pageActive && tabActive}>
<RemoteSchemaComponent uid={uid} />
<RemoteSchemaComponent
uid={uid}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</KeepAliveProvider>
</div>
);
@ -204,6 +210,7 @@ const InternalPageContent = (props: PageContentProps) => {
const currentRoute = useCurrentRoute();
const navigate = useNavigateNoUpdate();
const location = useLocationNoUpdate();
const { isMobileLayout } = useMobileLayout();
const children = currentRoute?.children || [];
const noTabs = children.every((tabRoute) => tabRoute.schemaUid !== activeKey && tabRoute.tabSchemaName !== activeKey);
@ -244,7 +251,10 @@ const InternalPageContent = (props: PageContentProps) => {
return (
<div className={className1}>
<NocoBaseRouteContext.Provider value={currentRoute?.children?.[0]}>
<RemoteSchemaComponent uid={currentRoute?.children?.[0].schemaUid} />
<RemoteSchemaComponent
uid={currentRoute?.children?.[0].schemaUid}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</NocoBaseRouteContext.Provider>
</div>
);

View File

@ -17,6 +17,7 @@ const PopupSettingsContext = React.createContext({
export const PopupSettingsProvider: FC<{
/**
* @default true
* Whether the popup should be controlled by URL
*/
enableURL?: boolean;
}> = (props) => {
@ -52,7 +53,7 @@ export const usePopupSettings = () => {
}, [enableURL, isInSettingsPage]);
return {
/** 弹窗窗口的显隐是否由 URL 控制 */
/** Whether the visibility of the popup window is controlled by URL */
isPopupVisibleControlledByURL,
};
};

View File

@ -8,7 +8,7 @@
*/
import { css } from '@emotion/css';
import { observer, RecursionField, useField, useFieldSchema } from '@formily/react';
import { observer, RecursionField, Schema, useField, useFieldSchema } from '@formily/react';
import { Tabs as AntdTabs, TabPaneProps, TabsProps } from 'antd';
import classNames from 'classnames';
import React, { useMemo } from 'react';
@ -20,6 +20,8 @@ import { SchemaComponent } from '../../core';
import { useDesigner } from '../../hooks/useDesigner';
import { useTabsContext } from './context';
import { TabsDesigner } from './Tabs.Designer';
import { useMobileLayout } from '../../../route-switch/antd/admin-layout';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
const MemoizeRecursionField = React.memo(RecursionField);
MemoizeRecursionField.displayName = 'MemoizeRecursionField';
@ -32,6 +34,7 @@ export const Tabs: any = React.memo((props: TabsProps) => {
const { render } = useSchemaInitializerRender(fieldSchema['x-initializer'], fieldSchema['x-initializer-props']);
const contextProps = useTabsContext();
const { PaneRoot = React.Fragment as React.FC<any> } = contextProps;
const { isMobileLayout } = useMobileLayout();
const items = useMemo(() => {
const result = fieldSchema.mapProperties((schema, key: string) => {
@ -40,14 +43,19 @@ export const Tabs: any = React.memo((props: TabsProps) => {
label: <MemoizeRecursionField name={key} schema={schema} onlyRenderSelf />,
children: (
<PaneRoot key={key} {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}>
<SchemaComponent name={key} schema={schema} onlyRenderProperties distributed />
<SchemaComponent
name={key}
schema={isMobileLayout ? new Schema(transformMultiColumnToSingleColumn(schema)) : schema}
onlyRenderProperties
distributed
/>
</PaneRoot>
),
};
});
return result;
}, [fieldSchema]);
}, [fieldSchema, isMobileLayout]);
const tabBarExtraContent = useMemo(
() => ({

View File

@ -14,6 +14,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useDesignable } from '..';
import { useToken } from '../../style';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
const designableStyle = {
backgroundColor: 'var(--colorSettings) !important',
@ -28,10 +29,15 @@ export const DesignableSwitch = () => {
const { t } = useTranslation();
const { token } = useToken();
const style = designable ? designableStyle : unDesignableStyle;
const { isMobileLayout } = useMobileLayout();
// 快捷键切换编辑状态
useHotkeys('Ctrl+Shift+U', () => setDesignable(!designable), [designable]);
if (isMobileLayout) {
return null;
}
return (
<Tooltip title={t('UI Editor')}>
<Button

View File

@ -25,6 +25,7 @@ import { addAppVersion } from './addAppVersion';
// @ts-ignore
import clientPkg from '../../../package.json';
import { useMobileLayout } from '../../route-switch/antd/admin-layout';
interface CreateDesignableProps {
current: Schema;
@ -780,9 +781,11 @@ export function useDesignable() {
dn.loadAPIClientEvents();
}, [dn]);
const { isMobileLayout } = useMobileLayout();
return {
dn,
designable,
designable: isMobileLayout ? false : designable,
reset,
refresh,
setDesignable,

View File

@ -9,9 +9,16 @@
import { observer, useField, useFieldSchema } from '@formily/react';
import React, { useMemo } from 'react';
import { BlockTemplateProvider, CollectionDeletedPlaceholder, RemoteSchemaComponent, useDesignable } from '..';
import {
BlockTemplateProvider,
CollectionDeletedPlaceholder,
RemoteSchemaComponent,
useDesignable,
useMobileLayout,
} from '..';
import { useTemplateBlockContext } from '../block-provider/TemplateBlockProvider';
import { useSchemaTemplateManager } from './SchemaTemplateManagerProvider';
import { transformMultiColumnToSingleColumn } from '@nocobase/utils/client';
export const BlockTemplate = observer(
(props: any) => {
@ -22,6 +29,7 @@ export const BlockTemplate = observer(
const { dn } = useDesignable();
const template = useMemo(() => getTemplateById(templateId), [templateId]);
const { onTemplateSuccess } = useTemplateBlockContext();
const { isMobileLayout } = useMobileLayout();
const onSuccess = (data) => {
fieldSchema['x-linkage-rules'] = data?.data?.['x-linkage-rules'] || [];
@ -30,7 +38,12 @@ export const BlockTemplate = observer(
};
return template ? (
<BlockTemplateProvider {...{ dn, field, fieldSchema, template }}>
<RemoteSchemaComponent noForm uid={template?.uid} onSuccess={onSuccess} />
<RemoteSchemaComponent
noForm
uid={template?.uid}
onSuccess={onSuccess}
schemaTransform={isMobileLayout ? transformMultiColumnToSingleColumn : undefined}
/>
</BlockTemplateProvider>
) : (
<CollectionDeletedPlaceholder type="Block template" name={templateId} />

View File

@ -0,0 +1,207 @@
/**
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { transformMultiColumnToSingleColumn } from '../transformMultiColumnToSingleColumn';
/**
* 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.
*/
// Mock dependencies
vi.mock('../uid', () => ({
uid: vi.fn(() => 'mocked-uid'),
}));
// Mock package.json
vi.mock('../../package.json', () => ({
default: { version: '1.0.0' },
}));
describe('transformMultiColumnToSingleColumn', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return schema as is if null or undefined', () => {
expect(transformMultiColumnToSingleColumn(null)).toBeNull();
expect(transformMultiColumnToSingleColumn(undefined)).toBeUndefined();
});
it('should recursively process non-Grid schemas', () => {
const schema = {
'x-component': 'Form',
properties: {
field1: {
'x-component': 'Input',
},
grid1: {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
},
},
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// The non-Grid top level should remain, but child Grid should be transformed
expect(result['x-component']).toBe('Form');
expect(result.properties.grid1.properties.row1['x-index']).toBe(1);
});
it('should handle Grid with non-Grid.Row components', () => {
const schema = {
'x-component': 'Grid',
properties: {
nonRow: {
'x-component': 'Input',
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(result.properties.nonRow['x-index']).toBe(1);
});
it('should ignore Grid.Row without properties', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
// No properties
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(Object.keys(result.properties).length).toBe(0);
});
it('should keep Grid.Row with single column as is', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
});
it('should transform Grid.Row with multiple columns', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
col2: { 'x-component': 'Select' },
},
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// Original row should keep only the first column
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
// Second column should be transformed into a new row
const newRowKey = 'mocked-uid_col2';
expect(result.properties[newRowKey]).toBeDefined();
expect(result.properties[newRowKey]['x-component']).toBe('Grid.Row');
expect(result.properties[newRowKey]['x-index']).toBe(2);
expect(result.properties[newRowKey].properties.col2).toBeDefined();
expect(result.properties[newRowKey].properties.col2['x-component-props'].width).toBe(100);
});
it('should handle complex Grid schema with multiple rows and columns', () => {
const schema = {
'x-component': 'Grid',
properties: {
row1: {
'x-component': 'Grid.Row',
properties: {
col1: { 'x-component': 'Input' },
col2: { 'x-component': 'Select' },
col3: { 'x-component': 'Checkbox' },
},
},
row2: {
'x-component': 'Grid.Row',
properties: {
col4: { 'x-component': 'DatePicker' },
},
},
nonRow: {
'x-component': 'Divider',
},
shouldRemove: {
'x-component': 'Grid.Row',
// No properties
},
},
};
const result = transformMultiColumnToSingleColumn(schema);
// Check structure
expect(Object.keys(result.properties).length).toBe(5); // row1 + 2 new rows from col2,col3 + row2 + nonRow
// Check row1 (first column stays)
expect(result.properties.row1['x-index']).toBe(1);
expect(Object.keys(result.properties.row1.properties).length).toBe(1);
expect(result.properties.row1.properties.col1).toBeDefined();
// Check col2 became its own row
expect(result.properties['mocked-uid_col2'].properties.col2).toBeDefined();
expect(result.properties['mocked-uid_col2'].properties.col2['x-component-props'].width).toBe(100);
// Check col3 became its own row (second call to uid would return the same mocked value)
expect(result.properties['mocked-uid_col3'].properties.col3).toBeDefined();
// Check row2 stayed intact
expect(result.properties.row2['x-index']).toBe(4);
expect(Object.keys(result.properties.row2.properties).length).toBe(1);
expect(result.properties.row2.properties.col4).toBeDefined();
// Check nonRow was processed
expect(result.properties.nonRow['x-index']).toBe(5);
// Check shouldRemove was ignored
expect(Object.keys(result.properties).includes('shouldRemove')).toBe(false);
});
});

View File

@ -30,4 +30,5 @@ export * from './isPortalInBody';
export * from './parseHTML';
export * from './uid';
export * from './url';
export * from './transformMultiColumnToSingleColumn';
export { dayjs, lodash };

View File

@ -0,0 +1,97 @@
/**
* 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 { uid } from './uid';
// @ts-ignore
import pkg from '../package.json';
import _ from 'lodash';
import { ISchema, Schema } from '@formily/json-schema';
/**
*
* @param {Object} schema - JSON Schema
* @returns {Object} - JSON Schema
*/
export const transformMultiColumnToSingleColumn = (schema: any): any => {
if (!schema) return schema;
if (schema.toJSON) {
schema = schema.toJSON();
}
if (schema['x-component'] !== 'Grid') {
Object.keys(schema.properties || {}).forEach((key) => {
schema.properties[key] = transformMultiColumnToSingleColumn(schema.properties[key]);
});
return schema;
}
schema = _.cloneDeep(schema);
const newProperties: any = {};
const { properties = {} } = schema;
let index = 0;
Object.keys(properties).forEach((key, rowIndex) => {
const row = properties[key];
if (row['x-component'] !== 'Grid.Row') {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
// 忽略没有列的行
if (!row.properties) {
return;
}
// 如果一个行只有一列,那么无需展开
if (Object.keys(row.properties).length === 1) {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
// 如果一个行有多列,则保留第一列,其余的列需要放到外面形成新的行(每一行依然保持一列)
Object.keys(row.properties).forEach((columnKey, colIndex) => {
const column = row.properties[columnKey];
_.set(column, 'x-component-props.width', 100);
if (colIndex === 0) {
row['x-index'] = ++index;
newProperties[key] = row;
return;
}
delete row.properties[columnKey];
// 将列转换为行
newProperties[`${uid()}_${columnKey}`] = createRow(column, columnKey, ++index);
});
});
schema.properties = newProperties;
return schema;
};
function createRow(column: any, key: string, index: number) {
return {
type: 'void',
version: '2.0',
'x-component': 'Grid.Row',
'x-app-version': pkg.version,
'x-uid': uid(),
'x-async': false,
'x-index': index,
_isJSONSchemaObject: true,
properties: {
[key]: column,
},
};
}

View File

@ -1,9 +1,17 @@
/**
* 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 { css } from '@emotion/css';
import { ISchema, useFieldSchema } from '@formily/react';
import { Action, ActionContextProvider, SchemaComponent, useCompile } from '@nocobase/client';
import { Action, ActionContextProvider, PopupSettingsProvider, SchemaComponent, useCompile } from '@nocobase/client';
import React, { useState } from 'react';
import { NAMESPACE } from './constants';
import { useTranslation } from 'react-i18next';
import { UploadOutlined } from '@ant-design/icons';
const importFormSchema: ISchema = {
@ -113,7 +121,6 @@ const importFormSchema: ISchema = {
export const ImportAction = (props) => {
const [visible, setVisible] = useState(false);
const { t } = useTranslation(NAMESPACE);
const compile = useCompile();
const fieldSchema = useFieldSchema();

View File

@ -17,7 +17,6 @@ import {
useSchemaInitializerItem,
} from '@nocobase/client';
import React from 'react';
import { NAMESPACE } from './constants';
import { useImportTranslation } from './locale';
import { useFields } from './useFields';
import { Alert } from 'antd';

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 { Action, OpenModeProvider, SchemaComponentOptions, useMobileLayout, usePopupSettings } from '@nocobase/client';
import { createGlobalStyle } from 'antd-style';
import React, { FC, useEffect } from 'react';
import { ActionDrawerUsedInMobile, useToAdaptActionDrawerToMobile } from './adaptor-of-desktop/ActionDrawer';
import { useToAdaptFilterActionToMobile } from './adaptor-of-desktop/FilterAction';
import { mobileComponents } from './pages/dynamic-page/MobilePage';
const GlobalStyle = createGlobalStyle`
::-webkit-scrollbar {
display: none;
}
`;
const CommonDrawer: FC = (props) => {
const { isMobileLayout } = useMobileLayout();
const { isPopupVisibleControlledByURL } = usePopupSettings();
// 在移动端布局中,只要弹窗是通过 URL 打开的,都需要显示成子页面的样子
if (isMobileLayout && isPopupVisibleControlledByURL()) {
return <Action.Page {...props} />;
}
return <ActionDrawerUsedInMobile {...props} />;
};
const openModeToComponent = {
page: Action.Page,
drawer: CommonDrawer,
modal: CommonDrawer,
};
const MobileAdapter: FC = (props) => {
useToAdaptFilterActionToMobile();
useToAdaptActionDrawerToMobile();
useEffect(() => {
document.body.style.setProperty('--nb-mobile-page-tabs-content-padding', '12px');
document.body.style.setProperty('--nb-mobile-page-header-height', '46px');
}, []);
return (
<>
<GlobalStyle />
<OpenModeProvider defaultOpenMode="page" isMobile={true} openModeToComponent={openModeToComponent}>
<SchemaComponentOptions components={mobileComponents}>{props.children}</SchemaComponentOptions>
</OpenModeProvider>
</>
);
};
export const MobileComponentsProvider: FC = (props) => {
const { isMobileLayout } = useMobileLayout();
if (!isMobileLayout) {
return <>{props.children} </>;
}
return <MobileAdapter>{props.children}</MobileAdapter>;
};

View File

@ -50,6 +50,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
overflowX: 'hidden',
backgroundColor: token.colorBgLayout,
// 不带 tab 页的半窗
'& > .nb-grid-container': {
padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`,
},
// 带有 tab 页的半窗
'.ant-tabs-nav': {
marginBottom: '0px !important',
padding: `0 ${token.paddingPageHorizontal + token.borderRadiusBlock / 2}px`,
backgroundColor: token.colorBgContainer,
},
// 带有 tab 页的半窗
'.ant-tabs-content-holder': {
padding: `${token.paddingPageVertical}px ${token.paddingPageHorizontal}px`,
},
// clear the margin-bottom of the last block
'& > .nb-grid-container > .nb-grid > .nb-grid-warp > .nb-grid-row:nth-last-child(2) .noco-card-item': {
marginBottom: 0,

View File

@ -13,6 +13,7 @@ import {
NocoBaseRecursionField,
SchemaComponent,
useActionContext,
useGlobalTheme,
useZIndexContext,
zIndexContext,
} from '@nocobase/client';
@ -31,6 +32,7 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
const { popupContainerRef, visiblePopup } = usePopupContainer(visible);
const { componentCls, hashId } = useMobileActionDrawerStyle();
const parentZIndex = useZIndexContext();
const { theme: globalTheme } = useGlobalTheme();
// this schema need to add padding in the content area of the popup
const isSpecialSchema = isChangePasswordSchema(fieldSchema) || isEditProfileSchema(fieldSchema);
@ -55,7 +57,6 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
});
const title = field.title || '';
const marginBlock = 18;
const closePopup = useCallback(() => {
setVisible(false);
@ -63,14 +64,14 @@ export const ActionDrawerUsedInMobile: any = observer((props: { footerNodeName?:
const theme = useMemo(() => {
return {
...globalTheme,
token: {
marginBlock,
borderRadiusBlock: 0,
boxShadowTertiary: 'none',
...globalTheme.token,
marginBlock: 12,
zIndexPopupBase: newZIndex,
},
};
}, [newZIndex]);
}, [globalTheme, newZIndex]);
return (
<zIndexContext.Provider value={newZIndex}>

View File

@ -109,12 +109,12 @@ export const useToAdaptFilterActionToMobile = () => {
};
/**
* mobile-container transformhttps://nocobase.height.app/T-4959
* mobile-container transform
* @param visible
* @returns
*/
export const usePopupContainer = (visible: boolean) => {
const [mobileContainer] = useState<HTMLElement>(() => document.querySelector('.mobile-container'));
const [mobileContainer] = useState<HTMLElement>(() => document.querySelector('.mobile-container') || document.body);
const [visiblePopup, setVisiblePopup] = useState(false);
const popupContainerRef = React.useRef<HTMLDivElement>(null);
const parentZIndex = useZIndexContext();

View File

@ -53,6 +53,7 @@ import { MobileSettingsBlockInitializer } from './mobile-blocks/settings-block/M
import { MobileSettingsBlockSchemaSettings } from './mobile-blocks/settings-block/schemaSettings';
// @ts-ignore
import pkg from './../../package.json';
import { MobileComponentsProvider } from './MobileComponentsProvider';
export * from './desktop-mode';
export * from './mobile';
@ -104,6 +105,7 @@ export class PluginMobileClient extends Plugin {
}
async load() {
this.app.use(MobileComponentsProvider);
this.addComponents();
this.addAppRoutes();
this.addRoutes();

View File

@ -8,7 +8,6 @@
*/
import {
Action,
AdminProvider,
AntdAppProvider,
AssociationFieldMode,
@ -18,9 +17,10 @@ import {
OpenModeProvider,
useAssociationFieldModeContext,
usePlugin,
usePopupSettings,
zIndexContext,
} from '@nocobase/client';
import React from 'react';
import React, { FC } from 'react';
import { isDesktop } from 'react-device-detect';
import { theme } from 'antd';
@ -36,10 +36,21 @@ import { PluginMobileClient } from '../index';
import { MobileAppProvider } from './MobileAppContext';
import { useStyles } from './styles';
const CommonDrawer: FC = (props) => {
const { isPopupVisibleControlledByURL } = usePopupSettings();
// 在移动端布局中,只要弹窗是通过 URL 打开的,都需要显示成子页面的样子。因为这样可以通过左滑返回
if (isPopupVisibleControlledByURL()) {
return <MobileActionPage {...(props as any)} />;
}
return <ActionDrawerUsedInMobile {...props} />;
};
const openModeToComponent = {
page: MobileActionPage,
page: CommonDrawer,
drawer: ActionDrawerUsedInMobile,
modal: Action.Modal,
modal: ActionDrawerUsedInMobile,
};
export const Mobile = () => {

View File

@ -60,7 +60,7 @@ DatePickerMobile.FilterWithPicker = (props) => {
};
DatePickerMobile.RangePicker = MobileRangePicker;
const mobileComponents = {
export const mobileComponents = {
Button: MobileButton,
Select: (props) => {
const { designable } = useDesignable();

View File

@ -285,5 +285,10 @@ const MobileTimePicker: ComposedMobileTimePicker = connect(
mapReadPretty(NBTimePicker.ReadPretty),
);
MobileDateTimePicker.displayName = 'MobileDateTimePicker';
MobileRangePicker.displayName = 'MobileRangePicker';
MobileDateFilterWithPicker.displayName = 'MobileDateFilterWithPicker';
MobileTimePicker.displayName = 'MobileTimePicker';
MobileTimePicker.RangePicker = NBTimePicker.RangePicker;
export { MobileDateTimePicker, MobileRangePicker, MobileDateFilterWithPicker, MobileTimePicker };

View File

@ -108,4 +108,6 @@ const MobilePicker = connect(
mapProps({ dataSource: 'options' }),
);
MobilePicker.displayName = 'MobilePicker';
export { MobilePicker };

View File

@ -6,12 +6,12 @@
* 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 { CheckCircleOutlined } from '@ant-design/icons';
import { PageHeader } from '@ant-design/pro-layout';
import { Badge, Button, Layout, Menu, Tabs, Tooltip } from 'antd';
import classnames from 'classnames';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link, Outlet, useNavigate, useParams } from 'react-router-dom';
import { Button, Layout, Menu, Badge, Tooltip, Tabs } from 'antd';
import { PageHeader } from '@ant-design/pro-layout';
import { CheckCircleOutlined } from '@ant-design/icons';
import classnames from 'classnames';
import {
css,
@ -36,19 +36,6 @@ const layoutClass = css`
overflow: hidden;
`;
const sideClass = css`
overflow: auto;
position: sticky;
top: 0;
bottom: 0;
height: 100%;
.ant-layout-sider-children {
width: 200px;
height: 100%;
}
`;
const contentClass = css`
padding: 24px;
min-height: 280px;
@ -196,7 +183,7 @@ export function WorkflowTasks() {
return (
<Layout className={layoutClass}>
<Layout.Sider className={sideClass} theme="light">
<Layout.Sider theme="light" breakpoint="md" collapsedWidth="0" zeroWidthTriggerStyle={{ top: 38 }}>
<Menu mode="inline" selectedKeys={[typeKey]} items={items} style={{ height: '100%' }} />
</Layout.Sider>
<Layout