feat: support configurable block height (#8583)

* feat: block height

* feat: table block support block height settings

* feat: form block support block height settings

* refactor: table blcok & form block support full height settings

* fix: bug

* fix: bug

* feat: detail block support block height settings

* feat: list block support block height settings

* feat: grid card block support block height settings

* feat: map block support block height settings

* fix: bug

* feat: iframe support block height settings

* fix: bug

* fix: bug

* feat: table block support block height settings

* feat: map block support block height settings

* test: block height

* fix: bug

* fix: bug

* fix: test

* test: detail block height test

* test: listBlock height

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Katherine
2026-02-10 09:30:00 +08:00
committed by GitHub
parent 153de33c35
commit 9694258fb9
24 changed files with 1842 additions and 382 deletions
@@ -0,0 +1,80 @@
/**
* 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 { defineAction, tExpr } from '@nocobase/flow-engine';
import { InputNumber } from 'antd';
import _ from 'lodash';
const HeightMode = {
DEFAULT: 'defaultHeight',
SPECIFY_VALUE: 'specifyValue',
FULL_HEIGHT: 'fullHeight',
};
export const blockHeight = defineAction({
name: 'blockHeight',
title: tExpr('Block Height'),
uiMode: {
type: 'dialog',
props: {
width: 800,
},
},
uiSchema: (ctx) => {
const { t } = ctx;
return {
heightMode: {
type: 'string',
enum: [
{ label: t('Default'), value: HeightMode.DEFAULT },
{ label: t('Specify height'), value: HeightMode.SPECIFY_VALUE },
{ label: t('Full height'), value: HeightMode.FULL_HEIGHT },
],
required: true,
'x-decorator': 'FormItem',
'x-component': 'Radio.Group',
},
height: {
title: t('Height'),
type: 'number',
required: true,
'x-decorator': 'FormItem',
'x-component': InputNumber,
'x-component-props': {
addonAfter: 'px',
},
'x-validator': [
{
minimum: 40,
},
],
'x-reactions': {
dependencies: ['heightMode'],
fulfill: {
state: {
hidden: '{{ $deps[0]==="fullHeight"||$deps[0]==="defaultHeight"}}',
value: '{{$deps[0]!=="specifyValue"?null:$self.value}}',
},
},
},
},
};
},
defaultParams(ctx) {
return {
heightMode: HeightMode.DEFAULT,
};
},
async handler(ctx, params) {
ctx.model.setDecoratorProps({
heightMode: params.heightMode,
height: params.height,
});
},
});
@@ -31,6 +31,7 @@ export * from './pattern';
export * from './validation';
export * from './columnFixed';
export * from './linkageRulesRefresh';
export * from './blockHeight';
export {
fieldLinkageRules,
subFormFieldLinkageRules,
@@ -8,25 +8,163 @@
*/
import { Card, CardProps, theme } from 'antd';
import _ from 'lodash';
import React from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { NAMESPACE_UI_SCHEMA } from '../../i18n/constant';
import { DisplayMarkdown } from '../internal/components/Markdown/DisplayMarkdown';
import { useFlowContext } from '@nocobase/flow-engine';
const useBlockHeight = ({ height, heightMode }) => {
if (heightMode !== 'specifyValue') {
return null;
const getRootElement = (element: HTMLElement | null) => {
if (!element) return document.documentElement;
return (
(element.closest('.nb-block-grid') as HTMLElement | null) ||
(element.closest('.nb-page-wrapper') as HTMLElement | null) ||
(element.closest('.nb-page') as HTMLElement | null) ||
document.documentElement
);
};
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
const getPadding = (element: HTMLElement | null) => {
if (!element || element === document.documentElement) {
return { top: 0, bottom: 0 };
}
return height;
const style = window.getComputedStyle(element);
return {
top: parseFloat(style.paddingTop) || 0,
bottom: parseFloat(style.paddingBottom) || 0,
};
};
const getPageHeader = (root: HTMLElement) => {
const page = root.closest('.nb-page') as HTMLElement | null;
if (!page) return null;
return (
(page.querySelector('.ant-page-header') as HTMLElement | null) ||
(page.querySelector('.pageHeaderCss') as HTMLElement | null)
);
};
const getAddBlockContainer = (root: HTMLElement) => {
const button = root.querySelector('[data-flow-add-block]') as HTMLElement | null;
if (!button) return null;
return (button.parentElement as HTMLElement | null) || button;
};
function getValidPageTop(a, b) {
const aValid = a > 0;
const bValid = b > 0;
if (aValid) return a;
if (bValid) return b;
return 0; // 都不是正数
}
const useBlockHeight = ({
height,
heightMode,
cardRef,
}: {
height?: number;
heightMode?: string;
cardRef: React.RefObject<HTMLDivElement>;
}) => {
const [fullHeight, setFullHeight] = useState<number>();
const ctx = useFlowContext();
const updateFullHeight = useCallback(() => {
if (heightMode !== 'fullHeight' || typeof window === 'undefined') {
setFullHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const cardEl = cardRef.current;
if (!cardEl) return;
const root = getRootElement(cardEl);
const cardRect = cardEl.getBoundingClientRect();
const rootRect = root === document.documentElement ? { top: 0 } : root.getBoundingClientRect();
const padding = getPadding(root);
const addBlockContainer = getAddBlockContainer(root);
const pageTop = rootRect.top + padding.top;
const topOffset = Math.min(Math.max(0, cardRect.top - pageTop), 0);
let bottomOffset = padding.bottom + ctx.themeToken.marginBlock;
if (addBlockContainer) {
const gapBetween = ctx.themeToken.marginBlock;
bottomOffset = gapBetween + getOuterHeight(addBlockContainer) + padding.bottom;
}
const nextHeight = Math.max(
0,
Math.floor(window.innerHeight - getValidPageTop(pageTop, 110) - topOffset - bottomOffset),
);
setFullHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, cardRef]);
useLayoutEffect(() => {
updateFullHeight();
}, [updateFullHeight]);
useEffect(() => {
if (heightMode !== 'fullHeight' || typeof window === 'undefined') return;
const cardEl = cardRef.current;
if (!cardEl || typeof ResizeObserver === 'undefined') return;
const root = getRootElement(cardEl);
const pageHeader = getPageHeader(root);
const addBlockContainer = getAddBlockContainer(root);
const observer = new ResizeObserver(() => updateFullHeight());
observer.observe(cardEl);
if (root instanceof HTMLElement) {
observer.observe(root);
}
if (pageHeader) observer.observe(pageHeader);
if (addBlockContainer) observer.observe(addBlockContainer);
window.addEventListener('resize', updateFullHeight);
return () => {
observer.disconnect();
window.removeEventListener('resize', updateFullHeight);
};
}, [heightMode, cardRef, updateFullHeight]);
if (heightMode === 'specifyValue') {
return height;
}
if (heightMode === 'fullHeight') {
return fullHeight;
}
return null;
};
export const BlockItemCard = React.forwardRef(
(props: CardProps & { beforeContent?: React.ReactNode; afterContent?: React.ReactNode; description?: any }, ref) => {
(
props: CardProps & {
beforeContent?: React.ReactNode;
afterContent?: React.ReactNode;
description?: any;
heightMode?: string;
},
ref,
) => {
const { t } = useTranslation();
const { token } = theme.useToken();
const { title: blockTitle, description, children, className, ...rest } = props;
const height = useBlockHeight(props as any);
const { title: blockTitle, description, children, className, heightMode, ...rest } = props;
const cardRef = useRef<HTMLDivElement | null>(null);
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
cardRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
const height = useBlockHeight({ ...(props as any), cardRef });
const title = (blockTitle || description) && (
<div>
<span> {t(blockTitle as any, { ns: NAMESPACE_UI_SCHEMA })}</span>
@@ -46,7 +184,7 @@ export const BlockItemCard = React.forwardRef(
);
return (
<Card
ref={ref as any}
ref={setCardRef as any}
title={title}
style={{ display: 'flex', flexDirection: 'column', height: height }}
styles={{
@@ -62,7 +62,9 @@ export class BlockGridModel extends GridModel {
renderAddSubModelButton() {
return (
<AddSubModelButton model={this} subModelKey="items" subModelBaseClasses={this.subModelBaseClasses}>
<FlowSettingsButton icon={<PlusOutlined />}>{this.context.t('Add block')}</FlowSettingsButton>
<FlowSettingsButton icon={<PlusOutlined />} data-flow-add-block>
{this.context.t('Add block')}
</FlowSettingsButton>
</AddSubModelButton>
);
}
@@ -70,10 +72,11 @@ export class BlockGridModel extends GridModel {
render() {
return (
<div
className="nb-block-grid"
style={
this.context.disableBlockGridPadding
? null
: { padding: this.context.isMobileLayout ? 8 : this.context.themeToken.marginBlock }
: { padding: this.context.isMobileLayout ? 8 : this.context.themeToken.marginBlock, paddingBottom: 0 }
}
>
{super.render()}
@@ -138,54 +138,9 @@ BlockModel.registerFlow({
linkageRules: {
use: 'blockLinkageRules',
},
// setBlockHeight: {
// title: tval('Set block height'),
// uiSchema: {
// heightMode: {
// type: 'string',
// enum: [
// { label: tval('Default'), value: HeightMode.DEFAULT },
// { label: tval('Specify height'), value: HeightMode.SPECIFY_VALUE },
// { label: tval('Full height'), value: HeightMode.FULL_HEIGHT },
// ],
// required: true,
// 'x-decorator': 'FormItem',
// 'x-component': 'Radio.Group',
// },
// height: {
// title: tval('Height'),
// type: 'string',
// required: true,
// 'x-decorator': 'FormItem',
// 'x-component': 'NumberPicker',
// 'x-component-props': {
// addonAfter: 'px',
// },
// 'x-validator': [
// {
// minimum: 40,
// },
// ],
// 'x-reactions': {
// dependencies: ['heightMode'],
// fulfill: {
// state: {
// hidden: '{{ $deps[0]==="fullHeight"||$deps[0]==="defaultHeight"}}',
// value: '{{$deps[0]!=="specifyValue"?null:$self.value}}',
// },
// },
// },
// },
// },
// defaultParams: () => {
// return {
// heightMode: HeightMode.DEFAULT,
// };
// },
// handler(ctx, params) {
// ctx.model.setDecoratorProps({ heightMode: params.heightMode, height: params.height });
// },
// },
blockHeight: {
use: 'blockHeight',
},
},
});
@@ -23,12 +23,12 @@ import {
tExpr,
} from '@nocobase/flow-engine';
import { Pagination, Space } from 'antd';
import _ from 'lodash';
import React from 'react';
import React, { useEffect, useRef } from 'react';
import { BlockGridModel, BlockSceneEnum, CollectionBlockModel, RecordActionModel } from '../../base';
import { FormComponent } from '../form/FormBlockModel';
import { DetailsGridModel } from './DetailsGridModel';
import { dispatchEventDeep } from '../../../utils';
import { useDetailsGridHeight } from './utils';
export class DetailsBlockModel extends CollectionBlockModel<{
parent?: BlockGridModel;
@@ -141,16 +141,17 @@ export class DetailsBlockModel extends CollectionBlockModel<{
renderComponent() {
const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props;
const isConfigMode = !!this.context.flowSettingsEnabled;
const { heightMode, height } = this.decoratorProps;
return (
<>
<DndProvider>
<div
style={{
textAlign: 'right',
lineHeight: '0px',
padding: isConfigMode && this.context.themeToken.padding,
}}
>
<DetailsBlockContent
model={this}
gridModel={this.subModels.grid}
isConfigMode={isConfigMode}
heightMode={heightMode}
height={height}
layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}
actions={
<DndProvider>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
@@ -174,17 +175,90 @@ export class DetailsBlockModel extends CollectionBlockModel<{
})}
{this.renderConfigureActions()}
</Space>
</div>
</DndProvider>
<FormComponent model={this} layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}>
<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />
</FormComponent>
{this.renderPagination()}
</>
</DndProvider>
}
/>
);
}
}
const DetailsBlockContent = ({
model,
gridModel,
isConfigMode,
heightMode,
height,
layoutProps,
actions,
}: {
model: DetailsBlockModel;
gridModel: DetailsGridModel;
isConfigMode: boolean;
heightMode?: string;
height?: number;
layoutProps?: any;
actions?: React.ReactNode;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const paginationRef = useRef<HTMLDivElement>(null);
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
const gridHeight = useDetailsGridHeight({
heightMode,
containerRef,
actionsRef,
paginationRef,
deps: [height],
});
useEffect(() => {
if (!gridModel) return;
const nextHeight = isFixedHeight ? gridHeight : undefined;
if (gridModel.props?.height === nextHeight && gridModel.props?.heightMode === heightMode) return;
gridModel.setProps({ height: nextHeight, heightMode });
}, [gridModel, gridHeight, isFixedHeight, heightMode]);
const formStyle = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
height: '100%',
}
: undefined;
const containerStyle: any = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
flex: 1,
}
: undefined;
return (
<FormComponent model={model} layoutProps={layoutProps} style={formStyle}>
<div ref={containerRef} style={containerStyle}>
<div
ref={actionsRef}
style={{
textAlign: 'right',
lineHeight: '0px',
paddingBottom: isConfigMode && model.context.themeToken.padding,
}}
>
{actions}
</div>
<FlowModelRenderer
key={`${gridModel?.uid || 'details-grid'}:${isConfigMode ? 'design' : 'runtime'}`}
model={gridModel}
showFlowSettings={false}
/>
<div ref={paginationRef}>{model.renderPagination()}</div>
</div>
</FormComponent>
);
};
DetailsBlockModel.registerFlow({
key: 'detailsSettings',
title: tExpr('Details settings'),
@@ -67,6 +67,30 @@ export class DetailsGridModel extends GridModel<{
</AddSubModelButton>
);
}
render() {
const height = this.props?.height;
const heightMode = this.props?.heightMode;
const token = this.context.themeToken;
const content = super.render();
if (heightMode === 'defaultHeight') {
return content;
}
return (
<div
style={{
height,
overflowY: 'auto',
marginLeft: `-${token.marginLG}px`,
marginRight: `-${token.marginLG}px`,
paddingLeft: `${token.marginLG}px`,
paddingRight: `${token.marginLG}px`,
}}
>
{content}
</div>
);
}
}
DetailsGridModel.registerFlow({
@@ -0,0 +1,139 @@
/**
* 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, { useRef } from 'react';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { useDetailsGridHeight } from '../utils';
const createRect = (height: number) => ({
x: 0,
y: 0,
width: 100,
height,
top: 0,
left: 0,
right: 100,
bottom: height,
toJSON: () => {},
});
const setRect = (node: HTMLElement | null, height: number) => {
if (!node) return;
node.getBoundingClientRect = () => createRect(height);
};
const HeightProbe = ({
heightMode,
containerHeight,
actionsHeight,
paginationHeight,
depsKey,
}: {
heightMode?: string;
containerHeight: number;
actionsHeight: number;
paginationHeight: number;
depsKey: number;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const paginationRef = useRef<HTMLDivElement>(null);
const gridHeight = useDetailsGridHeight({
heightMode,
containerRef,
actionsRef,
paginationRef,
deps: [depsKey],
});
return (
<div>
<div
ref={(node) => {
containerRef.current = node;
setRect(node, containerHeight);
}}
>
<div
ref={(node) => {
actionsRef.current = node;
setRect(node, actionsHeight);
}}
/>
<div data-testid="grid" />
<div
ref={(node) => {
paginationRef.current = node;
setRect(node, paginationHeight);
}}
/>
</div>
<span data-testid="grid-height">{gridHeight === undefined ? 'undefined' : String(gridHeight)}</span>
</div>
);
};
describe('DetailsBlockModel block height', () => {
const originalResizeObserver = globalThis.ResizeObserver;
beforeAll(() => {
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
afterAll(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('calculates grid height when heightMode is fixed', async () => {
render(
<HeightProbe
heightMode="specifyValue"
containerHeight={400}
actionsHeight={40}
paginationHeight={20}
depsKey={1}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('grid-height').textContent).toBe('340');
});
});
it('clears grid height when heightMode is not fixed', async () => {
const { rerender } = render(
<HeightProbe
heightMode="specifyValue"
containerHeight={400}
actionsHeight={40}
paginationHeight={20}
depsKey={1}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('grid-height').textContent).toBe('340');
});
rerender(
<HeightProbe heightMode="default" containerHeight={400} actionsHeight={40} paginationHeight={20} depsKey={2} />,
);
await waitFor(() => {
expect(screen.getByTestId('grid-height').textContent).toBe('undefined');
});
});
});
@@ -0,0 +1,67 @@
/**
* 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 { useCallback, useEffect, useLayoutEffect, useState } from 'react';
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
export const useDetailsGridHeight = ({
heightMode,
containerRef,
actionsRef,
paginationRef,
deps = [],
}: {
heightMode?: string;
containerRef: React.RefObject<HTMLDivElement>;
actionsRef: React.RefObject<HTMLDivElement>;
paginationRef: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
}) => {
const [gridHeight, setGridHeight] = useState<number>();
const calcGridHeight = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setGridHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const container = containerRef.current;
if (!container) return;
const containerHeight = container.getBoundingClientRect().height;
if (!containerHeight) return;
const actionsHeight = getOuterHeight(actionsRef.current);
const paginationHeight = getOuterHeight(paginationRef.current);
const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - paginationHeight));
setGridHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, containerRef, actionsRef, paginationRef]);
useLayoutEffect(() => {
calcGridHeight();
}, [calcGridHeight, ...deps]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const container = containerRef.current;
const actions = actionsRef.current;
const pagination = paginationRef.current;
const observer = new ResizeObserver(() => calcGridHeight());
observer.observe(container);
if (actions) observer.observe(actions);
if (pagination) observer.observe(pagination);
return () => observer.disconnect();
}, [calcGridHeight, containerRef, actionsRef, paginationRef, ...deps]);
return gridHeight;
};
@@ -19,7 +19,7 @@ import {
import { Space } from 'antd';
import React from 'react';
import { BlockSceneEnum } from '../../base/BlockModel';
import { FormBlockModel, FormComponent } from './FormBlockModel';
import { FormBlockContent, FormBlockModel } from './FormBlockModel';
import { submitHandler } from './submitHandler';
// CreateFormModel - 专门用于新增记录
@@ -56,30 +56,38 @@ export class CreateFormModel extends FormBlockModel {
renderComponent() {
const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props;
const isConfigMode = !!this.context.flowSettingsEnabled;
const { heightMode, height } = this.decoratorProps;
return (
<FormComponent model={this} layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}>
<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />
<DndProvider>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
return (
<Droppable model={action} key={action.uid}>
<MemoFlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={this.context.flowSettingsEnabled ? this.actionFlowSettings : false}
extraToolbarItems={this.actionExtraToolbarItems}
/>
</Droppable>
);
})}
{this.renderConfigureActions()}
</Space>
</DndProvider>
</FormComponent>
<FormBlockContent
model={this}
gridModel={this.subModels.grid}
layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}
heightMode={heightMode}
height={height}
grid={<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />}
actions={
<DndProvider>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
return (
<Droppable model={action} key={action.uid}>
<MemoFlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={this.context.flowSettingsEnabled ? this.actionFlowSettings : false}
extraToolbarItems={this.actionExtraToolbarItems}
/>
</Droppable>
);
})}
{this.renderConfigureActions()}
</Space>
</DndProvider>
}
/>
);
}
}
@@ -22,7 +22,7 @@ import { Pagination, Space } from 'antd';
import { isEqual } from 'lodash';
import React from 'react';
import { BlockSceneEnum } from '../../base';
import { FormBlockModel, FormComponent } from './FormBlockModel';
import { FormBlockContent, FormBlockModel } from './FormBlockModel';
import { submitHandler } from './submitHandler';
import { dispatchEventDeep } from '../../../utils';
@@ -87,49 +87,59 @@ export class EditFormModel extends FormBlockModel {
renderComponent() {
const { colon, labelAlign, labelWidth, labelWrap, layout } = this.props;
const isConfigMode = !!this.context.flowSettingsEnabled;
const { heightMode, height } = this.decoratorProps;
const footer =
this.isMultiRecordResource() && this.resource.getMeta('count') > 1 ? (
<div
style={{
textAlign: 'center',
marginTop: 16,
}}
>
<Pagination
simple
pageSize={1}
showSizeChanger={false}
defaultCurrent={(this.resource as MultiRecordResource).getPage()}
total={(this.resource as MultiRecordResource).getTotalPage()}
onChange={this.handlePageChange}
style={{ display: 'inline-block' }}
/>
</div>
) : null;
return (
<FormComponent model={this} layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}>
<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />
<DndProvider>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
return (
<Droppable model={action} key={action.uid}>
<MemoFlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={this.context.flowSettingsEnabled ? this.actionFlowSettings : false}
extraToolbarItems={this.actionExtraToolbarItems}
/>
</Droppable>
);
})}
{this.renderConfigureActions()}
</Space>
</DndProvider>
{this.isMultiRecordResource() && this.resource.getMeta('count') > 1 && (
<div
style={{
textAlign: 'center',
marginTop: 16,
}}
>
<Pagination
simple
pageSize={1}
showSizeChanger={false}
defaultCurrent={(this.resource as MultiRecordResource).getPage()}
total={(this.resource as MultiRecordResource).getTotalPage()}
onChange={this.handlePageChange}
style={{ display: 'inline-block' }}
/>
</div>
)}
</FormComponent>
<FormBlockContent
model={this}
gridModel={this.subModels.grid}
layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}
heightMode={heightMode}
height={height}
grid={<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />}
actions={
<DndProvider>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
return (
<Droppable model={action} key={action.uid}>
<MemoFlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={this.context.flowSettingsEnabled ? this.actionFlowSettings : false}
extraToolbarItems={this.actionExtraToolbarItems}
/>
</Droppable>
);
})}
{this.renderConfigureActions()}
</Space>
</DndProvider>
}
footer={footer}
/>
);
}
}
@@ -18,7 +18,7 @@ import {
} from '@nocobase/flow-engine';
import { Form, FormInstance } from 'antd';
import { omit } from 'lodash';
import React from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
import { BlockGridModel } from '../../base/BlockGridModel';
import { CollectionBlockModel } from '../../base/CollectionBlockModel';
@@ -338,6 +338,7 @@ export function FormComponent({
layoutProps?: any;
initialValues?: any;
onFinish?: (values: any) => void;
[key: string]: any;
}) {
return (
<Form
@@ -357,6 +358,141 @@ export function FormComponent({
);
}
type UseFormGridHeightOptions = {
heightMode?: string;
containerRef: React.RefObject<HTMLDivElement>;
actionsRef?: React.RefObject<HTMLDivElement>;
footerRef?: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
};
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
const useFormGridHeight = ({
heightMode,
containerRef,
actionsRef,
footerRef,
deps = [],
}: UseFormGridHeightOptions) => {
const [gridHeight, setGridHeight] = useState<number>();
const calcGridHeight = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setGridHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const container = containerRef.current;
if (!container) return;
const containerHeight = container.getBoundingClientRect().height;
if (!containerHeight) return;
const actionsHeight = getOuterHeight(actionsRef?.current || null);
const footerHeight = getOuterHeight(footerRef?.current || null);
const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - footerHeight));
setGridHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, containerRef, actionsRef, footerRef]);
useLayoutEffect(() => {
calcGridHeight();
}, [calcGridHeight, ...deps]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const container = containerRef.current;
const actions = actionsRef?.current || null;
const footer = footerRef?.current || null;
const observer = new ResizeObserver(() => calcGridHeight());
observer.observe(container);
if (actions) observer.observe(actions);
if (footer) observer.observe(footer);
return () => observer.disconnect();
}, [calcGridHeight, containerRef, actionsRef, footerRef, ...deps]);
return gridHeight;
};
type FormBlockContentProps = {
model: FormBlockModel;
gridModel: FormGridModel;
layoutProps?: any;
onFinish?: (values: any) => void;
grid: React.ReactNode;
actions?: React.ReactNode;
footer?: React.ReactNode;
heightMode?: string;
height?: number;
};
export const FormBlockContent = ({
model,
gridModel,
layoutProps,
onFinish,
grid,
actions,
footer,
heightMode,
height,
}: FormBlockContentProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
const gridHeight = useFormGridHeight({
heightMode,
containerRef,
actionsRef: actions ? actionsRef : undefined,
footerRef: footer ? footerRef : undefined,
deps: [height],
});
useEffect(() => {
if (!gridModel) return;
const nextHeight = isFixedHeight ? gridHeight : undefined;
if (gridModel.props?.height === nextHeight) return;
gridModel.setProps({ height: nextHeight });
}, [gridModel, gridHeight, isFixedHeight]);
const formStyle = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
height: '100%',
}
: undefined;
const containerStyle: any = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
flex: 1,
}
: undefined;
return (
<FormComponent model={model} layoutProps={layoutProps} onFinish={onFinish} style={formStyle}>
<div ref={containerRef} style={containerStyle}>
{grid}
{actions ? (
<div style={{ paddingTop: model.context?.themeToken?.padding }} ref={actionsRef}>
{actions}
</div>
) : null}
{footer ? <div ref={footerRef}>{footer}</div> : null}
</div>
</FormComponent>
);
};
FormBlockModel.define({
hide: true,
});
@@ -65,6 +65,30 @@ export class FormGridModel<T extends DefaultFormGridStructure = DefaultFormGridS
</AddSubModelButton>
);
}
render() {
const height = this.props?.height;
const heightModel = this.props?.heightModel;
const token = this.context.themeToken;
const content = super.render();
if (heightModel === 'defaultHeight') {
return content;
}
return (
<div
style={{
height,
overflowY: 'auto',
marginLeft: `-${token.marginLG}px`,
marginRight: `-${token.marginLG}px`,
paddingLeft: `${token.marginLG}px`,
paddingRight: `${token.marginLG}px`,
}}
>
{content}
</div>
);
}
}
FormGridModel.registerFlow({
@@ -7,12 +7,12 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import React from 'react';
import { render } from '@testing-library/react';
import React, { useRef } from 'react';
import { render, waitFor } from '@testing-library/react';
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
// 直接从 models 聚合导入,避免局部文件相互引用顺序导致的循环依赖
import { FormBlockModel } from '../../../..';
import { FormBlockContent, FormBlockModel } from '../../../..';
import { Application } from '../../../../../application/Application';
import {
InputFieldInterface,
@@ -21,6 +21,7 @@ import {
M2OFieldInterface,
NumberFieldInterface,
} from '../../../../../collection-manager/interfaces';
import { Form } from 'antd';
// -----------------------------
// Helpers
// -----------------------------
@@ -558,3 +559,115 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
expect(out).toEqual({ who: 'L1' });
});
});
const createRect = (height: number) => ({
x: 0,
y: 0,
width: 100,
height,
top: 0,
left: 0,
right: 100,
bottom: height,
toJSON: () => {},
});
const setRect = (node: HTMLElement | null, height: number) => {
if (!node) return;
node.getBoundingClientRect = () => createRect(height);
};
const FormContentHarness = ({
heightMode,
height,
gridModel,
}: {
heightMode?: string;
height?: number;
gridModel: any;
}) => {
const [form] = Form.useForm();
const modelRef = useRef<any>();
if (!modelRef.current) {
modelRef.current = {
form,
context: { view: { inputArgs: {} }, record: {} },
markUserModifiedFields: vi.fn(),
dispatchEvent: vi.fn(),
emitter: { emit: vi.fn() },
};
}
return (
<FormBlockContent
model={modelRef.current}
gridModel={gridModel}
heightMode={heightMode}
height={height}
grid={<div data-testid="grid" />}
actions={<div data-testid="actions" />}
footer={<div data-testid="footer" />}
/>
);
};
describe('FormBlockModel block height', () => {
const originalResizeObserver = globalThis.ResizeObserver;
beforeEach(() => {
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('updates grid height when heightMode is fixed', async () => {
const gridModel: any = {
props: {},
setProps: vi.fn(),
};
gridModel.setProps = vi.fn((next) => Object.assign(gridModel.props, next));
const { container, rerender } = render(
<FormContentHarness heightMode="specifyValue" height={200} gridModel={gridModel} />,
);
gridModel.setProps.mockClear();
const gridEl = container.querySelector('[data-testid="grid"]') as HTMLElement;
const containerEl = gridEl?.parentElement as HTMLElement;
const actionsWrapper = container.querySelector('[data-testid="actions"]')?.parentElement as HTMLElement;
const footerWrapper = container.querySelector('[data-testid="footer"]')?.parentElement as HTMLElement;
setRect(containerEl, 400);
setRect(actionsWrapper, 40);
setRect(footerWrapper, 20);
rerender(<FormContentHarness heightMode="specifyValue" height={201} gridModel={gridModel} />);
await waitFor(() => {
expect(gridModel.setProps).toHaveBeenLastCalledWith({ height: 340 });
});
});
it('clears grid height when heightMode is not fixed', async () => {
const gridModel: any = {
props: { height: 120 },
setProps: vi.fn(),
};
gridModel.setProps = vi.fn((next) => Object.assign(gridModel.props, next));
render(<FormContentHarness heightMode="default" height={200} gridModel={gridModel} />);
await waitFor(() => {
expect(gridModel.setProps).toHaveBeenCalledWith({ height: undefined });
});
});
});
@@ -34,7 +34,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActionModel, BlockSceneEnum, CollectionBlockModel } from '../../base';
import { QuickEditFormModel } from '../form/QuickEditFormModel';
import { TableColumnModel } from './TableColumnModel';
import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey } from './utils';
import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey, useBlockHeight } from './utils';
import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
import { HighPerformanceSpin } from '../../../../schema-component/common/high-performance-spin/HighPerformanceSpin';
@@ -444,6 +444,7 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
renderComponent() {
const highlightedRowKey = this.props.highlightedRowKey;
const isConfigMode = !!this.context.flowSettingsEnabled;
const { heightMode, height } = this.decoratorProps;
return !this.columns.value.length ? (
<Skeleton paragraph={{ rows: 3 }} />
) : (
@@ -506,24 +507,80 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
</Space>
</div>
</DndProvider>
<HighPerformanceSpin spinning={!!this.resource.loading}>
<HighPerformanceTable
model={this}
size={this.props.size}
virtual={this.props.virtual}
dataSource={this.resource.getData()}
columns={this.columns.value}
pagination={this.pagination()}
highlightedRowKey={highlightedRowKey}
defaultExpandAllRows={this.props.defaultExpandAllRows}
expandedRowKeys={this.props.expandedRowKeys}
/>
</HighPerformanceSpin>
<TableBlockContent
model={this}
size={this.props.size}
virtual={this.props.virtual}
dataSource={this.resource.getData()}
columns={this.columns.value}
pagination={this.pagination()}
highlightedRowKey={highlightedRowKey}
defaultExpandAllRows={this.props.defaultExpandAllRows}
expandedRowKeys={this.props.expandedRowKeys}
heightMode={heightMode}
height={height}
/>
</>
);
}
}
const TableBlockContent = (props: {
model: TableBlockModel;
size: any;
virtual: boolean;
dataSource: any;
columns: any;
pagination: any;
highlightedRowKey: string;
defaultExpandAllRows?: boolean;
expandedRowKeys?: any[];
heightMode?: string;
height?: number;
}) => {
const {
model,
size,
virtual,
dataSource,
columns,
pagination,
highlightedRowKey,
defaultExpandAllRows,
expandedRowKeys,
heightMode,
height,
} = props;
const tableAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useBlockHeight({
heightMode,
tableAreaRef,
deps: [height, heightMode],
});
const tableScroll = useMemo(() => {
const y = scrollY && scrollY > 0 && dataSource?.length ? scrollY : undefined;
return { x: 'max-content', y };
}, [scrollY, dataSource?.length]);
return (
<div ref={tableAreaRef} style={{ flex: 1, minHeight: 0 }}>
<HighPerformanceSpin spinning={!!model.resource.loading}>
<HighPerformanceTable
model={model}
size={size}
virtual={virtual}
dataSource={dataSource}
columns={columns}
pagination={pagination}
highlightedRowKey={highlightedRowKey}
defaultExpandAllRows={defaultExpandAllRows}
expandedRowKeys={expandedRowKeys}
tableScroll={tableScroll}
/>
</HighPerformanceSpin>
</div>
);
};
TableBlockModel.registerFlow({
key: 'resourceSettings2',
steps: {},
@@ -713,7 +770,6 @@ TableBlockModel.define({
sort: 300,
});
const tableScroll = { x: 'max-content' };
const HighPerformanceTable = React.memo(
(props: {
model: TableBlockModel;
@@ -725,6 +781,7 @@ const HighPerformanceTable = React.memo(
highlightedRowKey: string;
defaultExpandAllRows?: boolean;
expandedRowKeys?: any[];
tableScroll;
}) => {
const {
model,
@@ -736,6 +793,7 @@ const HighPerformanceTable = React.memo(
highlightedRowKey,
defaultExpandAllRows,
expandedRowKeys,
tableScroll,
} = props;
const [selectedRowKeys, setSelectedRowKeys] = React.useState<string[]>(() =>
model.resource.getSelectedRows().map((row) => getRowKey(row, model.collection.filterTargetKey)),
@@ -0,0 +1,120 @@
/**
* 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, { useRef } from 'react';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { useBlockHeight } from '../utils';
const createRect = (height: number) => ({
x: 0,
y: 0,
width: 100,
height,
top: 0,
left: 0,
right: 100,
bottom: height,
toJSON: () => {},
});
const setRect = (node: HTMLElement | null, height: number) => {
if (!node) return;
node.getBoundingClientRect = () => createRect(height);
};
const HeightProbe = ({
heightMode,
areaHeight,
headerHeight,
paginationHeight,
depsKey,
}: {
heightMode?: string;
areaHeight: number;
headerHeight: number;
paginationHeight: number;
depsKey: number;
}) => {
const tableAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useBlockHeight({
heightMode,
tableAreaRef,
deps: [depsKey],
});
return (
<div>
<div
ref={(node) => {
tableAreaRef.current = node;
setRect(node, areaHeight);
}}
>
<div
className="ant-table-header"
ref={(node) => {
setRect(node, headerHeight);
}}
/>
<div
className="ant-table-pagination"
ref={(node) => {
setRect(node, paginationHeight);
}}
/>
</div>
<span data-testid="scroll">{scrollY === undefined ? 'undefined' : String(scrollY)}</span>
</div>
);
};
describe('TableBlockModel block height', () => {
const originalResizeObserver = globalThis.ResizeObserver;
beforeAll(() => {
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
afterAll(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('calculates scrollY when heightMode is fixed', async () => {
render(
<HeightProbe heightMode="specifyValue" areaHeight={300} headerHeight={40} paginationHeight={30} depsKey={1} />,
);
await waitFor(() => {
expect(screen.getByTestId('scroll').textContent).toBe('230');
});
});
it('clears scrollY when heightMode is not fixed', async () => {
const { rerender } = render(
<HeightProbe heightMode="specifyValue" areaHeight={300} headerHeight={40} paginationHeight={30} depsKey={1} />,
);
await waitFor(() => {
expect(screen.getByTestId('scroll').textContent).toBe('230');
});
rerender(<HeightProbe heightMode="default" areaHeight={300} headerHeight={40} paginationHeight={30} depsKey={2} />);
await waitFor(() => {
expect(screen.getByTestId('scroll').textContent).toBe('undefined');
});
});
});
@@ -6,7 +6,7 @@
* 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 { useState, useEffect, useLayoutEffect, useCallback } from 'react';
export function extractIndex(str) {
const numbers = [];
str?.split('.').forEach(function (element) {
@@ -78,3 +78,48 @@ export function getRowKey(record: any, key: string | string[]) {
}
return record?.[key] ?? '';
}
type UseBlockHeightOptions = {
heightMode?: string;
tableAreaRef: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
};
export const useBlockHeight = ({ heightMode, tableAreaRef, deps = [] }: UseBlockHeightOptions) => {
const [scrollY, setScrollY] = useState<number>();
const calcScrollY = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setScrollY((prev) => (prev === undefined ? prev : undefined));
return;
}
const tableArea = tableAreaRef.current;
if (!tableArea) return;
const areaHeight = tableArea.getBoundingClientRect().height;
if (!areaHeight) return;
const headerEl = tableArea.querySelector('.ant-table-header') || tableArea.querySelector('.ant-table-thead');
const paginationEl = tableArea.querySelector('.ant-table-pagination');
const headerHeight = headerEl?.getBoundingClientRect().height ?? 0;
const paginationHeight = paginationEl?.getBoundingClientRect().height ?? 0;
const nextScrollY = Math.max(0, Math.floor(areaHeight - headerHeight - paginationHeight));
setScrollY((prev) => (prev === nextScrollY ? prev : nextScrollY));
}, [heightMode, tableAreaRef]);
useLayoutEffect(() => {
calcScrollY();
}, [calcScrollY, ...deps]);
useEffect(() => {
if (!tableAreaRef.current || typeof ResizeObserver === 'undefined') return;
const tableArea = tableAreaRef.current;
const headerEl = tableArea.querySelector('.ant-table-header') || tableArea.querySelector('.ant-table-thead');
const paginationEl = tableArea.querySelector('.ant-table-pagination');
const observer = new ResizeObserver(() => calcScrollY());
observer.observe(tableArea);
if (headerEl) observer.observe(headerEl);
if (paginationEl) observer.observe(paginationEl);
return () => observer.disconnect();
}, [calcScrollY, tableAreaRef, ...deps]);
return scrollY;
};
+4 -3
View File
@@ -1593,10 +1593,11 @@
"Cascader": "级联选择",
"Subform":"子表单",
"Edit popup (Add new)":"编辑弹窗(添加)",
"Edit popup (Select record)":"编辑弹窗(选择记录)",
"Remove record":"移除记录",
"Are you sure you want to remove it?":"你确定要移除吗?",
"Create record":"创建记录",
"Duplicate record":"复制记录",
"Are you sure you want to duplicate it?":"你确定要复制吗?"
"Are you sure you want to duplicate it?":"你确定要复制吗?",
"Edit popup (Select record)":"编辑弹窗(选择纪录)",
"Remove record":"移除记录",
"Block Height":"区块高度"
}
@@ -16,10 +16,11 @@ import {
AddSubModelButton,
FlowSettingsButton,
FlowModel,
observer,
} from '@nocobase/flow-engine';
import { SettingOutlined } from '@ant-design/icons';
import { CollectionBlockModel, BlockSceneEnum, ActionModel } from '@nocobase/client';
import React from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { List, Space, Slider, Grid, InputNumber, Col } from 'antd';
import { css } from '@emotion/css';
import { GridCardItemModel } from './GridCardItemModel';
@@ -137,103 +138,221 @@ export class GridCardBlockModel extends CollectionBlockModel<GridBlockModelStruc
}
renderComponent() {
const { columnCount } = this.props;
const token = this.context.themeToken;
const isConfigMode = !!this.context.flowSettingsEnabled;
return (
<>
<DndProvider>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<Space wrap>
{this.mapSubModels('actions', (action) => {
// @ts-ignore
if (action.props.position === 'left') {
return (
<FlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
/>
);
}
return null;
})}
{/* 占位 */}
<span></span>
</Space>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
// @ts-ignore
if (action.props.position !== 'left') {
return (
<Droppable model={action} key={action.uid}>
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
/>
</Droppable>
);
}
return null;
})}
{this.renderConfiguireActions()}
</Space>
</div>
</DndProvider>
<List
{...this.props}
pagination={this.pagination()}
loading={this.resource?.loading}
dataSource={this.resource.getData()}
grid={{
...columnCount,
sm: columnCount.xs,
xl: columnCount.lg,
gutter: [token.marginBlock / 2, token.marginBlock / 2],
}}
renderItem={(item, index) => {
const model = this.subModels.item.createFork({}, `${index}`);
model.context.defineProperty('record', {
get: () => item,
cache: false,
resolveOnServer: true,
});
model.context.defineProperty('index', {
get: () => index,
cache: false,
resolveOnServer: true,
});
return (
<Col
className={css`
height: 100%;
> div {
height: 100%;
}
`}
>
<FlowModelRenderer model={model} />
</Col>
);
}}
/>
</>
);
const { heightMode, height } = this.decoratorProps;
return <GridCardBlockContent model={this} heightMode={heightMode} height={height} />;
}
}
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
const useGridCardHeight = ({
heightMode,
containerRef,
actionsRef,
listRef,
deps = [],
}: {
heightMode?: string;
containerRef: React.RefObject<HTMLDivElement>;
actionsRef: React.RefObject<HTMLDivElement>;
listRef: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
}) => {
const [listHeight, setListHeight] = useState<number>();
const calcListHeight = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setListHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const container = containerRef.current;
if (!container) return;
const containerHeight = container.getBoundingClientRect().height;
if (!containerHeight) return;
const actionsHeight = getOuterHeight(actionsRef.current);
const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null;
const paginationHeight = getOuterHeight(paginationEl);
const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - paginationHeight));
setListHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, containerRef, actionsRef, listRef]);
useLayoutEffect(() => {
calcListHeight();
}, [calcListHeight, ...deps]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const container = containerRef.current;
const actions = actionsRef.current;
const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null;
const observer = new ResizeObserver(() => calcListHeight());
observer.observe(container);
if (actions) observer.observe(actions);
if (paginationEl) observer.observe(paginationEl);
return () => observer.disconnect();
}, [calcListHeight, containerRef, actionsRef, listRef, ...deps]);
return listHeight;
};
const GridCardBlockContent = observer(
({ model, heightMode, height }: { model: GridCardBlockModel; heightMode?: string; height?: number }) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
const ctx = model.context;
const token = ctx.themeToken;
const listHeight = useGridCardHeight({
heightMode,
containerRef,
actionsRef,
listRef,
deps: [height],
});
const listClassName = useMemo(
() => css`
.ant-spin-nested-loading {
height: var(--nb-grid-card-height);
overflow: auto;
margin-left: -${token.marginLG}px;
margin-right: -${token.marginLG}px;
padding-left: ${token.marginLG}px;
padding-right: ${token.marginLG}px;
}
.ant-spin-nested-loading > .ant-spin-container {
min-height: 100%;
}
`,
[],
);
const listStyle = useMemo(() => {
if (listHeight == null) return model.props?.style;
return {
...(model.props?.style || {}),
['--nb-grid-card-height' as any]: `${listHeight}px`,
};
}, [listHeight, model.props?.style]);
const isConfigMode = !!model.context.flowSettingsEnabled;
const columnCount = model.props.columnCount;
const containerStyle: any = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
height: '100%',
}
: undefined;
return (
<div ref={containerRef} style={containerStyle}>
<div ref={actionsRef}>
<DndProvider>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}
>
<Space wrap>
{model.mapSubModels('actions', (action) => {
// @ts-ignore
if (action.props.position === 'left') {
return (
<FlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
/>
);
}
return null;
})}
{/* 占位 */}
<span></span>
</Space>
<Space wrap>
{model.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
// @ts-ignore
if (action.props.position !== 'left') {
return (
<Droppable model={action} key={action.uid}>
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
/>
</Droppable>
);
}
return null;
})}
{model.renderConfiguireActions()}
</Space>
</div>
</DndProvider>
</div>
<div ref={listRef} style={{ flex: 1, minHeight: 0 }}>
<List
{...model.props}
className={model.props?.className ? `${model.props.className} ${listClassName}` : listClassName}
style={listStyle}
pagination={model.pagination()}
loading={model.resource?.loading}
dataSource={model.resource.getData()}
grid={{
...columnCount,
sm: columnCount.xs,
xl: columnCount.lg,
gutter: [token.marginBlock / 2, token.marginBlock / 2],
}}
renderItem={(item, index) => {
const itemModel = model.subModels.item.createFork({}, `${index}`);
itemModel.context.defineProperty('record', {
get: () => item,
cache: false,
resolveOnServer: true,
});
itemModel.context.defineProperty('index', {
get: () => index,
cache: false,
resolveOnServer: true,
});
return (
<Col
className={css`
height: 100%;
> div {
height: 100%;
}
`}
>
<FlowModelRenderer model={itemModel} />
</Col>
);
}}
/>
</div>
</div>
);
},
);
GridCardBlockModel.registerFlow({
key: 'resourceSettings2',
steps: {},
@@ -52,8 +52,8 @@ const HtmlEditorBase: React.FC<any> = (props) => {
};
const Iframe: any = observer(
(props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any }) => {
const { url, htmlId, mode = 'url', html, params, height, ...others } = props;
(props: IIframe & { html?: string; htmlId?: number; mode: string; params?: any; heightMode?: string }) => {
const { url, htmlId, mode = 'url', html, params, heightMode, ...others } = props;
const { token } = theme.useToken();
const compile = useCompile();
const ctx = useFlowContext();
@@ -91,7 +91,6 @@ const Iframe: any = observer(
} else {
try {
const targetUrl = joinUrlSearch(url, params);
console.log(targetUrl);
if (active) setSrc(targetUrl);
} catch (error) {
console.error('Error fetching target URL:', error);
@@ -107,13 +106,11 @@ const Iframe: any = observer(
active = false;
};
}, [htmlContent, mode, url, params, html, htmlId]);
console.log(src);
if (loading && !src) {
return (
<div
style={{
height: height || '60vh',
marginBottom: token.padding,
height: heightMode === 'defaultHeight' ? '60vh' : '100%',
border: 0,
}}
>
@@ -129,8 +126,7 @@ const Iframe: any = observer(
display="block"
position="relative"
styles={{
height: height || '60vh',
marginBottom: '24px',
height: heightMode === 'defaultHeight' ? '60vh' : '100%',
border: 0,
}}
{...others}
@@ -141,14 +137,15 @@ const Iframe: any = observer(
);
export class IframeBlockModel extends BlockModel {
render() {
renderComponent() {
const { url, htmlId, mode = 'url', html, params, ...others } = this.props;
const { heightMode } = this.decoratorProps;
const token = this.context.themeToken;
const t = this.context.t;
if ((mode === 'url' && !url) || (mode === 'html' && !htmlId)) {
return <Card style={{ marginBottom: token.padding }}>{t('Please fill in the iframe URL')}</Card>;
}
return <Iframe {...this.props} />;
return <Iframe {...this.props} heightMode={heightMode} />;
}
}
const AllowDescription = () => {
@@ -229,16 +226,6 @@ const AllowOptionsHelp = ({ type }) => {
);
};
IframeBlockModel.registerFlow({
key: 'cardSettings',
title: escapeT('Block settings', { ns: 'block-iframe' }),
steps: {
linkageRules: {
use: 'blockLinkageRules',
},
},
});
IframeBlockModel.registerFlow({
key: 'iframeBlockSettings',
title: escapeT('Iframe block setting', { ns: 'block-iframe' }),
@@ -17,13 +17,15 @@ import {
AddSubModelButton,
FlowSettingsButton,
FlowModel,
observer,
} from '@nocobase/flow-engine';
import { SettingOutlined } from '@ant-design/icons';
import { CollectionBlockModel, BlockSceneEnum, ActionModel, dispatchEventDeep } from '@nocobase/client';
import React from 'react';
import React, { useMemo, useRef } from 'react';
import { List, Space } from 'antd';
import { css } from '@emotion/css';
import { ListItemModel } from './ListItemModel';
import { useListHeight } from './utils';
type ListBlockModelStructure = {
subModels: {
@@ -184,43 +186,100 @@ export class ListBlockModel extends CollectionBlockModel<ListBlockModelStructure
}
renderComponent() {
return (
<>
{this.renderActions()}
<List
{...this.props}
pagination={this.pagination()}
loading={this.resource?.loading}
dataSource={this.resource.getData()}
renderItem={(item, index) => {
const model = this.subModels.item.createFork({}, `${index}`);
model.context.defineProperty('record', {
get: () => item,
cache: false,
});
model.context.defineProperty('index', {
get: () => index,
cache: false,
});
return (
<List.Item
key={index}
className={css`
> div {
width: 100%;
}
`}
>
<FlowModelRenderer model={model} />
</List.Item>
);
}}
/>
</>
);
const { heightMode, height } = this.decoratorProps;
return <ListBlockContent model={this} heightMode={heightMode} height={height} />;
}
}
const ListBlockContent = observer(
({ model, heightMode, height }: { model: ListBlockModel; heightMode?: string; height?: number }) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
const ctx = model.context;
const token = ctx.themeToken;
const listHeight = useListHeight({
heightMode,
containerRef,
actionsRef,
listRef,
deps: [height],
});
const listClassName = useMemo(
() => css`
.ant-spin-nested-loading {
height: var(--nb-list-height);
overflow: auto;
margin-left: -${token.marginLG}px;
margin-right: -${token.marginLG}px;
padding-left: ${token.marginLG}px;
padding-right: ${token.marginLG}px;
}
.ant-spin-nested-loading > .ant-spin-container {
min-height: 100%;
}
`,
[],
);
const listStyle = useMemo(() => {
if (listHeight == null) return model.props?.style;
return {
...(model.props?.style || {}),
['--nb-list-height' as any]: `${listHeight}px`,
};
}, [listHeight, model.props?.style]);
const containerStyle: any = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
height: '100%',
}
: undefined;
return (
<div ref={containerRef} style={containerStyle}>
<div ref={actionsRef}>{model.renderActions()}</div>
<div ref={listRef} style={{ flex: 1, minHeight: 0 }}>
<List
{...model.props}
className={model.props?.className ? `${model.props.className} ${listClassName}` : listClassName}
style={listStyle}
pagination={model.pagination()}
loading={model.resource?.loading}
dataSource={model.resource.getData()}
renderItem={(item, index) => {
const itemModel = model.subModels.item.createFork({}, `${index}`);
itemModel.context.defineProperty('record', {
get: () => item,
cache: false,
});
itemModel.context.defineProperty('index', {
get: () => index,
cache: false,
});
return (
<List.Item
key={index}
className={css`
> div {
width: 100%;
}
`}
>
<FlowModelRenderer model={itemModel} />
</List.Item>
);
}}
/>
</div>
</div>
);
},
);
ListBlockModel.registerFlow({
key: 'resourceSettings2',
steps: {},
@@ -0,0 +1,144 @@
/**
* 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, { useRef } from 'react';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { useListHeight } from '../utils';
const createRect = (height: number) => ({
x: 0,
y: 0,
width: 100,
height,
top: 0,
left: 0,
right: 100,
bottom: height,
toJSON: () => {},
});
const setRect = (node: HTMLElement | null, height: number) => {
if (!node) return;
node.getBoundingClientRect = () => createRect(height);
};
const HeightProbe = ({
heightMode,
containerHeight,
actionsHeight,
paginationHeight,
depsKey,
}: {
heightMode?: string;
containerHeight: number;
actionsHeight: number;
paginationHeight: number;
depsKey: number;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const listHeight = useListHeight({
heightMode,
containerRef,
actionsRef,
listRef,
deps: [depsKey],
});
return (
<div>
<div
ref={(node) => {
containerRef.current = node;
setRect(node, containerHeight);
}}
>
<div
ref={(node) => {
actionsRef.current = node;
setRect(node, actionsHeight);
}}
/>
<div
ref={(node) => {
listRef.current = node;
}}
>
<div
className="ant-list-pagination"
ref={(node) => {
setRect(node, paginationHeight);
}}
/>
</div>
</div>
<span data-testid="list-height">{listHeight === undefined ? 'undefined' : String(listHeight)}</span>
</div>
);
};
describe('ListBlockModel block height', () => {
const originalResizeObserver = globalThis.ResizeObserver;
beforeAll(() => {
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as any;
}
});
afterAll(() => {
globalThis.ResizeObserver = originalResizeObserver;
});
it('calculates list height when heightMode is fixed', async () => {
render(
<HeightProbe
heightMode="specifyValue"
containerHeight={400}
actionsHeight={40}
paginationHeight={20}
depsKey={1}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('list-height').textContent).toBe('340');
});
});
it('clears list height when heightMode is not fixed', async () => {
const { rerender } = render(
<HeightProbe
heightMode="specifyValue"
containerHeight={400}
actionsHeight={40}
paginationHeight={20}
depsKey={1}
/>,
);
await waitFor(() => {
expect(screen.getByTestId('list-height').textContent).toBe('340');
});
rerender(
<HeightProbe heightMode="default" containerHeight={400} actionsHeight={40} paginationHeight={20} depsKey={2} />,
);
await waitFor(() => {
expect(screen.getByTestId('list-height').textContent).toBe('undefined');
});
});
});
@@ -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, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
export const useListHeight = ({
heightMode,
containerRef,
actionsRef,
listRef,
deps = [],
}: {
heightMode?: string;
containerRef: React.RefObject<HTMLDivElement>;
actionsRef: React.RefObject<HTMLDivElement>;
listRef: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
}) => {
const [listHeight, setListHeight] = useState<number>();
const calcListHeight = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setListHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const container = containerRef.current;
if (!container) return;
const containerHeight = container.getBoundingClientRect().height;
if (!containerHeight) return;
const actionsHeight = getOuterHeight(actionsRef.current);
const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null;
const paginationHeight = getOuterHeight(paginationEl);
const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight - paginationHeight));
setListHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, containerRef, actionsRef, listRef]);
useLayoutEffect(() => {
calcListHeight();
}, [calcListHeight, ...deps]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const container = containerRef.current;
const actions = actionsRef.current;
const paginationEl = listRef.current?.querySelector('.ant-list-pagination') as HTMLElement | null;
const observer = new ResizeObserver(() => calcListHeight());
observer.observe(container);
if (actions) observer.observe(actions);
if (paginationEl) observer.observe(paginationEl);
return () => observer.disconnect();
}, [calcListHeight, containerRef, actionsRef, listRef, ...deps]);
return listHeight;
};
@@ -20,8 +20,8 @@ import {
import { Space, InputNumber, Cascader } from 'antd';
import { SettingOutlined } from '@ant-design/icons';
import { CollectionBlockModel, BlockSceneEnum, openViewFlow } from '@nocobase/client';
import React from 'react';
import { useField, observer } from '@formily/react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MapBlockComponent } from './MapBlockComponent';
import { NAMESPACE } from '../locale';
@@ -119,73 +119,160 @@ export class MapBlockModel extends CollectionBlockModel {
}
renderComponent() {
const isConfigMode = !!this.context.flowSettingsEnabled;
return (
<div>
<DndProvider>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<Space>
{this.mapSubModels('actions', (action) => {
// @ts-ignore
if (action.props.position === 'left') {
return (
<FlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
/>
);
}
return null;
})}
{/* 占位 */}
<span></span>
</Space>
<Space wrap>
{this.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
// @ts-ignore
if (action.props.position !== 'left') {
return (
<Droppable model={action} key={action.uid}>
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
/>
</Droppable>
);
}
return null;
})}
{this.renderConfigureAction()}
</Space>
</div>
</DndProvider>
<MapBlockComponent
{...this.props}
fields={this.collection.getFields()}
name={this.collection.name}
primaryKey={this.collection.filterTargetKey}
setSelectedRecordKeys={this.setSelectedRecordKeys.bind(this)}
dataSource={this.resource.getData()}
/>
</div>
);
const { heightMode, height } = this.decoratorProps;
return <MapBlockContent model={this} heightMode={heightMode} height={height} />;
}
}
const getOuterHeight = (element?: HTMLElement | null) => {
if (!element) return 0;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const marginTop = parseFloat(style.marginTop) || 0;
const marginBottom = parseFloat(style.marginBottom) || 0;
return rect.height + marginTop + marginBottom;
};
const useMapHeight = ({
heightMode,
containerRef,
actionsRef,
deps = [],
}: {
heightMode?: string;
containerRef: React.RefObject<HTMLDivElement>;
actionsRef: React.RefObject<HTMLDivElement>;
deps?: React.DependencyList;
}) => {
const [mapHeight, setMapHeight] = useState<number>();
const calcMapHeight = useCallback(() => {
if (heightMode !== 'specifyValue' && heightMode !== 'fullHeight') {
setMapHeight((prev) => (prev === undefined ? prev : undefined));
return;
}
const container = containerRef.current;
if (!container) return;
const containerHeight = container.getBoundingClientRect().height;
if (!containerHeight) return;
const actionsHeight = getOuterHeight(actionsRef.current);
const nextHeight = Math.max(0, Math.floor(containerHeight - actionsHeight));
setMapHeight((prev) => (prev === nextHeight ? prev : nextHeight));
}, [heightMode, containerRef, actionsRef]);
useLayoutEffect(() => {
calcMapHeight();
}, [calcMapHeight, ...deps]);
useEffect(() => {
if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
const container = containerRef.current;
const actions = actionsRef.current;
const observer = new ResizeObserver(() => calcMapHeight());
observer.observe(container);
if (actions) observer.observe(actions);
return () => observer.disconnect();
}, [calcMapHeight, containerRef, actionsRef, ...deps]);
return mapHeight;
};
const MapBlockContent = observer(
({ model, heightMode, height }: { model: MapBlockModel; heightMode?: string; height?: number }) => {
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const isFixedHeight = heightMode === 'specifyValue' || heightMode === 'fullHeight';
const mapHeight = useMapHeight({
heightMode,
containerRef,
actionsRef,
deps: [height],
});
const mapStyle = useMemo(() => {
if (mapHeight == null) return undefined;
return { height: mapHeight, overflow: 'auto' };
}, [mapHeight]);
const containerStyle: any = isFixedHeight
? {
display: 'flex',
flexDirection: 'column',
minHeight: 0,
height: '100%',
}
: undefined;
const isConfigMode = !!model.context.flowSettingsEnabled;
return (
<div ref={containerRef} style={containerStyle}>
<div ref={actionsRef}>
<DndProvider>
<div
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}
>
<Space>
{model.mapSubModels('actions', (action) => {
// @ts-ignore
if (action.props.position === 'left') {
return (
<FlowModelRenderer
key={action.uid}
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
/>
);
}
return null;
})}
{/* 占位 */}
<span></span>
</Space>
<Space wrap>
{model.mapSubModels('actions', (action) => {
if (action.hidden && !isConfigMode) {
return;
}
// @ts-ignore
if (action.props.position !== 'left') {
return (
<Droppable model={action} key={action.uid}>
<FlowModelRenderer
model={action}
showFlowSettings={{ showBackground: false, showBorder: false, toolbarPosition: 'above' }}
extraToolbarItems={[
{
key: 'drag-handler',
component: DragHandler,
sort: 1,
},
]}
/>
</Droppable>
);
}
return null;
})}
{model.renderConfigureAction()}
</Space>
</div>
</DndProvider>
</div>
<div className="nb-map-content" style={mapStyle}>
<MapBlockComponent
{...model.props}
fields={model.collection.getFields()}
name={model.collection.name}
primaryKey={model.collection.filterTargetKey}
setSelectedRecordKeys={model.setSelectedRecordKeys.bind(model)}
dataSource={model.resource.getData()}
height={mapHeight ? mapHeight - 5 : null}
/>
</div>
</div>
);
},
);
const getCollectionFieldsOptions = (
collectionName: string,
collectionManager: any,