mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
fix(plugin-mobile): restore mobile page scroll (#9898)
* fix(plugin-mobile): restore mobile page scroll * fix(plugin-mobile): scope mutation observer * fix(plugin-public-forms): restore mobile scroll
This commit is contained in:
+322
@@ -12,8 +12,79 @@ import { act, render, screen, userEvent, waitFor, waitForApp } from '@nocobase/t
|
||||
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';
|
||||
import { MobilePageContentContainer } from '../../pages/dynamic-page/content';
|
||||
|
||||
const device = vi.hoisted(() => ({ isDesktop: false }));
|
||||
|
||||
vi.mock('react-device-detect', async () => ({
|
||||
...(await vi.importActual('react-device-detect')),
|
||||
get isDesktop() {
|
||||
return device.isDesktop;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MobilePageContent', () => {
|
||||
let resizeObserverCallback: ResizeObserverCallback | undefined;
|
||||
let resizeObserverObserve: ReturnType<typeof vi.fn>;
|
||||
let mutationObserverObserve: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockDynamicViewportUnitSupport = (supported: boolean) => {
|
||||
vi.stubGlobal('CSS', {
|
||||
supports: vi.fn(() => supported),
|
||||
});
|
||||
};
|
||||
|
||||
const notifyResizeObserver = async () => {
|
||||
const resizeObserver = {
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
} as unknown as ResizeObserver;
|
||||
|
||||
await act(async () => {
|
||||
resizeObserverCallback?.([], resizeObserver);
|
||||
});
|
||||
};
|
||||
|
||||
const flushLayoutFrame = async () => {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
device.isDesktop = false;
|
||||
resizeObserverCallback = undefined;
|
||||
resizeObserverObserve = vi.fn();
|
||||
mutationObserverObserve = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe: resizeObserverObserve,
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
'MutationObserver',
|
||||
vi.fn(() => {
|
||||
return {
|
||||
observe: mutationObserverObserve,
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('basic', async () => {
|
||||
render(<Basic />);
|
||||
await waitForApp();
|
||||
@@ -48,4 +119,255 @@ describe('MobilePageContent', () => {
|
||||
expect(screen.queryByText('404')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps page content scrollable on mobile devices', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
let headerHeight = 50;
|
||||
let tabBarHeight = 49;
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('mobile-page-header')) {
|
||||
return headerHeight;
|
||||
}
|
||||
|
||||
if (this.classList.contains('mobile-tab-bar')) {
|
||||
return tabBarHeight;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const content = screen.getByTestId('mobile-page-content');
|
||||
expect(content.style.height).toBe('calc(100dvh - 99px)');
|
||||
expect(content.style.overflowY).toBe('auto');
|
||||
expect(content.style.WebkitOverflowScrolling).toBe('touch');
|
||||
});
|
||||
|
||||
headerHeight = 80;
|
||||
tabBarHeight = 60;
|
||||
await notifyResizeObserver();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 140px)');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-observe layout elements from resize notifications', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('mobile-page-header')) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
if (this.classList.contains('mobile-tab-bar')) {
|
||||
return 49;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 99px)');
|
||||
});
|
||||
await flushLayoutFrame();
|
||||
|
||||
const observeCount = resizeObserverObserve.mock.calls.length;
|
||||
await notifyResizeObserver();
|
||||
await flushLayoutFrame();
|
||||
|
||||
expect(resizeObserverObserve).toHaveBeenCalledTimes(observeCount);
|
||||
});
|
||||
|
||||
it('scopes mutation observation to the mobile container when available', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
|
||||
render(
|
||||
<div className="mobile-container" data-testid="mobile-container">
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mutationObserverObserve).toHaveBeenCalledWith(screen.getByTestId('mobile-container'), {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to 100vh when dynamic viewport units are unsupported', async () => {
|
||||
mockDynamicViewportUnitSupport(false);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('mobile-page-header')) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
if (this.classList.contains('mobile-tab-bar')) {
|
||||
return 49;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100vh - 99px)');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reserve a hidden page header height', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('mobile-page-header')) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
if (this.classList.contains('mobile-tab-bar')) {
|
||||
return 49;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 99px)');
|
||||
});
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer displayPageHeader={false}>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 49px)');
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves fixed action page footer space in the scroll container', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('nb-mobile-action-page-footer')) {
|
||||
return 46;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<div className="nb-mobile-action-page">
|
||||
<MobilePageContentContainer hideTabBar displayPageHeader={false}>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="nb-mobile-action-page-footer" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 46px)');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reserve a parent action page footer for a nested action page without footer', async () => {
|
||||
mockDynamicViewportUnitSupport(true);
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('nb-mobile-action-page-footer')) {
|
||||
return 46;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<div className="nb-mobile-action-page">
|
||||
<div className="nb-mobile-action-page">
|
||||
<MobilePageContentContainer hideTabBar displayPageHeader={false}>
|
||||
<div>Nested content</div>
|
||||
</MobilePageContentContainer>
|
||||
</div>
|
||||
<div className="nb-mobile-action-page-footer" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 0px)');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps desktop preview height based on its container', async () => {
|
||||
device.isDesktop = true;
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
|
||||
if (this.classList.contains('mobile-page-header')) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
if (this.classList.contains('mobile-tab-bar')) {
|
||||
return 49;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<>
|
||||
<div className="mobile-page-header" />
|
||||
<MobilePageContentContainer>
|
||||
<div>Scrollable content</div>
|
||||
</MobilePageContentContainer>
|
||||
<div className="mobile-tab-bar" />
|
||||
</>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const content = screen.getByTestId('mobile-page-content');
|
||||
expect(content.style.height).toBe('calc(100% - 99px)');
|
||||
expect(content.style.overflowY).toBe('');
|
||||
expect(content.style.WebkitOverflowScrolling).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+110
-9
@@ -8,11 +8,25 @@
|
||||
*/
|
||||
|
||||
import { useToken } from '@nocobase/client';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import _ from 'lodash';
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { isDesktop } from 'react-device-detect';
|
||||
import { PageBackgroundColor } from '../../../constants';
|
||||
|
||||
const getContentHeightBase = () => {
|
||||
if (isDesktop) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
|
||||
return '100dvh';
|
||||
}
|
||||
|
||||
return '100vh';
|
||||
};
|
||||
|
||||
export const MobilePageContentContainer: FC<{
|
||||
hideTabBar?: boolean;
|
||||
displayPageHeader?: boolean;
|
||||
@@ -20,29 +34,116 @@ export const MobilePageContentContainer: FC<{
|
||||
}> = ({ children, hideTabBar, displayPageHeader = true, className }) => {
|
||||
const [mobileTabBarHeight, setMobileTabBarHeight] = React.useState(0);
|
||||
const [mobilePageHeader, setMobilePageHeader] = React.useState(0);
|
||||
const [mobileActionPageFooterHeight, setMobileActionPageFooterHeight] = React.useState(0);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const { token } = useToken();
|
||||
const contentHeightBase = getContentHeightBase();
|
||||
const getMobileActionPageFooter = useMemoizedFn(() => {
|
||||
const actionPage = contentRef.current?.closest('.nb-mobile-action-page');
|
||||
|
||||
if (!actionPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
Array.from(actionPage.children).find(
|
||||
(element): element is HTMLDivElement =>
|
||||
element instanceof HTMLDivElement && element.classList.contains('nb-mobile-action-page-footer'),
|
||||
) || null
|
||||
);
|
||||
});
|
||||
const getLayoutElements = useMemoizedFn(() => {
|
||||
const navigationBar = displayPageHeader
|
||||
? _.last(document.querySelectorAll<HTMLDivElement>('.mobile-page-header'))
|
||||
: null;
|
||||
const mobileTabBar = hideTabBar ? null : document.querySelector<HTMLDivElement>('.mobile-tab-bar');
|
||||
const mobileActionPageFooter = getMobileActionPageFooter();
|
||||
|
||||
return { mobileActionPageFooter, mobileTabBar, navigationBar };
|
||||
});
|
||||
const updateLayoutHeights = useMemoizedFn(() => {
|
||||
const { mobileActionPageFooter, mobileTabBar, navigationBar } = getLayoutElements();
|
||||
setMobilePageHeader(navigationBar?.offsetHeight || 0);
|
||||
setMobileTabBarHeight(mobileTabBar?.offsetHeight || 0);
|
||||
setMobileActionPageFooterHeight(mobileActionPageFooter?.offsetHeight || 0);
|
||||
});
|
||||
const occupiedHeight = (mobileTabBarHeight || 0) + (mobilePageHeader || 0) + (mobileActionPageFooterHeight || 0);
|
||||
const bottomSpacerHeight = (mobileTabBarHeight || 0) + (mobileActionPageFooterHeight || 0);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationBar = _.last(document.querySelectorAll<HTMLDivElement>('.mobile-page-header'));
|
||||
setMobilePageHeader(navigationBar?.offsetHeight);
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let animationFrame: number | null = null;
|
||||
let shouldObserveOnNextFrame = false;
|
||||
const scheduleLayoutUpdate = (shouldObserveLayoutElements = false) => {
|
||||
shouldObserveOnNextFrame = shouldObserveOnNextFrame || shouldObserveLayoutElements;
|
||||
if (animationFrame !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hideTabBar) {
|
||||
const mobileTabBar = document.querySelector<HTMLDivElement>('.mobile-tab-bar');
|
||||
setMobileTabBarHeight(mobileTabBar?.offsetHeight);
|
||||
animationFrame = requestAnimationFrame(() => {
|
||||
const shouldObserve = shouldObserveOnNextFrame;
|
||||
shouldObserveOnNextFrame = false;
|
||||
animationFrame = null;
|
||||
updateLayoutHeights();
|
||||
if (shouldObserve) {
|
||||
observeLayoutElements();
|
||||
}
|
||||
});
|
||||
};
|
||||
const observeLayoutElements = () => {
|
||||
resizeObserver?.disconnect();
|
||||
Object.values(getLayoutElements())
|
||||
.filter(Boolean)
|
||||
.forEach((element) => {
|
||||
resizeObserver?.observe(element);
|
||||
});
|
||||
};
|
||||
const mutationObserver =
|
||||
typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
scheduleLayoutUpdate(true);
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleLayoutUpdate();
|
||||
});
|
||||
}
|
||||
// 这里依赖项要不需要填,每次都刷新
|
||||
});
|
||||
|
||||
updateLayoutHeights();
|
||||
observeLayoutElements();
|
||||
scheduleLayoutUpdate();
|
||||
const mutationObserverTarget = document.querySelector<HTMLElement>('.mobile-container') || document.body;
|
||||
mutationObserver?.observe(mutationObserverTarget, { childList: true, subtree: true });
|
||||
const handleWindowResize = () => {
|
||||
scheduleLayoutUpdate(true);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
if (animationFrame !== null) {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
mutationObserver?.disconnect();
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, [displayPageHeader, getLayoutElements, hideTabBar, updateLayoutHeights]);
|
||||
return (
|
||||
<>
|
||||
{mobilePageHeader && displayPageHeader ? <div style={{ height: mobilePageHeader }}></div> : null}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={classnames('mobile-page-content', className)}
|
||||
data-testid="mobile-page-content"
|
||||
style={{
|
||||
height: `calc(100% - ${(mobileTabBarHeight || 0) + (mobilePageHeader || 0)}px)`,
|
||||
height: `calc(${contentHeightBase} - ${occupiedHeight}px)`,
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'hidden',
|
||||
overflowY: isDesktop ? undefined : 'auto',
|
||||
WebkitOverflowScrolling: isDesktop ? undefined : 'touch',
|
||||
backgroundColor: PageBackgroundColor,
|
||||
paddingInline: token.paddingPageHorizontal,
|
||||
paddingBlock: token.paddingPageVertical,
|
||||
@@ -50,7 +151,7 @@ export const MobilePageContentContainer: FC<{
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{mobileTabBarHeight ? <div style={{ height: mobileTabBarHeight }}></div> : null}
|
||||
{bottomSpacerHeight ? <div style={{ height: bottomSpacerHeight }}></div> : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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 } from '@nocobase/test/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PublicFormPage } from '../components/PublicFormPage';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const requestInterceptorUse = vi.fn(() => 1);
|
||||
const requestInterceptorEject = vi.fn();
|
||||
|
||||
return {
|
||||
device: {
|
||||
isDesktop: false,
|
||||
isMobile: true,
|
||||
},
|
||||
requestInterceptorEject,
|
||||
requestInterceptorUse,
|
||||
useRequest: vi.fn(() => ({
|
||||
data: {
|
||||
data: {
|
||||
dataSource: { key: 'main' },
|
||||
schema: {},
|
||||
title: 'Public form',
|
||||
token: 'form-token',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
loading: false,
|
||||
run: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('react-device-detect', async () => ({
|
||||
...(await vi.importActual('react-device-detect')),
|
||||
get isDesktop() {
|
||||
return mocks.device.isDesktop;
|
||||
},
|
||||
get isMobile() {
|
||||
return mocks.device.isMobile;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-router', () => ({
|
||||
useParams: () => ({ name: 'public-form-1' }),
|
||||
}));
|
||||
|
||||
vi.mock('@formily/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@formily/react')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useField: () => ({
|
||||
form: {
|
||||
query: () => ({
|
||||
take: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../components/components/MobileDatePicker', () => ({
|
||||
MobileDateTimePicker: () => <div data-testid="mobile-date-time-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/components/MobilePicker', () => ({
|
||||
MobilePicker: () => <div data-testid="mobile-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
usePublicSubmitActionProps: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client', () => {
|
||||
const SchemaComponentContext = React.createContext({});
|
||||
const AssociationField = Object.assign(() => <div data-testid="association-field" />, {
|
||||
AddNewer: () => null,
|
||||
FileSelector: () => null,
|
||||
InternalSelect: () => null,
|
||||
Nester: () => null,
|
||||
ReadPretty: () => null,
|
||||
Selector: () => null,
|
||||
SubTable: () => null,
|
||||
Viewer: () => null,
|
||||
});
|
||||
const DatePicker = Object.assign(() => <div data-testid="date-picker" />, {
|
||||
FilterWithPicker: () => null,
|
||||
RangePicker: () => null,
|
||||
});
|
||||
|
||||
return {
|
||||
ACLCustomContext: React.createContext({}),
|
||||
Action: { Container: ({ children }: React.PropsWithChildren) => <>{children}</> },
|
||||
APIClientProvider: ({ children }: React.PropsWithChildren<{ apiClient?: unknown }>) => <>{children}</>,
|
||||
AssociationField,
|
||||
CollectionManager: class CollectionManager {},
|
||||
DataSource: class DataSource {},
|
||||
DataSourceApplicationProvider: ({ children }: React.PropsWithChildren) => <>{children}</>,
|
||||
DataSourceManager: class DataSourceManager {
|
||||
addDataSource() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
DatePicker,
|
||||
GlobalThemeProvider: ({ children }: React.PropsWithChildren<{ theme?: unknown }>) => <>{children}</>,
|
||||
PoweredBy: () => <div>Powered by NocoBase</div>,
|
||||
SchemaComponent: () => (
|
||||
<div data-testid="public-form-schema" style={{ height: 2000 }}>
|
||||
Public form content
|
||||
</div>
|
||||
),
|
||||
SchemaComponentContext,
|
||||
VariablesProvider: ({ children }: React.PropsWithChildren<{ filterVariables?: unknown }>) => <>{children}</>,
|
||||
useApp: () => ({
|
||||
apiClient: {
|
||||
axios: {
|
||||
interceptors: {
|
||||
request: {
|
||||
eject: mocks.requestInterceptorEject,
|
||||
use: mocks.requestInterceptorUse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
useCompile: () => (value: string) => value,
|
||||
useRequest: mocks.useRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('antd')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
Input: {
|
||||
...actual.Input,
|
||||
Password: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} type="password" />,
|
||||
},
|
||||
Modal: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
Spin: () => <div>Loading</div>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('antd-mobile', () => ({
|
||||
Button: ({ children }: React.PropsWithChildren) => <button>{children}</button>,
|
||||
Dialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('PublicFormPage', () => {
|
||||
const findPublicFormContainer = () =>
|
||||
Array.from(document.querySelectorAll('div')).find(
|
||||
(element) =>
|
||||
(element.style.overflowY === 'auto' || element.style.overflow === 'auto') &&
|
||||
element.textContent?.includes('Public form content'),
|
||||
) as HTMLDivElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.device.isDesktop = false;
|
||||
mocks.device.isMobile = true;
|
||||
vi.stubGlobal('CSS', {
|
||||
supports: vi.fn(() => true),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
document.body.style.backgroundColor = '';
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it('uses the viewport height as the mobile public form scroll container height', async () => {
|
||||
render(<PublicFormPage />);
|
||||
|
||||
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
|
||||
const publicFormContainer = findPublicFormContainer();
|
||||
|
||||
expect(publicFormContainer).toBeDefined();
|
||||
expect(publicFormContainer?.style.minHeight).toBe('100dvh');
|
||||
expect(publicFormContainer?.style.overflowY).toBe('auto');
|
||||
expect(publicFormContainer?.style.WebkitOverflowScrolling).toBe('touch');
|
||||
});
|
||||
|
||||
it('falls back to the viewport height supported by jsdom when dvh is unavailable', async () => {
|
||||
vi.stubGlobal('CSS', {
|
||||
supports: vi.fn(() => false),
|
||||
});
|
||||
|
||||
render(<PublicFormPage />);
|
||||
|
||||
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
|
||||
const publicFormContainer = findPublicFormContainer();
|
||||
|
||||
expect(publicFormContainer).toBeDefined();
|
||||
expect(publicFormContainer?.style.minHeight).toBe('100vh');
|
||||
expect(publicFormContainer?.style.height).toBe('100vh');
|
||||
expect(publicFormContainer?.style.overflowY).toBe('auto');
|
||||
});
|
||||
|
||||
it('keeps the desktop public form overflow behavior unchanged', async () => {
|
||||
mocks.device.isDesktop = true;
|
||||
mocks.device.isMobile = false;
|
||||
|
||||
render(<PublicFormPage />);
|
||||
|
||||
expect(await screen.findByTestId('public-form-schema')).toBeInTheDocument();
|
||||
const publicFormContainer = findPublicFormContainer();
|
||||
|
||||
expect(publicFormContainer).toBeDefined();
|
||||
expect(publicFormContainer?.style.height).toBe('100%');
|
||||
expect(publicFormContainer?.style.overflow).toBe('auto');
|
||||
expect(publicFormContainer?.style.overflowX).toBe('');
|
||||
});
|
||||
});
|
||||
+27
-7
@@ -9,6 +9,7 @@
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { useField } from '@formily/react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import {
|
||||
ACLCustomContext,
|
||||
Action,
|
||||
@@ -90,24 +91,36 @@ function useTitle(data) {
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
document.title = compile(data?.data?.title);
|
||||
}, [data]);
|
||||
}, [compile, data]);
|
||||
}
|
||||
|
||||
export const PublicFormMessageContext = createContext<any>({});
|
||||
export const PageBackgroundColor = '#f5f5f5';
|
||||
|
||||
const getPublicFormContainerHeight = () => {
|
||||
if (isDesktop) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
|
||||
return '100dvh';
|
||||
}
|
||||
|
||||
return '100vh';
|
||||
};
|
||||
|
||||
const PublicFormMessageProvider = ({ children }) => {
|
||||
const [showMessage, setShowMessage] = useState(false);
|
||||
const field = useField();
|
||||
|
||||
const toggleFieldVisibility = (fieldName, visible) => {
|
||||
const toggleFieldVisibility = useMemoizedFn((fieldName, visible) => {
|
||||
field.form.query(fieldName).take((f) => {
|
||||
if (f) {
|
||||
f.visible = visible;
|
||||
f.hidden = !visible;
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
toggleFieldVisibility('success', showMessage);
|
||||
@@ -120,7 +133,7 @@ const PublicFormMessageProvider = ({ children }) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [showMessage]);
|
||||
}, [field.form, showMessage, toggleFieldVisibility]);
|
||||
|
||||
return (
|
||||
<PublicFormMessageContext.Provider value={{ showMessage, setShowMessage }}>
|
||||
@@ -233,15 +246,22 @@ function InternalPublicForm() {
|
||||
return <UnEnabledFormPlaceholder />;
|
||||
}
|
||||
const components = isMobile ? mobileComponents : {};
|
||||
const containerHeight = getPublicFormContainerHeight();
|
||||
return (
|
||||
<ACLCustomContext.Provider value={{ allowAll: true }}>
|
||||
<PublicAPIClientProvider>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
minHeight: isDesktop ? '100vh' : containerHeight,
|
||||
background: PageBackgroundColor,
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
height: containerHeight,
|
||||
...(isDesktop
|
||||
? { overflow: 'auto' }
|
||||
: {
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user