refactor(DataBlock): table block (#3748)

* refactor: remove useless code

* chore: remove useless code

* feat: add createTableBlockSchema

* refactor: use createTableBlockUISchema

* refactor: extract useTableBlockParams

* refactor: extract useTableBlockSourceId

* refactor: compat

* refactor: fix typo in createTableBlockUISchema file

* refactor: should not get collection on getting association in UISchema

* refactor: use x-use-component-props instead of useProps

* chore: fix unit tests

* fix: fix errors

* refactor: refactor data block source ID hooks
This commit is contained in:
Zeke Zhang 2024-03-25 17:35:57 +08:00 committed by GitHub
parent 0269a1ff7d
commit 1c32983c00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 915 additions and 531 deletions

View File

@ -59,31 +59,6 @@ export const useBlockResource = () => {
return useContext(BlockResourceContext) || resource;
};
interface UseResourceProps {
resource: any;
association?: any;
useSourceId?: any;
collection?: any;
dataSource?: any;
block?: any;
}
const useAssociation = (props) => {
const { association } = props;
const { getCollectionField } = useCollectionManager_deprecated();
if (typeof association === 'string') {
return getCollectionField(association);
} else if (association?.collectionName && association?.name) {
return getCollectionField(`${association?.collectionName}.${association?.name}`);
}
};
const useActionParams = (props) => {
const { useParams } = props;
const params = useParams?.() || {};
return { ...props.params, ...params };
};
export const MaybeCollectionProvider = (props) => {
const { collection } = props;
return collection ? (
@ -218,6 +193,22 @@ export const useBlockContext = () => {
return useContext(BlockContext);
};
/**
* Schema
*/
const useCompatDataBlockSourceId = (props) => {
const fieldSchema = useFieldSchema();
// 如果存在 x-use-decorator-props说明是新版 Schema
if (fieldSchema['x-use-decorator-props']) {
return props.sourceId;
} else {
// 是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks
// eslint-disable-next-line react-hooks/rules-of-hooks
return useDataBlockSourceId(props);
}
};
/**
* @deprecated use `DataBlockProvider` instead
*/
@ -236,8 +227,11 @@ export const BlockProvider = (props: {
useParams?: any;
}) => {
const { name, dataSource, association, useParams, parentRecord } = props;
const sourceId = useDataBlockSourceId({ association });
const sourceId = useCompatDataBlockSourceId(props);
// 新版1.0)已弃用 useParams这里之所以继续保留是为了兼容旧版的 UISchema
const paramsFromHook = useParams?.();
const { getAssociationAppends } = useAssociationNames(dataSource);
const { appends, updateAssociationValues } = getAssociationAppends();
const params = useMemo(() => {

View File

@ -10,11 +10,13 @@ import { DetailsBlockProvider, useDetailsBlockProps } from './DetailsBlockProvid
import { FilterFormBlockProvider } from './FilterFormBlockProvider';
import { FormBlockProvider, useFormBlockProps } from './FormBlockProvider';
import { FormFieldProvider, useFormFieldProps } from './FormFieldProvider';
import { TableBlockProvider, useTableBlockProps } from './TableBlockProvider';
import { TableBlockProvider } from './TableBlockProvider';
import { useTableBlockProps } from '../modules/blocks/data-blocks/table/hooks/useTableBlockProps';
import { TableFieldProvider, useTableFieldProps } from './TableFieldProvider';
import { TableSelectorProvider, useTableSelectorProps } from './TableSelectorProvider';
import * as bp from './hooks';
import { BlockSchemaToolbar } from '../modules/blocks/BlockSchemaToolbar';
import { useTableBlockDecoratorProps } from '../modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps';
// TODO: delete this, replaced by `BlockSchemaComponentPlugin`
export const BlockSchemaComponentProvider: React.FC = (props) => {
@ -41,6 +43,7 @@ export const BlockSchemaComponentProvider: React.FC = (props) => {
useTableFieldProps,
useTableBlockProps,
useTableSelectorProps,
useTableBlockDecoratorProps,
}}
>
{props.children}
@ -84,6 +87,7 @@ export class BlockSchemaComponentPlugin extends Plugin {
useTableFieldProps,
useTableBlockProps,
useTableSelectorProps,
useTableBlockDecoratorProps,
});
}
}

View File

@ -1,12 +1,12 @@
import { ArrayField, createForm } from '@formily/core';
import { createForm } from '@formily/core';
import { FormContext, useField, useFieldSchema } from '@formily/react';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import React, { createContext, useContext, useMemo, useState } from 'react';
import { useCollectionManager_deprecated } from '../collection-manager';
import { useFilterBlock } from '../filter-provider/FilterProvider';
import { mergeFilter } from '../filter-provider/utils';
import { FixedBlockWrapper, SchemaComponentOptions, removeNullCondition } from '../schema-component';
import { FixedBlockWrapper, SchemaComponentOptions } from '../schema-component';
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
import { findFilterTargets, useParsedFilter } from './hooks';
import { useParsedFilter } from './hooks';
import { useTableBlockParams } from '../modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps';
import { withDynamicSchemaProps } from '../application/hoc/withDynamicSchemaProps';
export const TableBlockContext = createContext<any>({});
TableBlockContext.displayName = 'TableBlockContext';
@ -74,16 +74,37 @@ const InternalTableBlockProvider = (props: Props) => {
);
};
export const TableBlockProvider = (props) => {
const resourceName = props.resource;
const params = useMemo(() => ({ ...props.params }), [props.params]);
/**
* schema
* @param props
* @returns
*/
const useTableBlockParamsCompat = (props) => {
const fieldSchema = useFieldSchema();
let params;
// 1. 新版本的 schema 存在 x-use-decorator-props 属性
if (fieldSchema['x-use-decorator-props']) {
params = props.params;
} else {
// 2. 旧版本的 schema 不存在 x-use-decorator-props 属性
// 因为 schema 中是否存在 x-use-decorator-props 是固定不变的,所以这里可以使用 hooks
// eslint-disable-next-line react-hooks/rules-of-hooks
params = useTableBlockParams(props);
}
return params;
};
export const TableBlockProvider = withDynamicSchemaProps((props) => {
const resourceName = props.resource || props.association;
const fieldSchema = useFieldSchema();
const { getCollection, getCollectionField } = useCollectionManager_deprecated(props.dataSource);
const collection = getCollection(props.collection, props.dataSource);
const { treeTable, dragSortBy } = fieldSchema?.['x-decorator-props'] || {};
if (props.dragSort && dragSortBy) {
params['sort'] = dragSortBy;
}
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
const params = useTableBlockParamsCompat(props);
let childrenColumnName = 'children';
if (collection?.tree && treeTable !== false) {
if (resourceName?.includes('.')) {
@ -101,140 +122,18 @@ export const TableBlockProvider = (props) => {
}
}
const form = useMemo(() => createForm(), [treeTable]);
const { filter: parsedFilter } = useParsedFilter({
filterOption: params?.filter,
});
const paramsWithFilter = useMemo(() => {
return {
...params,
filter: parsedFilter,
};
}, [parsedFilter, params]);
return (
<SchemaComponentOptions scope={{ treeTable }}>
<FormContext.Provider value={form}>
<BlockProvider name={props.name || 'table'} {...props} params={paramsWithFilter} runWhenParamsChanged>
<InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={paramsWithFilter} />
<BlockProvider name={props.name || 'table'} {...props} params={params} runWhenParamsChanged>
<InternalTableBlockProvider {...props} childrenColumnName={childrenColumnName} params={params} />
</BlockProvider>
</FormContext.Provider>
</SchemaComponentOptions>
);
};
});
export const useTableBlockContext = () => {
return useContext(TableBlockContext);
};
export const useTableBlockProps = () => {
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
const ctx = useTableBlockContext();
const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort'];
const { getDataBlocks } = useFilterBlock();
useEffect(() => {
if (!ctx?.service?.loading) {
field.value = [];
field.value = ctx?.service?.data?.data;
field?.setInitialValue(ctx?.service?.data?.data);
field.data = field.data || {};
field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
field.componentProps.pagination = field.componentProps.pagination || {};
field.componentProps.pagination.pageSize = ctx?.service?.data?.meta?.pageSize;
field.componentProps.pagination.total = ctx?.service?.data?.meta?.count;
field.componentProps.pagination.current = ctx?.service?.data?.meta?.page;
}
}, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题:
return {
childrenColumnName: ctx.childrenColumnName,
loading: ctx?.service?.loading,
showIndex: ctx.showIndex,
dragSort: ctx.dragSort && ctx.dragSortBy,
rowKey: ctx.rowKey || 'id',
pagination:
ctx?.params?.paginate !== false
? {
defaultCurrent: ctx?.params?.page || 1,
defaultPageSize: ctx?.params?.pageSize,
}
: false,
onRowSelectionChange(selectedRowKeys) {
ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx?.field?.onRowSelect?.(selectedRowKeys);
},
async onRowDragEnd({ from, to }) {
await ctx.resource.move({
sourceId: from[ctx.rowKey || 'id'],
targetId: to[ctx.rowKey || 'id'],
sortField: ctx.dragSort && ctx.dragSortBy,
});
ctx.service.refresh();
},
onChange({ current, pageSize }, filters, sorter) {
const sort = sorter.order ? (sorter.order === `ascend` ? [sorter.field] : [`-${sorter.field}`]) : globalSort;
ctx.service.run({ ...ctx.service.params?.[0], page: current, pageSize, sort });
},
onClickRow(record, setSelectedRow, selectedRow) {
const { targets, uid } = findFilterTargets(fieldSchema);
const dataBlocks = getDataBlocks();
// 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错
if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) {
// 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。
// 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。
setSelectedRow((prev) => (prev.length ? [] : prev));
return;
}
const value = [record[ctx.rowKey]];
dataBlocks.forEach((block) => {
const target = targets.find((target) => target.uid === block.uid);
if (!target) return;
const param = block.service.params?.[0] || {};
// 保留原有的 filter
const storedFilter = block.service.params?.[1]?.filters || {};
if (selectedRow.includes(record[ctx.rowKey])) {
if (block.dataLoadingMode === 'manual') {
return block.clearData();
}
delete storedFilter[uid];
} else {
storedFilter[uid] = {
$and: [
{
[target.field || ctx.rowKey]: {
[target.field ? '$in' : '$eq']: value,
},
},
],
};
}
const mergedFilter = mergeFilter([
...Object.values(storedFilter).map((filter) => removeNullCondition(filter)),
block.defaultFilter,
]);
return block.doFilter(
{
...param,
page: 1,
filter: mergedFilter,
},
{ filters: storedFilter },
);
});
// 更新表格的选中状态
setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value]));
},
onExpand(expanded, record) {
ctx?.field.onExpandClick?.(expanded, record);
},
};
};

