mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-19 10:54:38 +08:00
Merge branch 'main' into next
This commit is contained in:
@@ -18,7 +18,7 @@ const { pick } = require('lodash');
|
||||
exports.getAccessKeyPair = async function () {
|
||||
const keyFile = resolve(process.cwd(), 'storage/.license/license-key');
|
||||
if (!fs.existsSync(keyFile)) {
|
||||
logger.error('License key not found');
|
||||
logger.info('License key not found');
|
||||
return {};
|
||||
}
|
||||
logger.info('License key found');
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
VIEW_ACTIVATED_EVENT,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { Tabs } from 'antd';
|
||||
import _ from 'lodash';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { TextAreaWithContextSelector } from '../../../components/TextAreaWithContextSelector';
|
||||
import { BasePageTabModel } from './PageTabModel';
|
||||
@@ -335,9 +334,18 @@ export class PageModel extends FlowModel<PageModelStructure> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const token = this.context.themeToken;
|
||||
const headerStyle = { ...this.props.headerStyle } as Record<string, any>;
|
||||
if (token) {
|
||||
headerStyle.paddingBlock = token.paddingSM;
|
||||
headerStyle.paddingInline = token.paddingLG;
|
||||
}
|
||||
if (this.props.enableTabs) {
|
||||
headerStyle.paddingBottom = 0;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{this.props.displayTitle && <PageHeader title={this.props.title} style={this.props.headerStyle} />}
|
||||
{this.props.displayTitle && <PageHeader title={this.props.title} style={headerStyle} />}
|
||||
{this.props.enableTabs ? this.renderTabs() : this.renderFirstTab()}
|
||||
</>
|
||||
);
|
||||
@@ -398,6 +406,8 @@ PageModel.registerFlow({
|
||||
};
|
||||
},
|
||||
async handler(ctx, params) {
|
||||
const token = ctx.themeToken;
|
||||
const tabPaddingInline = token?.paddingLG ?? 16;
|
||||
ctx.model.setProps('displayTitle', params.displayTitle);
|
||||
if (ctx.model.context.closable) {
|
||||
ctx.model.setProps('title', ctx.t(params.title, { ns: 'lm-desktop-routes' }));
|
||||
@@ -413,7 +423,7 @@ PageModel.registerFlow({
|
||||
});
|
||||
ctx.model.setProps('tabBarStyle', {
|
||||
backgroundColor: 'var(--colorBgContainer)',
|
||||
paddingInline: 16,
|
||||
paddingInline: tabPaddingInline,
|
||||
marginBottom: 0,
|
||||
});
|
||||
} else {
|
||||
@@ -422,7 +432,7 @@ PageModel.registerFlow({
|
||||
});
|
||||
ctx.model.setProps('tabBarStyle', {
|
||||
backgroundColor: 'var(--colorBgLayout)',
|
||||
paddingInline: 16,
|
||||
paddingInline: tabPaddingInline,
|
||||
marginBottom: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +182,41 @@ describe('PageModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('render header spacing with tabs', () => {
|
||||
it('should compact page header bottom spacing when tabs are enabled', () => {
|
||||
pageModel.props = {
|
||||
displayTitle: true,
|
||||
enableTabs: true,
|
||||
title: 'Title',
|
||||
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
|
||||
} as any;
|
||||
pageModel.renderTabs = vi.fn(() => null);
|
||||
|
||||
const result = pageModel.render() as any;
|
||||
const header = result.props.children[0];
|
||||
|
||||
expect(header.props.style).toMatchObject({
|
||||
backgroundColor: 'var(--colorBgLayout)',
|
||||
paddingBottom: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep original header style when tabs are disabled', () => {
|
||||
pageModel.props = {
|
||||
displayTitle: true,
|
||||
enableTabs: false,
|
||||
title: 'Title',
|
||||
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
|
||||
} as any;
|
||||
pageModel.renderFirstTab = vi.fn(() => null);
|
||||
|
||||
const result = pageModel.render() as any;
|
||||
const header = result.props.children[0];
|
||||
|
||||
expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dirty refresh signal', () => {
|
||||
it('should invoke current tab onActive when dataSource:dirty is emitted and page is active', async () => {
|
||||
const listeners: Record<string, any> = {};
|
||||
|
||||
@@ -36,6 +36,13 @@ import { QuickEditFormModel } from '../form/QuickEditFormModel';
|
||||
import { TableColumnModel } from './TableColumnModel';
|
||||
import { extractIndex, adjustColumnOrder, setNestedValue, extractIds, getRowKey, useBlockHeight } from './utils';
|
||||
import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
|
||||
import {
|
||||
applyMobilePaginationProps,
|
||||
createCompactSimpleItemRender,
|
||||
getSimpleModePaginationClassName,
|
||||
getUnknownCountPaginationTotal,
|
||||
mergePaginationClassName,
|
||||
} from '../../../utils';
|
||||
import { HighPerformanceSpin } from '../../../../schema-component/common/high-performance-spin/HighPerformanceSpin';
|
||||
import {
|
||||
SortHandle,
|
||||
@@ -434,8 +441,9 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
|
||||
const hasNext = this.resource.getMeta('hasNext');
|
||||
const current = this.resource.getPage();
|
||||
const data = this.resource.getData();
|
||||
const isMobileLayout = !!this.context.isMobileLayout;
|
||||
if (totalCount) {
|
||||
return {
|
||||
const result = {
|
||||
current,
|
||||
pageSize,
|
||||
total: totalCount,
|
||||
@@ -444,39 +452,30 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
|
||||
},
|
||||
showSizeChanger: true,
|
||||
};
|
||||
return applyMobilePaginationProps(result, isMobileLayout);
|
||||
} else {
|
||||
return {
|
||||
const nextPageSize = pageSize || 10;
|
||||
const nextCurrent = current || 1;
|
||||
const result = {
|
||||
// showTotal: false,
|
||||
simple: true,
|
||||
showTitle: false,
|
||||
showSizeChanger: true,
|
||||
hideOnSinglePage: false,
|
||||
pageSize,
|
||||
total: data?.length < pageSize || !hasNext ? pageSize * current : pageSize * current + 1,
|
||||
className: css`
|
||||
.ant-pagination-simple-pager {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
itemRender: (_, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex' }}
|
||||
className={css`
|
||||
.ant-pagination-item-link {
|
||||
min-width: ${this.context.themeToken.controlHeight}px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{originalElement} <div>{this.resource.getPage()}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return originalElement;
|
||||
}
|
||||
},
|
||||
pageSize: nextPageSize,
|
||||
total: getUnknownCountPaginationTotal({
|
||||
dataLength: data?.length,
|
||||
pageSize: nextPageSize,
|
||||
current: nextCurrent,
|
||||
hasNext,
|
||||
}),
|
||||
className: mergePaginationClassName(getSimpleModePaginationClassName(), undefined),
|
||||
itemRender: createCompactSimpleItemRender({
|
||||
current: nextCurrent,
|
||||
controlHeight: this.context.themeToken.controlHeight,
|
||||
}),
|
||||
};
|
||||
return applyMobilePaginationProps(result, isMobileLayout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyMobilePaginationProps,
|
||||
createCompactSimpleItemRender,
|
||||
getSimpleModePaginationClassName,
|
||||
getUnknownCountPaginationTotal,
|
||||
} from '../pagination';
|
||||
|
||||
describe('flow pagination utils', () => {
|
||||
it('未知总数场景应按 hasNext 估算 total', () => {
|
||||
expect(
|
||||
getUnknownCountPaginationTotal({
|
||||
dataLength: 12,
|
||||
pageSize: 12,
|
||||
current: 1,
|
||||
hasNext: true,
|
||||
}),
|
||||
).toBe(13);
|
||||
|
||||
expect(
|
||||
getUnknownCountPaginationTotal({
|
||||
dataLength: 9,
|
||||
pageSize: 12,
|
||||
current: 2,
|
||||
hasNext: true,
|
||||
}),
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
it('移动端分页应隐藏 total 与 size changer', () => {
|
||||
const result = applyMobilePaginationProps(
|
||||
{
|
||||
total: 43,
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
showTotal: () => 'Total 43 items',
|
||||
showSizeChanger: true,
|
||||
},
|
||||
true,
|
||||
) as any;
|
||||
|
||||
expect(result.showTotal).toBe(false);
|
||||
expect(result.showSizeChanger).toBe(false);
|
||||
expect(result.showLessItems).toBe(true);
|
||||
expect(typeof result.className).toBe('string');
|
||||
});
|
||||
|
||||
it('simple 模式工具应提供 className 与 itemRender', () => {
|
||||
const className = getSimpleModePaginationClassName(true);
|
||||
const itemRender = createCompactSimpleItemRender({ current: 2, controlHeight: 32 });
|
||||
|
||||
expect(typeof className).toBe('string');
|
||||
expect(typeof itemRender).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -9,3 +9,4 @@
|
||||
|
||||
export * from './blockUtils';
|
||||
export * from './dispatchEventDeep';
|
||||
export * from './pagination';
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import type { PaginationProps } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* 计算未知总数场景下的 Pagination total:
|
||||
* - 本页不足 pageSize 或 hasNext=false,说明到末页
|
||||
* - 否则用 +1 触发“还有下一页”
|
||||
*/
|
||||
export const getUnknownCountPaginationTotal = (options: {
|
||||
dataLength?: number;
|
||||
pageSize?: number;
|
||||
current?: number;
|
||||
hasNext?: boolean;
|
||||
}) => {
|
||||
const dataLength = options.dataLength || 0;
|
||||
const pageSize = options.pageSize || 10;
|
||||
const current = options.current || 1;
|
||||
if (dataLength < pageSize || !options.hasNext) {
|
||||
return pageSize * current;
|
||||
}
|
||||
return pageSize * current + 1;
|
||||
};
|
||||
|
||||
export const getSimpleModePaginationClassName = (withLineHeight = false) => {
|
||||
return css`
|
||||
.ant-pagination-simple-pager {
|
||||
display: none !important;
|
||||
}
|
||||
${withLineHeight
|
||||
? `
|
||||
li {
|
||||
line-height: 32px !important;
|
||||
}
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
};
|
||||
|
||||
const mobileCompactPaginationClassName = css`
|
||||
justify-content: flex-end !important;
|
||||
.ant-pagination-total-text,
|
||||
.ant-pagination-options,
|
||||
.ant-pagination-jump-prev,
|
||||
.ant-pagination-jump-next {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export const getMobileCompactPaginationClassName = () => mobileCompactPaginationClassName;
|
||||
|
||||
export const mergePaginationClassName = (...classNames: Array<string | undefined | false>) => {
|
||||
return cx(...classNames.filter(Boolean));
|
||||
};
|
||||
|
||||
export const createCompactSimpleItemRender = (options: {
|
||||
current?: number;
|
||||
controlHeight?: number;
|
||||
currentTextMarginLeft?: number;
|
||||
}): PaginationProps['itemRender'] => {
|
||||
const current = options.current || 1;
|
||||
const controlHeight = options.controlHeight || 32;
|
||||
const currentTextStyle = options.currentTextMarginLeft
|
||||
? { marginLeft: `${options.currentTextMarginLeft}px` }
|
||||
: undefined;
|
||||
|
||||
return (_, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
style: { display: 'flex' },
|
||||
className: css`
|
||||
.ant-pagination-item-link {
|
||||
min-width: ${controlHeight}px;
|
||||
}
|
||||
`,
|
||||
},
|
||||
originalElement,
|
||||
React.createElement('div', { style: currentTextStyle }, current),
|
||||
);
|
||||
}
|
||||
return originalElement;
|
||||
};
|
||||
};
|
||||
|
||||
export const applyMobilePaginationProps = <T extends PaginationProps>(
|
||||
pagination: T,
|
||||
isMobileLayout: boolean,
|
||||
): PaginationProps => {
|
||||
if (!isMobileLayout) {
|
||||
return pagination;
|
||||
}
|
||||
return {
|
||||
...pagination,
|
||||
showTotal: false,
|
||||
showSizeChanger: false,
|
||||
showLessItems: true,
|
||||
className: mergePaginationClassName(pagination.className, getMobileCompactPaginationClassName()),
|
||||
};
|
||||
};
|
||||
+29
-28
@@ -19,7 +19,16 @@ import {
|
||||
observer,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { CollectionBlockModel, BlockSceneEnum, ActionModel } from '@nocobase/client';
|
||||
import {
|
||||
CollectionBlockModel,
|
||||
BlockSceneEnum,
|
||||
ActionModel,
|
||||
getUnknownCountPaginationTotal,
|
||||
getSimpleModePaginationClassName,
|
||||
createCompactSimpleItemRender,
|
||||
applyMobilePaginationProps,
|
||||
mergePaginationClassName,
|
||||
} from '@nocobase/client';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { List, Space, Slider, Grid, InputNumber, Col } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
@@ -79,13 +88,14 @@ export class GridCardBlockModel extends CollectionBlockModel<GridBlockModelStruc
|
||||
const data = this.resource.getData();
|
||||
const columns = this.props.columnCount?.[this._screens] || 1;
|
||||
const rowCount = this.props.rowCount || 1;
|
||||
const isMobileLayout = !!this.context.isMobileLayout;
|
||||
|
||||
const multiples = [1, 2, 3, 5, 10];
|
||||
|
||||
const pageSizeOptions = multiples.map((m) => columns * rowCount * m);
|
||||
|
||||
if (totalCount) {
|
||||
return {
|
||||
const result = {
|
||||
current,
|
||||
pageSize,
|
||||
total: totalCount,
|
||||
@@ -101,39 +111,30 @@ export class GridCardBlockModel extends CollectionBlockModel<GridBlockModelStruc
|
||||
this.resource.refresh();
|
||||
},
|
||||
};
|
||||
return applyMobilePaginationProps(result, isMobileLayout);
|
||||
} else {
|
||||
return {
|
||||
const nextPageSize = pageSize || 10;
|
||||
const nextCurrent = current || 1;
|
||||
const result = {
|
||||
// showTotal: false,
|
||||
simple: true,
|
||||
showTitle: false,
|
||||
showSizeChanger: true,
|
||||
hideOnSinglePage: false,
|
||||
pageSize,
|
||||
total: data?.length < pageSize || !hasNext ? pageSize * current : pageSize * current + 1,
|
||||
className: css`
|
||||
.ant-pagination-simple-pager {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
itemRender: (_, type, originalElement) => {
|
||||
if (type === 'prev') {
|
||||
return (
|
||||
<div
|
||||
style={{ display: 'flex' }}
|
||||
className={css`
|
||||
.ant-pagination-item-link {
|
||||
min-width: ${this.context.themeToken.controlHeight}px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{originalElement} <div>{this.resource.getPage()}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return originalElement;
|
||||
}
|
||||
},
|
||||
pageSize: nextPageSize,
|
||||
total: getUnknownCountPaginationTotal({
|
||||
dataLength: data?.length,
|
||||
pageSize: nextPageSize,
|
||||
current: nextCurrent,
|
||||
hasNext,
|
||||
}),
|
||||
className: mergePaginationClassName(getSimpleModePaginationClassName(true), undefined),
|
||||
itemRender: createCompactSimpleItemRender({
|
||||
current: nextCurrent,
|
||||
controlHeight: this.context.themeToken.controlHeight,
|
||||
}),
|
||||
};
|
||||
return applyMobilePaginationProps(result, isMobileLayout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { GridCardBlockModel } from '../GridCardBlockModel';
|
||||
|
||||
function createGridCardModel(options: {
|
||||
count?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
hasNext?: boolean;
|
||||
dataLength?: number;
|
||||
isMobileLayout?: boolean;
|
||||
}) {
|
||||
const count = options.count ?? 0;
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 12;
|
||||
const hasNext = options.hasNext ?? false;
|
||||
const dataLength = options.dataLength ?? pageSize;
|
||||
const data = Array.from({ length: dataLength }, (_, i) => ({ id: i + 1 }));
|
||||
const setPage = vi.fn();
|
||||
const setPageSize = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
const model = Object.create(GridCardBlockModel.prototype) as GridCardBlockModel;
|
||||
Object.defineProperty(model, 'resource', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getMeta: (key: string) => (key === 'count' ? count : key === 'hasNext' ? hasNext : undefined),
|
||||
getPageSize: () => pageSize,
|
||||
getPage: () => page,
|
||||
getData: () => data,
|
||||
setPage,
|
||||
setPageSize,
|
||||
refresh,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(model, 'context', {
|
||||
configurable: true,
|
||||
value: {
|
||||
isMobileLayout: !!options.isMobileLayout,
|
||||
themeToken: { controlHeight: 32 },
|
||||
},
|
||||
});
|
||||
(model as any).props = {
|
||||
columnCount: { xs: 1, md: 2, lg: 3, xxl: 4 },
|
||||
rowCount: 3,
|
||||
};
|
||||
(model as any)._screens = 'lg';
|
||||
Object.defineProperty(model, 'translate', {
|
||||
configurable: true,
|
||||
value: (key: string, vars?: any) => {
|
||||
if (key === 'Total {{count}} items') {
|
||||
return `Total ${vars?.count ?? ''} items`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
});
|
||||
return { model, setPage, setPageSize, refresh };
|
||||
}
|
||||
|
||||
describe('GridCardBlockModel pagination', () => {
|
||||
it('移动端已知总数时隐藏 total 与 size changer', () => {
|
||||
const { model } = createGridCardModel({
|
||||
count: 43,
|
||||
page: 2,
|
||||
pageSize: 12,
|
||||
isMobileLayout: true,
|
||||
dataLength: 12,
|
||||
});
|
||||
|
||||
const pagination = model.pagination() as any;
|
||||
expect(pagination.current).toBe(2);
|
||||
expect(pagination.total).toBe(43);
|
||||
expect(pagination.showTotal).toBe(false);
|
||||
expect(pagination.showSizeChanger).toBe(false);
|
||||
expect(pagination.showLessItems).toBe(true);
|
||||
});
|
||||
|
||||
it('移动端未知总数时保持 simple 并隐藏 size changer', () => {
|
||||
const { model } = createGridCardModel({
|
||||
count: 0,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
hasNext: true,
|
||||
isMobileLayout: true,
|
||||
dataLength: 12,
|
||||
});
|
||||
|
||||
const pagination = model.pagination() as any;
|
||||
expect(pagination.simple).toBe(true);
|
||||
expect(pagination.showSizeChanger).toBe(false);
|
||||
expect(pagination.showTotal).toBe(false);
|
||||
expect(pagination.total).toBe(13);
|
||||
expect(typeof pagination.itemRender).toBe('function');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user