mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-07 22:49:26 +08:00
* feat(client): add disabled for initializer switch and undeletable for action settings * refactor(client): adjust api
618 lines
18 KiB
TypeScript
618 lines
18 KiB
TypeScript
import { ISchema, observer, useForm } from '@formily/react';
|
||
import { error, isString } from '@nocobase/utils/client';
|
||
import { Button, Dropdown, Empty, Menu, MenuProps, Spin, Switch } from 'antd';
|
||
import classNames from 'classnames';
|
||
// @ts-ignore
|
||
import { isEmpty } from 'lodash';
|
||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useCollection } from '../collection-manager';
|
||
import { useCollectMenuItem, useMenuItem } from '../hooks/useMenuItem';
|
||
import { Icon } from '../icon';
|
||
import { SchemaComponent, useActionContext } from '../schema-component';
|
||
import { useCompile, useDesignable } from '../schema-component/hooks';
|
||
import { SearchCollections } from './SearchCollections';
|
||
import { useStyles } from './style';
|
||
import {
|
||
SchemaInitializerButtonProps,
|
||
SchemaInitializerItemComponent,
|
||
SchemaInitializerItemOptions,
|
||
SchemaInitializerItemProps,
|
||
} from './types';
|
||
|
||
const defaultWrap = (s: ISchema) => s;
|
||
|
||
export const SchemaInitializerItemContext = createContext(null);
|
||
export const SchemaInitializerButtonContext = createContext<{
|
||
visible?: boolean;
|
||
setVisible?: (v: boolean) => void;
|
||
}>({});
|
||
|
||
export const SchemaInitializer = () => null;
|
||
|
||
const CollectionSearch = ({
|
||
onChange: _onChange,
|
||
clearValueRef,
|
||
}: {
|
||
onChange: (value: string) => void;
|
||
clearValueRef?: React.MutableRefObject<() => void>;
|
||
}) => {
|
||
const [searchValue, setSearchValue] = useState('');
|
||
const onChange = useCallback(
|
||
(value) => {
|
||
setSearchValue(value);
|
||
_onChange(value);
|
||
},
|
||
[_onChange],
|
||
);
|
||
|
||
if (clearValueRef) {
|
||
clearValueRef.current = () => {
|
||
setSearchValue('');
|
||
};
|
||
}
|
||
|
||
return <SearchCollections value={searchValue} onChange={onChange} />;
|
||
};
|
||
|
||
const LoadingItem = ({ loadMore }) => {
|
||
const spinRef = React.useRef(null);
|
||
|
||
useEffect(() => {
|
||
let root = spinRef.current;
|
||
|
||
while (root) {
|
||
if (root.classList?.contains('ant-dropdown-menu')) {
|
||
break;
|
||
}
|
||
root = root.parentNode;
|
||
}
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const item of entries) {
|
||
if (item.isIntersecting) {
|
||
return loadMore();
|
||
}
|
||
}
|
||
},
|
||
{
|
||
root,
|
||
},
|
||
);
|
||
|
||
observer.observe(spinRef.current);
|
||
return () => {
|
||
observer.disconnect();
|
||
};
|
||
}, [loadMore]);
|
||
|
||
return (
|
||
<div ref={spinRef}>
|
||
<Spin size="small" style={{ width: '100%' }} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// 清除所有的 searchValue
|
||
const clearSearchValue = (items: any[]) => {
|
||
items.forEach((item) => {
|
||
if (item._clearSearchValueRef?.current) {
|
||
item._clearSearchValueRef.current();
|
||
}
|
||
if (item.children?.length) {
|
||
clearSearchValue(item.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 当存在 loadChildren 方法时,且 children 为空时,会触发懒加载
|
||
*
|
||
* 1. 如果存在 loadChildren 方法,则需要懒加载 children
|
||
* 2. 一开始先注入一个 loading 组件,当 loading 显示的时候,触发 loadChildren 方法
|
||
* 3. 每次在 loading 后显示的数目增加 minStep,直到显示完所有 children
|
||
* 4. 重置 item.label 为一个搜索框,用于筛选列表
|
||
* 5. 每次组件刷新的时候,会重复上面的步骤
|
||
* @param param0
|
||
* @returns
|
||
*/
|
||
const lazyLoadChildren = ({
|
||
items,
|
||
minStep = 30,
|
||
beforeLoading,
|
||
afterLoading,
|
||
}: {
|
||
items: any[];
|
||
minStep?: number;
|
||
beforeLoading?: () => void;
|
||
afterLoading?: ({ currentCount }) => void;
|
||
}) => {
|
||
if (isEmpty(items)) {
|
||
return;
|
||
}
|
||
|
||
const addLoading = (item: any, searchValue: string) => {
|
||
if (isEmpty(item.children)) {
|
||
item.children = [];
|
||
}
|
||
|
||
item.children.push({
|
||
key: `${item.key}-loading`,
|
||
label: (
|
||
<LoadingItem
|
||
loadMore={() => {
|
||
beforeLoading?.();
|
||
item._allChildren = item.loadChildren({ searchValue }) || [];
|
||
item._count += minStep;
|
||
item.children = item._allChildren.slice(0, item._count);
|
||
if (item.children.length < item._allChildren.length) {
|
||
addLoading(item, searchValue);
|
||
}
|
||
afterLoading?.({ currentCount: item._count });
|
||
}}
|
||
/>
|
||
),
|
||
});
|
||
};
|
||
|
||
for (const item of items) {
|
||
if (!item) {
|
||
continue;
|
||
}
|
||
|
||
if (item.loadChildren && isEmpty(item.children)) {
|
||
item._count = 0;
|
||
item._clearSearchValueRef = {};
|
||
item.label = (
|
||
<CollectionSearch
|
||
clearValueRef={item._clearSearchValueRef}
|
||
onChange={(value) => {
|
||
item._count = minStep;
|
||
beforeLoading?.();
|
||
item._allChildren = item.loadChildren({ searchValue: value }) || [];
|
||
|
||
if (isEmpty(item._allChildren)) {
|
||
item.children = [
|
||
{
|
||
key: 'empty',
|
||
label: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
|
||
},
|
||
];
|
||
} else {
|
||
item.children = item._allChildren.slice(0, item._count);
|
||
}
|
||
|
||
if (item.children.length < item._allChildren.length) {
|
||
addLoading(item, value);
|
||
}
|
||
afterLoading?.({ currentCount: item._count });
|
||
}}
|
||
/>
|
||
);
|
||
|
||
// 通过 loading 加载新数据
|
||
addLoading(item, '');
|
||
}
|
||
|
||
lazyLoadChildren({
|
||
items: item.children,
|
||
minStep,
|
||
beforeLoading,
|
||
afterLoading,
|
||
});
|
||
}
|
||
};
|
||
|
||
const MenuWithLazyLoadChildren = ({ items: _items, style, clean, component: Component }) => {
|
||
const [items, setItems] = useState(_items);
|
||
const currentCountRef = useRef(0);
|
||
|
||
useEffect(() => {
|
||
setItems(_items);
|
||
}, [_items]);
|
||
|
||
lazyLoadChildren({
|
||
items,
|
||
beforeLoading: () => {
|
||
clean();
|
||
},
|
||
afterLoading: ({ currentCount }) => {
|
||
currentCountRef.current = currentCount;
|
||
setItems([...items]);
|
||
},
|
||
});
|
||
|
||
return (
|
||
<>
|
||
{/* 用于收集 menu item */}
|
||
<Component limitCount={currentCountRef.current} />
|
||
<Menu style={style} items={items} />
|
||
</>
|
||
);
|
||
};
|
||
|
||
SchemaInitializer.Button = observer(
|
||
(props: SchemaInitializerButtonProps) => {
|
||
const {
|
||
title,
|
||
insert,
|
||
wrap = defaultWrap,
|
||
items = [],
|
||
insertPosition = 'beforeEnd',
|
||
dropdown,
|
||
component,
|
||
style,
|
||
icon,
|
||
onSuccess,
|
||
...others
|
||
} = props;
|
||
const compile = useCompile();
|
||
const { insertAdjacent, findComponent, designable } = useDesignable();
|
||
const [visible, setVisible] = useState(false);
|
||
const { Component: CollectComponent, getMenuItem, clean } = useMenuItem();
|
||
const menuItems = useRef([]);
|
||
const { styles } = useStyles();
|
||
const { name } = useCollection();
|
||
|
||
const changeMenu = (v: boolean) => {
|
||
setVisible(v);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (visible === false) {
|
||
clearSearchValue(menuItems.current);
|
||
}
|
||
}, [visible]);
|
||
|
||
if (!designable && props.designable !== true) {
|
||
return null;
|
||
}
|
||
|
||
if (others['data-testid'] && name) {
|
||
others['data-testid'] = `${others['data-testid']}-${name}`;
|
||
}
|
||
|
||
const buttonDom = component || (
|
||
<Button
|
||
type={'dashed'}
|
||
style={{
|
||
borderColor: 'var(--colorSettings)',
|
||
color: 'var(--colorSettings)',
|
||
...style,
|
||
}}
|
||
{...others}
|
||
icon={typeof icon === 'string' ? <Icon type={icon as string} /> : icon}
|
||
>
|
||
{compile(props.children || props.title)}
|
||
</Button>
|
||
);
|
||
|
||
const insertSchema = (schema) => {
|
||
if (insert) {
|
||
insert(wrap(schema));
|
||
} else {
|
||
insertAdjacent(insertPosition, wrap(schema), { onSuccess });
|
||
}
|
||
};
|
||
|
||
const renderItems = (items: any) => {
|
||
return items
|
||
.filter((v: any) => {
|
||
return v && (v?.visible ? v.visible() : true);
|
||
})
|
||
?.map((item: any, indexA: number) => {
|
||
if (item.type === 'divider') {
|
||
return { type: 'divider', key: item.key || `item-${indexA}` };
|
||
}
|
||
if (item.type === 'item' && item.component) {
|
||
const Component = findComponent(item.component);
|
||
if (!Component) {
|
||
error(`SchemaInitializer: component "${item.component}" not found`);
|
||
return null;
|
||
}
|
||
if (!item.key) {
|
||
item.key = `${item.title}-${indexA}`;
|
||
}
|
||
return getMenuItem(() => {
|
||
return (
|
||
<SchemaInitializerItemContext.Provider
|
||
key={item.key}
|
||
value={{
|
||
index: indexA,
|
||
item,
|
||
info: item,
|
||
insert: insertSchema,
|
||
}}
|
||
>
|
||
<Component
|
||
{...item}
|
||
item={{
|
||
...item,
|
||
title: compile(item.title),
|
||
}}
|
||
insert={insertSchema}
|
||
/>
|
||
</SchemaInitializerItemContext.Provider>
|
||
);
|
||
});
|
||
}
|
||
if (item.type === 'itemGroup') {
|
||
const label = isString(item.title) ? compile(item.title) : item.title;
|
||
return {
|
||
type: 'group',
|
||
key: item.key || `item-group-${indexA}`,
|
||
label,
|
||
title: label,
|
||
style: item.style,
|
||
loadChildren:
|
||
isEmpty(item.children) && item.loadChildren
|
||
? ({ searchValue } = { searchValue: '' }) => renderItems(item.loadChildren({ searchValue }) || [])
|
||
: null,
|
||
children: isEmpty(item.children) ? [] : renderItems(item.children),
|
||
};
|
||
}
|
||
if (item.type === 'subMenu') {
|
||
const label = compile(item.title);
|
||
return {
|
||
key: item.key || `item-group-${indexA}`,
|
||
label,
|
||
title: label,
|
||
popupClassName: styles.nbMenuItemSubMenu,
|
||
loadChildren:
|
||
isEmpty(item.children) && item.loadChildren
|
||
? ({ searchValue } = { searchValue: '' }) => renderItems(item.loadChildren({ searchValue }) || [])
|
||
: null,
|
||
children: isEmpty(item.children) ? [] : renderItems(item.children),
|
||
};
|
||
}
|
||
})
|
||
.filter(Boolean);
|
||
};
|
||
|
||
if (visible) {
|
||
clean();
|
||
menuItems.current = renderItems(items);
|
||
}
|
||
|
||
const dropdownRender = () => (
|
||
<MenuWithLazyLoadChildren
|
||
style={{
|
||
maxHeight: '50vh',
|
||
overflowY: 'auto',
|
||
}}
|
||
items={menuItems.current}
|
||
clean={clean}
|
||
component={CollectComponent}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<SchemaInitializerButtonContext.Provider value={{ visible, setVisible }}>
|
||
{visible ? <CollectComponent /> : null}
|
||
<Dropdown
|
||
className={classNames('nb-schema-initializer-button')}
|
||
openClassName={`nb-schema-initializer-button-open`}
|
||
open={visible}
|
||
onOpenChange={(open) => {
|
||
changeMenu(open);
|
||
}}
|
||
dropdownRender={dropdownRender}
|
||
{...dropdown}
|
||
>
|
||
{component || buttonDom}
|
||
</Dropdown>
|
||
</SchemaInitializerButtonContext.Provider>
|
||
);
|
||
},
|
||
{ displayName: 'SchemaInitializer.Button' },
|
||
);
|
||
|
||
SchemaInitializer.Item = function Item(props: SchemaInitializerItemProps) {
|
||
const { styles } = useStyles();
|
||
const { info } = useContext(SchemaInitializerItemContext);
|
||
const compile = useCompile();
|
||
const { items = [], children = info?.title, icon, onClick } = props;
|
||
const { collectMenuItem } = useCollectMenuItem();
|
||
|
||
if (process.env.NODE_ENV !== 'production' && !collectMenuItem) {
|
||
throw new Error('SchemaInitializer.Item: collectMenuItem is undefined, please check the context');
|
||
}
|
||
|
||
if (items?.length > 0) {
|
||
const renderMenuItem = (items: SchemaInitializerItemOptions[], parentKey: string) => {
|
||
if (!items?.length) {
|
||
return [];
|
||
}
|
||
return items.map((item, indexA) => {
|
||
if (item.type === 'divider') {
|
||
return { type: 'divider', key: `divider-${indexA}` };
|
||
}
|
||
if (item.type === 'itemGroup') {
|
||
const label = isString(item.title) ? compile(item.title) : item.title;
|
||
const key = `${parentKey}-item-group-${indexA}`;
|
||
return {
|
||
type: 'group',
|
||
key,
|
||
label,
|
||
title: label,
|
||
className: styles.nbMenuItemGroup,
|
||
loadChildren:
|
||
isEmpty(item.children) && item.loadChildren
|
||
? ({ searchValue } = { searchValue: '' }) =>
|
||
renderMenuItem(item.loadChildren({ searchValue }) || [], key)
|
||
: null,
|
||
children: isEmpty(item.children) ? [] : renderMenuItem(item.children, key),
|
||
} as MenuProps['items'][0] & { loadChildren?: ({ searchValue }?: { searchValue: string }) => any[] };
|
||
}
|
||
if (item.type === 'subMenu') {
|
||
const label = compile(item.title);
|
||
const key = `${parentKey}-sub-menu-${indexA}`;
|
||
return {
|
||
key,
|
||
label,
|
||
title: label,
|
||
loadChildren:
|
||
isEmpty(item.children) && item.loadChildren
|
||
? ({ searchValue } = { searchValue: '' }) =>
|
||
renderMenuItem(item.loadChildren({ searchValue }) || [], key)
|
||
: null,
|
||
children: isEmpty(item.children) ? [] : renderMenuItem(item.children, key),
|
||
} as MenuProps['items'][0] & { loadChildren?: ({ searchValue }?: { searchValue: string }) => any[] };
|
||
}
|
||
const label = compile(item.title);
|
||
return {
|
||
key: `${parentKey}-${item.title}-${indexA}`,
|
||
label,
|
||
title: label,
|
||
onClick: (info) => {
|
||
if (item.onClick) {
|
||
item.onClick({ ...info, item });
|
||
} else {
|
||
onClick({ ...info, item });
|
||
}
|
||
},
|
||
};
|
||
});
|
||
};
|
||
|
||
const item = {
|
||
key: info.key,
|
||
label: isString(children) ? compile(children) : children,
|
||
icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon,
|
||
children: renderMenuItem(items, info.key),
|
||
};
|
||
|
||
collectMenuItem(item);
|
||
return null;
|
||
}
|
||
|
||
const label = isString(children) ? compile(children) : children;
|
||
const item = {
|
||
key: info.key,
|
||
label,
|
||
title: label,
|
||
icon: typeof icon === 'string' ? <Icon type={icon as string} /> : icon,
|
||
onClick: (opts) => {
|
||
onClick({ ...opts, item: info });
|
||
},
|
||
};
|
||
|
||
collectMenuItem(item);
|
||
return null;
|
||
};
|
||
|
||
SchemaInitializer.itemWrap = (component?: SchemaInitializerItemComponent) => {
|
||
return component;
|
||
};
|
||
|
||
interface SchemaInitializerActionModalProps {
|
||
title: string;
|
||
schema: any;
|
||
onCancel?: () => void;
|
||
onSubmit?: (values: any) => void;
|
||
buttonText?: any;
|
||
}
|
||
SchemaInitializer.ActionModal = function ActionModal(props: SchemaInitializerActionModalProps) {
|
||
const { title, schema, buttonText, onCancel, onSubmit } = props;
|
||
|
||
const useCancelAction = useCallback(() => {
|
||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||
const form = useForm();
|
||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||
const ctx = useActionContext();
|
||
return {
|
||
async run() {
|
||
await onCancel?.();
|
||
ctx.setVisible(false);
|
||
void form.reset();
|
||
},
|
||
};
|
||
}, [onCancel]);
|
||
|
||
const useSubmitAction = useCallback(() => {
|
||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||
const form = useForm();
|
||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||
const ctx = useActionContext();
|
||
return {
|
||
async run() {
|
||
await form.validate();
|
||
await onSubmit?.(form.values);
|
||
ctx.setVisible(false);
|
||
void form.reset();
|
||
},
|
||
};
|
||
}, [onSubmit]);
|
||
const defaultSchema = useMemo(() => {
|
||
return {
|
||
type: 'void',
|
||
properties: {
|
||
action1: {
|
||
type: 'void',
|
||
'x-component': 'Action',
|
||
'x-component-props': {
|
||
icon: 'PlusOutlined',
|
||
style: {
|
||
borderColor: 'var(--colorSettings)',
|
||
color: 'var(--colorSettings)',
|
||
},
|
||
title: buttonText,
|
||
type: 'dashed',
|
||
},
|
||
properties: {
|
||
drawer1: {
|
||
'x-decorator': 'Form',
|
||
'x-component': 'Action.Modal',
|
||
'x-component-props': {
|
||
style: {
|
||
maxWidth: '520px',
|
||
width: '100%',
|
||
},
|
||
},
|
||
type: 'void',
|
||
title,
|
||
properties: {
|
||
...(schema?.properties || schema),
|
||
footer: {
|
||
'x-component': 'Action.Modal.Footer',
|
||
type: 'void',
|
||
properties: {
|
||
cancel: {
|
||
title: '{{t("Cancel")}}',
|
||
'x-component': 'Action',
|
||
'x-component-props': {
|
||
useAction: useCancelAction,
|
||
},
|
||
},
|
||
submit: {
|
||
title: '{{t("Submit")}}',
|
||
'x-component': 'Action',
|
||
'x-component-props': {
|
||
type: 'primary',
|
||
useAction: useSubmitAction,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}, [buttonText, schema, title, useCancelAction, useSubmitAction]);
|
||
|
||
return <SchemaComponent schema={defaultSchema as any} />;
|
||
};
|
||
|
||
SchemaInitializer.SwitchItem = (props) => {
|
||
return (
|
||
<SchemaInitializer.Item onClick={props.onClick}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<label>{props.title}</label>
|
||
<Switch disabled={props.disabled} style={{ marginLeft: 20 }} size={'small'} checked={props.checked} />
|
||
</div>
|
||
</SchemaInitializer.Item>
|
||
);
|
||
};
|