被雨水过滤的空气-Rain 29bf187fbf
chore: optimize locators (#2833)
* test(e2e): better locators for designer buttons

* fix: make test passing

* refactor: remove DesignerControl

* chore: better locators

* fix: should not disable add-menu-item

* chore: better test id for block

* chore: optimize Action

* chore: remove role in BlockItem

* feat: improve locators

* chore: menu & add block

* chore: initializer

* chore: testid -> aria label

* chore: tabs

* chore: designers

* refactor: optimize schemaInitializer

* refactor: rename

* chore: add collectionName

* chore: block item

* chore: action

* fix: avoid crashting

* chore(e2e): add __E2E__

* chore: all dialog

* chore: add aria-label for block menu

* Revert "chore: add aria-label for block menu"

This reverts commit 6a840ef816ee1095484dc268b5dfa1bbe6cd8cbe.

* chore: optimize aria-label of Action

* chore: schema-initializer

* chore(e2e): increase timeout

* chore: schema settings

* chore: optimize table

* chore: workflow

* chore: plugin manager

* chore: collection manager and workflow

* chore: details of workflow

* chore: remove testid of Select

* test: fix unit-tests

* test: fix unit-tests

* test(e2e): passing tests

* test: fix unit test

* chore: should use hover

* test: passing tests

* chore: passing tests

* chore: fix CI

* chore: fix CI

* chore: increase timeout in CI

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
2023-10-27 15:32:17 +08:00

651 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <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 { 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 || (
<Button
type={'dashed'}
style={{
borderColor: 'var(--colorSettings)',
color: 'var(--colorSettings)',
...style,
}}
{...others}
aria-label={others['aria-label'] || getAriaLabel()}
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, 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 (
<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;
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 = () => (
<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: 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 type={icon as 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 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>
);
};
function getKey(key: string, parentKey: string) {
if (parentKey && key) {
return `${parentKey}-${key}`;
}
if (!parentKey && !key) {
return '';
}
if (!parentKey) {
return key;
}
if (!key) {
return parentKey;
}
}