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,28 +253,33 @@ export const ACLActionProvider = (props) => {
export const useACLFieldWhitelist = () => {
const params = useContext(ACLActionParamsContext);
const whitelist = []
.concat(params?.whitelist || [])
.concat(params?.fields || [])
.concat(params?.appends || []);
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?) {
if (isSkip) {
return true;
}
if (whitelist.length === 0) {
return true;
}
if (!fieldSchema) {
return true;
}
if (!fieldSchema['x-collection-field']) {
return true;
}
const [key1, key2] = fieldSchema['x-collection-field'].split('.');
return whitelist?.includes(key2 || key1);
},
schemaInWhitelist: useCallback(
(fieldSchema: Schema, isSkip?) => {
if (isSkip) {
return true;
}
if (whitelist.length === 0) {
return true;
}
if (!fieldSchema) {
return true;
}
if (!fieldSchema['x-collection-field']) {
return true;
}
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() || {};
return { type: ctx.type } as { type: 'update' | 'create' };
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) => {
field[key] = typeof field[key] === 'undefined' ? value : field[key];
};
const setRequired = () => {
const setFieldProps = useCallback(
(key, value) => {
field[key] = typeof field[key] === 'undefined' ? value : field[key];
},
[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);
},
{
...requestOptions,
manual: true,
},
);
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

@ -3,7 +3,7 @@ import { expect, oneEmptyTableBlockBasedOnUsers, test } from '@nocobase/test/e2e
test('actions', async ({ page, mockPage }) => {
await mockPage(oneEmptyTableBlockBasedOnUsers).goto();
await page.getByLabel('schema-initializer-ActionBar-table:configureActions-users').hover();
//添加按钮
// 添加按钮
await page.getByRole('menuitem', { name: 'Add new' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Refresh' }).click();
@ -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,109 +14,135 @@ 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);
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);
}
field.data = field.data || {};
field.data.selectedRowKeys = ctx?.field?.data?.selectedRowKeys;
if (!isEqual(field.data.selectedRowKeys, selectedRowKeys)) {
field.data.selectedRowKeys = 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;
field.componentProps.pagination.pageSize = meta?.pageSize;
field.componentProps.pagination.total = meta?.count;
field.componentProps.pagination.current = meta?.page;
}
}, [ctx?.service?.data, ctx?.service?.loading]); // 这里如果依赖了 ctx?.field?.data?.selectedRowKeys 的话,会导致这个问题:
}, [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,
}
: false,
onRowSelectionChange(selectedRowKeys) {
defaultCurrent: params?.page || 1,
defaultPageSize: params?.pageSize,
}
: 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 }) {
await ctx.resource.move({
sourceId: from[ctx.rowKey || 'id'],
targetId: to[ctx.rowKey || 'id'],
sortField: ctx.dragSort && ctx.dragSortBy,
});
ctx.service.refresh();
},
onChange({ current, pageSize }, filters, sorter) {
const sort = sorter.order ? (sorter.order === `ascend` ? [sorter.field] : [`-${sorter.field}`]) : globalSort;
ctx.service.run({ ...ctx.service.params?.[0], page: current, pageSize, sort });
},
onClickRow(record, setSelectedRow, selectedRow) {
const { targets, uid } = findFilterTargets(fieldSchema);
const dataBlocks = getDataBlocks();
}, []),
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
},
[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({ ...params?.[0], page: current, pageSize, sort });
// ctx.service
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[globalSort, params],
),
onClickRow: useCallback(
(record, setSelectedRow, selectedRow) => {
const { targets, uid } = findFilterTargets(fieldSchema);
const dataBlocks = getDataBlocks();
// 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错
if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) {
// 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。
// 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。
setSelectedRow((prev) => (prev.length ? [] : prev));
return;
}
const value = [record[ctx.rowKey]];
dataBlocks.forEach((block) => {
const target = targets.find((target) => target.uid === block.uid);
if (!target) return;
const param = block.service.params?.[0] || {};
// 保留原有的 filter
const storedFilter = block.service.params?.[1]?.filters || {};
if (selectedRow.includes(record[ctx.rowKey])) {
if (block.dataLoadingMode === 'manual') {
return block.clearData();
}
delete storedFilter[uid];
} else {
storedFilter[uid] = {
$and: [
{
[target.field || ctx.rowKey]: {
[target.field ? '$in' : '$eq']: value,
},
},
],
};
// 如果是之前创建的区块是没有 x-filter-targets 属性的,所以这里需要判断一下避免报错
if (!targets || !targets.some((target) => dataBlocks.some((dataBlock) => dataBlock.uid === target.uid))) {
// 当用户已经点击过某一行,如果此时再把相连接的区块给删除的话,行的高亮状态就会一直保留。
// 这里暂时没有什么比较好的方法,只是在用户再次点击的时候,把高亮状态给清除掉。
setSelectedRow((prev) => (prev.length ? [] : prev));
return;
}
const mergedFilter = mergeFilter([
...Object.values(storedFilter).map((filter) => removeNullCondition(filter)),
block.defaultFilter,
]);
const value = [record[ctx.rowKey]];
return block.doFilter(
{
...param,
page: 1,
filter: mergedFilter,
},
{ filters: storedFilter },
);
});
dataBlocks.forEach((block) => {
const target = targets.find((target) => target.uid === block.uid);
if (!target) return;
// 更新表格的选中状态
setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value]));
},
onExpand(expanded, record) {
const param = block.service.params?.[0] || {};
// 保留原有的 filter
const storedFilter = block.service.params?.[1]?.filters || {};
if (selectedRow.includes(record[ctx.rowKey])) {
if (block.dataLoadingMode === 'manual') {
return block.clearData();
}
delete storedFilter[uid];
} else {
storedFilter[uid] = {
$and: [
{
[target.field || ctx.rowKey]: {
[target.field ? '$in' : '$eq']: value,
},
},
],
};
}
const mergedFilter = mergeFilter([
...Object.values(storedFilter).map((filter) => removeNullCondition(filter)),
block.defaultFilter,
]);
return block.doFilter(
{
...param,
page: 1,
filter: mergedFilter,
},
{ filters: storedFilter },
);
});
// 更新表格的选中状态
setSelectedRow((prev) => (prev?.includes(record[ctx.rowKey]) ? [] : [...value]));
},
[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,14 +26,22 @@ 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 settings = app.pluginSettingsManager.getList();
const [open, setOpen] = useState(false);
const settingItems = useMemo(() => {
const settings = app.pluginSettingsManager.getList();
return settings
.filter((v) => v.isTopLevel !== false)
.map((setting) => {
return {
key: setting.name,
icon: setting.icon,
label: <Link to={setting.path}>{compile(setting.title)}</Link>,
};
});
}, [app, t]);
return (
<Dropdown
menu={{
@ -42,15 +49,7 @@ export const SettingsCenterDropdown = () => {
maxHeight: '70vh',
overflow: 'auto',
},
items: settings
.filter((v) => v.isTopLevel !== false)
.map((setting) => {
return {
key: setting.name,
icon: setting.icon,
label: <Link to={setting.path}>{compile(setting.title)}</Link>,
};
}),
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,66 +280,100 @@ 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>();
if (!params.name) return null;
return (
<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 {
background-color: ${token.colorBgHeaderMenuActive} !important;
color: ${token.colorTextHeaderMenuActive} !important;
}
.ant-menu-submenu-horizontal.ant-menu-submenu-selected > .ant-menu-submenu-title {
color: ${token.colorTextHeaderMenuActive} !important;
}
.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:hover {
background-color: ${token.colorBgHeaderMenuHover} !important;
color: ${token.colorTextHeaderMenuHover} !important;
}
position: fixed;
left: 0;
right: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
padding: 0;
z-index: 100;
background-color: ${token.colorBgHeader} !important;
.ant-menu {
background-color: transparent;
}
.ant-menu-item,
.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={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 {
background-color: ${token.colorBgHeaderMenuActive} !important;
color: ${token.colorTextHeaderMenuActive} !important;
}
.ant-menu-submenu-horizontal.ant-menu-submenu-selected > .ant-menu-submenu-title {
color: ${token.colorTextHeaderMenuActive} !important;
}
.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:hover {
background-color: ${token.colorBgHeaderMenuHover} !important;
color: ${token.colorTextHeaderMenuHover} !important;
}
position: fixed;
left: 0;
right: 0;
height: var(--nb-header-height);
line-height: var(--nb-header-height);
padding: 0;
z-index: 100;
background-color: ${token.colorBgHeader} !important;
.ant-menu {
background-color: transparent;
}
.ant-menu-item,
.ant-menu-submenu-horizontal {
color: ${token.colorTextHeaderMenu} !important;
}
`}
>
<Layout.Header className={layoutHeaderCss}>
<div
className={css`
position: relative;
width: 100%;
height: 100%;
display: flex;
`}
style={{
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 userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});
@ -22,14 +21,14 @@ describe('Action', () => {
expect(getByText('Hello')).toBeInTheDocument();
// close button
await userEvent.click(getByText('Close'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Close'));
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
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 userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
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 userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Drawer'));
await userEvent.click(getByText('Open'));
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 userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Close'));
await userEvent.click(getByText('Modal'));
await userEvent.click(getByText('Open'));
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
expect(document.querySelector('.ant-modal')).toBeInTheDocument();
expect(document.querySelector('.nb-action-page')).not.toBeInTheDocument();
});
await userEvent.click(getByText('Close'));
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Close'));
// page
await userEvent.click(getByText('Page'));
await userEvent.click(getByText('Open'));
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 userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
// drawer
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
// mask
@ -112,14 +104,14 @@ describe('Action.Drawer without Action', () => {
});
// close button
await userEvent.click(getByText('Close'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Close'));
expect(document.querySelector('.ant-drawer')).not.toBeInTheDocument();
});
// should also close when click the mask
await userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
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 userEvent.click(getByText('Open'));
await waitFor(() => {
await waitFor(async () => {
await userEvent.click(getByText('Open'));
expect(document.querySelector('.ant-drawer')).toBeInTheDocument();
});

View File

@ -7,21 +7,24 @@ export function useSetAriaLabelForDrawer(visible: boolean) {
// 因 Drawer 设置 aria-label 无效,所以使用下面这种方式设置,方便 e2e 录制工具选中
useEffect(() => {
if (visible) {
const wrappers = [...document.querySelectorAll('.ant-drawer-wrapper-body')];
const masks = [...document.querySelectorAll('.ant-drawer-mask')];
// 如果存在多个 mask最后一个 mask 为当前打开的 maskwrapper 也是同理
const currentMask = masks[masks.length - 1];
const currentWrapper = wrappers[wrappers.length - 1];
if (currentMask) {
currentMask.setAttribute('role', 'button');
currentMask.setAttribute('aria-label', getAriaLabel('mask'));
}
if (currentWrapper) {
currentWrapper.setAttribute('role', 'button');
// 都设置一下,让 e2e 录制工具自己选择
currentWrapper.setAttribute('data-testid', getAriaLabel());
currentWrapper.setAttribute('aria-label', getAriaLabel());
}
// 因为 Action 是点击后渲染内容,所以需要延迟一下
setTimeout(() => {
const wrappers = [...document.querySelectorAll('.ant-drawer-wrapper-body')];
const masks = [...document.querySelectorAll('.ant-drawer-mask')];
// 如果存在多个 mask最后一个 mask 为当前打开的 maskwrapper 也是同理
const currentMask = masks[masks.length - 1];
const currentWrapper = wrappers[wrappers.length - 1];
if (currentMask) {
currentMask.setAttribute('role', 'button');
currentMask.setAttribute('aria-label', getAriaLabel('mask'));
}
if (currentWrapper) {
currentWrapper.setAttribute('role', 'button');
// 都设置一下,让 e2e 录制工具自己选择
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,75 +1,74 @@
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 blockItemCss = css`
position: relative;
&:hover {
> .general-schema-designer {
display: block;
}
}
&.nb-form-item:hover {
> .general-schema-designer {
background: var(--colorBgSettingsHover) !important;
border: 0 !important;
top: -5px !important;
bottom: -5px !important;
left: -5px !important;
right: -5px !important;
}
}
> .general-schema-designer {
position: absolute;
z-index: 999;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
border: 2px solid var(--colorBorderSettingsHover);
pointer-events: none;
> .general-schema-designer-icons {
position: absolute;
right: 2px;
top: 2px;
line-height: 16px;
pointer-events: all;
.ant-space-item {
background-color: var(--colorSettings);
color: #fff;
line-height: 16px;
width: 16px;
padding-left: 1px;
align-self: stretch;
}
}
}
`;
const Designer = useDesigner();
const fieldSchema = useFieldSchema();
const { getAriaLabel } = useGetAriaLabelOfBlockItem(props.name);
export const BlockItem: React.FC<any> = withDynamicSchemaProps(
(props) => {
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { className, children } = useProps(props);
return (
<SortableItem
role="button"
aria-label={getAriaLabel()}
className={cls(
'nb-block-item',
className,
css`
position: relative;
&:hover {
> .general-schema-designer {
display: block;
}
}
&.nb-form-item:hover {
> .general-schema-designer {
background: var(--colorBgSettingsHover) !important;
border: 0 !important;
top: -5px !important;
bottom: -5px !important;
left: -5px !important;
right: -5px !important;
}
}
> .general-schema-designer {
position: absolute;
z-index: 999;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: none;
border: 2px solid var(--colorBorderSettingsHover);
pointer-events: none;
> .general-schema-designer-icons {
position: absolute;
right: 2px;
top: 2px;
line-height: 16px;
pointer-events: all;
.ant-space-item {
background-color: var(--colorSettings);
color: #fff;
line-height: 16px;
width: 16px;
padding-left: 1px;
align-self: stretch;
}
}
}
`,
)}
>
<Designer {...fieldSchema['x-toolbar-props']} />
{children}
</SortableItem>
);
});
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,20 +54,23 @@ Checkbox.Group = connect(
}
const field = useField<any>();
const collectionField = useCollectionField();
const dataSource = field.dataSource || collectionField?.uiSchema.enum || [];
const value = uniq(field.value ? field.value : []);
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) => (
<Tag key={key} color={option.color} icon={option.icon}>
{option.label}
</Tag>
))}
{tags.map((option, key) => (
<Tag key={key} color={option.color} icon={option.icon}>
{option.label}
</Tag>
))}
</EllipsisWithTooltip>
);
}),
);
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));
tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
const 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');
expect(tooltip).toBeInTheDocument();
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,9 +60,10 @@ describe('Form', () => {
it('Form & Drawer', async () => {
render(<App1 />);
const openBtn = screen.getByText('Open');
await userEvent.click(openBtn);
expect(screen.getByText(/drawer title/i)).toBeInTheDocument();
await waitFor(async () => {
await userEvent.click(screen.getByText('Open'));
expect(screen.getByText(/drawer title/i)).toBeInTheDocument();
});
});
it('initialValue', async () => {
@ -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,29 +16,30 @@ 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`
height: 100%;
> .ant-card-body {
padding: 24px 24px 0px;
height: 100%;
}
.nb-action-bar {
padding: 5px 0;
}
`}
>
<div className={itemCss}>
<RecordProvider record={field.value} parent={parentRecordData}>
{props.children}
</RecordProvider>
</div>
</Card>
);
});
const gridCardCss = css`
height: 100%;
> .ant-card-body {
padding: 24px 24px 0px;
height: 100%;
}
.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}
</RecordProvider>
</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

@ -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,86 +1,92 @@
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) => {
const { active, over } = event;
const activeSchema = active?.data?.current?.schema;
const overSchema = over?.data?.current?.schema;
const insertAdjacent = over?.data?.current?.insertAdjacent;
const breakRemoveOn = over?.data?.current?.breakRemoveOn;
const wrapSchema = over?.data?.current?.wrapSchema;
const onSuccess = over?.data?.current?.onSuccess;
const removeParentsIfNoChildren = over?.data?.current?.removeParentsIfNoChildren ?? true;
if (!activeSchema || !overSchema) {
props?.onDragEnd?.(event);
return;
}
return useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const activeSchema = active?.data?.current?.schema;
const overSchema = over?.data?.current?.schema;
const insertAdjacent = over?.data?.current?.insertAdjacent;
const breakRemoveOn = over?.data?.current?.breakRemoveOn;
const wrapSchema = over?.data?.current?.wrapSchema;
const onSuccess = over?.data?.current?.onSuccess;
const removeParentsIfNoChildren = over?.data?.current?.removeParentsIfNoChildren ?? true;
if (!activeSchema || !overSchema) {
onDragEnd?.(event);
return;
}
if (activeSchema === overSchema) {
props?.onDragEnd?.(event);
return;
}
if (activeSchema === overSchema) {
onDragEnd?.(event);
return;
}
if (activeSchema.parent === overSchema && insertAdjacent === 'beforeEnd') {
props?.onDragEnd?.(event);
return;
}
if (activeSchema.parent === overSchema && insertAdjacent === 'beforeEnd') {
onDragEnd?.(event);
return;
}
const dn = createDesignable({
t,
api,
refresh,
current: overSchema,
});
dn.loadAPIClientEvents();
if (activeSchema.parent === overSchema.parent) {
dn.insertBeforeBeginOrAfterEnd(activeSchema);
props?.onDragEnd?.(event);
return;
}
if (insertAdjacent) {
dn.insertAdjacent(insertAdjacent, activeSchema, {
wrap: wrapSchema,
breakRemoveOn,
removeParentsIfNoChildren,
onSuccess,
const dn = createDesignable({
t,
api,
refresh,
current: overSchema,
});
props?.onDragEnd?.(event);
return;
}
};
dn.loadAPIClientEvents();
if (activeSchema.parent === overSchema.parent) {
dn.insertBeforeBeginOrAfterEnd(activeSchema);
onDragEnd?.(event);
return;
}
if (insertAdjacent) {
dn.insertAdjacent(insertAdjacent, activeSchema, {
wrap: wrapSchema,
breakRemoveOn,
removeParentsIfNoChildren,
onSuccess,
});
onDragEnd?.(event);
return;
}
},
[onDragEnd],
);
};
export const DndContext = observer(
(props: Props) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(true);
const onDragStart = useCallback(
(event) => {
const { active } = event;
const activeSchema = active?.data?.current?.schema;
setVisible(!!activeSchema);
if (props?.onDragStart) {
props?.onDragStart?.(event);
}
},
[props?.onDragStart],
);
const onDragEnd = useDragEnd(props?.onDragEnd);
return (
<DndKitContext
collisionDetection={rectIntersection}
{...props}
onDragStart={(event) => {
const { active } = event;
const activeSchema = active?.data?.current?.schema;
setVisible(!!activeSchema);
if (props?.onDragStart) {
props?.onDragStart?.(event);
}
}}
onDragEnd={useDragEnd(props)}
>
<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: () => {
ctx.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') {
setActive(value);
}
onDesignableChange?.(value);
},
}),
[uidValue, scope, components, designable, active],
);
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);
};
}, [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,29 +1,36 @@
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 clientRect = elementRef.current.getBoundingClientRect();
const tableHeight = Math.ceil(clientRect?.height || 0);
const headerHeight = elementRef.current.querySelector('.ant-table-header')?.getBoundingClientRect().height || 0;
const tableContentRect = elementRef.current.querySelector('.ant-table')?.getBoundingClientRect();
if (!tableContentRect) return;
const paginationRect = elementRef.current.querySelector('.ant-table-pagination')?.getBoundingClientRect();
const paginationHeight = paginationRect
? paginationRect.y - tableContentRect.height - tableContentRect.y + paginationRect.height
: 0;
setTableWidth(clientRect.width);
setTableHeight(tableHeight - headerHeight - paginationHeight);
}, []);
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;
const tableContentRect = elementRef.current.querySelector('.ant-table')?.getBoundingClientRect();
if (!tableContentRect) return;
const paginationRect = elementRef.current.querySelector('.ant-table-pagination')?.getBoundingClientRect();
const paginationHeight = paginationRect
? paginationRect.y - tableContentRect.height - tableContentRect.y + paginationRect.height
: 0;
setTableWidth(clientRect.width);
setTableHeight(tableHeight - headerHeight - paginationHeight);
}, 100),
[enable],
);
const tableSizeRefCallback: React.RefCallback<HTMLDivElement> = (ref) => {
elementRef.current = ref && ref.children ? (ref.children[0] as HTMLDivElement) : null;
calcTableSize();
};
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>
<SchemaComponentOptions
components={{ Gantt, GanttBlockInitializer, GanttBlockProvider }}
scope={{ useGanttBlockProps }}
>
{props.children}
</SchemaComponentOptions>
);
});

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,122 +1,124 @@
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';
const cardCss = css`
.ant-card-body {
padding: 16px;
}
.nb-row-divider {
height: 16px;
margin-top: -16px;
&:last-child {
margin-top: 0;
}
}
.ant-description-input {
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}
.ant-description-textarea {
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}
.ant-formily-item {
margin-bottom: 12px;
}
.nb-grid-row:last-of-type {
.nb-grid-col {
.nb-form-item:last-of-type {
.ant-formily-item {
margin-bottom: 0;
}
}
}
}
.ant-formily-item-control .ant-space-item: {
whitespace: normal;
wordbreak: break-all;
wordwrap: break-word;
}
.ant-formily-item-label {
color: #8c8c8c;
fontweight: normal;
}
`;
const MemorizedRecursionField = React.memo(RecursionField);
MemorizedRecursionField.displayName = 'MemorizedRecursionField';
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);
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);
}, []);
const onDragEnd = useCallback(() => {
setDisableCardDrag(false);
}, []);
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 (
<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`
.ant-card-body {
padding: 16px;
}
.nb-row-divider {
height: 16px;
margin-top: -16px;
&:last-child {
margin-top: 0;
}
}
.ant-description-input {
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}
.ant-description-textarea {
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
}
.ant-formily-item {
margin-bottom: 12px;
}
.nb-grid-row:last-of-type {
.nb-grid-col {
.nb-form-item:last-of-type {
.ant-formily-item {
margin-bottom: 0;
}
}
}
}
.ant-formily-item-control .ant-space-item: {
whitespace: normal;
wordbreak: break-all;
wordwrap: break-word;
}
.ant-formily-item-label {
color: #8c8c8c;
fontweight: normal;
}
`)}
>
<DndContext
onDragStart={() => {
setDisableCardDrag(true);
}}
onDragEnd={() => {
setDisableCardDrag(false);
}}
>
<>
<Card onClick={handleCardClick} bordered={false} hoverable style={cardStyle} className={cardCss}>
<DndContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<FormLayout layout={'vertical'}>
<RecursionField
basePath={cardField.address.concat(`${columnIndex}.cards.${cardIndex}`)}
schema={fieldSchema}
onlyRenderProperties
/>
<MemorizedRecursionField basePath={basePath} schema={fieldSchema} onlyRenderProperties />
</FormLayout>
</DndContext>
</Card>
{cardViewerSchema && (
<ActionContextProvider
value={{
openMode: fieldSchema['x-component-props']?.['openMode'] || 'drawer',
openSize: fieldSchema['x-component-props']?.['openSize'],
visible,
setVisible,
}}
>
<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,105 +59,130 @@ 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) => {
const { styles } = useStyles();
observer((props: any) => {
const { styles } = useStyles();
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { groupField, onCardDragEnd, dataSource, setDataSource, ...restProps } = useProps(props);
// 新版 UISchema1.0 之后)中已经废弃了 useProps这里之所以继续保留是为了兼容旧版的 UISchema
const { groupField, onCardDragEnd, dataSource, setDataSource, ...restProps } = useProps(props);
const parentRecordData = useCollectionParentRecordData();
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
const [disableCardDrag, setDisableCardDrag] = useState(false);
const schemas = useMemo(
() =>
fieldSchema.reduceProperties(
(buf, current) => {
if (current['x-component'].endsWith('.Card')) {
buf.card = current;
} else if (current['x-component'].endsWith('.CardAdder')) {
buf.cardAdder = current;
} else if (current['x-component'].endsWith('.CardViewer')) {
buf.cardViewer = current;
}
return buf;
},
{ card: null, cardAdder: null, cardViewer: null },
),
[],
);
const handleCardRemove = (card, column) => {
const collection = useCollection();
const primaryKey = collection.getPrimaryKey();
const parentRecordData = useCollectionParentRecordData();
const field = useField<ArrayField>();
const fieldSchema = useFieldSchema();
const [disableCardDrag, setDisableCardDrag] = useState(false);
const schemas = useMemo(
() =>
fieldSchema.reduceProperties(
(buf, current) => {
if (current['x-component'].endsWith('.Card')) {
buf.card = current;
} else if (current['x-component'].endsWith('.CardAdder')) {
buf.cardAdder = current;
} else if (current['x-component'].endsWith('.CardViewer')) {
buf.cardViewer = current;
}
return buf;
},
{ card: null, cardAdder: null, cardViewer: null },
),
[],
);
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);
};
return (
<Spin wrapperClassName={styles.nbKanban} spinning={field.loading || false}>
<Board
{...restProps}
allowAddCard={!!schemas.cardAdder}
disableColumnDrag
cardAdderPosition={'bottom'}
disableCardDrag={restProps.disableCardDrag || disableCardDrag}
onCardRemove={handleCardRemove}
onCardDragEnd={handleCardDragEnd}
renderColumnHeader={({ title, color }) => (
<div className={'react-kanban-column-header'}>
<Tag color={color}>{title}</Tag>
</div>
)}
renderCard={(card, { column, dragging }) => {
const columnIndex = dataSource?.indexOf(column);
const cardIndex = column?.cards?.indexOf(card);
return (
schemas.card && (
<RecordProvider record={card} parent={parentRecordData}>
<KanbanCardContext.Provider
value={{
setDisableCardDrag,
cardViewerSchema: schemas.cardViewer,
cardField: field,
card,
column,
dragging,
columnIndex,
cardIndex,
}}
>
<RecursionField name={schemas.card.name} schema={schemas.card} />
</KanbanCardContext.Provider>
</RecordProvider>
)
);
}}
renderCardAdder={({ column }) => {
if (!schemas.cardAdder) {
return null;
}
return (
<KanbanColumnContext.Provider value={{ column, groupField }}>
<SchemaComponentOptions scope={{ useCreateActionProps }}>
<RecursionField name={schemas.cardAdder.name} schema={schemas.cardAdder} />
</SchemaComponentOptions>
</KanbanColumnContext.Provider>
);
}}
>
{{
columns: dataSource || [],
}}
</Board>
</Spin>
);
},
{ displayName: 'Kanban' },
),
},
[field],
);
return (
<Spin wrapperClassName={styles.nbKanban} spinning={field.loading || false}>
<Board
{...restProps}
allowAddCard={!!schemas.cardAdder}
disableColumnDrag
cardAdderPosition={'bottom'}
disableCardDrag={restProps.disableCardDrag || disableCardDrag}
onCardRemove={handleCardRemove}
onCardDragEnd={handleCardDragEnd}
renderColumnHeader={({ title, color }) => (
<div className={'react-kanban-column-header'}>
<Tag color={color}>{title}</Tag>
</div>
)}
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}>
<KanbanCardContext.Provider
value={{
setDisableCardDrag,
cardViewerSchema: schemas.cardViewer,
cardField: field,
card,
column,
dragging,
columnIndex,
cardIndex,
}}
>
<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>
)
);
}}
renderCardAdder={({ column }) => {
if (!schemas.cardAdder) {
return null;
}
return (
<KanbanColumnContext.Provider value={{ column, groupField }}>
<SchemaComponentOptions scope={{ useCreateActionProps }}>
<MemorizedRecursionField name={schemas.cardAdder.name} schema={schemas.cardAdder} />
</SchemaComponentOptions>
</KanbanColumnContext.Provider>
);
}}
>
{{
columns: dataSource || [],
}}
</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>
<SchemaComponentOptions
components={{ Kanban, KanbanBlockProvider, KanbanV2, KanbanBlockInitializer }}
scope={{ useKanbanBlockProps }}
>
{props.children}
</SchemaComponentOptions>
);
});
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>
<SchemaComponentOptions components={{ Map }}>
<MapBlockOptions>{props.children}</MapBlockOptions>
</SchemaComponentOptions>
);
});
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"