View File

@ -1,4 +1,3 @@
import { useFieldSchema } from '@formily/react';
import { useCollection, useCollectionManager, useCollectionParentRecordData, useCollectionRecordData } from '../..';
/**

View File

@ -2,9 +2,9 @@ import { TableOutlined } from '@ant-design/icons';
import { useSchemaInitializer, useSchemaInitializerItem } from '../../../../application/schema-initializer/context';
import { useCollectionManager_deprecated } from '../../../../collection-manager/hooks/useCollectionManager_deprecated';
import { DataBlockInitializer } from '../../../../schema-initializer/items/DataBlockInitializer';
import { createTableBlockSchema } from '../../../../schema-initializer/utils';
import React from 'react';
import { Collection, CollectionFieldOptions } from '../../../../data-source/collection/Collection';
import { createTableBlockUISchema } from './createTableBlockUISchema';
export const TableBlockInitializer = ({
filterCollections,
@ -42,8 +42,8 @@ export const TableBlockInitializer = ({
}
const collection = getCollection(item.name, item.dataSource);
const schema = createTableBlockSchema({
collection: item.name,
const schema = createTableBlockUISchema({
collectionName: item.name,
dataSource: item.dataSource,
rowKey: collection.filterTargetKey || 'id',
});

View File

@ -0,0 +1,85 @@
import { createTableBlockUISchema } from '../createTableBlockUISchema';
vi.mock('@formily/shared', () => {
return {
uid: () => 'mocked-uid',
};
});
describe('createTableBLockSchemaV2', () => {
it('should create a default table block schema with minimum options', () => {
const options = { dataSource: 'abc', collectionName: 'users', association: 'users.roles', rowKey: 'rowKey' };
const schema = createTableBlockUISchema(options);
expect(schema).toMatchInlineSnapshot(`
{
"properties": {
"actions": {
"properties": {},
"type": "void",
"x-component": "ActionBar",
"x-component-props": {
"style": {
"marginBottom": "var(--nb-spacing)",
},
},
"x-initializer": "table:configureActions",
},
"mocked-uid": {
"properties": {
"actions": {
"properties": {
"mocked-uid": {
"type": "void",
"x-component": "Space",
"x-component-props": {
"split": "|",
},
"x-decorator": "DndContext",
},
},
"title": "{{ t("Actions") }}",
"type": "void",
"x-action-column": "actions",
"x-component": "TableV2.Column",
"x-decorator": "TableV2.Column.ActionBar",
"x-designer": "TableV2.ActionColumnDesigner",
"x-initializer": "table:configureItemActions",
},
},
"type": "array",
"x-component": "TableV2",
"x-component-props": {
"rowKey": "id",
"rowSelection": {
"type": "checkbox",
},
},
"x-initializer": "table:configureColumns",
"x-use-component-props": "useTableBlockProps",
},
},
"type": "void",
"x-acl-action": "users:list",
"x-component": "CardItem",
"x-decorator": "TableBlockProvider",
"x-decorator-props": {
"action": "list",
"association": "users.roles",
"collection": "users",
"dataSource": "abc",
"dragSort": false,
"params": {
"pageSize": 20,
},
"rowKey": "rowKey",
"showIndex": true,
},
"x-filter-targets": [],
"x-settings": "blockSettings:table",
"x-toolbar": "BlockSchemaToolbar",
"x-use-decorator-props": "useTableBlockDecoratorProps",
}
`);
});
});

View File

@ -0,0 +1,84 @@
import { ISchema } from '@formily/react';
import { uid } from '@formily/shared';
export const createTableBlockUISchema = (options: {
dataSource: string;
collectionName?: string;
rowKey?: string;
association?: string;
}): ISchema => {
const { collectionName, dataSource, rowKey, association } = options;
if (!dataSource) {
throw new Error('dataSource is required');
}
return {
type: 'void',
'x-decorator': 'TableBlockProvider',
'x-acl-action': `${collectionName}:list`,
'x-use-decorator-props': 'useTableBlockDecoratorProps',
'x-decorator-props': {
collection: collectionName,
association,
dataSource,
action: 'list',
params: {
pageSize: 20,
},
rowKey,
showIndex: true,
dragSort: false,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
properties: {
actions: {
type: 'void',
'x-initializer': 'table:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
properties: {},
},
[uid()]: {
type: 'array',
'x-initializer': 'table:configureColumns',
'x-component': 'TableV2',
'x-use-component-props': 'useTableBlockProps',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
},
properties: {
actions: {
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-designer': 'TableV2.ActionColumnDesigner',
'x-initializer': 'table:configureItemActions',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
},
},
},
},
},
},
};
};

View File

@ -0,0 +1,51 @@
import { useFieldSchema } from '@formily/react';
import { useParsedFilter } from '../../../../../block-provider/hooks/useParsedFilter';
import { useMemo } from 'react';
import { useDataBlockSourceId } from '../../../../../block-provider/hooks/useDataBlockSourceId';
export const useTableBlockDecoratorProps = (props) => {
const params = useTableBlockParams(props);
const sourceId = useTableBlockSourceId(props);
return {
params,
sourceId,
};
};
export function useTableBlockParams(props) {
const fieldSchema = useFieldSchema();
const { filter: parsedFilter } = useParsedFilter({
filterOption: props.params?.filter,
});
return useMemo(() => {
const params = props.params || {};
// 1. sort
const { dragSortBy } = fieldSchema?.['x-decorator-props'] || {};
if (props.dragSort && dragSortBy) {
params['sort'] = dragSortBy;
}
// 2. filter
const paramsWithFilter = {
...params,
filter: parsedFilter,
};
return paramsWithFilter;
}, [fieldSchema, parsedFilter, props.dragSort, props.params]);
}
function useTableBlockSourceId(props) {
let sourceId: string | undefined;
// 因为 association 是固定不变的,所以在条件中使用 hooks 是安全的
if (props.association) {
// eslint-disable-next-line react-hooks/rules-of-hooks
sourceId = useDataBlockSourceId({ association: props.association });
}
return sourceId;
}

View File

@ -0,0 +1,121 @@
import { ArrayField } from '@formily/core';
import { useField, useFieldSchema } from '@formily/react';
import { useEffect } from 'react';
import { useFilterBlock } from '../../../../../filter-provider/FilterProvider';
import { mergeFilter } from '../../../../../filter-provider/utils';
import { removeNullCondition } from '../../../../../schema-component';
import { findFilterTargets } from '../../../../../block-provider/hooks';
import { useTableBlockContext } from '../../../../../block-provider/TableBlockProvider';
export const useTableBlockProps = () => {
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
const ctx = useTableBlockContext();
const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort'];
const { getDataBlocks } = useFilterBlock();
useEffect(() => {
if (!ctx?.service?.loading) {
field.value = [];
field.value = ctx?.service?.data?.data;
field?.setInitialValue(ctx?.service?.data?.data);
field.data = field.data || {};
field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
field.componentProps.pagination = field.componentProps.pagination || {};
field.componentProps.pagination.pageSize = ctx?.service?.data?.meta?.pageSize;
field.componentProps.pagination.total = ctx?.service?.data?.meta?.count;
field.componentProps.pagination.current = ctx?.service?.data?.meta?.page;
}
}, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题:
return {
childrenColumnName: ctx.childrenColumnName,
loading: ctx?.service?.loading,
showIndex: ctx.showIndex,
dragSort: ctx.dragSort && ctx.dragSortBy,
rowKey: ctx.rowKey || 'id',
pagination:
ctx?.params?.paginate !== false
? {
defaultCurrent: ctx?.params?.page || 1,
defaultPageSize: ctx?.params?.pageSize,
}
: false,
onRowSelectionChange(selectedRowKeys) {
ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx?.field?.onRowSelect?.(selectedRowKeys);
},
async onRowDragEnd({ from, to }) {
await ctx.resource.move({
sourceId: from[ctx.rowKey || 'id'],
targetId: to[ctx.rowKey || 'id'],
sortField: ctx.dragSort && ctx.dragSortBy,
});
ctx.service.refresh();
},
onChange({ current, pageSize }, filters, sorter) {
const sort = sorter.order ? (sorter.order === `ascend` ? [sorter.field] : [`-${sorter.field}`]) : globalSort;
ctx.service.run({ ...ctx.service.params?.[0], page: current, pageSize, sort });
},
onClickRow(record, setSelectedRow, selectedRow) {
const { targets, uid } = findFilterTargets(fieldSchema);
const dataBlocks = getDataBlocks();
// 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错
if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) {
// 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。
// 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。
setSelectedRow((prev) => (prev.length ? [] : prev));
return;
}
const value = [record[ctx.rowKey]];
dataBlocks.forEach((block) => {
const target = targets.find((target) => target.uid === block.uid);
if (!target) return;
const param = block.service.params?.[0] || {};
// 保留原有的 filter
const storedFilter = block.service.params?.[1]?.filters || {};
if (selectedRow.includes(record[ctx.rowKey])) {
if (block.dataLoadingMode === 'manual') {
return block.clearData();
}
delete storedFilter[uid];
} else {
storedFilter[uid] = {
$and: [
{
[target.field || ctx.rowKey]: {
[target.field ? '$in' : '$eq']: value,
},
},
],
};
}
const mergedFilter = mergeFilter([
...Object.values(storedFilter).map((filter) => removeNullCondition(filter)),
block.defaultFilter,
]);
return block.doFilter(
{
...param,
page: 1,
filter: mergedFilter,
},
{ filters: storedFilter },
);
});
// 更新表格的选中状态
setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value]));
},
onExpand(expanded, record) {
ctx?.field.onExpandClick?.(expanded, record);
},
};
};

View File

@ -5,3 +5,5 @@ export * from './TableColumnSchemaToolbar';
export * from './tableBlockSettings';
export * from './tableColumnSettings';
export * from './TableColumnInitializers';
export * from './createTableBlockUISchema';
export * from './hooks/useTableBlockDecoratorProps';

View File

@ -25,6 +25,7 @@ import {
useTableBlockContext,
useTableSelectorContext,
} from '../../../';
import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps';
import { useACLFieldWhitelist } from '../../../acl/ACLProvider';
import { useToken } from '../__builtins__';
import { SubFormProvider } from '../association-field/hooks';
@ -211,385 +212,391 @@ const usePaginationProps = (pagination1, pagination2) => {
return result.total <= result.pageSize ? false : result;
};
export const Table: any = observer(
(props: {
useProps?: () => any;
onChange?: (pagination, filters, sorter, extra) => void;
onRowSelectionChange?: (selectedRowKeys: any[], selectedRows: any[]) => void;
onRowDragEnd?: (e: { from: any; to: any }) => void;
onClickRow?: (record: any, setSelectedRow: (selectedRow: any[]) => void, selectedRow: any[]) => void;
pagination?: any;
showIndex?: boolean;
dragSort?: boolean;
rowKey?: string | ((record: any) => string);
rowSelection?: any;
required?: boolean;
onExpand?: (flag: boolean, record: any) => void;
isSubTable?: boolean;
}) => {
const { token } = useToken();
const { pagination: pagination1, useProps, onChange, ...others1 } = props;
const { pagination: pagination2, onClickRow, ...others2 } = useProps?.() || {};
const {
dragSort = false,
showIndex = true,
onRowSelectionChange,
onChange: onTableChange,
rowSelection,
rowKey,
required,
onExpand,
...others
} = { ...others1, ...others2 } as any;
const field = useArrayField(others);
const columns = useTableColumns(others);
const schema = useFieldSchema();
const collection = useCollection_deprecated();
const isTableSelector = schema?.parent?.['x-decorator'] === 'TableSelectorProvider';
const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext();
const { expandFlag, allIncludesChildren } = ctx;
const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => {}));
const paginationProps = usePaginationProps(pagination1, pagination2);
const [expandedKeys, setExpandesKeys] = useState([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<any[]>(field?.data?.selectedRowKeys || []);
const [selectedRow, setSelectedRow] = useState([]);
const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || [];
const isRowSelect = rowSelection?.type !== 'none';
const defaultRowKeyMap = useRef(new Map());
let onRow = null,
highlightRow = '';
export const Table: any = withDynamicSchemaProps(
observer(
(props: {
useProps?: () => any;
onChange?: (pagination, filters, sorter, extra) => void;
onRowSelectionChange?: (selectedRowKeys: any[], selectedRows: any[]) => void;
onRowDragEnd?: (e: { from: any; to: any }) => void;
onClickRow?: (record: any, setSelectedRow: (selectedRow: any[]) => void, selectedRow: any[]) => void;
pagination?: any;
showIndex?: boolean;
dragSort?: boolean;
rowKey?: string | ((record: any) => string);
rowSelection?: any;
required?: boolean;
onExpand?: (flag: boolean, record: any) => void;
isSubTable?: boolean;
}) => {
const { token } = useToken();
const { pagination: pagination1, useProps, onChange, ...others1 } = props;
if (onClickRow) {
onRow = (record) => {
return {
onClick: (e) => {
if (isPortalInBody(e.target)) {
return;
}
onClickRow(record, setSelectedRow, selectedRow);
},
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { pagination: pagination2, ...others2 } = useProps?.() || {};
const {
dragSort = false,
showIndex = true,
onRowSelectionChange,
onChange: onTableChange,
rowSelection,
rowKey,
required,
onExpand,
onClickRow,
...others
} = { ...others1, ...others2 } as any;
const field = useArrayField(others);
const columns = useTableColumns(others);
const schema = useFieldSchema();
const collection = useCollection_deprecated();
const isTableSelector = schema?.parent?.['x-decorator'] === 'TableSelectorProvider';
const ctx = isTableSelector ? useTableSelectorContext() : useTableBlockContext();
const { expandFlag, allIncludesChildren } = ctx;
const onRowDragEnd = useMemoizedFn(others.onRowDragEnd || (() => {}));
const paginationProps = usePaginationProps(pagination1, pagination2);
const [expandedKeys, setExpandesKeys] = useState([]);
const [selectedRowKeys, setSelectedRowKeys] = useState<any[]>(field?.data?.selectedRowKeys || []);
const [selectedRow, setSelectedRow] = useState([]);
const dataSource = field?.value?.slice?.()?.filter?.(Boolean) || [];
const isRowSelect = rowSelection?.type !== 'none';
const defaultRowKeyMap = useRef(new Map());
let onRow = null,
highlightRow = '';
if (onClickRow) {
onRow = (record) => {
return {
onClick: (e) => {
if (isPortalInBody(e.target)) {
return;
}
onClickRow(record, setSelectedRow, selectedRow);
},
};
};
};
highlightRow = css`
& > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
&:hover > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
`;
}
useEffect(() => {
if (expandFlag) {
setExpandesKeys(allIncludesChildren);
} else {
setExpandesKeys([]);
highlightRow = css`
& > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
&:hover > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
`;
}
}, [expandFlag, allIncludesChildren]);
const components = useMemo(() => {
return {
header: {
wrapper: (props) => {
return (
<DndContext>
<thead {...props} />
</DndContext>
);
useEffect(() => {
if (expandFlag) {
setExpandesKeys(allIncludesChildren);
} else {
setExpandesKeys([]);
}
}, [expandFlag, allIncludesChildren]);
const components = useMemo(() => {
return {
header: {
wrapper: (props) => {
return (
<DndContext>
<thead {...props} />
</DndContext>
);
},
cell: (props) => {
return (
<th
{...props}
className={cls(
props.className,
css`
max-width: 300px;
white-space: nowrap;
&:hover .general-schema-designer {
display: block;
}
`,
)}
/>
);
},
},
cell: (props) => {
return (
<th
body: {
wrapper: (props) => {
return (
<DndContext
onDragEnd={(e) => {
if (!e.active || !e.over) {
console.warn('move cancel');
return;
}
const fromIndex = e.active?.data.current?.sortable?.index;
const toIndex = e.over?.data.current?.sortable?.index;
const from = field.value[fromIndex] || e.active;
const to = field.value[toIndex] || e.over;
void field.move(fromIndex, toIndex);
onRowDragEnd({ from, to });
}}
>
<tbody {...props} />
</DndContext>
);
},
row: (props) => {
return <SortableRow {...props}></SortableRow>;
},
cell: (props) => (
<td
{...props}
className={cls(
className={classNames(
props.className,
css`
max-width: 300px;
white-space: nowrap;
&:hover .general-schema-designer {
display: block;
.nb-read-pretty-input-number {
text-align: right;
}
.ant-color-picker-trigger {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
`,
)}
/>
);
),
},
},
body: {
wrapper: (props) => {
return (
<DndContext
onDragEnd={(e) => {
if (!e.active || !e.over) {
console.warn('move cancel');
return;
}
const fromIndex = e.active?.data.current?.sortable?.index;
const toIndex = e.over?.data.current?.sortable?.index;
const from = field.value[fromIndex] || e.active;
const to = field.value[toIndex] || e.over;
void field.move(fromIndex, toIndex);
onRowDragEnd({ from, to });
}}
>
<tbody {...props} />
</DndContext>
);
},
row: (props) => {
return <SortableRow {...props}></SortableRow>;
},
cell: (props) => (
<td
{...props}
className={classNames(
props.className,
css`
max-width: 300px;
white-space: nowrap;
.nb-read-pretty-input-number {
text-align: right;
}
.ant-color-picker-trigger {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
`,
)}
/>
),
},
};
}, [field, onRowDragEnd, dragSort]);
/**
* key key
* 1. rowKey key record.key
* 2. key record
* 3. key
*
* record
*
* @param record
* @returns
*/
const defaultRowKey = (record: any) => {
if (record.key) {
return record.key;
}
if (defaultRowKeyMap.current.has(record)) {
return defaultRowKeyMap.current.get(record);
}
const key = uid();
defaultRowKeyMap.current.set(record, key);
return key;
};
}, [field, onRowDragEnd, dragSort]);
/**
* key key
* 1. rowKey key record.key
* 2. key record
* 3. key
*
* record
*
* @param record
* @returns
*/
const defaultRowKey = (record: any) => {
if (record.key) {
return record.key;
}
const getRowKey = (record: any) => {
if (typeof rowKey === 'string') {
return record[rowKey]?.toString();
} else {
return (rowKey ?? defaultRowKey)(record)?.toString();
}
};
if (defaultRowKeyMap.current.has(record)) {
return defaultRowKeyMap.current.get(record);
}
const key = uid();
defaultRowKeyMap.current.set(record, key);
return key;
};
const getRowKey = (record: any) => {
if (typeof rowKey === 'string') {
return record[rowKey]?.toString();
} else {
return (rowKey ?? defaultRowKey)(record)?.toString();
}
};
const restProps = {
rowSelection: rowSelection
? {
type: 'checkbox',
selectedRowKeys: selectedRowKeys,
onChange(selectedRowKeys: any[], selectedRows: any[]) {
field.data = field.data || {};
field.data.selectedRowKeys = selectedRowKeys;
setSelectedRowKeys(selectedRowKeys);
onRowSelectionChange?.(selectedRowKeys, selectedRows);
},
getCheckboxProps(record) {
return {
'aria-label': `checkbox`,
};
},
renderCell: (checked, record, index, originNode) => {
if (!dragSort && !showIndex) {
return originNode;
}
const current = props?.pagination?.current;
const pageSize = props?.pagination?.pageSize || 20;
if (current) {
index = index + (current - 1) * pageSize + 1;
} else {
index = index + 1;
}
if (record.__index) {
index = extractIndex(record.__index);
}
return (
<div
role="button"
aria-label={`table-index-${index}`}
className={classNames(
checked ? 'checked' : null,
css`
position: relative;
display: flex;
float: left;
align-items: center;
justify-content: space-evenly;
padding-right: 8px;
.nb-table-index {
opacity: 0;
}
&:not(.checked) {
.nb-table-index {
opacity: 1;
}
}
`,
{
[css`
&:hover {
.nb-table-index {
opacity: 0;
}
.nb-origin-node {
display: block;
}
}
`]: isRowSelect,
},
)}
>
const restProps = {
rowSelection: rowSelection
? {
type: 'checkbox',
selectedRowKeys: selectedRowKeys,
onChange(selectedRowKeys: any[], selectedRows: any[]) {
field.data = field.data || {};
field.data.selectedRowKeys = selectedRowKeys;
setSelectedRowKeys(selectedRowKeys);
onRowSelectionChange?.(selectedRowKeys, selectedRows);
},
getCheckboxProps(record) {
return {
'aria-label': `checkbox`,
};
},
renderCell: (checked, record, index, originNode) => {
if (!dragSort && !showIndex) {
return originNode;
}
const current = props?.pagination?.current;
const pageSize = props?.pagination?.pageSize || 20;
if (current) {
index = index + (current - 1) * pageSize + 1;
} else {
index = index + 1;
}
if (record.__index) {
index = extractIndex(record.__index);
}
return (
<div
role="button"
aria-label={`table-index-${index}`}
className={classNames(
checked ? 'checked' : null,
css`
position: relative;
display: flex;
float: left;
align-items: center;
justify-content: space-evenly;
padding-right: 8px;
.nb-table-index {
opacity: 0;
}
&:not(.checked) {
.nb-table-index {
opacity: 1;
}
}
`,
{
[css`
&:hover {
.nb-table-index {
opacity: 0;
}
.nb-origin-node {
display: block;
}
}
`]: isRowSelect,
},
)}
>
{dragSort && <SortHandle id={getRowKey(record)} />}
{showIndex && <TableIndex index={index} />}
</div>
{isRowSelect && (
<div
className={classNames(
'nb-origin-node',
checked ? 'checked' : null,
css`
position: absolute;
right: 50%;
transform: translateX(50%);
&:not(.checked) {
display: none;
}
position: relative;
display: flex;
align-items: center;
justify-content: space-evenly;
`,
)}
>
{originNode}
{dragSort && <SortHandle id={getRowKey(record)} />}
{showIndex && <TableIndex index={index} />}
</div>
)}
</div>
);
},
...rowSelection,
}
: undefined,
};
const SortableWrapper = useCallback<React.FC>(
({ children }) => {
return dragSort
? React.createElement<Omit<SortableContextProps, 'children'>>(
SortableContext,
{
items: field.value?.map?.(getRowKey) || [],
{isRowSelect && (
<div
className={classNames(
'nb-origin-node',
checked ? 'checked' : null,
css`
position: absolute;
right: 50%;
transform: translateX(50%);
&:not(.checked) {
display: none;
}
`,
)}
>
{originNode}
</div>
)}
</div>
);
},
children,
)
: React.createElement(React.Fragment, {}, children);
},
[field, dragSort],
);
const fieldSchema = useFieldSchema();
const fixedBlock = fieldSchema?.parent?.['x-decorator-props']?.fixedBlock;
...rowSelection,
}
: undefined,
};
const SortableWrapper = useCallback<React.FC>(
({ children }) => {
return dragSort
? React.createElement<Omit<SortableContextProps, 'children'>>(
SortableContext,
{
items: field.value?.map?.(getRowKey) || [],
},
children,
)
: React.createElement(React.Fragment, {}, children);
},
[field, dragSort],
);
const fieldSchema = useFieldSchema();
const fixedBlock = fieldSchema?.parent?.['x-decorator-props']?.fixedBlock;
const { height: tableHeight, tableSizeRefCallback } = useTableSize();
const scroll = useMemo(() => {
return fixedBlock
? {
x: 'max-content',
y: tableHeight,
}
: {
x: 'max-content',
};
}, [fixedBlock, tableHeight]);
return (
<div
className={css`
height: 100%;
overflow: hidden;
.ant-table-wrapper {
const { height: tableHeight, tableSizeRefCallback } = useTableSize();
const scroll = useMemo(() => {
return fixedBlock
? {
x: 'max-content',
y: tableHeight,
}
: {
x: 'max-content',
};
}, [fixedBlock, tableHeight]);
return (
<div
className={css`
height: 100%;
.ant-spin-nested-loading {
overflow: hidden;
.ant-table-wrapper {
height: 100%;
.ant-spin-container {
.ant-spin-nested-loading {
height: 100%;
display: flex;
flex-direction: column;
.ant-spin-container {
height: 100%;
display: flex;
flex-direction: column;
}
}
}
}
.ant-table {
overflow-x: auto;
overflow-y: hidden;
}
`}
>
<SortableWrapper>
<AntdTable
ref={tableSizeRefCallback}
rowKey={rowKey ?? defaultRowKey}
dataSource={dataSource}
tableLayout="auto"
{...others}
{...restProps}
pagination={paginationProps}
components={components}
onChange={(pagination, filters, sorter, extra) => {
onTableChange?.(pagination, filters, sorter, extra);
}}
onRow={onRow}
rowClassName={(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : '')}
scroll={scroll}
columns={columns}
expandable={{
onExpand: (flag, record) => {
const newKeys = flag
? [...expandedKeys, record[collection.getPrimaryKey()]]
: expandedKeys.filter((i) => record[collection.getPrimaryKey()] !== i);
setExpandesKeys(newKeys);
onExpand?.(flag, record);
},
expandedRowKeys: expandedKeys,
}}
/>
</SortableWrapper>
{field.errors.length > 0 && (
<div className="ant-formily-item-error-help ant-formily-item-help ant-formily-item-help-enter ant-formily-item-help-enter-active">
{field.errors.map((error) => {
return error.messages.map((message) => <div key={message}>{message}</div>);
})}
</div>
)}
</div>
);
},
.ant-table {
overflow-x: auto;
overflow-y: hidden;
}
`}
>
<SortableWrapper>
<AntdTable
ref={tableSizeRefCallback}
rowKey={rowKey ?? defaultRowKey}
dataSource={dataSource}
tableLayout="auto"
{...others}
{...restProps}
pagination={paginationProps}
components={components}
onChange={(pagination, filters, sorter, extra) => {
onTableChange?.(pagination, filters, sorter, extra);
}}
onRow={onRow}
rowClassName={(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : '')}
scroll={scroll}
columns={columns}
expandable={{
onExpand: (flag, record) => {
const newKeys = flag
? [...expandedKeys, record[collection.getPrimaryKey()]]
: expandedKeys.filter((i) => record[collection.getPrimaryKey()] !== i);
setExpandesKeys(newKeys);
onExpand?.(flag, record);
},
expandedRowKeys: expandedKeys,
}}
/>
</SortableWrapper>
{field.errors.length > 0 && (
<div className="ant-formily-item-error-help ant-formily-item-help ant-formily-item-help-enter ant-formily-item-help-enter-active">
{field.errors.map((error) => {
return error.messages.map((message) => <div key={message}>{message}</div>);
})}
</div>
)}
</div>
);
},
),
{ displayName: 'Table' },
);

