mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-01 14:57:36 +08:00
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:
@@ -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({
|
||||
|
||||
+139
@@ -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({
|
||||
|
||||
+116
-3
@@ -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)),
|
||||
|
||||
+120
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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":"区块高度"
|
||||
}
|
||||
|
||||
+214
-95
@@ -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: {},
|
||||
|
||||
+7
-20
@@ -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: {},
|
||||
|
||||
+144
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user