feat: table performance (#3791)

* fix: table add useMemo and useCallback

* fix: memo bug

* fix: sub table bug

* fix: form item performance

* fix: settings center performance impove

* fix: bug

* fix: bug

* fix: form first value change check performance

* fix: revert first form change

* fix: css move out component

* fix: page change table should not render

* fix: pre process merge bug

* fix: assotion bug

* fix: input and useDeppMemoized performance

* fix: bug

* fix: bug

* fix: improve Action.tsx lazy show content

* fix: remove Action performance imporve

* fix: assocication read pretty not loading

* fix: cssInJs imporve

* fix: imporve kanban rerender

* fix: remove useless CurrentAppInfoProvider in plugin

* fix: divide the schema into several parts

* fix: tabs.tsx and Page.tsx divide

* fix: form-item imporve

* fix: add OverrideSchemaComponentRefresher

* fix: page and tabs bug

* fix: workflow bug

* fix: remove useDeepMemorized()

* fix: e2e bug

* fix: internal Tag and viewer

* fix: collection field read pretty mode skip

* fix: others performance

* fix: revert collection field read pretty

* fix: table column not render when value is null or undefined

* fix: table and grid add view check

* fix: kanban lazy render

* fix: remove table useWhyDidYouUpdate

* fix: table index skip loading

* fix: card drag rerender loading

* fix: e2e skip lazy render

* fix: e2e bug

* fix: action e2e bug

* fix: grid and kanban card

* fix: remove override refresher component

* fix: unit test bug

* fix: change schema component props name

* fix: e2e and unit test bug

* fix: e2e bug

* fix: not lazy render when data length less 10 (T-3784)

* chore: fix merge

* chore: fix e2e

* fix: drag bug (T-3807)

* fix: repetitive refresh (T-3729)

* fix: pre fix merge confict

---------

Co-authored-by: Zeke Zhang <958414905@qq.com>
This commit is contained in:
jack zhang 2024-03-30 20:31:11 +08:00 committed by GitHub
parent 7b073727b1
commit 8a1345a5b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1750 additions and 1416 deletions

View File

@ -6,6 +6,7 @@
"module": "es/index.mjs",
"types": "es/index.d.ts",
"dependencies": {
"react-intersection-observer": "9.8.1",
"@ahooksjs/use-url-state": "3.5.1",
"@ant-design/cssinjs": "^1.11.1",
"@ant-design/icons": "^5.1.4",

View File

@ -1,6 +1,6 @@
import { Field } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react';
import React, { createContext, useContext, useEffect } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo } from 'react';
import { Navigate } from 'react-router-dom';
import { omit } from 'lodash';
import { useAPIClient, useRequest } from '../api-client';
@ -253,13 +253,16 @@ export const ACLActionProvider = (props) => {
export const useACLFieldWhitelist = () => {
const params = useContext(ACLActionParamsContext);
const whitelist = []
const whitelist = useMemo(() => {
return []
.concat(params?.whitelist || [])
.concat(params?.fields || [])
.concat(params?.appends || []);
}, [params?.whitelist, params?.fields, params?.appends]);
return {
whitelist,
schemaInWhitelist(fieldSchema: Schema, isSkip?) {
schemaInWhitelist: useCallback(
(fieldSchema: Schema, isSkip?) => {
if (isSkip) {
return true;
}
@ -275,6 +278,8 @@ export const useACLFieldWhitelist = () => {
const [key1, key2] = fieldSchema['x-collection-field'].split('.');
return whitelist?.includes(key2 || key1);
},
[whitelist],
),
};
};
@ -290,7 +295,7 @@ export const ACLCollectionFieldProvider = (props) => {
field.required = false;
field.display = 'hidden';
}
}, [allowed]);
}, [allowed, field]);
if (allowAll) {
return <>{props.children}</>;

View File

@ -121,9 +121,9 @@ export class RouterManager {
};
const ReactRouter = Routers[type];
const routes = this.getRoutesTree();
const RenderRoutes = () => {
const routes = this.getRoutesTree();
const element = useRoutes(routes);
return element;
};

View File

@ -58,6 +58,17 @@ export function withInitializer<T>(C: ComponentType<T>) {
[componentProps, props, style],
);
const overlayClassName = useMemo(() => {
return css`
.ant-popover-inner {
padding: ${`${token.paddingXXS}px 0`};
.ant-menu-submenu-title {
margin-block: 0;
}
}
`;
}, [token.paddingXXS]);
// designable 为 false 时,不渲染
if (!designable && propsDesignable !== true) {
return null;
@ -79,14 +90,7 @@ export function withInitializer<T>(C: ComponentType<T>) {
placement={'bottomLeft'}
{...popoverProps}
arrow={false}
overlayClassName={css`
.ant-popover-inner {
padding: ${`${token.paddingXXS}px 0`};
.ant-menu-submenu-title {
margin-block: 0;
}
}
`}
overlayClassName={overlayClassName}
open={visible}
onOpenChange={setVisible}
content={wrapSSR(

View File

@ -136,19 +136,13 @@ export const RenderChildrenWithAssociationFilter: React.FC<any> = (props) => {
if (associationFilterSchema) {
return (
<Component {...field.componentProps}>
<Row
className={css`
height: 100%;
`}
gutter={16}
wrap={false}
>
<Row style={{ height: '100%' }} gutter={16} wrap={false}>
<Col
className={css`
width: 200px;
flex: 0 0 auto;
`}
style={props.associationFilterStyle}
style={{
...(props.associationFilterStyle || {}),
width: 200,
flex: '0 0 auto',
}}
>
<RecursionField
schema={fieldSchema}
@ -157,17 +151,17 @@ export const RenderChildrenWithAssociationFilter: React.FC<any> = (props) => {
/>
</Col>
<Col
className={css`
flex: 1 1 auto;
min-width: 0;
`}
style={{
flex: '1 1 auto',
minWidth: 0,
}}
>
<div
className={css`
display: flex;
flex-direction: column;
height: 100%;
`}
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<RecursionField
schema={fieldSchema}

View File

@ -81,7 +81,11 @@ const InternalFormBlockProvider = (props) => {
*/
export const useFormBlockType = () => {
const ctx = useFormBlockContext() || {};
const res = useMemo(() => {
return { type: ctx.type } as { type: 'update' | 'create' };
}, [ctx.type]);
return res;
};
export const useIsDetailBlock = () => {

View File

@ -1,11 +1,10 @@
import { createForm } from '@formily/core';
import { FormContext, useField, useFieldSchema } from '@formily/react';
import React, { createContext, useContext, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { useCollectionManager_deprecated } from '../collection-manager';
import { FixedBlockWrapper, SchemaComponentOptions } from '../schema-component';
import { BlockProvider, RenderChildrenWithAssociationFilter, useBlockRequestContext } from './BlockProvider';
import { useParsedFilter } from './hooks';
import { useTableBlockParams } from '../modules/blocks/data-blocks/table/hooks/useTableBlockDecoratorProps';
import { useTableBlockParams } from '../modules/blocks/data-blocks/table';
import { withDynamicSchemaProps } from '../application/hoc/withDynamicSchemaProps';
export const TableBlockContext = createContext<any>({});
@ -41,14 +40,19 @@ const InternalTableBlockProvider = (props: Props) => {
const field: any = useField();
const { resource, service } = useBlockRequestContext();
const fieldSchema = useFieldSchema();
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
const [expandFlag, setExpandFlag] = useState(fieldNames ? true : false);
const allIncludesChildren = useMemo(() => {
const { treeTable } = fieldSchema?.['x-decorator-props'] || {};
const data = service?.data?.data;
if (treeTable !== false) {
const keys = getIdsWithChildren(service?.data?.data);
return keys || [];
const keys = getIdsWithChildren(data);
return keys;
}
}, [service?.loading]);
}, [service?.loading, fieldSchema]);
const setExpandFlagValue = useCallback(() => {
setExpandFlag(!expandFlag);
}, [expandFlag]);
return (
<FixedBlockWrapper>
@ -65,7 +69,7 @@ const InternalTableBlockProvider = (props: Props) => {
expandFlag,
childrenColumnName,
allIncludesChildren,
setExpandFlag: () => setExpandFlag(!expandFlag),
setExpandFlag: setExpandFlagValue,
}}
>
<RenderChildrenWithAssociationFilter {...props} />

View File

@ -34,21 +34,16 @@ export const CollectionHistoryProvider: React.FC = (props) => {
// console.log('location', location);
const service = useRequest<{
data: any;
}>(options, {
manual: true,
});
const isAdminPage = location.pathname.startsWith('/admin');
const token = api.auth.getToken() || '';
const { render } = useAppSpin();
useEffect(() => {
if (isAdminPage && token) {
service.run();
}
}, [isAdminPage, token]);
const service = useRequest<{
data: any;
}>(options, {
refreshDeps: [isAdminPage, token],
ready: !!(isAdminPage && token),
});
// 刷新 collecionHistory
const refreshCH = async () => {

View File

@ -348,6 +348,7 @@ export const AddFieldAction = (props) => {
</Dropdown>
<SchemaComponent
schema={schema}
distributed={false}
components={{ ...components, ArrayTable }}
scope={{
getContainer,

View File

@ -236,6 +236,7 @@ export const EditFieldAction = (props) => {
</a>
<SchemaComponent
schema={schema}
distributed={false}
components={{ ...components, ArrayTable }}
scope={{
getContainer,

View File

@ -2,7 +2,7 @@ import { Field } from '@formily/core';
import { connect, useField, useFieldSchema } from '@formily/react';
import { merge } from '@formily/shared';
import { concat } from 'lodash';
import React, { useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useFormBlockContext } from '../../block-provider/FormBlockProvider';
import { useCompile, useComponent } from '../../schema-component';
import { useIsAllowToSetDefaultValue } from '../../schema-settings/hooks/useIsAllowToSetDefaultValue';
@ -26,14 +26,17 @@ export const CollectionFieldInternalField: React.FC = (props: Props) => {
const { isAllowToSetDefaultValue } = useIsAllowToSetDefaultValue();
const uiSchema = useMemo(() => compile(uiSchemaOrigin), [JSON.stringify(uiSchemaOrigin)]);
const Component = useComponent(component || uiSchema?.['x-component'] || 'Input');
const setFieldProps = (key, value) => {
const setFieldProps = useCallback(
(key, value) => {
field[key] = typeof field[key] === 'undefined' ? value : field[key];
};
const setRequired = () => {
},
[field],
);
const setRequired = useCallback(() => {
if (typeof fieldSchema['required'] === 'undefined') {
field.required = !!uiSchema['required'];
}
};
}, [fieldSchema, uiSchema]);
const ctx = useFormBlockContext();
useEffect(() => {
@ -71,7 +74,7 @@ export const CollectionFieldInternalField: React.FC = (props: Props) => {
const originalProps = compile(uiSchema['x-component-props']) || {};
const componentProps = merge(originalProps, field.componentProps || {});
field.component = [Component, componentProps];
}, [JSON.stringify(uiSchema)]);
}, [uiSchema]);
if (!uiSchema) {
return null;
}

View File

@ -6,6 +6,7 @@ import { CollectionRecordProvider, CollectionRecord } from '../collection-record
import { AllDataBlockProps, useDataBlockProps } from './DataBlockProvider';
import { useDataBlockResource } from './DataBlockResourceProvider';
import { useDataSourceHeaders } from '../utils';
import _ from 'lodash';
import { useDataLoadingMode } from '../../modules/blocks/data-blocks/details-multi/setDataLoadingModeSettingsItem';
import { useCollection, useCollectionManager } from '../collection';
@ -16,11 +17,8 @@ function useCurrentRequest<T>(options: Omit<AllDataBlockProps, 'type'>) {
const dataLoadingMode = useDataLoadingMode();
const resource = useDataBlockResource();
const { action, params = {}, record, requestService, requestOptions } = options;
if (params.filterByTk === undefined) {
delete params.filterByTk;
}
const request = useRequest<T>(
requestService
const service = useMemo(() => {
return requestService
? requestService
: (customParams) => {
if (record) return Promise.resolve({ data: record });
@ -29,13 +27,16 @@ function useCurrentRequest<T>(options: Omit<AllDataBlockProps, 'type'>) {
`[nocobase]: The 'action' parameter is missing in the 'DataBlockRequestProvider' component`,
);
}
return resource[action]({ ...params, ...customParams }).then((res) => res.data);
},
{
const paramsValue = params.filterByTk === undefined ? _.omit(params, 'filterByTk') : params;
return resource[action]({ ...paramsValue, ...customParams }).then((res) => res.data);
};
}, [resource, action, params, record, requestService]);
const request = useRequest<T>(service, {
...requestOptions,
manual: true,
},
);
});
// 因为修改 Schema 会导致 params 对象发生变化,所以这里使用 `DeepCompare`
useDeepCompareEffect(() => {

View File

@ -6,7 +6,6 @@ import { CollectionFieldOptions_deprecated, useCollection_deprecated } from '../
import { removeNullCondition } from '../schema-component';
import { mergeFilter, useAssociatedFields } from './utils';
import { useDataLoadingMode } from '../modules/blocks/data-blocks/details-multi/setDataLoadingModeSettingsItem';
import { GeneralField } from '@formily/core';
enum FILTER_OPERATOR {
AND = '$and',
@ -161,11 +160,13 @@ export const DataBlockCollector = ({
export const useFilterBlock = () => {
const ctx = React.useContext(FilterContext);
// 有可能存在页面没有提供 FilterBlockProvider 的情况,比如内部使用的数据表管理页面
const getDataBlocks = useCallback<() => DataBlock[]>(() => ctx?.dataBlocks || [], [ctx?.dataBlocks]);
if (!ctx) {
return {
inProvider: false,
recordDataBlocks: () => {},
getDataBlocks: () => [] as DataBlock[],
getDataBlocks,
removeDataBlock: () => {},
};
}
@ -183,8 +184,10 @@ export const useFilterBlock = () => {
// 由于 setDataBlocks 是异步操作,所以上面的 existingBlock 在判断时有可能用的是旧的 dataBlocks,所以下面还需要根据 uid 进行去重操作
setDataBlocks((prev) => uniqBy([...prev, block], 'uid'));
};
const getDataBlocks = () => dataBlocks;
const removeDataBlock = (uid: string) => {
const blocks = dataBlocks.filter((item) => item.uid !== uid);
if (blocks.length === dataBlocks.length) return;
setDataBlocks((prev) => prev.filter((item) => item.uid !== uid));
};

View File

@ -24,9 +24,9 @@ test('actions', async ({ page, mockPage }) => {
const addNew = await addNewBtn.boundingBox();
const del = await delBtn.boundingBox();
const refresh = await refreshBtn.boundingBox();
const max = Math.max(addNew.x, refresh.x, del.x);
//拖拽调整排序符合预期
expect(addNew.x).toBe(max);
expect(addNew.x).toBeGreaterThan(refresh.x);
expect(refresh.x).toBeGreaterThan(del.x);
});
test('fields', async () => {});

View File

@ -1,11 +1,12 @@
import { ArrayField } from '@formily/core';
import { useField, useFieldSchema } from '@formily/react';
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo } 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';
import { isEqual } from 'lodash';
export const useTableBlockProps = () => {
const field = useField<ArrayField>();
@ -13,51 +14,75 @@ export const useTableBlockProps = () => {
const ctx = useTableBlockContext();
const globalSort = fieldSchema.parent?.['x-decorator-props']?.['params']?.['sort'];
const { getDataBlocks } = useFilterBlock();
const isLoading = ctx?.service?.loading;
const params = useMemo(() => ctx?.service?.params, [JSON.stringify(ctx?.service?.params)]);
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;
if (!isLoading) {
const serviceResponse = ctx?.service?.data;
const data = serviceResponse?.data || [];
const meta = serviceResponse?.meta || {};
const selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
if (!isEqual(field.value, data)) {
field.value = data;
field?.setInitialValue(data);
}
}, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题:
field.data = field.data || {};
if (!isEqual(field.data.selectedRowKeys, selectedRowKeys)) {
field.data.selectedRowKeys = selectedRowKeys;
}
field.componentProps.pagination = field.componentProps.pagination || {};
field.componentProps.pagination.pageSize = meta?.pageSize;
field.componentProps.pagination.total = meta?.count;
field.componentProps.pagination.current = meta?.page;
}
}, [field, ctx?.service?.data, isLoading]);
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
pagination: useMemo(() => {
return params?.paginate !== false
? {
defaultCurrent: ctx?.params?.page || 1,
defaultPageSize: ctx?.params?.pageSize,
defaultCurrent: params?.page || 1,
defaultPageSize: params?.pageSize,
}
: false,
onRowSelectionChange(selectedRowKeys) {
: false;
}, [params?.page, params?.pageSize, params?.paginate]),
onRowSelectionChange: useCallback((selectedRowKeys) => {
ctx.field.data = ctx?.field?.data || {};
ctx.field.data.selectedRowKeys = selectedRowKeys;
ctx?.field?.onRowSelect?.(selectedRowKeys);
},
async onRowDragEnd({ from, to }) {
}, []),
onRowDragEnd: useCallback(
async ({ from, to }) => {
await ctx.resource.move({
sourceId: from[ctx.rowKey || 'id'],
targetId: to[ctx.rowKey || 'id'],
sortField: ctx.dragSort && ctx.dragSortBy,
});
ctx.service.refresh();
// ctx.resource
// eslint-disable-next-line react-hooks/exhaustive-deps
},
onChange({ current, pageSize }, filters, sorter) {
[ctx.rowKey, ctx.dragSort, ctx.dragSortBy],
),
onChange: useCallback(
({ 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 });
ctx.service.run({ ...params?.[0], page: current, pageSize, sort });
// ctx.service
// eslint-disable-next-line react-hooks/exhaustive-deps
},
onClickRow(record, setSelectedRow, selectedRow) {
[globalSort, params],
),
onClickRow: useCallback(
(record, setSelectedRow, selectedRow) => {
const { targets, uid } = findFilterTargets(fieldSchema);
const dataBlocks = getDataBlocks();
@ -114,8 +139,10 @@ export const useTableBlockProps = () => {
// 更新表格的选中状态
setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value]));
},
onExpand(expanded, record) {
[ctx.rowKey, fieldSchema, getDataBlocks],
),
onExpand: useCallback((expanded, record) => {
ctx?.field.onExpandClick?.(expanded, record);
},
}, []),
};
};

View File

@ -12,10 +12,10 @@ import { useDesignable, removeNullCondition } from '../../../../schema-component
import {
SchemaSettingsBlockTitleItem,
SchemaSettingsSortField,
SchemaSettingsDataScope,
SchemaSettingsConnectDataBlocks,
SchemaSettingsTemplate,
} from '../../../../schema-settings';
} from '../../../../schema-settings/SchemaSettings';
import { SchemaSettingsDataScope } from '../../../../schema-settings/SchemaSettingsDataScope'
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrayItems } from '@formily/antd-v5';

View File

@ -7,8 +7,9 @@ import { useCollectionManager_deprecated } from '../../../../collection-manager'
import { useDesignable } from '../../../../schema-component';
import { useAssociationFieldContext } from '../../../../schema-component/antd/association-field/hooks';
import { useColumnSchema } from '../../../../schema-component/antd/table-v2/Table.Column.Decorator';
import { SchemaSettingsDefaultValue, isPatternDisabled } from '../../../../schema-settings';
import { SchemaSettingsDefaultValue } from '../../../../schema-settings/SchemaSettingsDefaultValue';
import { useFieldComponentName } from './utils';
import { isPatternDisabled } from '../../../../schema-settings/isPatternDisabled';
export const tableColumnSettings = new SchemaSettings({
name: 'fieldSettings:TableColumn',

View File

@ -1,11 +1,10 @@
import { ApiOutlined, SettingOutlined } from '@ant-design/icons';
import { css } from '@emotion/css';
import { Button, Card, Dropdown, Popover, Tooltip } from 'antd';
import React, { useState } from 'react';
import { Button, Dropdown, Tooltip } from 'antd';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useApp } from '../application';
import { ActionContextProvider, useCompile } from '../schema-component';
import { useCompile } from '../schema-component';
import { useToken } from '../style';
export const PluginManagerLink = () => {
@ -27,22 +26,13 @@ export const PluginManagerLink = () => {
};
export const SettingsCenterDropdown = () => {
const [visible, setVisible] = useState(false);
const compile = useCompile();
const { t } = useTranslation();
const { token } = useToken();
const navigate = useNavigate();
const app = useApp();
const settingItems = useMemo(() => {
const settings = app.pluginSettingsManager.getList();
const [open, setOpen] = useState(false);
return (
<Dropdown
menu={{
style: {
maxHeight: '70vh',
overflow: 'auto',
},
items: settings
return settings
.filter((v) => v.isTopLevel !== false)
.map((setting) => {
return {
@ -50,7 +40,16 @@ export const SettingsCenterDropdown = () => {
icon: setting.icon,
label: <Link to={setting.path}>{compile(setting.title)}</Link>,
};
}),
});
}, [app, t]);
return (
<Dropdown
menu={{
style: {
maxHeight: '70vh',
overflow: 'auto',
},
items: settingItems,
}}
>
<Button
@ -60,82 +59,4 @@ export const SettingsCenterDropdown = () => {
/>
</Dropdown>
);
return (
settings.length > 0 && (
<ActionContextProvider value={{ visible, setVisible }}>
<Popover
open={open}
onOpenChange={(open) => {
setOpen(open);
}}
arrow={false}
content={
<div style={{ maxWidth: '21rem', overflow: 'auto', maxHeight: '50vh' }}>
<Card
bordered={false}
className={css`
box-shadow: none;
`}
style={{ boxShadow: 'none' }}
>
{settings
.filter((v) => v.isTopLevel !== false)
.map((setting) => (
<Card.Grid
style={{
width: settings.length === 1 ? '100%' : settings.length === 2 ? '50%' : '33.33%',
}}
className={css`
cursor: pointer;
padding: 0 !important;
box-shadow: none !important;
&:hover {
border-radius: ${token.borderRadius}px;
background: rgba(0, 0, 0, 0.045);
}
`}
key={setting.name}
>
<a
role="button"
aria-label={setting.name}
onClick={(e) => {
e.preventDefault();
setOpen(false);
navigate(setting.path);
}}
title={compile(setting.title)}
style={{ display: 'block', color: 'inherit', minWidth: '4.5rem', padding: token.marginSM }}
href={setting.path}
>
<div style={{ fontSize: '1.2rem', textAlign: 'center', marginBottom: '0.3rem' }}>
{setting.icon || <SettingOutlined />}
</div>
<div
style={{
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontSize: token.fontSizeSM,
}}
>
{compile(setting.title)}
</div>
</a>
</Card.Grid>
))}
</Card>
</div>
}
>
<Button
data-testid="plugin-settings-button"
icon={<SettingOutlined style={{ color: token.colorTextHeaderMenu }} />}
// title={t('All plugin settings')}
/>
</Popover>
</ActionContextProvider>
)
);
};

View File

@ -1,6 +1,5 @@
import React, { createContext, useContext } from 'react';
import { useCollection_deprecated } from '../collection-manager';
import { CollectionRecordProvider } from '../data-source';
import React, { createContext, useContext, useMemo } from 'react';
import { CollectionRecordProvider, useCollection } from '../data-source';
import { useCurrentUserContext } from '../user';
export const RecordContext_deprecated = createContext({});
@ -18,10 +17,14 @@ export const RecordProvider: React.FC<{
collectionName?: string;
}> = (props) => {
const { record, children, parent, isNew } = props;
const { name: __collectionName } = useCollection_deprecated();
const value = { ...record };
value['__parent'] = parent;
value['__collectionName'] = __collectionName;
const collection = useCollection();
const value = useMemo(() => {
const res = { ...record };
res['__parent'] = parent;
res['__collectionName'] = collection?.name;
return res;
}, [record, parent, collection?.name]);
return (
<RecordContext_deprecated.Provider value={value}>
<CollectionRecordProvider isNew={isNew} record={record} parentRecord={parent}>

View File

@ -204,7 +204,12 @@ const MenuEditor = (props) => {
}
return (
<SchemaIdContext.Provider value={defaultSelectedUid}>
<SchemaComponent memoized scope={{ useMenuProps, onSelect, sideMenuRef, defaultSelectedUid }} schema={schema} />
<SchemaComponent
distributed
memoized
scope={{ useMenuProps, onSelect, sideMenuRef, defaultSelectedUid }}
schema={schema}
/>
</SchemaIdContext.Provider>
);
};
@ -275,17 +280,39 @@ const SetThemeOfHeaderSubmenu = ({ children }) => {
);
};
export const InternalAdminLayout = (props: any) => {
const sideMenuRef = useRef<HTMLDivElement>();
const result = useSystemSettings();
// const { service } = useCollectionManager_deprecated();
const AdminSideBar = ({ sideMenuRef }) => {
const params = useParams<any>();
const { token } = useToken();
if (!params.name) return null;
return (
<Layout>
<GlobalStyleForAdminLayout />
<Layout.Header
<Layout.Sider
className={css`
height: 100%;
/* position: fixed; */
position: relative;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
.ant-layout-sider-children {
top: var(--nb-header-height);
position: fixed;
width: 200px;
height: calc(100vh - var(--nb-header-height));
}
`}
theme={'light'}
ref={sideMenuRef}
></Layout.Sider>
);
};
export const InternalAdminLayout = () => {
const result = useSystemSettings();
const { token } = useToken();
const sideMenuRef = useRef<HTMLDivElement>();
const layoutHeaderCss = useMemo(() => {
return css`
.ant-menu.ant-menu-dark .ant-menu-item-selected,
.ant-menu-submenu-popup.ant-menu-dark .ant-menu-item-selected,
.ant-menu-submenu-horizontal.ant-menu-submenu-selected {
@ -317,24 +344,36 @@ export const InternalAdminLayout = (props: any) => {
.ant-menu-submenu-horizontal {
color: ${token.colorTextHeaderMenu} !important;
}
`}
`;
}, [
token.colorBgHeaderMenuActive,
token.colorTextHeaderMenuActive,
token.colorBgHeaderMenuHover,
token.colorTextHeaderMenuHover,
token.colorBgHeader,
token.colorTextHeaderMenu,
]);
return (
<Layout>
<GlobalStyleForAdminLayout />
<Layout.Header className={layoutHeaderCss}>
<div
style={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
}}
>
<div
className={css`
position: relative;
width: 100%;
height: 100%;
display: flex;
`}
>
<div
className={css`
position: relative;
z-index: 1;
flex: 1 1 auto;
display: flex;
height: 100%;
`}
style={{
position: 'relative',
zIndex: 1,
flex: '1 1 auto',
display: 'flex',
height: '100%',
}}
>
<div
className={css`
@ -390,27 +429,7 @@ export const InternalAdminLayout = (props: any) => {
</div>
</div>
</Layout.Header>
{params.name && (
<Layout.Sider
className={css`
height: 100%;
/* position: fixed; */
position: relative;
left: 0;
top: 0;
background: rgba(0, 0, 0, 0);
z-index: 100;
.ant-layout-sider-children {
top: var(--nb-header-height);
position: fixed;
width: 200px;
height: calc(100vh - var(--nb-header-height));
}
`}
theme={'light'}
ref={sideMenuRef}
></Layout.Sider>
)}
<AdminSideBar sideMenuRef={sideMenuRef} />
<Layout.Content
className={css`
display: flex;

View File

@ -3,7 +3,7 @@ import { isPortalInBody } from '@nocobase/utils/client';
import { App, Button } from 'antd';
import classnames from 'classnames';
import { default as lodash } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StablePopover, useActionContext } from '../..';
import { useDesignable } from '../../';
@ -46,6 +46,7 @@ export const Action: ComposedAction = observer(
actionCallback,
/** 如果为 true 则说明该按钮是树表格的 Add child 按钮 */
addChild,
onMouseEnter,
...others
} = useProps(props);
const aclCtx = useACLActionParamsContext();
@ -65,17 +66,28 @@ export const Action: ComposedAction = observer(
const openSize = fieldSchema?.['x-component-props']?.['openSize'];
const disabled = form.disabled || field.disabled || field.data?.disabled || propsDisabled;
const linkageRules = fieldSchema?.['x-linkage-rules'] || [];
const linkageRules = useMemo(() => fieldSchema?.['x-linkage-rules'] || [], [fieldSchema?.['x-linkage-rules']]);
const { designable } = useDesignable();
const tarComponent = useComponent(component) || component;
const { modal } = App.useApp();
const variables = useVariables();
const localVariables = useLocalVariables({ currentForm: { values: record } as any });
const { getAriaLabel } = useGetAriaLabelOfAction(title);
let actionTitle = title || compile(fieldSchema.title);
actionTitle = lodash.isString(actionTitle) ? t(actionTitle) : actionTitle;
const [btnHover, setBtnHover] = useState(popover);
useEffect(() => {
if (popover) {
setBtnHover(true);
}
}, [popover]);
const actionTitle = useMemo(() => {
const res = title || compile(fieldSchema.title);
return lodash.isString(res) ? t(res) : res;
}, [title, fieldSchema.title, t]);
useEffect(() => {
if (!btnHover) return;
field.stateOfLinkageRules = {};
linkageRules
.filter((k) => !k.disabled)
@ -90,13 +102,14 @@ export const Action: ComposedAction = observer(
});
});
});
}, [field, linkageRules, localVariables, record, variables]);
}, [btnHover, field, linkageRules, localVariables, record, variables]);
const handleButtonClick = useCallback(
(e: React.MouseEvent) => {
if (isPortalInBody(e.target as Element)) {
return;
}
setBtnHover(true);
e.preventDefault();
e.stopPropagation();
@ -128,6 +141,13 @@ export const Action: ComposedAction = observer(
};
}, [designable, field?.data?.hidden, style]);
const handleMouseEnter = useCallback(
(e) => {
setBtnHover(true);
onMouseEnter?.(e);
},
[onMouseEnter],
);
const renderButton = () => {
if (!designable && (field?.data?.hidden || !aclCtx)) {
return null;
@ -138,6 +158,7 @@ export const Action: ComposedAction = observer(
role="button"
aria-label={getAriaLabel()}
{...others}
onMouseEnter={handleMouseEnter}
loading={field?.data?.loading}
icon={icon ? <Icon type={icon} /> : null}
disabled={disabled}
@ -152,9 +173,16 @@ export const Action: ComposedAction = observer(
</SortableItem>
);
};
const buttonElement = renderButton();
// if (!btnHover) {
// return buttonElement;
// }
const result = (
<ActionContextProvider
button={renderButton()}
button={buttonElement}
visible={visible}
setVisible={setVisible}
formValueChanged={formValueChanged}

View File

@ -8,9 +8,8 @@ import App4 from '../demos/demo4';
describe('Action', () => {
it('show the drawer when click the button', async () => {
const { getByText } = render(<App1 />);
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
@ -22,14 +21,14 @@ describe('Action', () => {
expect(getByText('Hello')).toBeInTheDocument();
// close button
await waitFor(async () => {
await userEvent.click(getByText('Close'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
@ -38,8 +37,8 @@ describe('Action', () => {
});
// should also close when click the close icon
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-close') as HTMLElement);
@ -56,34 +55,28 @@ describe('Action', () => {
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
// drawer
await waitFor(async () => {
await userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// modal
await waitFor(async () => {
await userEvent.click(getByText('Close'));
await userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await waitFor(async () => {
await userEvent.click(getByText('Close'));
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).not.toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).toBeInTheDocument();
@ -98,9 +91,8 @@ describe('Action', () => {
describe('Action.Drawer without Action', () => {
it('show the drawer when click the button', async () => {
const { getByText } = render(<App2 />);
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
// mask
@ -112,14 +104,14 @@ describe('Action.Drawer without Action', () => {
});
// close button
await waitFor(async () => {
await userEvent.click(getByText('Close'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
await userEvent.click(document.querySelector('.ant-drawer-mask') as HTMLElement);
@ -128,9 +120,8 @@ describe('Action.Drawer without Action', () => {
});
// should also close when click the close icon
await waitFor(async () => {
await userEvent.click(getByText('Open'));
await waitFor(() => {
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});

View File

@ -7,6 +7,8 @@ export function useSetAriaLabelForDrawer(visible: boolean) {
// 因 Drawer 设置 aria-label 无效,所以使用下面这种方式设置,方便 e2e 录制工具选中
useEffect(() => {
if (visible) {
// 因为 Action 是点击后渲染内容,所以需要延迟一下
setTimeout(() => {
const wrappers = [...document.querySelectorAll('.ant-drawer-wrapper-body')];
const masks = [...document.querySelectorAll('.ant-drawer-mask')];
// 如果存在多个 mask最后一个 mask 为当前打开的 maskwrapper 也是同理
@ -22,6 +24,7 @@ export function useSetAriaLabelForDrawer(visible: boolean) {
currentWrapper.setAttribute('data-testid', getAriaLabel());
currentWrapper.setAttribute('aria-label', getAriaLabel());
}
}, 0);
}
}, [getAriaLabel, visible]);
}

View File

@ -1,25 +1,31 @@
import { Field } from '@formily/core';
import { observer, useField, useFieldSchema } from '@formily/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useCollectionManager_deprecated } from '../../../collection-manager';
import { AssociationFieldContext } from './context';
import { markRecordAsNew } from '../../../data-source/collection-record/isNewRecord';
import { useCollection, useCollectionManager } from '../../../data-source/collection';
import { useSchemaComponentContext } from '../../hooks';
export const AssociationFieldProvider = observer(
(props) => {
const field = useField<Field>();
const { getCollectionJoinField, getCollection } = useCollectionManager_deprecated();
const collection = useCollection();
const dm = useCollectionManager();
const fieldSchema = useFieldSchema();
// 这里有点奇怪,在 Table 切换显示的组件时,这个组件并不会触发重新渲染,所以增加这个 Hooks 让其重新渲染
useSchemaComponentContext();
const allowMultiple = fieldSchema['x-component-props']?.multiple !== false;
const allowDissociate = fieldSchema['x-component-props']?.allowDissociate !== false;
const collectionField = useMemo(
() => getCollectionJoinField(fieldSchema['x-collection-field']),
() => collection.getField(fieldSchema['x-collection-field']),
// eslint-disable-next-line react-hooks/exhaustive-deps
[fieldSchema['x-collection-field'], fieldSchema.name],
);
const isFileCollection = useMemo(
() => getCollection(collectionField?.target)?.template === 'file',
() => dm.getCollection(collectionField?.target)?.template === 'file',
// eslint-disable-next-line react-hooks/exhaustive-deps
[fieldSchema['x-collection-field']],
);
@ -31,9 +37,13 @@ export const AssociationFieldProvider = observer(
const fieldValue = useMemo(() => JSON.stringify(field.value), [field.value]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(!field.readPretty);
useEffect(() => {
if (field.readPretty) {
return;
}
setLoading(true);
if (!collectionField) {
setLoading(false);

View File

@ -7,6 +7,27 @@ import { useAssociationFieldContext, useInsertSchema } from './hooks';
import { ACLCollectionProvider, useACLActionParamsContext } from '../../../acl';
import schema from './schema';
const InternalNesterCss = css`
& .ant-formily-item-layout-vertical {
margin-bottom: 10px;
}
.ant-card-body {
padding: 15px 20px 5px;
}
.ant-divider-horizontal {
margin: 10px 0;
}
`;
const InternalNesterCardCss = css`
.ant-card-bordered {
border: none;
}
.ant-card-body {
padding: 0px 20px 20px 0px;
}
`;
export const InternalNester = observer(
() => {
const field = useField();
@ -23,29 +44,9 @@ export const InternalNester = observer(
<ACLCollectionProvider actionPath={`${collectionField.target}:${actionName}`}>
<FormLayout layout={'vertical'}>
<div
className={cx(
css`
& .ant-formily-item-layout-vertical {
margin-bottom: 10px;
}
.ant-card-body {
padding: 15px 20px 5px;
}
.ant-divider-horizontal {
margin: 10px 0;
}
`,
{
[css`
.ant-card-body {
padding: 0px 20px 20px 0px;
}
> .ant-card-bordered {
border: none;
}
`]: showTitle === false,
},
)}
className={cx(InternalNesterCss, {
[InternalNesterCardCss]: showTitle === false,
})}
>
<RecursionField
onlyRenderProperties

View File

@ -11,6 +11,15 @@ import { InternalNester } from './InternalNester';
import { ReadPrettyInternalViewer } from './InternalViewer';
import { useAssociationFieldContext } from './hooks';
const InternaPopoverNesterContentCss = css`
min-width: 600px;
max-height: 440px;
overflow: auto;
.ant-card {
border: 0px;
}
`;
export const InternaPopoverNester = observer(
(props) => {
const { options } = useAssociationFieldContext();
@ -27,14 +36,7 @@ export const InternaPopoverNester = observer(
<div
ref={ref}
style={{ minWidth: '600px', maxWidth: '800px', maxHeight: '440px', overflow: 'auto' }}
className={css`
min-width: 600px;
max-height: 440px;
overflow: auto;
.ant-card {
border: 0px;
}
`}
className={InternaPopoverNesterContentCss}
>
<InternalNester {...nesterProps} />
</div>
@ -63,9 +65,9 @@ export const InternaPopoverNester = observer(
>
<span style={{ cursor: 'pointer', display: 'flex' }}>
<div
className={css`
max-width: 95%;
`}
style={{
maxWidth: '95%',
}}
>
<ReadPrettyInternalViewer {...props} />
</div>
@ -77,15 +79,15 @@ export const InternaPopoverNester = observer(
role="button"
aria-label={getAriaLabel('mask')}
onClick={() => setVisible(false)}
className={css`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
z-index: 9999;
`}
style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'transparent',
zIndex: 9999,
}}
/>
)}
</ActionContextProvider>

View File

@ -45,6 +45,7 @@ export const ReadPrettyInternalTag: React.FC = observer(
const { getCollection } = useCollectionManager_deprecated();
const targetCollection = getCollection(collectionField?.target);
const isTreeCollection = targetCollection?.template === 'tree';
const [btnHover, setBtnHover] = useState(false);
const renderRecords = () =>
toArr(props.value).map((record, index, arr) => {
@ -65,7 +66,11 @@ export const ReadPrettyInternalTag: React.FC = observer(
text
) : enableLink !== false ? (
<a
onMouseEnter={() => {
setBtnHover(true);
}}
onClick={(e) => {
setBtnHover(true);
e.stopPropagation();
e.preventDefault();
if (designable) {
@ -87,6 +92,16 @@ export const ReadPrettyInternalTag: React.FC = observer(
);
});
const btnElement = (
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
);
if (enableLink === false || !btnHover) {
return btnElement;
}
const renderWithoutTableFieldResourceProvider = () => (
<WithoutTableFieldResource.Provider value={true}>
<FormProvider>
@ -120,9 +135,7 @@ export const ReadPrettyInternalTag: React.FC = observer(
<div>
<BlockAssociationContext.Provider value={`${collectionField?.collectionName}.${collectionField?.name}`}>
<CollectionProvider_deprecated name={collectionField?.target ?? collectionField?.targetCollection}>
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
{btnElement}
<ActionContextProvider
value={{ visible, setVisible, openMode: 'drawer', snapshot: collectionField?.interface === 'snapshot' }}
>

View File

@ -47,6 +47,8 @@ export const ReadPrettyInternalViewer: React.FC = observer(
const isTreeCollection = targetCollection?.template === 'tree';
const ellipsisWithTooltipRef = useRef<IEllipsisWithTooltipRef>();
const getLabelUiSchema = useLabelUiSchemaV2();
const [btnHover, setBtnHover] = useState(false);
const renderRecords = () =>
toArr(props.value).map((record, index, arr) => {
const value = record?.[fieldNames?.label || 'label'];
@ -70,7 +72,11 @@ export const ReadPrettyInternalViewer: React.FC = observer(
text
) : enableLink !== false ? (
<a
onMouseEnter={() => {
setBtnHover(true);
}}
onClick={(e) => {
setBtnHover(true);
e.stopPropagation();
e.preventDefault();
if (designable) {
@ -91,6 +97,16 @@ export const ReadPrettyInternalViewer: React.FC = observer(
</Fragment>
);
});
const btnElement = (
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
);
if (enableLink === false || !btnHover) {
return btnElement;
}
const renderWithoutTableFieldResourceProvider = () => (
<WithoutTableFieldResource.Provider value={true}>
<FormProvider>
@ -124,9 +140,7 @@ export const ReadPrettyInternalViewer: React.FC = observer(
<div>
<BlockAssociationContext.Provider value={`${collectionField?.collectionName}.${collectionField?.name}`}>
<CollectionProvider_deprecated name={collectionField?.target ?? collectionField?.targetCollection}>
<EllipsisWithTooltip ellipsis={true} ref={ellipsisWithTooltipRef}>
{renderRecords()}
</EllipsisWithTooltip>
{btnElement}
<ActionContextProvider
value={{
visible,

View File

@ -1,9 +1,11 @@
import { APIClientProvider, AssociationSelect, FormProvider, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { mockAPIClient } from '../../../../testUtils';
import { sleep } from '@nocobase/test/client';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/posts:list').reply(() => {
mockRequest.onGet('/posts:list').reply(async () => {
await sleep(100);
return [
200,
{

View File

@ -1,28 +1,13 @@
import { css } from '@emotion/css';
import cls from 'classnames';
import React from 'react';
import React, { useMemo } from 'react';
import { SortableItem } from '../../common';
import { useDesigner, useProps } from '../../hooks';
import { useGetAriaLabelOfBlockItem } from './hooks/useGetAriaLabelOfBlockItem';
import { useFieldSchema } from '@formily/react';
import { withDynamicSchemaProps } from '../../../application/hoc/withDynamicSchemaProps';
export const BlockItem: React.FC<any> = withDynamicSchemaProps((props) => {
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { className, children } = useProps(props);
const Designer = useDesigner();
const fieldSchema = useFieldSchema();
const { getAriaLabel } = useGetAriaLabelOfBlockItem(props.name);
return (
<SortableItem
role="button"
aria-label={getAriaLabel()}
className={cls(
'nb-block-item',
className,
css`
const blockItemCss = css`
position: relative;
&:hover {
> .general-schema-designer {
@ -65,11 +50,25 @@ export const BlockItem: React.FC<any> = withDynamicSchemaProps((props) => {
}
}
}
`,
)}
>
`;
export const BlockItem: React.FC<any> = withDynamicSchemaProps(
(props) => {
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { className, children } = useProps(props);
const Designer = useDesigner();
const fieldSchema = useFieldSchema();
const { getAriaLabel } = useGetAriaLabelOfBlockItem(props.name);
const label = useMemo(() => getAriaLabel(), [getAriaLabel]);
return (
<SortableItem role="button" aria-label={label} className={cls('nb-block-item', className, blockItemCss)}>
<Designer {...fieldSchema['x-toolbar-props']} />
{children}
</SortableItem>
);
});
},
{ displayName: 'BlockItem' },
);

View File

@ -1,9 +1,10 @@
import { useFieldSchema } from '@formily/react';
import { Card } from 'antd';
import { Card, Skeleton } from 'antd';
import React from 'react';
import { useSchemaTemplate } from '../../../schema-templates';
import { BlockItem } from '../block-item';
import useStyles from './style';
import { useInView } from 'react-intersection-observer';
interface Props {
children?: React.ReactNode;
@ -18,11 +19,18 @@ export const CardItem = (props: Props) => {
const fieldSchema = useFieldSchema();
const templateKey = fieldSchema?.['x-template-key'];
const { wrapSSR, componentCls, hashId } = useStyles();
const { ref, inView } = useInView({
threshold: 1,
initialInView: true,
triggerOnce: true,
skip: !!process.env.__E2E__,
});
return wrapSSR(
templateKey && !template ? null : (
<BlockItem name={name} className={`${componentCls} ${hashId} noco-card-item`}>
<Card className="card" bordered={false} {...restProps}>
{props.children}
<Card ref={ref} className="card" bordered={false} {...restProps}>
{inView ? props.children : <Skeleton active paragraph={{ rows: 4 }} />}
</Card>
</BlockItem>
),

View File

@ -4,7 +4,7 @@ import { isValid } from '@formily/shared';
import { Checkbox as AntdCheckbox, Tag } from 'antd';
import type { CheckboxGroupProps, CheckboxProps } from 'antd/es/checkbox';
import uniq from 'lodash/uniq';
import React from 'react';
import React, { useMemo } from 'react';
import { useCollectionField } from '../../../data-source/collection-field/CollectionFieldProvider';
import { EllipsisWithTooltip } from '../input/EllipsisWithTooltip';
@ -36,8 +36,10 @@ export const Checkbox: ComposedCheckbox = connect(
}),
mapReadPretty(ReadPretty),
);
Checkbox.displayName = 'Checkbox';
Checkbox.ReadPretty = ReadPretty;
Checkbox.ReadPretty.displayName = 'Checkbox.ReadPretty';
Checkbox.__ANT_CHECKBOX = true;
@ -52,13 +54,15 @@ Checkbox.Group = connect(
}
const field = useField<any>();
const collectionField = useCollectionField();
const tags = useMemo(() => {
const dataSource = field.dataSource || collectionField?.uiSchema.enum || [];
const value = uniq(field.value ? field.value : []);
return dataSource.filter((option) => value.includes(option.value));
}, [field.value]);
return (
<EllipsisWithTooltip ellipsis={props.ellipsis}>
{dataSource
.filter((option) => value.includes(option.value))
.map((option, key) => (
{tags.map((option, key) => (
<Tag key={key} color={option.color} icon={option.icon}>
{option.label}
</Tag>
@ -67,5 +71,6 @@ Checkbox.Group = connect(
);
}),
);
Checkbox.Group.displayName = 'Checkbox.Group';
export default Checkbox;

View File

@ -10,11 +10,12 @@ describe('Filter', () => {
it('Filter & Action', async () => {
render(<App3 />);
let tooltip;
await waitFor(async () => {
await userEvent.click(screen.getByText(/open/i));
});
const tooltip = screen.getByRole('tooltip');
tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
// 弹窗中显示的内容
expect(within(tooltip).getByText(/name/i)).toBeInTheDocument();
@ -78,9 +79,12 @@ describe('Filter', () => {
it('FilterAction', async () => {
render(<App5 />);
await waitFor(() => userEvent.click(screen.getByText(/filter/i)));
const tooltip = screen.getByRole('tooltip');
let tooltip;
await waitFor(async () => {
await userEvent.click(screen.getByText(/filter/i));
tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
// 弹窗中显示的内容
expect(within(tooltip).getByText(/name/i)).toBeInTheDocument();

View File

@ -18,6 +18,20 @@ import useLazyLoadDisplayAssociationFieldsOfForm from './hooks/useLazyLoadDispla
import useParseDefaultValue from './hooks/useParseDefaultValue';
import { CollectionFieldProvider } from '../../../data-source';
Item.displayName = 'FormilyFormItem';
const formItemWrapCss = css`
& .ant-space {
flex-wrap: wrap;
}
`;
const formItemLabelCss = css`
> .ant-formily-item-label {
display: none;
}
`;
export const FormItem: any = observer(
(props: any) => {
useEnsureOperatorsValid();
@ -52,20 +66,9 @@ export const FormItem: any = observer(
);
}, [field.description]);
const className = useMemo(() => {
return cx(
css`
& .ant-space {
flex-wrap: wrap;
}
`,
{
[css`
> .ant-formily-item-label {
display: none;
}
`]: showTitle === false,
},
);
return cx(formItemWrapCss, {
[formItemLabelCss]: showTitle === false,
});
}, [showTitle]);
return (

View File

@ -11,6 +11,7 @@ import { useVariables } from '../../../../variables';
import { transformVariableValue } from '../../../../variables/utils/transformVariableValue';
import { useSubFormValue } from '../../association-field/hooks';
import { isDisplayField } from '../utils';
import { untracked } from '@formily/reactive';
/**
* Form
@ -31,13 +32,13 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
const { getAssociationAppends } = useAssociationNames();
const schemaName = fieldSchema.name.toString();
const formValue = _.cloneDeep(isInSubForm || isInSubTable ? subFormValue : form.values);
const collectionFieldRef = useRef(null);
const sourceCollectionFieldRef = useRef(null);
// 是否已经预加载了数据(通过 appends 的形式)
const hasPreloadData = useMemo(() => hasPreload(recordData, schemaName), [recordData, schemaName]);
const collectionFieldRef = useRef(null);
const sourceCollectionFieldRef = useRef(null);
if (collectionFieldRef.current == null && isDisplayField(schemaName)) {
collectionFieldRef.current = getCollectionJoinField(`${name}.${schemaName}`);
}
@ -45,6 +46,16 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
sourceCollectionFieldRef.current = getCollectionJoinField(`${name}.${schemaName.split('.')[0]}`);
}
const shouldNotLazyLoad = useMemo(() => {
return (
!isDisplayField(schemaName) || !variables || name === 'fields' || !collectionFieldRef.current || hasPreloadData
);
}, [schemaName, variables, name, collectionFieldRef.current, hasPreloadData]);
const formValue = shouldNotLazyLoad
? {}
: untracked(() => _.cloneDeep(isInSubForm || isInSubTable ? subFormValue : form.values));
const sourceKeyValue =
isDisplayField(schemaName) && sourceCollectionFieldRef.current
? _.get(formValue, `${schemaName.split('.')[0]}.${sourceCollectionFieldRef.current?.targetKey || 'id'}`)
@ -53,13 +64,7 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
useEffect(() => {
// 如果 schemaName 中是以 `.` 分割的,说明是一个关联字段,需要去获取关联字段的值;
// 在数据表管理页面,也存在 `a.b` 之类的 schema name其 collectionName 为 fields所以在这里排除一下 `name === 'fields'` 的情况
if (
!isDisplayField(schemaName) ||
!variables ||
name === 'fields' ||
!collectionFieldRef.current ||
hasPreloadData
) {
if (shouldNotLazyLoad) {
return;
}
@ -97,7 +102,7 @@ const useLazyLoadDisplayAssociationFieldsOfForm = () => {
.catch((err) => {
console.error(err);
});
}, [sourceKeyValue]);
}, [sourceKeyValue, shouldNotLazyLoad]);
};
export default useLazyLoadDisplayAssociationFieldsOfForm;

View File

@ -195,6 +195,12 @@ const WithoutForm = (props) => {
);
};
const formLayoutCss = css`
.ant-formily-item-feedback-layout-loose {
margin-bottom: 12px;
}
`;
export const Form: React.FC<FormProps> & {
Designer?: any;
FilterDesigner?: any;
@ -210,14 +216,7 @@ export const Form: React.FC<FormProps> & {
const formDisabled = disabled || field.disabled;
return (
<ConfigProvider componentDisabled={formDisabled}>
<form
onSubmit={(e) => e.preventDefault()}
className={css`
.ant-formily-item-feedback-layout-loose {
margin-bottom: 12px;
}
`}
>
<form onSubmit={(e) => e.preventDefault()} className={formLayoutCss}>
<Spin spinning={field.loading || false}>
{form ? (
<WithForm form={form} {...others} disabled={formDisabled} />

View File

@ -17,9 +17,9 @@ describe('FormV2', () => {
});
await userEvent.type(input, '李四');
await userEvent.click(submit);
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(screen.getByText('Submit'));
// notification 的内容
expect(screen.getByText(/\{"nickname":"李四"\}/i)).toBeInTheDocument();
});

View File

@ -60,10 +60,11 @@ describe('Form', () => {
it('Form & Drawer', async () => {
render(<App1 />);
const openBtn = screen.getByText('Open');
await userEvent.click(openBtn);
await waitFor(async () => {
await userEvent.click(screen.getByText('Open'));
expect(screen.getByText(/drawer title/i)).toBeInTheDocument();
});
});
it('initialValue', async () => {
render(<App3 />);
@ -83,12 +84,13 @@ describe('Form', () => {
it('initialValue of decorator', async () => {
render(<App4 />);
const openBtn = screen.getByText('Open');
await userEvent.click(openBtn);
await waitFor(async () => {
await userEvent.click(screen.getByText('Open'));
expect(screen.getByText(/drawer title/i)).toBeInTheDocument();
});
const input = document.querySelector('.ant-input') as HTMLInputElement;
expect(screen.getByText(/drawer title/i)).toBeInTheDocument();
expect(input).toBeInTheDocument();
expect(input).toHaveValue('aaa');
expect(screen.getByText(/\{ "field1": "aaa" \}/i)).toBeInTheDocument();

View File

@ -16,14 +16,7 @@ const itemCss = css`
gap: 8px;
`;
export const GridCardItem = withDynamicSchemaProps((props) => {
const field = useField<ObjectField>();
const parentRecordData = useCollectionParentRecordData();
return (
<Card
role="button"
aria-label="grid-card-item"
className={css`
const gridCardCss = css`
height: 100%;
> .ant-card-body {
padding: 24px 24px 0px;
@ -32,8 +25,14 @@ export const GridCardItem = withDynamicSchemaProps((props) => {
.nb-action-bar {
padding: 5px 0;
}
`}
>
`;
export const GridCardItem = withDynamicSchemaProps(
(props) => {
const field = useField<ObjectField>();
const parentRecordData = useCollectionParentRecordData();
return (
<Card role="button" aria-label="grid-card-item" className={gridCardCss}>
<div className={itemCss}>
<RecordProvider record={field.value} parent={parentRecordData}>
{props.children}
@ -41,4 +40,6 @@ export const GridCardItem = withDynamicSchemaProps((props) => {
</div>
</Card>
);
});
},
{ displayName: 'GridCardItem' },
);

View File

@ -3,8 +3,8 @@ import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-k
import { ISchema, RecursionField, Schema, observer, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
import cls from 'classnames';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useDesignable, useFormBlockContext, useSchemaInitializerRender } from '../../../';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { SchemaComponent, useDesignable, useFormBlockContext, useSchemaInitializerRender } from '../../../';
import { useFormBlockType } from '../../../block-provider';
import { DndContext } from '../../common/dnd-context';
import { useToken } from '../__builtins__';
@ -20,6 +20,9 @@ GridContext.displayName = 'GridContext';
const breakRemoveOnGrid = (s: Schema) => s['x-component'] === 'Grid';
const breakRemoveOnRow = (s: Schema) => s['x-component'] === 'Grid.Row';
const MemorizedRecursionField = React.memo(RecursionField);
MemorizedRecursionField.displayName = 'MemorizedRecursionField';
const ColDivider = (props) => {
const { token } = useToken();
const dragIdRef = useRef<string | null>(null);
@ -303,25 +306,38 @@ export const useGridRowContext = () => {
export const Grid: any = observer(
(props: any) => {
const { showDivider = true } = props;
const { distributed, showDivider = true } = props;
const gridRef = useRef(null);
const field = useField();
const fieldSchema = useFieldSchema();
const { render } = useSchemaInitializerRender(fieldSchema['x-initializer'], fieldSchema['x-initializer-props']);
const InitializerComponent = (props) => render(props);
const InitializerComponent = useCallback((props) => render(props), []);
const addr = field.address.toString();
const rows = useRowProperties();
const { setPrintContent } = useFormBlockContext();
const { wrapSSR, componentCls, hashId } = useStyles();
const distributedValue =
distributed === undefined
? fieldSchema?.parent['x-component'] === 'Page' || fieldSchema?.parent['x-component'] === 'Tabs.TabPane'
: distributed;
useEffect(() => {
gridRef.current && setPrintContent?.(gridRef.current);
}, [gridRef.current]);
const gridContextValue = useMemo(() => {
return {
ref: gridRef,
fieldSchema,
renderSchemaInitializer: render,
InitializerComponent,
showDivider,
};
}, [fieldSchema, render, InitializerComponent, showDivider]);
return wrapSSR(
<GridContext.Provider
value={{ ref: gridRef, fieldSchema, renderSchemaInitializer: render, InitializerComponent, showDivider }}
>
<GridContext.Provider value={gridContextValue}>
<div className={`nb-grid ${componentCls} ${hashId}`} style={{ position: 'relative' }} ref={gridRef}>
<DndWrapper dndContext={props.dndContext}>
{showDivider ? (
@ -340,7 +356,11 @@ export const Grid: any = observer(
{rows.map((schema, index) => {
return (
<React.Fragment key={index}>
<RecursionField name={schema.name} schema={schema} />
{distributedValue ? (
<SchemaComponent name={schema.name} schema={schema} distributed />
) : (
<MemorizedRecursionField name={schema.name} schema={schema} />
)}
{showDivider ? (
<RowDivider
rows={rows}
@ -375,8 +395,25 @@ Grid.Row = observer(
const { showDivider } = useGridContext();
const { type } = useFormBlockType();
const ctxValue = useMemo(() => {
return {
schema: fieldSchema,
cols,
};
}, [fieldSchema, cols]);
const mapProperties = useCallback(
(schema) => {
if (type === 'update') {
schema.default = null;
}
return schema;
},
[type],
);
return (
<GridRowContext.Provider value={{ schema: fieldSchema, cols }}>
<GridRowContext.Provider value={ctxValue}>
<div
className={cls('nb-grid-row', 'CardRow', {
showDivider,
@ -398,7 +435,7 @@ Grid.Row = observer(
{cols.map((schema, index) => {
return (
<React.Fragment key={index}>
<RecursionField
<MemorizedRecursionField
name={schema.name}
schema={schema}
mapProperties={(schema) => {

View File

@ -1,5 +1,5 @@
import { Popover } from 'antd';
import React, { CSSProperties, forwardRef, useImperativeHandle, useRef, useState } from 'react';
import React, { CSSProperties, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
const getContentWidth = (element) => {
if (element) {
@ -32,16 +32,40 @@ export const EllipsisWithTooltip = forwardRef((props: Partial<IEllipsisWithToolt
setPopoverVisible: setVisible,
};
});
if (!props.ellipsis) {
return <>{props.children}</>;
}
const { popoverContent } = props;
const isOverflowTooltip = () => {
const isOverflowTooltip = useCallback(() => {
if (!elRef.current) return false;
const contentWidth = getContentWidth(elRef.current);
const offsetWidth = elRef.current?.offsetWidth;
return contentWidth > offsetWidth;
};
}, [elRef.current]);
const divContent = useMemo(
() =>
props.ellipsis ? (
<div
ref={elRef}
style={{ ...ellipsisDefaultStyle }}
onMouseEnter={(e) => {
const el = e.target as any;
const isShowTooltips = isOverflowTooltip();
if (isShowTooltips) {
setEllipsis(el.scrollWidth >= el.clientWidth);
}
}}
>
{props.children}
</div>
) : (
props.children
),
[props.children, props.ellipsis],
);
if (!props.ellipsis || !ellipsis) {
return divContent;
}
const { popoverContent } = props;
return (
<Popover
@ -61,19 +85,7 @@ export const EllipsisWithTooltip = forwardRef((props: Partial<IEllipsisWithToolt
</div>
}
>
<div
ref={elRef}
style={{ ...ellipsisDefaultStyle }}
onMouseEnter={(e) => {
const el = e.target as any;
const isShowTooltips = isOverflowTooltip();
if (isShowTooltips) {
setEllipsis(el.scrollWidth >= el.clientWidth);
}
}}
>
{props.children}
</div>
{divContent}
</Popover>
);
});

View File

@ -46,4 +46,6 @@ export const Input: ComposedInput = Object.assign(
},
);
Input.displayName = 'Input';
export default Input;

View File

@ -7,6 +7,11 @@ import { cx, css } from '@emotion/css';
export type JSONTextAreaProps = TextAreaProps & { value?: string; space?: number };
const jsonCss = css`
font-size: 80%;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
`;
export const Json = React.forwardRef<typeof Input.TextArea, JSONTextAreaProps>(
({ value, onChange, space = 2, ...props }: JSONTextAreaProps, ref: Ref<any>) => {
const field = useField<Field>();
@ -25,13 +30,7 @@ export const Json = React.forwardRef<typeof Input.TextArea, JSONTextAreaProps>(
return (
<Input.TextArea
{...props}
className={cx(
css`
font-size: 80%;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
`,
props.className,
)}
className={cx(jsonCss, props.className)}
ref={ref}
value={text}
onChange={(ev) => {

View File

@ -213,6 +213,7 @@ function PageContent(
return (
<FixedBlock key={schema.name} height={`calc(${height}px + 46px + ${token.marginLG}px * 2)`}>
<SchemaComponent
distributed
schema={
new Schema({
properties: {
@ -226,7 +227,9 @@ function PageContent(
})
) : (
<FixedBlock height={`calc(${height}px + 46px + ${token.marginLG}px * 2)`}>
<div className={`pageWithFixedBlockCss nb-page-content`}>{props.children}</div>
<div className={`pageWithFixedBlockCss nb-page-content`}>
<SchemaComponent schema={fieldSchema} distributed />
</div>
</FixedBlock>
);
}

View File

@ -1,9 +1,11 @@
import { APIClientProvider, FormProvider, RemoteSelect, SchemaComponent } from '@nocobase/client';
import React from 'react';
import { mockAPIClient } from '../../../../testUtils';
import { sleep } from '@nocobase/test/client';
const { apiClient, mockRequest } = mockAPIClient();
mockRequest.onGet('/posts:list').reply(() => {
mockRequest.onGet('/posts:list').reply(async () => {
await sleep(100);
return [
200,
{

View File

@ -3,23 +3,23 @@ import { TinyColor } from '@ctrl/tinycolor';
import { SortableContext, SortableContextProps, useSortable } from '@dnd-kit/sortable';
import { css } from '@emotion/css';
import { ArrayField } from '@formily/core';
import { useCreation } from 'ahooks';
import { spliceArrayState } from '@formily/core/esm/shared/internals';
import { RecursionField, Schema, observer, useField, useFieldSchema } from '@formily/react';
import { action } from '@formily/reactive';
import { uid } from '@formily/shared';
import { isPortalInBody } from '@nocobase/utils/client';
import { useMemoizedFn } from 'ahooks';
import { Table as AntdTable, TableColumnProps } from 'antd';
import { useDeepCompareEffect, useMemoizedFn } from 'ahooks';
import { Table as AntdTable, Skeleton, TableColumnProps } from 'antd';
import { default as classNames, default as cls } from 'classnames';
import _ from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import _, { omit } from 'lodash';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DndContext, useDesignable, useTableSize } from '../..';
import {
RecordIndexProvider,
RecordProvider,
useCollection,
useCollection_deprecated,
useCollectionParentRecordData,
useSchemaInitializerRender,
useTableBlockContext,
@ -32,12 +32,30 @@ import { SubFormProvider } from '../association-field/hooks';
import { ColumnFieldProvider } from './components/ColumnFieldProvider';
import { extractIndex, isCollectionFieldComponent, isColumnComponent } from './utils';
import { isNewRecord } from '../../../data-source/collection-record/isNewRecord';
import { useInView } from 'react-intersection-observer';
const MemoizedAntdTable = React.memo(AntdTable);
const useArrayField = (props) => {
const field = useField<ArrayField>();
return (props.field || field) as ArrayField;
};
function getSchemaArrJSON(schemaArr: Schema[]) {
return schemaArr.map((item) => (item.name === 'actions' ? omit(item.toJSON(), 'properties') : item.toJSON()));
}
export const useColumnsDeepMemoized = (columns: any[]) => {
const columnsJSON = getSchemaArrJSON(columns);
const oldObj = useCreation(() => ({ value: _.cloneDeep(columnsJSON) }), []);
if (!_.isEqual(columnsJSON, oldObj.value)) {
oldObj.value = _.cloneDeep(columnsJSON);
}
return oldObj.value;
};
const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) => {
const { token } = useToken();
const field = useArrayField(props);
@ -46,16 +64,29 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
const { designable } = useDesignable();
const { exists, render } = useSchemaInitializerRender(schema['x-initializer'], schema['x-initializer-props']);
const parentRecordData = useCollectionParentRecordData();
const collection = useCollection();
const columns = schema
.reduceProperties((buf, s) => {
const columnsSchema = schema.reduceProperties((buf, s) => {
if (isColumnComponent(s) && schemaInWhitelist(Object.values(s.properties || {}).pop())) {
return buf.concat([s]);
}
return buf;
}, [])
?.map((s: Schema) => {
}, []);
const hasChangedColumns = useColumnsDeepMemoized(columnsSchema);
const schemaToolbarBigger = useMemo(() => {
return css`
.nb-action-link {
margin: -${token.paddingContentVerticalLG}px -${token.marginSM}px;
padding: ${token.paddingContentVerticalLG}px ${token.marginSM}px;
}
`;
}, [token.paddingContentVerticalLG, token.marginSM]);
const collection = useCollection();
const columns = useMemo(
() =>
columnsSchema?.map((s: Schema) => {
const collectionFields = s.reduceProperties((buf, s) => {
if (isCollectionFieldComponent(s)) {
return buf.concat([s]);
@ -70,27 +101,17 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
width: 200,
...s['x-component-props'],
render: (v, record) => {
if (collectionFields?.length === 1 && collectionFields[0]['x-read-pretty'] && v == undefined) return null;
const index = field.value?.indexOf(record);
const basePath = field.address.concat(record.__index || index);
return (
<SubFormProvider value={{ value: record, collection }}>
<RecordIndexProvider index={record.__index || index}>
<RecordProvider isNew={isNewRecord(record)} record={record} parent={parentRecordData}>
<ColumnFieldProvider schema={s} basePath={field.address.concat(record.__index || index)}>
<span
role="button"
className={css`
// 扩大 SchemaToolbar 的面积
.nb-action-link {
margin: -${token.paddingContentVerticalLG}px -${token.marginSM}px;
padding: ${token.paddingContentVerticalLG}px ${token.marginSM}px;
}
`}
>
<RecursionField
basePath={field.address.concat(record.__index || index)}
schema={s}
onlyRenderProperties
/>
<ColumnFieldProvider schema={s} basePath={basePath}>
<span role="button" className={schemaToolbarBigger}>
<RecursionField basePath={basePath} schema={s} onlyRenderProperties />
</span>
</ColumnFieldProvider>
</RecordProvider>
@ -99,20 +120,36 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
);
},
} as TableColumnProps<any>;
});
// 这里不能把 columnsSchema 作为依赖,因为其每次都会变化,这里使用 hasChangedColumns 作为依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
}),
[
hasChangedColumns,
schema,
field,
parentRecordData,
schemaInWhitelist,
token.paddingContentVerticalLG,
token.marginSM,
],
);
const tableColumns = useMemo(() => {
if (!exists) {
return columns;
}
const tableColumns = columns.concat({
const res = [
...columns,
{
title: render(),
dataIndex: 'TABLE_COLUMN_INITIALIZER',
key: 'TABLE_COLUMN_INITIALIZER',
render: designable ? () => <div style={{ minWidth: 300 }} /> : null,
});
},
];
if (props.showDel) {
tableColumns.push({
res.push({
title: '',
key: 'delete',
width: 60,
@ -138,6 +175,10 @@ const useTableColumns = (props: { showDel?: boolean; isSubTable?: boolean }) =>
},
});
}
return res;
}, [columns, exists, field, render, props.showDel, designable]);
return tableColumns;
};
@ -197,24 +238,114 @@ const TableIndex = (props) => {
const usePaginationProps = (pagination1, pagination2) => {
const { t } = useTranslation();
const pagination = useMemo(
() => ({ ...pagination1, ...pagination2 }),
[JSON.stringify({ ...pagination1, ...pagination2 })],
);
const showTotal = useCallback((total) => t('Total {{count}} items', { count: total }), [t]);
const result = useMemo(
() => ({
showTotal,
showSizeChanger: true,
...pagination,
}),
[pagination, t, showTotal],
);
if (pagination2 === false) {
return false;
}
if (!pagination2 && pagination1 === false) {
return false;
}
const result = {
showTotal: (total) => t('Total {{count}} items', { count: total }),
showSizeChanger: true,
...pagination1,
...pagination2,
};
return result.total <= result.pageSize ? false : result;
};
export const Table: any = withDynamicSchemaProps(
observer(
(props: {
const headerClass = css`
max-width: 300px;
white-space: nowrap;
&:hover .general-schema-designer {
display: block;
}
`;
const cellClass = 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%);
}
`;
const rowSelectCheckboxWrapperClass = 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;
}
}
`;
const rowSelectCheckboxWrapperClassHover = css`
&:hover {
.nb-table-index {
opacity: 0;
}
.nb-origin-node {
display: block;
}
}
`;
const rowSelectCheckboxContentClass = css`
position: relative;
display: flex;
align-items: center;
justify-content: space-evenly;
`;
const rowSelectCheckboxCheckedClassHover = css`
position: absolute;
right: 50%;
transform: translateX(50%);
&:not(.checked) {
display: none;
}
`;
const HeaderWrapperComponent = (props) => {
return (
<DndContext>
<thead {...props} />
</DndContext>
);
};
const HeaderCellComponent = (props) => {
return <th {...props} className={cls(props.className, headerClass)} />;
};
const BodyRowComponent = (props) => {
return <SortableRow {...props} />;
};
interface TableProps {
useProps?: () => any;
onChange?: (pagination, filters, sorter, extra) => void;
onRowSelectionChange?: (selectedRowKeys: any[], selectedRows: any[]) => void;
@ -228,13 +359,14 @@ export const Table: any = withDynamicSchemaProps(
required?: boolean;
onExpand?: (flag: boolean, record: any) => void;
isSubTable?: boolean;
}) => {
const { token } = useToken();
const { pagination: pagination1, useProps, ...others1 } = props;
}
export const Table: any = withDynamicSchemaProps(
observer((props: TableProps) => {
const { token } = useToken();
const { pagination: pagination1, useProps, ...others1 } = omit(props, ['onBlur', 'onFocus', 'value']);
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { pagination: pagination2, ...others2 } = useProps?.() || {};
const {
dragSort = false,
showIndex = true,
@ -244,29 +376,44 @@ export const Table: any = withDynamicSchemaProps(
rowKey,
required,
onExpand,
loading,
onClickRow,
...others
} = { ...others1, ...others2 } as any;
const field = useArrayField(others);
const columns = useTableColumns(others);
const schema = useFieldSchema();
const collection = useCollection_deprecated();
const collection = useCollection();
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 [expandedKeys, setExpandesKeys] = useState(() => (expandFlag ? allIncludesChildren : []));
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 = '';
const highlightRowCss = useMemo(() => {
return css`
& > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
&:hover > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
`;
}, [token.controlItemBgActiveHover]);
const highlightRow = useMemo(
() => (onClickRow ? highlightRowCss : ''),
[onClickRow, token.controlItemBgActiveHover],
);
const onRow = useMemo(() => {
if (onClickRow) {
onRow = (record) => {
return (record) => {
return {
onClick: (e) => {
if (isPortalInBody(e.target)) {
@ -276,54 +423,62 @@ export const Table: any = withDynamicSchemaProps(
},
};
};
highlightRow = css`
& > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
&:hover > td {
background-color: ${token.controlItemBgActiveHover} !important;
}
`;
}
return null;
}, [onClickRow, selectedRow]);
useEffect(() => {
if (expandFlag) {
setExpandesKeys(allIncludesChildren);
} else {
setExpandesKeys([]);
useDeepCompareEffect(() => {
const newExpandesKeys = expandFlag ? allIncludesChildren : [];
if (!_.isEqual(newExpandesKeys, expandedKeys)) {
setExpandesKeys(newExpandesKeys);
}
}, [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;
/**
* key key
* 1. rowKey key record.key
* 2. key record
* 3. key
*
* record
*
* @param record
* @returns
*/
const defaultRowKey = useCallback((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;
}, []);
const getRowKey = useCallback(
(record: any) => {
if (typeof rowKey === 'string') {
return record[rowKey]?.toString();
} else {
return (rowKey ?? defaultRowKey)(record)?.toString();
}
},
[rowKey, defaultRowKey],
);
},
},
body: {
wrapper: (props) => {
const dataSourceKeys = field?.value?.map(getRowKey);
const memoizedDataSourceKeys = useMemo(() => dataSourceKeys, [JSON.stringify(dataSourceKeys)]);
const dataSource = useMemo(
() => [...(field?.value || [])].filter(Boolean),
[field?.value, field?.value?.length, memoizedDataSourceKeys],
);
const bodyWrapperComponent = useMemo(() => {
return (props) => {
return (
<DndContext
onDragEnd={(e) => {
@ -342,69 +497,48 @@ export const Table: any = withDynamicSchemaProps(
<tbody {...props} />
</DndContext>
);
};
}, [onRowDragEnd, field]);
const BodyCellComponent = useCallback(
(props) => {
const isIndex = props.className?.includes('selection-column');
const { ref, inView } = useInView({
threshold: 0,
triggerOnce: true,
initialInView: isIndex || !!process.env.__E2E__ || dataSource.length <= 10,
skip: isIndex || !!process.env.__E2E__,
});
return (
<td {...props} ref={ref} className={classNames(props.className, cellClass)}>
{inView || isIndex ? props.children : <Skeleton.Input active />}
</td>
);
},
row: (props) => {
return <SortableRow {...props}></SortableRow>;
[dataSource.length],
);
const components = useMemo(() => {
return {
header: {
wrapper: HeaderWrapperComponent,
cell: HeaderCellComponent,
},
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%);
}
`,
)}
/>
),
body: {
wrapper: bodyWrapperComponent,
row: BodyRowComponent,
cell: BodyCellComponent,
},
};
}, [field, onRowDragEnd, dragSort]);
}, [bodyWrapperComponent]);
/**
* 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 memoizedRowSelection = useMemo(() => rowSelection, [JSON.stringify(rowSelection)]);
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
const restProps = useMemo(
() => ({
rowSelection: memoizedRowSelection
? {
type: 'checkbox',
selectedRowKeys: selectedRowKeys,
@ -437,49 +571,11 @@ export const Table: any = withDynamicSchemaProps(
<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,
},
)}
>
<div
className={classNames(
checked ? 'checked' : null,
css`
position: relative;
display: flex;
align-items: center;
justify-content: space-evenly;
`,
)}
className={classNames(checked ? 'checked' : null, rowSelectCheckboxWrapperClass, {
[rowSelectCheckboxWrapperClassHover]: isRowSelect,
})}
>
<div className={classNames(checked ? 'checked' : null, rowSelectCheckboxContentClass)}>
{dragSort && <SortHandle id={getRowKey(record)} />}
{showIndex && <TableIndex index={index} />}
</div>
@ -488,14 +584,7 @@ export const Table: any = withDynamicSchemaProps(
className={classNames(
'nb-origin-node',
checked ? 'checked' : null,
css`
position: absolute;
right: 50%;
transform: translateX(50%);
&:not(.checked) {
display: none;
}
`,
rowSelectCheckboxCheckedClassHover,
)}
>
{originNode}
@ -504,10 +593,23 @@ export const Table: any = withDynamicSchemaProps(
</div>
);
},
...rowSelection,
...memoizedRowSelection,
}
: undefined,
};
}),
[
memoizedRowSelection,
selectedRowKeys,
onRowSelectionChange,
showIndex,
dragSort,
field,
getRowKey,
isRowSelect,
memoizedRowSelection,
],
);
const SortableWrapper = useCallback<React.FC>(
({ children }) => {
return dragSort
@ -520,25 +622,55 @@ export const Table: any = withDynamicSchemaProps(
)
: React.createElement(React.Fragment, {}, children);
},
[field, dragSort],
[field, dragSort, getRowKey],
);
const fieldSchema = useFieldSchema();
const fixedBlock = fieldSchema?.parent?.['x-decorator-props']?.fixedBlock;
const { height: tableHeight, tableSizeRefCallback } = useTableSize();
const { height: tableHeight, tableSizeRefCallback } = useTableSize(fixedBlock);
const maxContent = useMemo(() => {
return {
x: 'max-content',
};
}, []);
const scroll = useMemo(() => {
return fixedBlock
? {
x: 'max-content',
y: tableHeight,
}
: {
x: 'max-content',
: maxContent;
}, [fixedBlock, tableHeight, maxContent]);
const rowClassName = useCallback(
(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : ''),
[selectedRow, highlightRow, rowKey],
);
const onExpandValue = useCallback(
(flag, record) => {
const newKeys = flag
? [...expandedKeys, record[collection.getPrimaryKey()]]
: expandedKeys.filter((i) => record[collection.getPrimaryKey()] !== i);
setExpandesKeys(newKeys);
onExpand?.(flag, record);
},
[expandedKeys, onExpand, collection],
);
const expandable = useMemo(() => {
return {
onExpand: onExpandValue,
expandedRowKeys: expandedKeys,
};
}, [fixedBlock, tableHeight]);
}, [expandedKeys, onExpandValue]);
return (
<div
className={css`
height: 100%;
overflow: hidden;
.ant-table-wrapper {
height: 100%;
overflow: hidden;
.ant-table-wrapper {
@ -552,6 +684,7 @@ export const Table: any = withDynamicSchemaProps(
}
}
}
}
.ant-table {
overflow-x: auto;
overflow-y: hidden;
@ -559,32 +692,22 @@ export const Table: any = withDynamicSchemaProps(
`}
>
<SortableWrapper>
<AntdTable
<MemoizedAntdTable
ref={tableSizeRefCallback}
rowKey={rowKey ?? defaultRowKey}
dataSource={dataSource}
tableLayout="auto"
{...others}
{...restProps}
loading={loading}
pagination={paginationProps}
components={components}
onChange={(pagination, filters, sorter, extra) => {
onTableChange?.(pagination, filters, sorter, extra);
}}
onChange={onTableChange}
onRow={onRow}
rowClassName={(record) => (selectedRow.includes(record[rowKey]) ? highlightRow : '')}
rowClassName={rowClassName}
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,
}}
expandable={expandable}
/>
</SortableWrapper>
{field.errors.length > 0 && (
@ -596,7 +719,6 @@ export const Table: any = withDynamicSchemaProps(
)}
</div>
);
},
),
{ displayName: 'Table' },
}),
{ displayName: 'NocoBaseTable' },
);

View File

@ -1,19 +1,20 @@
import { observer, RecursionField } from '@formily/react';
import React from 'react';
import { useCollectionManager_deprecated } from '../../../../collection-manager';
import { useRecord } from '../../../../record-provider';
import { useCollection } from '../../../../data-source';
export const ColumnFieldProvider = observer(
(props: { schema: any; basePath: any; children: any }) => {
const { schema, basePath } = props;
const record = useRecord();
const { getCollectionJoinField } = useCollectionManager_deprecated();
const collection = useCollection();
const fieldSchema = schema.reduceProperties((buf, s) => {
if (s['x-component'] === 'CollectionField') {
return s;
}
return buf;
}, null);
const collectionField = fieldSchema && getCollectionJoinField(fieldSchema['x-collection-field']);
const collectionField = fieldSchema && collection.getField(fieldSchema['x-collection-field']);
if (
fieldSchema &&
record?.__collection &&

View File

@ -9,6 +9,7 @@ import { useDesigner } from '../../hooks/useDesigner';
import { useTabsContext } from './context';
import { TabsDesigner } from './Tabs.Designer';
import { useSchemaInitializerRender } from '../../../application';
import { SchemaComponent } from '../../core';
export const Tabs: any = observer(
(props: TabsProps) => {
@ -23,8 +24,8 @@ export const Tabs: any = observer(
key,
label: <RecursionField name={key} schema={schema} onlyRenderSelf />,
children: (
<PaneRoot {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}>
<RecursionField name={key} schema={schema} onlyRenderProperties />
<PaneRoot key={key} {...(PaneRoot !== React.Fragment ? { active: key === contextProps.activeKey } : {})}>
<SchemaComponent schema={schema} distributed />
</PaneRoot>
),
};

View File

@ -5,10 +5,11 @@ import App1 from '../demos/demo1';
describe('Tabs', () => {
it('basic', async () => {
render(<App1 />);
await waitFor(async () => {
expect(screen.getByText('Hello1')).toBeInTheDocument();
});
await waitFor(async () => {
await userEvent.click(screen.getByText('Tab2'));
expect(screen.getByText('Hello2')).toBeInTheDocument();
});

View File

@ -35,7 +35,7 @@ const schema: ISchema = {
tab: 'Tab2',
},
properties: {
aaa: {
bbb: {
'x-content': 'Hello2',
},
},

View File

@ -1,17 +1,18 @@
import { DndContext as DndKitContext, DragEndEvent, DragOverlay, rectIntersection } from '@dnd-kit/core';
import { Props } from '@dnd-kit/core/dist/components/DndContext/DndContext';
import { observer } from '@formily/react';
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAPIClient } from '../../../';
import { createDesignable, useDesignable } from '../../hooks';
const useDragEnd = (props?: any) => {
const useDragEnd = (onDragEnd) => {
const { refresh } = useDesignable();
const api = useAPIClient();
const { t } = useTranslation();
return (event: DragEndEvent) => {
return useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const activeSchema = active?.data?.current?.schema;
const overSchema = over?.data?.current?.schema;
@ -21,17 +22,17 @@ const useDragEnd = (props?: any) => {
const onSuccess = over?.data?.current?.onSuccess;
const removeParentsIfNoChildren = over?.data?.current?.removeParentsIfNoChildren ?? true;
if (!activeSchema || !overSchema) {
props?.onDragEnd?.(event);
onDragEnd?.(event);
return;
}
if (activeSchema === overSchema) {
props?.onDragEnd?.(event);
onDragEnd?.(event);
return;
}
if (activeSchema.parent === overSchema && insertAdjacent === 'beforeEnd') {
props?.onDragEnd?.(event);
onDragEnd?.(event);
return;
}
@ -46,7 +47,7 @@ const useDragEnd = (props?: any) => {
if (activeSchema.parent === overSchema.parent) {
dn.insertBeforeBeginOrAfterEnd(activeSchema);
props?.onDragEnd?.(event);
onDragEnd?.(event);
return;
}
@ -57,30 +58,35 @@ const useDragEnd = (props?: any) => {
removeParentsIfNoChildren,
onSuccess,
});
props?.onDragEnd?.(event);
onDragEnd?.(event);
return;
}
};
},
[onDragEnd],
);
};
export const DndContext = observer(
(props: Props) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(true);
return (
<DndKitContext
collisionDetection={rectIntersection}
{...props}
onDragStart={(event) => {
const onDragStart = useCallback(
(event) => {
const { active } = event;
const activeSchema = active?.data?.current?.schema;
setVisible(!!activeSchema);
if (props?.onDragStart) {
props?.onDragStart?.(event);
}
}}
onDragEnd={useDragEnd(props)}
>
},
[props?.onDragStart],
);
const onDragEnd = useDragEnd(props?.onDragEnd);
return (
<DndKitContext collisionDetection={rectIntersection} {...props} onDragStart={onDragStart} onDragEnd={onDragEnd}>
<DragOverlay
dropAnimation={{
duration: 10,

View File

@ -2,6 +2,7 @@ import { IRecursionFieldProps, ISchemaFieldProps, RecursionField, Schema } from
import React, { useContext, useMemo } from 'react';
import { SchemaComponentContext } from '../context';
import { SchemaComponentOptions } from './SchemaComponentOptions';
import { useUpdate } from 'ahooks';
type SchemaComponentOnChange = {
onChange?: (s: Schema) => void;
@ -26,16 +27,30 @@ const useMemoizedSchema = (schema) => {
return useMemo(() => toSchema(schema), []);
};
const RecursionSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange) => {
const { components, scope, schema, ...others } = props;
interface DistributedProps {
/**
*
* @default false
*/
distributed?: boolean;
}
const RecursionSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const { components, scope, schema, distributed, ...others } = props;
const ctx = useContext(SchemaComponentContext);
const s = useMemo(() => toSchema(schema), [schema]);
const refresh = useUpdate();
return (
<SchemaComponentContext.Provider
value={{
...ctx,
distributed: ctx.distributed == false ? false : distributed,
refresh: () => {
refresh();
if (ctx.distributed === false || distributed === false) {
ctx.refresh?.();
}
props.onChange?.(s);
},
}}
@ -47,14 +62,15 @@ const RecursionSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnCh
);
};
const MemoizedSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange) => {
const MemoizedSchemaComponent = (props: ISchemaFieldProps & SchemaComponentOnChange & DistributedProps) => {
const { schema, ...others } = props;
const s = useMemoizedSchema(schema);
return <RecursionSchemaComponent {...others} schema={s} />;
};
export const SchemaComponent = (
props: (ISchemaFieldProps | IRecursionFieldProps) & { memoized?: boolean } & SchemaComponentOnChange,
props: (ISchemaFieldProps | IRecursionFieldProps) & { memoized?: boolean } & SchemaComponentOnChange &
DistributedProps,
) => {
const { memoized, ...others } = props;
if (memoized) {

View File

@ -1,11 +1,12 @@
import { createForm } from '@formily/core';
import { FormProvider, Schema } from '@formily/react';
import { uid } from '@formily/shared';
import React, { useMemo, useState } from 'react';
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUpdate } from 'ahooks';
import { SchemaComponentContext } from '../context';
import { ISchemaComponentProvider } from '../types';
import { SchemaComponentOptions } from './SchemaComponentOptions';
import { SchemaComponentOptions, useSchemaOptionsContext } from './SchemaComponentOptions';
const randomString = (prefix = '') => {
return `${prefix}${uid()}`;
@ -44,36 +45,47 @@ Schema.registerCompiler(Registry.compile);
export const SchemaComponentProvider: React.FC<ISchemaComponentProvider> = (props) => {
const { designable, onDesignableChange, components, children } = props;
const [uidValue, setUid] = useState(uid());
const ctx = useContext(SchemaComponentContext);
const ctxOptions = useSchemaOptionsContext();
const refresh = useUpdate();
const [formId, setFormId] = useState(uid());
const form = useMemo(() => props.form || createForm(), [formId]);
const { t } = useTranslation();
const scope = useMemo(() => {
return { ...props.scope, t, randomString };
}, [props.scope, t]);
}, [props.scope, t, ctxOptions?.scope]);
const [active, setActive] = useState(designable);
const schemaComponentContextValue = useMemo(
() => ({
scope,
components,
reset: () => setFormId(uid()),
refresh: () => {
setUid(uid());
},
designable: typeof designable === 'boolean' ? designable : active,
setDesignable: (value) => {
if (typeof designable !== 'boolean') {
const designableValue = useMemo(() => {
return typeof designable === 'boolean' ? designable : active;
}, [designable, active, ctx.designable]);
const setDesignable = useMemo(() => {
return (value) => {
if (typeof designableValue !== 'boolean') {
setActive(value);
}
onDesignableChange?.(value);
},
}),
[uidValue, scope, components, designable, active],
);
};
}, [designableValue, onDesignableChange]);
const reset = useCallback(() => {
setFormId(uid());
}, []);
return (
<SchemaComponentContext.Provider value={schemaComponentContextValue}>
<SchemaComponentContext.Provider
value={{
scope,
components,
reset,
refresh,
designable: designableValue,
setDesignable,
}}
>
<FormProvider form={form}>
<SchemaComponentOptions inherit scope={scope} components={components}>
{children}

View File

@ -1,12 +1,14 @@
import { useEventListener } from 'ahooks';
import { debounce } from 'lodash';
import { useCallback, useRef, useState } from 'react';
export const useTableSize = () => {
export const useTableSize = (enable?: boolean) => {
const [height, setTableHeight] = useState(0);
const [width, setTableWidth] = useState(0);
const elementRef = useRef<HTMLDivElement>(null);
const calcTableSize = useCallback(() => {
if (!elementRef.current) return;
const calcTableSize = useCallback(
debounce(() => {
if (!elementRef.current || !enable) return;
const clientRect = elementRef.current.getBoundingClientRect();
const tableHeight = Math.ceil(clientRect?.height || 0);
const headerHeight = elementRef.current.querySelector('.ant-table-header')?.getBoundingClientRect().height || 0;
@ -18,12 +20,17 @@ export const useTableSize = () => {
: 0;
setTableWidth(clientRect.width);
setTableHeight(tableHeight - headerHeight - paginationHeight);
}, []);
}, 100),
[enable],
);
const tableSizeRefCallback: React.RefCallback<HTMLDivElement> = (ref) => {
const tableSizeRefCallback: React.RefCallback<HTMLDivElement> = useCallback(
(ref) => {
elementRef.current = ref && ref.children ? (ref.children[0] as HTMLDivElement) : null;
calcTableSize();
};
},
[calcTableSize],
);
useEventListener('resize', calcTableSize);

View File

@ -10,6 +10,7 @@ export interface ISchemaComponentContext {
designable?: boolean;
setDesignable?: (value: boolean) => void;
SchemaField?: React.FC<ISchemaFieldProps>;
distributed?: boolean;
}
export interface ISchemaComponentProvider {

View File

@ -1,12 +1,8 @@
import { SchemaComponentOptions, CurrentAppInfoProvider } from '@nocobase/client';
import { SchemaComponentOptions } from '@nocobase/client';
import React, { FC } from 'react';
export const DuplicatorProvider: FC = function (props) {
return (
<CurrentAppInfoProvider>
<SchemaComponentOptions>{props.children}</SchemaComponentOptions>
</CurrentAppInfoProvider>
);
return <SchemaComponentOptions>{props.children}</SchemaComponentOptions>;
};
DuplicatorProvider.displayName = 'DuplicatorProvider';

View File

@ -23,20 +23,15 @@ export const ChartQueryMetadataProvider: React.FC = (props) => {
const location = useLocation();
const service = useRequest<{
data: any;
}>(options, {
manual: true,
});
const isAdminPage = location.pathname.startsWith('/admin');
const token = api.auth.getToken() || '';
useEffect(() => {
if (isAdminPage && token) {
service.run();
}
}, [isAdminPage, token]);
const service = useRequest<{
data: any;
}>(options, {
refreshDeps: [isAdminPage, token],
ready: !!(isAdminPage && token),
});
const refresh = async () => {
const { data } = await api.request(options);

View File

@ -1,4 +1,4 @@
import { ActionBar, CurrentAppInfoProvider, Plugin, SchemaComponentOptions } from '@nocobase/client';
import { ActionBar, Plugin, SchemaComponentOptions } from '@nocobase/client';
import React from 'react';
import { GanttDesigner } from './Gantt.Designer';
import { ganttSettings, oldGanttSettings } from './Gantt.Settings';
@ -17,14 +17,12 @@ export { Gantt };
const GanttProvider = React.memo((props) => {
return (
<CurrentAppInfoProvider>
<SchemaComponentOptions
components={{ Gantt, GanttBlockInitializer, GanttBlockProvider }}
scope={{ useGanttBlockProps }}
>
{props.children}
</SchemaComponentOptions>
</CurrentAppInfoProvider>
);
});

View File

@ -14,6 +14,9 @@
"@nocobase/server": "0.x",
"@nocobase/test": "0.x"
},
"dependencies": {
"react-intersection-observer": "^9.8.1"
},
"gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1",
"keywords": [
"Blocks"

View File

@ -1,43 +1,12 @@
import { css } from '@emotion/css';
import { FormLayout } from '@formily/antd-v5';
import { observer, RecursionField, useFieldSchema } from '@formily/react';
import {
ActionContextProvider,
DndContext,
RecordProvider,
SchemaComponentOptions,
useCollectionParentRecordData,
} from '@nocobase/client';
import { ActionContextProvider, DndContext, RecordProvider, useCollectionParentRecordData } from '@nocobase/client';
import { Card } from 'antd';
import cls from 'classnames';
import React, { useContext, useState } from 'react';
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { KanbanCardContext } from './context';
export const KanbanCard: any = observer(
(props: any) => {
const { setDisableCardDrag, cardViewerSchema, card, cardField, columnIndex, cardIndex } =
useContext(KanbanCardContext);
const parentRecordData = useCollectionParentRecordData();
const fieldSchema = useFieldSchema();
const [visible, setVisible] = useState(false);
return (
<SchemaComponentOptions components={{}} scope={{}}>
<Card
onClick={(e) => {
const targetElement = e.target as Element; // 将事件目标转换为Element类型
const currentTargetElement = e.currentTarget as Element;
if (currentTargetElement.contains(targetElement)) {
setVisible(true);
e.stopPropagation();
} else {
e.stopPropagation();
}
}}
bordered={false}
hoverable
style={{ cursor: 'pointer', overflow: 'hidden' }}
// bodyStyle={{ paddingBottom: 0 }}
className={cls(css`
const cardCss = css`
.ant-card-body {
padding: 16px;
}
@ -79,44 +48,77 @@ export const KanbanCard: any = observer(
color: #8c8c8c;
fontweight: normal;
}
`)}
>
<DndContext
onDragStart={() => {
`;
const MemorizedRecursionField = React.memo(RecursionField);
MemorizedRecursionField.displayName = 'MemorizedRecursionField';
export const KanbanCard: any = observer(
() => {
const { setDisableCardDrag, cardViewerSchema, card, cardField, columnIndex, cardIndex } =
useContext(KanbanCardContext);
const parentRecordData = useCollectionParentRecordData();
const fieldSchema = useFieldSchema();
const [visible, setVisible] = useState(false);
const handleCardClick = useCallback((e: React.MouseEvent) => {
const targetElement = e.target as Element; // 将事件目标转换为Element类型
const currentTargetElement = e.currentTarget as Element;
if (currentTargetElement.contains(targetElement)) {
setVisible(true);
e.stopPropagation();
} else {
e.stopPropagation();
}
}, []);
const cardStyle = useMemo(() => {
return {
cursor: 'pointer',
overflow: 'hidden',
};
}, []);
const onDragStart = useCallback(() => {
setDisableCardDrag(true);
}}
onDragEnd={() => {
}, []);
const onDragEnd = useCallback(() => {
setDisableCardDrag(false);
}}
>
<FormLayout layout={'vertical'}>
<RecursionField
basePath={cardField.address.concat(`${columnIndex}.cards.${cardIndex}`)}
schema={fieldSchema}
onlyRenderProperties
/>
</FormLayout>
</DndContext>
</Card>
{cardViewerSchema && (
<ActionContextProvider
value={{
}, []);
const actionContextValue = useMemo(() => {
return {
openMode: fieldSchema['x-component-props']?.['openMode'] || 'drawer',
openSize: fieldSchema['x-component-props']?.['openSize'],
visible,
setVisible,
}}
>
};
}, [fieldSchema, visible]);
const basePath = useMemo(
() => cardField.address.concat(`${columnIndex}.cards.${cardIndex}`),
[cardField, columnIndex, cardIndex],
);
const cardViewerBasePath = useMemo(
() => cardField.address.concat(`${columnIndex}.cardViewer.${cardIndex}`),
[cardField, columnIndex, cardIndex],
);
return (
<>
<Card onClick={handleCardClick} bordered={false} hoverable style={cardStyle} className={cardCss}>
<DndContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<FormLayout layout={'vertical'}>
<MemorizedRecursionField basePath={basePath} schema={fieldSchema} onlyRenderProperties />
</FormLayout>
</DndContext>
</Card>
{cardViewerSchema && (
<ActionContextProvider value={actionContextValue}>
<RecordProvider record={card} parent={parentRecordData}>
<RecursionField
basePath={cardField.address.concat(`${columnIndex}.cardViewer.${cardIndex}`)}
schema={cardViewerSchema}
onlyRenderProperties
/>
<MemorizedRecursionField basePath={cardViewerBasePath} schema={cardViewerSchema} onlyRenderProperties />
</RecordProvider>
</ActionContextProvider>
)}
</SchemaComponentOptions>
</>
);
},
{ displayName: 'KanbanCard' },

View File

@ -4,12 +4,14 @@ import {
RecordProvider,
SchemaComponentOptions,
useCreateActionProps as useCAP,
useCollection,
useCollectionParentRecordData,
useProps,
withDynamicSchemaProps,
} from '@nocobase/client';
import { Spin, Tag } from 'antd';
import React, { useContext, useMemo, useState } from 'react';
import { Spin, Tag, Card, Skeleton } from 'antd';
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { Board } from './board';
import { KanbanCardContext, KanbanColumnContext } from './context';
import { useStyles } from './style';
@ -57,14 +59,18 @@ export const toColumns = (groupField: any, dataSource: Array<any> = [], primaryK
return Object.values(columns);
};
const MemorizedRecursionField = React.memo(RecursionField);
MemorizedRecursionField.displayName = 'MemorizedRecursionField';
export const Kanban: any = withDynamicSchemaProps(
observer(
(props: any) => {
observer((props: any) => {
const { styles } = useStyles();
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { groupField, onCardDragEnd, dataSource, setDataSource, ...restProps } = useProps(props);
const collection = useCollection();
const primaryKey = collection.getPrimaryKey();
const parentRecordData = useCollectionParentRecordData();
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
@ -86,17 +92,26 @@ export const Kanban: any = withDynamicSchemaProps(
),
[],
);
const handleCardRemove = (card, column) => {
const handleCardRemove = useCallback(
(card, column) => {
const updatedBoard = Board.removeCard({ columns: field.value }, column, card);
field.value = updatedBoard.columns;
setDataSource(updatedBoard.columns);
};
const handleCardDragEnd = (card, fromColumn, toColumn) => {
},
[field],
);
const lastDraggedCard = useRef(null);
const handleCardDragEnd = useCallback(
(card, fromColumn, toColumn) => {
lastDraggedCard.current = card[primaryKey];
onCardDragEnd?.({ columns: field.value, groupField }, fromColumn, toColumn);
const updatedBoard = Board.moveCard({ columns: field.value }, fromColumn, toColumn);
field.value = updatedBoard.columns;
setDataSource(updatedBoard.columns);
};
},
[field],
);
return (
<Spin wrapperClassName={styles.nbKanban} spinning={field.loading || false}>
<Board
@ -115,6 +130,11 @@ export const Kanban: any = withDynamicSchemaProps(
renderCard={(card, { column, dragging }) => {
const columnIndex = dataSource?.indexOf(column);
const cardIndex = column?.cards?.indexOf(card);
const { ref, inView } = useInView({
threshold: 0.8,
triggerOnce: true,
initialInView: lastDraggedCard.current && lastDraggedCard.current === card[primaryKey],
});
return (
schemas.card && (
<RecordProvider record={card} parent={parentRecordData}>
@ -130,7 +150,15 @@ export const Kanban: any = withDynamicSchemaProps(
cardIndex,
}}
>
<RecursionField name={schemas.card.name} schema={schemas.card} />
<div ref={ref}>
{inView ? (
<MemorizedRecursionField name={schemas.card.name} schema={schemas.card} />
) : (
<Card bordered={false}>
<Skeleton active paragraph={{ rows: 4 }} />
</Card>
)}
</div>
</KanbanCardContext.Provider>
</RecordProvider>
)
@ -143,7 +171,7 @@ export const Kanban: any = withDynamicSchemaProps(
return (
<KanbanColumnContext.Provider value={{ column, groupField }}>
<SchemaComponentOptions scope={{ useCreateActionProps }}>
<RecursionField name={schemas.cardAdder.name} schema={schemas.cardAdder} />
<MemorizedRecursionField name={schemas.cardAdder.name} schema={schemas.cardAdder} />
</SchemaComponentOptions>
</KanbanColumnContext.Provider>
);
@ -155,7 +183,6 @@ export const Kanban: any = withDynamicSchemaProps(
</Board>
</Spin>
);
},
}),
{ displayName: 'Kanban' },
),
);

View File

@ -2,7 +2,7 @@ import { ArrayField } from '@formily/core';
import { Schema, useField, useFieldSchema } from '@formily/react';
import { Spin } from 'antd';
import uniq from 'lodash/uniq';
import React, { createContext, useContext, useEffect, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import {
useACLRoleContext,
useCollection_deprecated,
@ -10,7 +10,9 @@ import {
FixedBlockWrapper,
BlockProvider,
useBlockRequestContext,
useCollection,
} from '@nocobase/client';
import { isEqual } from 'lodash';
import { toColumns } from './Kanban';
export const KanbanBlockContext = createContext<any>({});
@ -138,20 +140,21 @@ export const useKanbanBlockProps = () => {
const field = useField<ArrayField>();
const ctx = useKanbanBlockContext();
const [dataSource, setDataSource] = useState([]);
const primaryKey = useCollection_deprecated().getPrimaryKey();
const primaryKey = useCollection()?.getPrimaryKey();
useEffect(() => {
if (!ctx?.service?.loading) {
field.value = toColumns(ctx.groupField, ctx?.service?.data?.data, primaryKey);
setDataSource(toColumns(ctx.groupField, ctx?.service?.data?.data, primaryKey));
const data = toColumns(ctx.groupField, ctx?.service?.data?.data, primaryKey);
if (isEqual(field.value, data)) {
return;
}
// field.loading = ctx?.service?.loading;
field.value = data;
setDataSource(data);
}, [ctx?.service?.loading]);
return {
setDataSource,
dataSource,
groupField: ctx.groupField,
disableCardDrag: useDisableCardDrag(),
async onCardDragEnd({ columns, groupField }, { fromColumnId, fromPosition }, { toColumnId, toPosition }) {
const disableCardDrag = useDisableCardDrag();
const onCardDragEnd = useCallback(
async ({ columns, groupField }, { fromColumnId, fromPosition }, { toColumnId, toPosition }) => {
const sourceColumn = columns.find((column) => column.id === fromColumnId);
const destinationColumn = columns.find((column) => column.id === toColumnId);
const sourceCard = sourceColumn?.cards?.[fromPosition];
@ -169,5 +172,14 @@ export const useKanbanBlockProps = () => {
}
await ctx.resource.move(values);
},
[ctx?.sortField],
);
return {
setDataSource,
dataSource,
groupField: ctx.groupField,
disableCardDrag,
onCardDragEnd,
};
};

View File

@ -1,4 +1,4 @@
import { Action, CurrentAppInfoProvider, Plugin, SchemaComponentOptions } from '@nocobase/client';
import { Action, Plugin, SchemaComponentOptions } from '@nocobase/client';
import React from 'react';
import { Kanban } from './Kanban';
import { KanbanCard } from './Kanban.Card';
@ -20,14 +20,12 @@ const KanbanV2 = Kanban;
const KanbanPluginProvider = React.memo((props) => {
return (
<CurrentAppInfoProvider>
<SchemaComponentOptions
components={{ Kanban, KanbanBlockProvider, KanbanV2, KanbanBlockInitializer }}
scope={{ useKanbanBlockProps }}
>
{props.children}
</SchemaComponentOptions>
</CurrentAppInfoProvider>
);
});
KanbanPluginProvider.displayName = 'KanbanPluginProvider';

View File

@ -1,4 +1,4 @@
import { CurrentAppInfoProvider, Plugin, SchemaComponentOptions } from '@nocobase/client';
import { Plugin, SchemaComponentOptions } from '@nocobase/client';
import React from 'react';
import { MapBlockOptions } from './block';
import { mapActionInitializers, mapActionInitializers_deprecated } from './block/MapActionInitializers';
@ -9,11 +9,9 @@ import { NAMESPACE, generateNTemplate } from './locale';
import { useMapBlockProps } from './block/MapBlockProvider';
const MapProvider = React.memo((props) => {
return (
<CurrentAppInfoProvider>
<SchemaComponentOptions components={{ Map }}>
<MapBlockOptions>{props.children}</MapBlockOptions>
</SchemaComponentOptions>
</CurrentAppInfoProvider>
);
});
MapProvider.displayName = 'MapProvider';

View File

@ -359,6 +359,7 @@ export function NodeDefaultView(props) {
>
<FormProvider form={form}>
<SchemaComponent
distributed={false}
scope={{
...instruction.scope,
useFormProviderProps,

View File

@ -21808,6 +21808,11 @@ react-image-lightbox@^5.1.4:
prop-types "^15.7.2"
react-modal "^3.11.1"
react-intersection-observer@9.8.1, react-intersection-observer@^9.8.1:
version "9.8.1"
resolved "https://registry.npmmirror.com/react-intersection-observer/-/react-intersection-observer-9.8.1.tgz#9c3631c0c9acd624a2af1c192318752ea73b5d91"
integrity sha512-QzOFdROX8D8MH3wE3OVKH0f3mLjKTtEN1VX/rkNuECCff+aKky0pIjulDhr3Ewqj5el/L+MhBkM3ef0Tbt+qUQ==
react-intl@^6.4.4:
version "6.5.5"
resolved "https://registry.npmmirror.com/react-intl/-/react-intl-6.5.5.tgz#d2de7bfd79718a7e3d8031e2599e94e0c8638377"