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 { 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 { useGetAriaLabelOfSchemaInitializer } from './hooks/useGetAriaLabelOfSchemaInitializer';
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 ;
};
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 (
);
};
// 清除所有的 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: (
{
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 = (
{
item._count = minStep;
beforeLoading?.();
item._allChildren = item.loadChildren({ searchValue: value }) || [];
if (isEmpty(item._allChildren)) {
item.children = [
{
key: 'empty',
label: ,
},
];
} 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 */}
>
);
};
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 { getAriaLabel } = useGetAriaLabelOfSchemaInitializer();
const changeMenu = (v: boolean) => {
setVisible(v);
};
useEffect(() => {
if (visible === false) {
clearSearchValue(menuItems.current);
}
}, [visible]);
if (!designable && props.designable !== true) {
return null;
}
const buttonDom = component || (
: icon}
>
{compile(props.children || props.title)}
);
const insertSchema = (schema) => {
if (insert) {
insert(wrap(schema));
} else {
insertAdjacent(insertPosition, wrap(schema), { onSuccess });
}
};
const renderItems = (items: any, parentKey: string) => {
return items
.filter((v: any) => {
return v && (v?.visible ? v.visible() : true);
})
?.map((item: any, indexA: number) => {
// 防止 key 属性无限累加
item = { ...item };
if (item.type === 'divider') {
return {
type: 'divider',
key: getKey(item.key || `divider-${indexA}`, parentKey),
};
}
if (item.type === 'item' && item.component) {
const Component = findComponent(item.component);
if (!Component) {
error(`SchemaInitializer: component "${item.component}" not found`);
return null;
}
item.key = getKey(item.key || compile(item.title) || item.component, parentKey);
return getMenuItem(() => {
return (
);
});
}
if (item.type === 'itemGroup') {
const label = isString(item.title) ? compile(item.title) : item.title;
const key = getKey(item.key || label, parentKey);
return {
type: 'group',
key,
label,
title: label,
style: item.style,
loadChildren:
isEmpty(item.children) && item.loadChildren
? ({ searchValue } = { searchValue: '' }) =>
renderItems(item.loadChildren({ searchValue }) || [], key)
: null,
children: isEmpty(item.children) ? [] : renderItems(item.children, key),
};
}
if (item.type === 'subMenu') {
const label = isString(item.title) ? compile(item.title) : item.title;
const key = getKey(item.key || label, parentKey);
return {
role: 'button',
'aria-label': item.key || label,
key,
label,
title: label,
popupClassName: styles.nbMenuItemSubMenu,
loadChildren:
isEmpty(item.children) && item.loadChildren
? ({ searchValue } = { searchValue: '' }) =>
renderItems(item.loadChildren({ searchValue }) || [], key)
: null,
children: isEmpty(item.children) ? [] : renderItems(item.children, key),
};
}
})
.filter(Boolean);
};
if (visible) {
clean();
menuItems.current = renderItems(items, '');
}
const dropdownRender = () => (
);
return (
{visible ? : null}
{
changeMenu(open);
}}
dropdownRender={dropdownRender}
{...dropdown}
>
{component || buttonDom}
);
},
{ 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: getKey(item.key || `divider-${indexA}`, parentKey) };
}
if (item.type === 'itemGroup') {
const label = isString(item.title) ? compile(item.title) : item.title;
const key = getKey(item.key || label, parentKey);
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 = isString(item.title) ? compile(item.title) : item.title;
const key = getKey(item.key || label, parentKey);
return {
role: 'button',
'aria-label': key,
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 = isString(item.title) ? compile(item.title) : item.title;
const key = getKey(item.key || label, parentKey);
return {
role: 'button',
'aria-label': key,
key,
label,
title: label,
onClick: (info) => {
if (item.onClick) {
item.onClick({ ...info, item });
} else {
onClick({ ...info, item });
}
},
};
});
};
const item = {
role: 'button',
'aria-label': info.key,
key: info.key,
label: isString(children) ? compile(children) : children,
icon: typeof icon === 'string' ? : icon,
children: renderMenuItem(items, info.key),
};
collectMenuItem(item);
return null;
}
const label = isString(children) ? compile(children) : children;
const item = {
role: 'button',
'aria-label': info.key,
key: info.key,
label,
title: label,
icon: typeof icon === '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 ;
};
SchemaInitializer.SwitchItem = (props) => {
return (
);
};
function getKey(key: string, parentKey: string) {
if (parentKey && key) {
return `${parentKey}-${key}`;
}
if (!parentKey && !key) {
return '';
}
if (!parentKey) {
return key;
}
if (!key) {
return parentKey;
}
}