diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/__tests__/DynamicPage/MobilePageContent.test.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/__tests__/DynamicPage/MobilePageContent.test.tsx index 38faa1fb0b1..69408d22177 100644 --- a/packages/plugins/@nocobase/plugin-mobile/src/client/__tests__/DynamicPage/MobilePageContent.test.tsx +++ b/packages/plugins/@nocobase/plugin-mobile/src/client/__tests__/DynamicPage/MobilePageContent.test.tsx @@ -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; + let mutationObserverObserve: ReturnType; + + 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((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(); 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( + <> +
+ +
Scrollable content
+
+
+ , + ); + + 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( + <> +
+ +
Scrollable content
+
+
+ , + ); + + 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( +
+ +
Scrollable content
+
+
, + ); + + 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( + <> +
+ +
Scrollable content
+
+
+ , + ); + + 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( + <> +
+ +
Scrollable content
+
+
+ , + ); + + await waitFor(() => { + expect(screen.getByTestId('mobile-page-content').style.height).toBe('calc(100dvh - 99px)'); + }); + + rerender( + <> +
+ +
Scrollable content
+
+
+ , + ); + + 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( +
+ +
Scrollable content
+
+
+
, + ); + + 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( +
+
+ +
Nested content
+
+
+
+
, + ); + + 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( + <> +
+ +
Scrollable content
+
+
+ , + ); + + 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(''); + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/content/MobilePageContentContainer.tsx b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/content/MobilePageContentContainer.tsx index 4b8474c803f..c42ab8af477 100644 --- a/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/content/MobilePageContentContainer.tsx +++ b/packages/plugins/@nocobase/plugin-mobile/src/client/pages/dynamic-page/content/MobilePageContentContainer.tsx @@ -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(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('.mobile-page-header')) + : null; + const mobileTabBar = hideTabBar ? null : document.querySelector('.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('.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('.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('.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 ?
: null}
{children}
- {mobileTabBarHeight ?
: null} + {bottomSpacerHeight ?
: null} ); }; diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/__tests__/PublicFormPage.test.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/__tests__/PublicFormPage.test.tsx new file mode 100644 index 00000000000..953c8d41a67 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/__tests__/PublicFormPage.test.tsx @@ -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(); + + return { + ...actual, + useField: () => ({ + form: { + query: () => ({ + take: vi.fn(), + }), + }, + }), + }; +}); + +vi.mock('../components/components/MobileDatePicker', () => ({ + MobileDateTimePicker: () =>
, +})); + +vi.mock('../components/components/MobilePicker', () => ({ + MobilePicker: () =>
, +})); + +vi.mock('../hooks', () => ({ + usePublicSubmitActionProps: vi.fn(), +})); + +vi.mock('@nocobase/client', () => { + const SchemaComponentContext = React.createContext({}); + const AssociationField = Object.assign(() =>
, { + AddNewer: () => null, + FileSelector: () => null, + InternalSelect: () => null, + Nester: () => null, + ReadPretty: () => null, + Selector: () => null, + SubTable: () => null, + Viewer: () => null, + }); + const DatePicker = Object.assign(() =>
, { + 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: () =>
Powered by NocoBase
, + SchemaComponent: () => ( +
+ Public form content +
+ ), + 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(); + + return { + ...actual, + Input: { + ...actual.Input, + Password: (props: React.InputHTMLAttributes) => , + }, + Modal: ({ children }: React.PropsWithChildren) =>
{children}
, + Spin: () =>
Loading
, + }; +}); + +vi.mock('antd-mobile', () => ({ + Button: ({ children }: React.PropsWithChildren) => , + Dialog: ({ children }: React.PropsWithChildren) =>
{children}
, +})); + +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(); + + 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(); + + 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(); + + 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(''); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx index e8b282e3957..63a4c42ea5b 100644 --- a/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx +++ b/packages/plugins/@nocobase/plugin-public-forms/src/client/components/PublicFormPage.tsx @@ -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({}); 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 ( @@ -233,15 +246,22 @@ function InternalPublicForm() { return ; } const components = isMobile ? mobileComponents : {}; + const containerHeight = getPublicFormContainerHeight(); return (