View File

@ -3,8 +3,9 @@ import { TableOutlined } from '@ant-design/icons';
import { useCollectionManager_deprecated } from '../../collection-manager';
import { useSchemaTemplateManager } from '../../schema-templates';
import { createTableBlockSchema, useRecordCollectionDataSourceItems } from '../utils';
import { useRecordCollectionDataSourceItems } from '../utils';
import { SchemaInitializerItem, useSchemaInitializer, useSchemaInitializerItem } from '../../application';
import { createTableBlockUISchema } from '../../modules/blocks/data-blocks/table/createTableBlockUISchema';
/**
* @deprecated
@ -17,7 +18,7 @@ export const RecordAssociationBlockInitializer = () => {
const { getCollection } = useCollectionManager_deprecated();
const field = itemConfig.field;
const collection = getCollection(field.target);
const resource = `${field.collectionName}.${field.name}`;
const association = `${field.collectionName}.${field.name}`;
return (
<SchemaInitializerItem
icon={<TableOutlined />}
@ -28,17 +29,15 @@ export const RecordAssociationBlockInitializer = () => {
insert(s);
} else {
insert(
createTableBlockSchema({
createTableBlockUISchema({
rowKey: collection.filterTargetKey,
collection: field.target,
dataSource: collection.dataSource,
resource,
association: resource,
association: association,
}),
);
}
}}
items={useRecordCollectionDataSourceItems('Table', itemConfig, field.target, resource)}
items={useRecordCollectionDataSourceItems('Table', itemConfig, field.target, association)}
/>
);
};
@ -53,9 +52,8 @@ export function useCreateAssociationTableBlock() {
const collection = getCollection(field.target);
insert(
createTableBlockSchema({
createTableBlockUISchema({
rowKey: collection.filterTargetKey,
collection: field.target,
dataSource: collection.dataSource,
association: `${field.collectionName}.${field.name}`,
}),

View File

@ -1181,8 +1181,6 @@ export const createFormBlockSchema = (options) => {
resource: resourceName,
collection,
association,
// action: 'get',
// useParams: '{{ useParamsFromRecord }}',
},
'x-toolbar': 'BlockSchemaToolbar',
...(settings ? { 'x-settings': settings } : { 'x-designer': designer }),
@ -1358,6 +1356,12 @@ export const createReadPrettyFormBlockSchema = (options) => {
return schema;
};
/**
* @deprecated
* 使 createTableBlockUISchema
* @param options
* @returns
*/
export const createTableBlockSchema = (options) => {
const {
collection,

View File

@ -1,28 +1,16 @@
import { TableOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/react';
import {
createTableBlockSchema,
SchemaInitializerItem,
useSchemaInitializer,
useSchemaInitializerItem,
} from '@nocobase/client';
import { SchemaInitializerItem, useSchemaInitializer, useSchemaInitializerItem } from '@nocobase/client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { createAuditLogsBlockSchema } from './createAuditLogsBlockSchema';
export const AuditLogsBlockInitializer = () => {
const { insert } = useSchemaInitializer();
const { t } = useTranslation();
const itemConfig = useSchemaInitializerItem();
const schema = createTableBlockSchema({
collection: 'auditLogs',
rowKey: 'id',
tableActionInitializers: 'auditLogsTable:configureActions',
tableColumnInitializers: 'auditLogsTable:configureColumns',
tableActionColumnInitializers: 'auditLogsTable:configureItemActions',
tableBlockProvider: 'AuditLogsBlockProvider',
disableTemplate: true,
});
const schema = createAuditLogsBlockSchema();
return (
<SchemaInitializerItem

View File

@ -0,0 +1,77 @@
import { ISchema } from '@formily/react';
import { createAuditLogsBlockSchema } from '../createAuditLogsBlockSchema';
describe('createAuditLogsBlockSchema', () => {
let schema: ISchema;
beforeAll(() => {
schema = createAuditLogsBlockSchema();
});
it('should return a valid schema object', () => {
expect(schema).toBeDefined();
expect(typeof schema).toBe('object');
});
it('should have specific top-level properties with expected values', () => {
expect(schema).toMatchObject({
type: 'void',
'x-decorator': 'AuditLogsBlockProvider',
'x-acl-action': 'auditLogs:list',
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': expect.arrayContaining([]),
});
});
describe('x-decorator-props section', () => {
it('should have a collection named "auditLogs"', () => {
expect(schema['x-decorator-props'].collection).toEqual('auditLogs');
});
it('should have a list action', () => {
expect(schema['x-decorator-props'].action).toEqual('list');
});
it('should set pageSize to 20', () => {
expect(schema['x-decorator-props'].params.pageSize).toEqual(20);
});
it('contains expected boolean flags', () => {
expect(schema['x-decorator-props'].showIndex).toBeTruthy();
expect(schema['x-decorator-props'].dragSort).toBeFalsy();
expect(schema['x-decorator-props'].disableTemplate).toBeTruthy();
});
});
describe('actions property inside schema.properties', () => {
let actions;
beforeAll(() => {
// @ts-ignore
actions = schema.properties.actions;
});
it('should be defined and have a specific structure', () => {
expect(actions).toBeDefined();
expect(actions.type).toBe('void');
expect(actions['x-initializer']).toBe('auditLogsTable:configureActions');
expect(actions['x-component']).toBe('ActionBar');
});
it('has x-component-props with expected style', () => {
expect(actions['x-component-props'].style.marginBottom).toBe('var(--nb-spacing)');
});
});
describe('properties should include dynamically keyed objects', () => {
it('keyed objects should match the expected structure for tables', () => {
const propertyKeys = Object.keys(schema.properties).filter((key) => key !== 'actions');
propertyKeys.forEach((key) => {
expect(schema.properties[key].type).toBe('array');
expect(schema.properties[key]['x-component']).toBe('TableV2');
});
});
});
});

View File

@ -0,0 +1,71 @@
import { ISchema } from '@formily/react';
import { uid } from '@nocobase/utils/client';
export function createAuditLogsBlockSchema(): ISchema {
return {
type: 'void',
'x-decorator': 'AuditLogsBlockProvider',
'x-acl-action': `auditLogs:list`,
'x-decorator-props': {
collection: 'auditLogs',
action: 'list',
params: {
pageSize: 20,
},
rowKey: 'id',
showIndex: true,
dragSort: false,
disableTemplate: true,
},
'x-toolbar': 'BlockSchemaToolbar',
'x-settings': 'blockSettings:table',
'x-component': 'CardItem',
'x-filter-targets': [],
properties: {
actions: {
type: 'void',
'x-initializer': 'auditLogsTable:configureActions',
'x-component': 'ActionBar',
'x-component-props': {
style: {
marginBottom: 'var(--nb-spacing)',
},
},
properties: {},
},
[uid()]: {
type: 'array',
'x-initializer': 'auditLogsTable:configureColumns',
'x-component': 'TableV2',
'x-component-props': {
rowKey: 'id',
rowSelection: {
type: 'checkbox',
},
useProps: '{{ useTableBlockProps }}',
},
properties: {
actions: {
type: 'void',
title: '{{ t("Actions") }}',
'x-action-column': 'actions',
'x-decorator': 'TableV2.Column.ActionBar',
'x-component': 'TableV2.Column',
'x-designer': 'TableV2.ActionColumnDesigner',
'x-initializer': 'auditLogsTable:configureItemActions',
properties: {
[uid()]: {
type: 'void',
'x-decorator': 'DndContext',
'x-component': 'Space',
'x-component-props': {
split: '|',
},
},
},
},
},
},
},
};
}