diff --git a/packages/arex/src/components/MenuSelect.tsx b/packages/arex/src/components/MenuSelect.tsx index 3165aa68e..303ecf365 100644 --- a/packages/arex/src/components/MenuSelect.tsx +++ b/packages/arex/src/components/MenuSelect.tsx @@ -35,6 +35,9 @@ const MenuList = styled(Menu, { }>` border: none !important; background: transparent !important; + .ant-menu-title-content { + width: 100%; + } .ant-menu-item { color: ${(props) => props.theme.colorTextSecondary}!important; padding-left: 8px; diff --git a/packages/arex/src/i18n/locales/cn/common.json b/packages/arex/src/i18n/locales/cn/common.json index 6f8dcc440..0970fb822 100644 --- a/packages/arex/src/i18n/locales/cn/common.json +++ b/packages/arex/src/i18n/locales/cn/common.json @@ -65,6 +65,7 @@ "modifiedTime": "修改时间", "monday": "周一", "more": "更多...", + "neverExpired": "永不过期", "new": "新", "no": "否", "noSave": "不保存", @@ -79,6 +80,7 @@ "remove": "移除", "replay": "回放", "request": "请求", + "reset": "重置", "response": "响应", "saturday": "周六", "save": "保存", diff --git a/packages/arex/src/i18n/locales/cn/components.json b/packages/arex/src/i18n/locales/cn/components.json index cd9f0d142..c79f849eb 100644 --- a/packages/arex/src/i18n/locales/cn/components.json +++ b/packages/arex/src/i18n/locales/cn/components.json @@ -1,8 +1,11 @@ { "appSetting": { "QPSTips": "有效范围 1 - 20", + "action": "操作", + "addCategoryIgnore": "添加类型忽略", "addKey": "添加节点", - "addSortKey": "添加 List 节点", + "addListSort": "添加数组排序", + "addNodesIgnore": "添加忽略节点", "addTransformNode": "添加转换节点", "advanced": "高级", "agentHost": "Agent 地址", @@ -71,10 +74,14 @@ "importYaml": "Yaml 配置导入", "inclusion": "包含规则", "inclusionTooltip": "选择要录制的路径。", + "inputIgnorePath": "请输入忽略路径", + "inputListSortKeys": "请输入数组排序键名", + "inputListSortPath": "请输入数组节点路径", "interface": "接口", "keyFormula": "参数组合表达式", "keyFormulaTooltip": "非必填,参数组合,多个用’,‘隔开,支持 SpEL 表达式。", "keys": "节点", + "listSort": "数组排序", "maxQPS": "最大QPS", "methodName": "方法名", "methodNameTooltip": "需要 Mock 的方法名", @@ -103,6 +110,8 @@ "recordMachineNum": "录制机器数量", "replay": "回放", "runningStatus": "运行状态", + "selectDependency": "请选择一个外部依赖", + "selectInterface": "请选择一个接口", "selectMethodTip": "请选择一个方法以保存", "selectNodePath": "选择节点路径", "selectRangeTip": "请选择录制时间范围", diff --git a/packages/arex/src/i18n/locales/en/common.json b/packages/arex/src/i18n/locales/en/common.json index 776217480..6ed4172c1 100644 --- a/packages/arex/src/i18n/locales/en/common.json +++ b/packages/arex/src/i18n/locales/en/common.json @@ -68,6 +68,7 @@ "modifiedTime": "Modified Time", "monday": "Mon.", "more": "More...", + "neverExpired": "Never Expired", "new": "New", "no": "No", "noSave": "Don't save", @@ -82,6 +83,7 @@ "remove": "Remove", "replay": "Replay", "request": "Request", + "reset": "Reset", "response": "Response", "saturday": "Sat.", "save": "Save", diff --git a/packages/arex/src/i18n/locales/en/components.json b/packages/arex/src/i18n/locales/en/components.json index b664ddd95..99f48f203 100644 --- a/packages/arex/src/i18n/locales/en/components.json +++ b/packages/arex/src/i18n/locales/en/components.json @@ -2,8 +2,10 @@ "appSetting": { "QPSTips": "Allowable value range 1 - 20", "action": "Action", + "addCategoryIgnore": "Add Category Ignore", "addKey": "Add Key", - "addSortKey": "Add Sort Key", + "addListSort": "Add List Sort ", + "addNodesIgnore": "Add Nodes Ignore", "addTransformNode": "Add Transform Node", "advanced": "Advanced", "agentHost": "Agent Host", @@ -72,10 +74,14 @@ "importYaml": "Import Yaml", "inclusion": "Inclusion", "inclusionTooltip": "Select the paths to record", + "inputIgnorePath": "Please input the ignore path", + "inputListSortKeys": "Please input List Sort Keys", + "inputListSortPath": "Please input list node path", "interface": "Interfaces", "keyFormula": "Key Formula", "keyFormulaTooltip": "Not required, parameter combination, multiple separated by ',' , support SpEL expression.", "keys": "keys", + "listSort": "List Sort", "maxQPS": "Max QPS", "methodName": "Method Name", "methodNameTooltip": "The method name to be mocked.", @@ -104,6 +110,8 @@ "recordMachineNum": "Record Machine Count", "replay": "Replay", "runningStatus": "Running Status", + "selectDependency": "Please select an external dependency", + "selectInterface": "Please select an interface", "selectMethodTip": "Please select a method to save", "selectNodePath": "Select Node Path", "selectRangeTip": "Please select record time range", diff --git a/packages/arex/src/menus/Replay.tsx b/packages/arex/src/menus/Replay.tsx index 1e8859729..8c43cceb1 100644 --- a/packages/arex/src/menus/Replay.tsx +++ b/packages/arex/src/menus/Replay.tsx @@ -70,8 +70,8 @@ const MenuItem = styled((props: MenuItemProps) => { ); })` + width: 100%; .menu-item-heart { - padding-right: 8px; .menu-item-heart-outlined { opacity: 0; transition: opacity ease 0.3s; diff --git a/packages/arex/src/panes/AppSetting/AppSetting.tsx b/packages/arex/src/panes/AppSetting/AppSetting.tsx index 26c9ce418..7c643f108 100644 --- a/packages/arex/src/panes/AppSetting/AppSetting.tsx +++ b/packages/arex/src/panes/AppSetting/AppSetting.tsx @@ -5,7 +5,7 @@ import { setLocalStorage, useTranslation, } from '@arextest/arex-core'; -import { Tabs } from 'antd'; +import { Badge, Tabs } from 'antd'; import React, { useEffect, useMemo } from 'react'; import { APP_ID_KEY, PanesType } from '@/constant'; @@ -14,6 +14,7 @@ import CompareConfig from '@/panes/AppSetting/CompareConfig'; import { useMenusPanes } from '@/store'; import { decodePaneKey } from '@/store/useMenusPanes'; +import CompareConfigNew from './CompareConfigNew'; import SettingImportYaml from './ImportYaml'; import SettingOther from './Other'; import SettingRecord from './Record'; @@ -43,6 +44,15 @@ const AppSetting: ArexPaneFC<{ key: string }> = (props) => { label: t('appSetting.compareConfig'), children: , }, + { + key: 'compareConfigNew', + label: ( + + {t('appSetting.compareConfig')} + + ), + children: , + }, { key: 'importYaml', label: t('appSetting.importYaml'), diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/AddConfigModal.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/AddConfigModal.tsx new file mode 100644 index 000000000..b4e9d6cba --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/AddConfigModal.tsx @@ -0,0 +1,302 @@ +import { ReloadOutlined } from '@ant-design/icons'; +import { css, useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Button, Form, FormInstance, Input, Modal, Select, SelectProps, Tooltip } from 'antd'; +import { Rule } from 'antd/es/form'; +import React, { + FC, + ForwardedRef, + ReactNode, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; + +import { Connector } from '@/constant'; +import { ApplicationService, ReportService } from '@/services'; +import { DependencyData, OperationInterface } from '@/services/ApplicationService'; +import { IgnoreNodeBase } from '@/services/ComparisonService'; + +type DependencyParams = Pick; + +type CompareConfigParams = { + appId: string; + operationId?: string; + dependency?: string; +}; + +type CompareConfigForm = CompareConfigParams & T; + +export type AddConfigModalFieldProps = { + appId: string; + operationId?: string; + dependency?: DependencyParams; + contract?: Record; + loadingContract: boolean; + form: FormInstance>; +}; + +export type AddConfigModalProps = { + appId: string; + title?: ReactNode; + field?: FC>; + rules?: { + operationId?: Rule[]; + dependency?: Rule[]; + }; + builtInItems?: Partial>; + operationList?: OperationInterface<'Interface'>[]; + onSubmit?: (form: FormInstance>) => Promise; + onClose?: () => void; +}; + +export type AddConfigModalRef = { + open: () => void; + close: () => void; + getForm: () => FormInstance; + validate: () => Promise; + submit: () => void; +}; + +type FixedForwardRef = ( + render: (props: P, ref: React.Ref) => React.ReactElement | null, +) => (props: P & React.RefAttributes) => React.ReactElement | null; + +const forwardRef = React.forwardRef as FixedForwardRef; + +const AddConfigModal = forwardRef( + (props: AddConfigModalProps, ref: ForwardedRef) => { + const { t } = useTranslation(); + + const [form] = Form.useForm>(); + const operationId = Form.useWatch('operationId', form); + const dependency = Form.useWatch('dependency', form); + + // reset dependency when operationId changed + useEffect(() => { + // @ts-ignore + form.setFieldValue('dependency', undefined); + }, [operationId]); + + const [openAddConfigModal, setOpenAddConfigModal] = useState(false); + + useImperativeHandle( + ref, + () => ({ + open: () => setOpenAddConfigModal(true), + close: () => handleCloseModal(), + getForm: () => form, + validate: () => form.validateFields(), + submit: () => form.submit(), + }), + [form], + ); + + const interfaceOptions = useMemo( + () => + Object.entries( + (props.operationList || []).reduce< + Record + >((options, item) => { + item.operationTypes?.forEach((operation) => { + if (options[operation]) { + options[operation].push({ + label: item.operationName, + value: item.id, + }); + } else { + options[operation] = [ + { + label: item.operationName, + value: item.id, + }, + ]; + } + }); + return options; + }, {}), + ).map(([label, options]) => ({ + label, + options, + })), + [props.operationList], + ); + + /** + * 请求 DependencyList + * // TODO 从 props.operationList 获取对应的 dependencyOptions + */ + const { loading: loadingDependency } = useRequest( + () => ApplicationService.getDependencyList({ operationId: operationId! }), + { + ready: !!operationId, + refreshDeps: [operationId], + onSuccess(res) { + const dependencyList = res.dependencyList; + setDependencyOptions( + dependencyList.map((dependency) => ({ + label: dependency.operationType + '-' + dependency.operationName, + value: stringifyDependency(dependency), + })), + ); + }, + }, + ); + + const [dependencyOptions, setDependencyOptions] = useState(); + + const { run: handleSync, loading: syncing } = useRequest( + () => + ReportService.syncResponseContract({ + operationId: operationId as string, + }), + { + manual: true, + ready: !!operationId, + onSuccess: (data) => { + if (data?.dependencyList) { + setDependencyOptions( + data.dependencyList.map((dependency) => ({ + label: dependency.operationType + '-' + dependency.operationName, + value: dependency.operationType + '-' + dependency.operationName, + })), + ); + queryContract(); + } + }, + }, + ); + + const { + data: contract, + loading: loadingContract, + mutate: setContract, + run: queryContract, + } = useRequest( + () => + ReportService.queryContract({ + appId: props.appId, + operationId, + ...parseDependency(dependency), + }), + { + ready: !!operationId, + refreshDeps: [props.appId, operationId, dependency], + onBefore() { + setContract(); + }, + }, + ); + + const handleOk = () => + props.onSubmit?.(form).then(() => { + setOpenAddConfigModal(false); + form.resetFields(); + }); + + const handleCloseModal = () => { + setOpenAddConfigModal(false); + form.resetFields(); + props.onClose?.(); + }; + + return ( + + > + form={form} + requiredMark='optional' + initialValues={{ + appId: props.appId, + }} + style={{ padding: '8px 0' }} + > + {props.builtInItems?.appId !== false && ( + + + + )} + + {props.builtInItems?.operationId !== false && ( + + {React.createElement((props) => ( + + + + + } + disabled={!operationId} + loading={syncing} + onClick={handleSync} + style={{ marginLeft: '8px' }} + /> + + + ))} + + )} + + {props.builtInItems?.dependency !== false && ( + + + + )} + {(() => + props.field?.({ + appId: props.appId, + operationId, + dependency: parseDependency(dependency), + contract, + loadingContract, + form, + }))()} + + + ); + }, +); + +export default AddConfigModal; +export function stringifyDependency(dependency: DependencyData) { + return dependency.operationType + Connector + dependency.operationName; +} +export function parseDependency(dependencyString?: string | null) { + const [operationType, operationName] = dependencyString?.split(Connector) || []; + return { operationType, operationName }; +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/DependencyInput.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/DependencyInput.tsx new file mode 100644 index 000000000..baca3a27f --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/DependencyInput.tsx @@ -0,0 +1,103 @@ +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Form, Select, SelectProps } from 'antd'; +import React, { FC, useEffect, useMemo, useState } from 'react'; + +import { AddConfigModalFieldProps } from '@/panes/AppSetting/CompareConfigNew/AddConfigModal'; +import { ComparisonService } from '@/services'; +import { OperationInterface } from '@/services/ApplicationService'; +import { IgnoreCategory } from '@/services/ComparisonService'; + +const DependencyInput: FC< + AddConfigModalFieldProps & { + operationList?: OperationInterface<'Interface'>[]; + } +> = (props) => { + const { t } = useTranslation(); + + const operationTypeValue = Form.useWatch('operationTypeValue', props.form); + + useEffect(() => { + props.form.setFieldValue('operationName', undefined); + }, [operationTypeValue]); + + const [operationNameOptions, setOperationNameOptions] = useState([]); + + const [categoryTypeOptions, setCategoryOptions] = useState([]); + useRequest(ComparisonService.queryCategoryType, { + onSuccess(res) { + const options = res + .filter((item) => !item.entryPoint) + .map((item) => ({ + label: item.name, + value: item.name, + })); + setCategoryOptions(options); + }, + }); + + const optionsGroupMap = useMemo( + () => + props.operationList?.reduce((group, item) => { + if (!item.dependencyList) return group; + + item.dependencyList?.forEach((dependency) => { + if (group.has(dependency.operationType)) { + group.get(dependency.operationType)?.add(dependency.operationName); + } else { + group.set(dependency.operationType, new Set([dependency.operationName])); + } + }); + return group; + }, new Map>()) || new Map>(), + [props.operationList], + ); + + function handleOperationTypeChange(value: string) { + setOperationNameOptions( + props.operationId + ? // GLOBAL: set all dependency + Array.from(optionsGroupMap.get(value) || [])?.map((value) => ({ + label: value, + value, + })) + : // INTERFACE: set dependency of current interface + props.operationList + ?.find((item) => item.id === props.operationId) + ?.dependencyList?.filter((item) => item.operationType === value) + .map((item) => ({ + label: item.operationName, + value: item.operationName, + })) || [], + ); + } + return ( + <> + + + + + + + + > + ); +}; + +export default DependencyInput; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/index.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/index.tsx new file mode 100644 index 000000000..9a171a1f7 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/CategoryIgnore/index.tsx @@ -0,0 +1,243 @@ +import { CloseOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { App, Button, Pagination, Popconfirm, Typography } from 'antd'; +import React, { FC, useEffect, useRef, useState } from 'react'; + +import { CompareConfigNewProps } from '@/panes/AppSetting/CompareConfigNew'; +import AddConfigModal, { + AddConfigModalProps, + AddConfigModalRef, +} from '@/panes/AppSetting/CompareConfigNew/AddConfigModal'; +import DependencyInput from '@/panes/AppSetting/CompareConfigNew/CategoryIgnore/DependencyInput'; +import ConfigInfoTable, { + CONFIG_INFO_TABLE_MODE, + ConfigColumnsType, +} from '@/panes/AppSetting/CompareConfigNew/ConfigInfoTable'; +import { IgnoreCategoryInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { ComparisonService } from '@/services'; +import { IgnoreCategory, QueryIgnoreCategorySearchParams } from '@/services/ComparisonService'; + +export type CategoryIgnoreProps = CompareConfigNewProps & + Pick, 'operationList'>; + +const PAGE_SIZE = { + SIZE_10: 10, + SIZE_20: 20, + SIZE_30: 30, +}; + +const pageSizeOptions = Object.values(PAGE_SIZE); + +const CategoryIgnore: FC = (props) => { + const { message } = App.useApp(); + const { t } = useTranslation(); + + const [tableMode, setTableMode] = useState( + CONFIG_INFO_TABLE_MODE.DISPLAY, + ); + + const [selectedRows, setSelectedRows] = useState([]); + + const [pagination, setPagination] = useState({ + current: 1, + pageSize: PAGE_SIZE.SIZE_10, + }); + + const [searchParams, setSearchParams] = useState({}); + + useEffect(() => { + handleSearch({ + operationName: props.operation?.operationName, + dependencyName: props.dependency?.operationName, + }); + }, [props.operation, props.dependency, props.operationList]); + + const addConfigModalRef = useRef(null); + + const { + data = { totalCount: 0, ignoreCategories: [] }, + loading, + run: queryIgnoreCategory, + } = useRequest( + () => + ComparisonService.queryAggregateIgnoreCategory({ + appId: props.appId, + pageSize: pagination.pageSize, + pageIndex: pagination.current, + ...searchParams, + }), + { + refreshDeps: [pagination, searchParams], + }, + ); + + const { run: insertIgnoreCategory } = useRequest(ComparisonService.insertIgnoreCategory, { + manual: true, + onSuccess(success) { + if (success) { + message.success(t('message.updateSuccess')); + queryIgnoreCategory(); + } else message.error(t('message.updateFailed')); + }, + }); + + const { run: deleteIgnoreCategory } = useRequest(ComparisonService.deleteIgnoreCategory, { + manual: true, + onSuccess(success) { + if (success) { + message.success(t('message.delSuccess')); + setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY); + queryIgnoreCategory(); + } else message.error(t('message.delFailed')); + }, + }); + + const columns: ConfigColumnsType = [ + { + title: t('appSetting.categoryType', { ns: 'components' }), + dataIndex: ['ignoreCategoryDetail', 'operationType'], + search: true, + }, + { + title: t('appSetting.operationName', { ns: 'components' }), + dataIndex: ['ignoreCategoryDetail', 'operationName'], + search: true, + renderFallback: -, + }, + ]; + + function handleSearch(search: Record) { + if (pagination.current !== 1) { + setPagination({ current: 1, pageSize: pagination.pageSize }); + } + + setSearchParams({ + keyOfOperationName: search.operationName, + keyOfIgnoreOperationType: search.ignoreCategoryDetail?.operationType, + keyOfIgnoreOperationName: search.ignoreCategoryDetail?.operationName, + }); + } + + const handleDelete = () => { + const id = selectedRows[0]?.id; + if (id) { + deleteIgnoreCategory({ id }); + } else message.error(t('message.selectRowWarning')); + }; + + const handleSubmit: AddConfigModalProps['onSubmit'] = (form) => + form.validateFields().then((params) => { + const { appId, operationId, operationType, operationName } = params; + insertIgnoreCategory({ + appId, + operationId, + ignoreCategoryDetail: { + operationType, + operationName, + }, + }); + }); + const TableFooter = () => ( + + + {data.totalCount > pagination.pageSize && ( + { + setPagination({ + current: page, + pageSize, + }); + }} + /> + )} + + + + {tableMode === CONFIG_INFO_TABLE_MODE.DISPLAY ? ( + <> + } + onClick={() => addConfigModalRef.current?.open()} + > + {t('common:add')} + + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.EDIT)} + > + {t('common:edit')} + + > + ) : ( + // tableMode === CONFIG_INFO_TABLE_MODE.EDIT + <> + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY)} + > + {t('common:cancel')} + + + } disabled={!selectedRows?.length}> + {t('common:delete')} + + + > + )} + + + ); + + return ( + + + rowKey='id' + requestSearch + loading={loading} + columns={columns} + builtInColumns={{ + dependencyName: { + hidden: true, + }, + }} + dataSource={data.ignoreCategories} + footer={TableFooter} + rowSelection={ + tableMode !== CONFIG_INFO_TABLE_MODE.DISPLAY + ? { + type: 'radio', + onSelect: (record, selected, selectedRows) => { + setSelectedRows(selectedRows.filter(Boolean)); + }, + } + : undefined + } + onSearch={handleSearch} + /> + + + ref={addConfigModalRef} + appId={props.appId} + operationList={props.operationList} + title={t('components:appSetting.addCategoryIgnore')} + builtInItems={{ + dependency: false, + }} + field={(fieldProps) => ( + + )} + onSubmit={handleSubmit} + /> + + ); +}; + +export default CategoryIgnore; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ConfigInfoTable.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ConfigInfoTable.tsx new file mode 100644 index 000000000..342d9e445 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ConfigInfoTable.tsx @@ -0,0 +1,198 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { useTranslation } from '@arextest/arex-core'; +import { InputRef, TableProps, theme } from 'antd'; +import { Button, Card, Input, Table, Typography } from 'antd'; +import { ColumnsType, ColumnType } from 'antd/es/table'; +import type { FilterConfirmProps } from 'antd/es/table/interface'; +import dayjs from 'dayjs'; +import React, { Key, ReactNode, useRef } from 'react'; +import { useImmer } from 'use-immer'; + +import { SearchHighLight } from '@/components'; +import { getValueByPath, setValueByPath } from '@/utils'; + +import { ComparisonConfigInfo, ExpirationType } from './type'; + +export enum CONFIG_INFO_TABLE_MODE { + DISPLAY, + EDIT, +} + +export type ConfigColumnsType = (Omit, 'dataIndex'> & { + dataIndex?: string | string[]; + search?: boolean; + renderFallback?: ReactNode | ((record: T) => ReactNode); +})[]; + +export interface ConfigInfoTableProps extends TableProps { + builtInColumns?: Partial[number]>>; + requestSearch?: boolean; + onSearch?: (search: Record) => void; + columns?: ConfigColumnsType; +} + +export default function ConfigInfoTable( + props: ConfigInfoTableProps, +) { + const { t } = useTranslation(); + + const { columns: configColumns, ...tableProps } = props; + const { token } = theme.useToken(); + + const [search, setSearch] = useImmer | undefined>(undefined); + const searchInput = useRef(null); + + const handleSearch = ( + selectedKeys: string[], + confirm: (param?: FilterConfirmProps) => void, + dataIndex: string | string[], + ) => { + const searchValue = selectedKeys[0]; + + setSearch((draft) => { + if (!draft) { + return setValueByPath({}, dataIndex, searchValue); + } else { + return setValueByPath(draft, dataIndex, searchValue); + } + }); + !props.requestSearch && confirm(); + props.onSearch?.({ ...search, ...setValueByPath({}, dataIndex, searchValue) }); + }; + + // 获取列搜索配置 + const getColumnSearchProps = ( + dataIndex: string | string[], + fallback?: ReactNode | ((record: T) => ReactNode), + ): ColumnType => ({ + filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }) => ( + + setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => handleSearch(selectedKeys as string[], confirm, dataIndex)} + style={{ width: '160px' }} + /> + + + { + clearFilters?.(); + setSearch((draft) => { + if (!draft) { + return {}; + } else { + setValueByPath(draft, dataIndex, ''); + } + }); + handleSearch([''], confirm, dataIndex); + }} + > + {t('common:reset')} + + handleSearch(selectedKeys as string[], confirm, dataIndex)} + icon={} + > + {t('common:search')} + + + + ), + filterIcon: (filtered: boolean) => ( + + ), + onFilter: (value: boolean | Key, record: T) => { + const data = getValueByPath(record, dataIndex); + + return data ? data?.toLowerCase().includes(value.toString().toLowerCase()) : false; + }, + onFilterDropdownOpenChange: (open: boolean) => { + open && setTimeout(() => searchInput.current?.focus(), 100); + }, + render: (_: string | null, record) => { + const text = getValueByPath(record, dataIndex); + const columnsSearchValue = getValueByPath(search, dataIndex); + + if ( + !!columnsSearchValue && + text?.toLowerCase().includes(columnsSearchValue.toLowerCase() || '') + ) { + return ; + } else { + return ( + text || + (typeof fallback === 'function' + ? (fallback as (record: T) => ReactNode)?.(record) + : fallback) + ); + } + }, + }); + + // 定义表格的列配置 + const columns: ColumnsType = [ + { + title: t('components:appSetting.interface'), + dataIndex: 'operationName', + ...getColumnSearchProps( + 'operationName', + + Global + , + ), + ...props.builtInColumns?.operationName, + }, + { + title: t('components:appSetting.dependency'), + dataIndex: 'dependencyName', + ...getColumnSearchProps('dependencyName', (record) => ( + + {record.operationName ? 'Entry' : 'Global'} + + )), + ...props.builtInColumns?.dependencyName, + }, + ...(configColumns?.map(({ search, ...column }) => ({ + // TODO + ...(search && column.dataIndex + ? getColumnSearchProps(column.dataIndex, column.renderFallback) + : undefined), + ...column, + })) || []), + { + title: t('components:appSetting.expireOn'), + dataIndex: 'expirationDate', + render: (date: number, record) => + record.expirationType === ExpirationType.PINNED_NEVER_EXPIRED ? ( + + {t('common:neverExpired')} + + ) : ( + dayjs(date).format('YYYY/MM/HH:mm') + ), + ...props.builtInColumns?.expirationDate, + }, + ]; + + return ( + + pagination={false} {...tableProps} columns={columns} /> + + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/AddListSortDrawer.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/AddListSortDrawer.tsx new file mode 100644 index 000000000..4417134c1 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/AddListSortDrawer.tsx @@ -0,0 +1,296 @@ +import { SaveOutlined, SyncOutlined } from '@ant-design/icons'; +import { PaneDrawer, SpaceBetweenWrapper, useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { App, Button, Space, TreeProps, Typography } from 'antd'; +import { CarouselRef } from 'antd/lib/carousel'; +import React, { forwardRef, useImperativeHandle, useState } from 'react'; +import { useImmer } from 'use-immer'; + +import { ComparisonService, ReportService } from '@/services'; +import { DependencyParams, SortNode } from '@/services/ComparisonService'; + +import ArrayTree from './ArrayTree'; +import SortTree from './SortTree'; +import TreeCarousel from './TreeCarousel'; +import { getSortArray } from './utils'; + +enum TreeEditModeEnum { + ArrayTree, + SortTree, +} + +export type AddListSortDrawerProps = { + appId?: string; + operationId?: string; + dependency?: { + operationType?: string; + operationName?: string; + }; + onClose?: () => void; +}; + +export type AddListSortDrawerRef = { + open: (sortArrayPath?: string[]) => void; +}; +const AddListSortDrawer = forwardRef((props, ref) => { + const { message } = App.useApp(); + const { t } = useTranslation(['components', 'common']); + + const treeCarouselRef = React.useRef(null); + const [treeEditMode, setTreeEditMode] = useState(TreeEditModeEnum.ArrayTree); + + const [sortArray, setSortArray] = useState(); + const [activeSortNode, setActiveSortNode] = useState(); + + const [checkedNodesData, setCheckedNodesData] = useImmer<{ + path?: string; + pathKeyList: string[]; + }>({ pathKeyList: [] }); + + // 控制 SortTree 组件防止在获取到有效的 treeData 数据前渲染,导致 defaultExpandAll 失效 + const [treeReady, setTreeReady] = useState(false); + const [openSortModal, setOpenSortModal] = useState(false); + + useImperativeHandle(ref, () => ({ + open: async (sortArrayPath) => { + setOpenSortModal(true); + const res = await Promise.all([queryContract(), querySortNode()]); + handleEditCollapseItem(res[0], (sortArrayPath?.join('/') || '') + '/', res[1][0]); + }, + })); + + const { run: handleSync, loading: syncing } = useRequest( + () => + ReportService.syncResponseContract({ + operationId: props.operationId!, + }), + { + manual: true, + ready: !!props.operationId, + onSuccess: (data) => { + if (data?.dependencyList) { + queryContract(); + } + }, + }, + ); + + /** + * 获取 SortNode + */ + const { + data: sortNodeList = [], + loading: loadingSortNode, + runAsync: querySortNode, + mutate: setSortNodeList, + } = useRequest( + (listPath?: string[]) => + ComparisonService.querySortNode({ + appId: props.appId as string, + operationId: props.operationId, + ...props.dependency, + }), + { + ready: !!props.appId, + refreshDeps: [props.operationId, props.dependency], + onSuccess(res, [listPath]) { + // 新增 SortNode 时设置 activeSortNode, 防止继续新增 + if (listPath) { + const pathKey = listPath.join('_'); + // 获取新增的 node + const node = res.find((node) => node.listPath.join('_') === pathKey); + node && setActiveSortNode(node); + } + // 由于增量调用,取消状态重置 + // props.appId && handleCancelEditResponse(false, false); + }, + }, + ); + + const { + data: contract, + loading: loadingContract, + mutate: setContract, + runAsync: queryContract, + } = useRequest( + () => + ReportService.queryContract({ + appId: props.appId!, + operationId: props.operationId!, + ...props.dependency, + }), + { + manual: true, + ready: !!props.operationId && !loadingSortNode, + refreshDeps: [props.appId, props.operationId, props.dependency], + onBefore() { + setContract(); + }, + }, + ); + + const SaveSortNodeOptions = { + manual: true, + onSuccess(success: boolean) { + if (success) { + setOpenSortModal(false); + // querySortNode(); + // treeCarouselRef.current?.goTo(0); + message.success('Update successfully'); + } else { + message.error('Update failed'); + } + }, + }; + + /** + * 新增 SortNode + */ + const { run: insertSortNode } = useRequest(ComparisonService.insertSortNode, SaveSortNodeOptions); + + /** + * 更新 SortNode + */ + const { run: updateSortNode } = useRequest(ComparisonService.updateSortNode, SaveSortNodeOptions); + + const handleSaveSort = () => { + const params = { + listPath: checkedNodesData?.path?.split('/').filter(Boolean) || [], + keys: checkedNodesData.pathKeyList.map((key) => key?.split('/').filter(Boolean)), + }; + if (activeSortNode) { + updateSortNode({ id: activeSortNode.id, ...params }); + } else if (props.appId && props.operationId) { + const dependencyParams: DependencyParams = { + operationType: props.dependency?.operationType, + operationName: props.dependency?.operationName, + // ...props.dependency, + }; + + insertSortNode({ + appId: props.appId, + operationId: props.operationId, + ...params, + ...dependencyParams, + }); + } + }; + + /** + * 点击 PathCollapseItem 或 ArrayTreeItem 时 + * @param path + * @param sortNode 点击 ArrayTreeItem 新的未配置节点时 sortNode 为 undefined + */ + const handleEditCollapseItem = ( + contract: Record, + path: string, + sortNode?: SortNode, + ) => { + setActiveSortNode(sortNode); + setCheckedNodesData((state) => { + state.path = path; + state.pathKeyList = sortNode?.pathKeyList || []; + }); + + try { + const sa = getSortArray(path, contract, false); + console.log(sa); + setSortArray(sa); + } catch (error) { + console.warn('failed to analytic path'); + } + + setTreeEditMode(TreeEditModeEnum.SortTree); + setTreeReady(true); + + setTimeout(() => treeCarouselRef.current?.goTo(1)); // 防止初始化时 treeCarouselRef 未绑定 + }; + + const handleSortTreeChecked: TreeProps['onCheck'] = (checkedKeys) => { + setCheckedNodesData((state) => { + state.pathKeyList = (checkedKeys as { checked: string[]; halfChecked: string[] }).checked; + }); + }; + + const handleSortTreeSelected: TreeProps['onSelect'] = (selectedKeys) => { + const key = selectedKeys[0] as string; + if (key) { + setCheckedNodesData((state) => { + const includes = state.pathKeyList.includes(key); + state.pathKeyList = includes + ? state.pathKeyList.filter((pathKey) => pathKey !== key) + : [...state.pathKeyList, key]; + }); + } + }; + + /** + * 取消编辑 nodes sort + */ + const handleCancelEdit = () => { + props.onClose?.(); + setTreeEditMode(TreeEditModeEnum.ArrayTree); + treeCarouselRef.current?.goTo(0); + setSortArray(undefined); + setActiveSortNode(undefined); + setOpenSortModal(false); + }; + + return ( + + + + {t('appSetting.nodesSort')} + + + } + onClick={handleSync} + > + {t('appSetting.sync', { ns: 'components' })} + + + {treeEditMode === TreeEditModeEnum.SortTree && ( + } onClick={handleSaveSort}> + {t('save', { ns: 'common' })} + + )} + + } + open={openSortModal} + onClose={handleCancelEdit} + > + setTreeEditMode(to)}> + + handleEditCollapseItem( + contract || {}, + selectedKeys[0] as string, + sortNodeList.find((node) => node.path === selectedKeys[0]), + ) + } + /> + + {treeReady && ( + + )} + + + ); +}); + +export default AddListSortDrawer; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/ArrayTree.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/ArrayTree.tsx new file mode 100644 index 000000000..2e223bc2a --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/ArrayTree.tsx @@ -0,0 +1,59 @@ +import { EmptyWrapper, useTranslation } from '@arextest/arex-core'; +import { css } from '@emotion/react'; +import { Card, Tree, Typography } from 'antd'; +import { TreeProps } from 'antd/es'; +import React, { FC, useMemo } from 'react'; + +import { useColorPrimary } from '@/hooks'; +import { SortNode } from '@/services/ComparisonService'; + +import { getArrayNodes } from './utils'; + +type ResponseTreeProps = Omit & { + loading?: boolean; + sortNodeList?: SortNode[]; + treeData?: object; + onEditResponse?: () => void; +}; + +const ArrayTree: FC = (props) => { + const { t } = useTranslation('components'); + + const color = useColorPrimary(); + + const treeData = useMemo( + () => getArrayNodes(props.treeData || {}, '', props.sortNodeList, color.name), + [props.treeData, props.sortNodeList, color.name], + ); + + return ( + {t('appSetting.chooseOneNode')}} + > + + + + + ); +}; + +export default ArrayTree; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortPathKeyInput.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortPathKeyInput.tsx new file mode 100644 index 000000000..4c40fca2d --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortPathKeyInput.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Form, Input, Select } from 'antd'; +import React from 'react'; + +import { ReportService } from '@/services'; +import { DependencyParams } from '@/services/ComparisonService'; + +export type SortPathKeyInputProps = { + appId: string; + operationId?: string; + dependency?: DependencyParams; +}; + +export default function SortPathKeyInput(props: SortPathKeyInputProps) { + const { appId, operationId, dependency } = props; + const { t } = useTranslation(); + + // const { + // data: contract, + // mutate: setContract, + // loading: loadingContract, + // } = useRequest( + // () => + // ReportService.queryContract({ + // appId, + // operationId, + // ...dependency, + // }), + // { + // ready: !!appId, // TODO && collapseExpand + // refreshDeps: [appId, operationId, dependency], + // onBefore() { + // setContract(); + // }, + // }, + // ); + + return ( + <> + + {/* TODO select path from contract tree */} + + + + + {/* TODO select path from contract tree */} + + + > + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortTree.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortTree.tsx new file mode 100644 index 000000000..3bef2c8da --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/SortTree.tsx @@ -0,0 +1,43 @@ +import { css, EmptyWrapper, useTranslation } from '@arextest/arex-core'; +import { Card, Tree } from 'antd'; +import { TreeProps } from 'antd/es'; +import React, { FC, useMemo } from 'react'; + +import { getSortNodes } from './utils'; + +type SortTreeProps = Omit & { + treeData?: any[]; + title?: string; +}; + +const SortTree: FC = (props) => { + const { t } = useTranslation('components'); + + const treeData = useMemo(() => getSortNodes(props.treeData, ''), [props.treeData]); + return ( + + + + + + ); +}; + +export default SortTree; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/TreeCarousel.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/TreeCarousel.tsx new file mode 100644 index 000000000..82fbacb84 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/TreeCarousel.tsx @@ -0,0 +1,21 @@ +import { styled } from '@arextest/arex-core'; +import { Carousel } from 'antd'; + +const TreeCarousel = styled(Carousel)` + .slick-dots-bottom { + position: relative; + margin: 16px 0 0 0; + } + .slick-dots.slick-dots-bottom { + li > button { + height: 4px; + border-radius: 2px; + background-color: ${(props) => props.theme.colorTextTertiary}!important; + } + * > li.slick-active > button { + background-color: ${(props) => props.theme.colorTextQuaternary}!important; + } + } +`; + +export default TreeCarousel; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/index.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/index.tsx new file mode 100644 index 000000000..8bd41997e --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/index.tsx @@ -0,0 +1,299 @@ +import { CloseOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { css, useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { App, Button, Pagination, Popconfirm, Select, Tag, Tooltip } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; + +import { SearchHighLight } from '@/components'; +import { CompareConfigNewProps } from '@/panes/AppSetting/CompareConfigNew'; +import { ComparisonService } from '@/services'; +import { QuerySortSearchParams, SortNodeBase } from '@/services/ComparisonService'; + +import { AddConfigModalProps, AddConfigModalRef, parseDependency } from '../AddConfigModal'; +import ConfigInfoTable, { CONFIG_INFO_TABLE_MODE, ConfigColumnsType } from '../ConfigInfoTable'; +import { ListSortInfo } from '../type'; + +type ListSortPrivate = { + listPath: string; + keys: string[]; +}; + +export type ListSortProps = CompareConfigNewProps & + Pick, 'operationList'>; + +const PAGE_SIZE = { + SIZE_10: 10, + SIZE_20: 20, + SIZE_30: 30, +}; + +const pageSizeOptions = Object.values(PAGE_SIZE); + +export default function ListSort(props: ListSortProps) { + const { t } = useTranslation(); + const { message } = App.useApp(); + + const addConfigModalRef = useRef(null); + + const [tableMode, setTableMode] = useState( + CONFIG_INFO_TABLE_MODE.DISPLAY, + ); + + const [pagination, setPagination] = useState({ + current: 1, + pageSize: PAGE_SIZE.SIZE_10, + }); + + const [searchParams, setSearchParams] = useState({}); + + useEffect(() => { + handleSearch({ + operationName: props.operation?.operationName, + dependencyName: props.dependency?.operationName, + }); + }, [props.operation, props.dependency, props.operationList]); + + const [selectedRows, setSelectedRows] = useState([]); + + const { + data = { totalCount: 0, listSorts: [] }, + loading, + run: queryAggregateSortNode, + } = useRequest( + () => + ComparisonService.queryAggregateSortNode({ + appId: props.appId, + pageSize: pagination.pageSize, + pageIndex: pagination.current, + ...searchParams, + }), + { + refreshDeps: [pagination, searchParams], + }, + ); + + /** + * 新增 SortNode + */ + const { run: insertSortNode } = useRequest(ComparisonService.insertSortNode, { + manual: true, + onSuccess(success: boolean) { + if (success) { + addConfigModalRef.current?.close(); + queryAggregateSortNode(); + message.success(t('components:replay.compareConfigSuccess')); + } else { + message.error('common:message.createFailed'); + } + }, + }); + + /** + * 删除 SortNode + */ + const { run: deleteSortNode } = useRequest(ComparisonService.deleteSortNode, { + manual: true, + onSuccess(success) { + if (success) { + message.success(t('message.delSuccess', { ns: 'common' })); + addConfigModalRef.current?.close(); + queryAggregateSortNode(); + } else { + message.error(t('message.delFailed', { ns: 'common' })); + } + }, + }); + + const columns: ConfigColumnsType = [ + { + title: t('components:appSetting.path'), + dataIndex: 'listPath', + search: true, + render: (path: string[]) => ( + + ), + }, + { + title: t('components:appSetting.keys'), + dataIndex: 'keys', + search: true, + render: (keys: string[][]) => { + const maxTagCount = searchParams.keyOfValue ? undefined : 2; + return ( + '/' + key.join('/'))} + maxTagCount={maxTagCount} + tagRender={(tag) => + tag.isMaxTag ? ( + + '/' + key.join('/')) + .join(', ')} + > + {keys.length - 2} more ... + + + ) : ( + + + + ) + } + labelRender={(tag) => ( + + )} + maxTagPlaceholder={(omittedValues) => ( + label).join(', ')}> + {omittedValues.length} more... + + )} + css={css` + pointer-events: none; // readonly mode + .ant-select-selection-overflow-item-rest { + pointer-events: all; // enable tooltip hover event + } + .ant-select-selection-overflow-item-suffix { + display: none; + } + `} + /> + ); + }, + }, + ]; + + const TableFooter = () => ( + + + {data.totalCount > pagination.pageSize && ( + { + setPagination({ + current: page, + pageSize, + }); + }} + /> + )} + + + + {tableMode === CONFIG_INFO_TABLE_MODE.DISPLAY ? ( + <> + {/*}*/} + {/* onClick={() => addConfigModalRef.current?.open()}*/} + {/*>*/} + {/* {t('common:add')}*/} + {/**/} + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.EDIT)} + > + {t('common:edit')} + + > + ) : ( + // tableMode === CONFIG_INFO_TABLE_MODE.EDIT + <> + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY)} + > + {t('common:cancel')} + + + } disabled={!selectedRows?.length}> + {t('common:delete')} + + + > + )} + + + ); + + function handleSearch(search: Record) { + if (pagination.current !== 1) { + setPagination({ current: 1, pageSize: pagination.pageSize }); + } + + setSearchParams({ + keyOfOperationName: search.operationName, + keyOfDependencyName: search.dependencyName, + keyOfValue: search.keys, + keyOfListPath: search.listPath, + }); + } + + function handleDelete() { + selectedRows.length && + deleteSortNode({ + id: selectedRows[0].id, + }); + } + + const handleAddListSort: AddConfigModalProps['onSubmit'] = (form) => + form.validateFields().then((res) => { + const { dependency, listPath, keys, ...rest } = res; + const params = { + ...rest, + ...parseDependency(dependency), + listPath: listPath.split('/').filter(Boolean), + keys: keys.map((key) => key.split('/').filter(Boolean)), + } as SortNodeBase; + insertSortNode(params); + }); + + return ( + + + rowKey='id' + requestSearch + loading={loading} + columns={columns} + dataSource={data?.listSorts} + footer={TableFooter} + rowSelection={ + tableMode !== CONFIG_INFO_TABLE_MODE.DISPLAY + ? { + type: 'radio', + onSelect: (record, selected, selectedRows) => { + setSelectedRows(selectedRows.filter(Boolean)); + }, + } + : undefined + } + onSearch={handleSearch} + /> + + {/**/} + {/* ref={addConfigModalRef}*/} + {/* operationList={props.operationList}*/} + {/* appId={props.appId}*/} + {/* title={t('components:appSetting.addListSort')}*/} + {/* rules={{*/} + {/* operationId: [{ required: true }],*/} + {/* }}*/} + {/* field={({ appId, operationId, dependency }) => (*/} + {/* */} + {/* )}*/} + {/* onSubmit={handleAddListSort}*/} + {/*/>*/} + + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/__snapshots__/getArrayNodes.test.ts.snap b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/__snapshots__/getArrayNodes.test.ts.snap new file mode 100644 index 000000000..e69de29bb diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.test.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.test.ts new file mode 100644 index 000000000..41d2c2824 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'vitest'; + +import { getArrayNodes } from './getArrayNodes'; + +describe('getArrayNode', () => { + test('no array object', () => { + const object = { name: 'Helen', age: 20 }; + expect(getArrayNodes(object)).toMatchSnapshot(); + }); + + test('object with simple array and sortNodeList', () => { + const object = { + responsestatustype: { + responsecode: 1, + responsedesc: 'String', + timestamp: 1, + }, + body: [ + { + name: 'String', + id: 1, + age: 1, + }, + ], + }; + expect( + getArrayNodes( + object, + '', + [ + { + id: '', + appId: '', + operationId: '', + modifiedTime: 0, + expirationType: 0, + expirationDate: 0, + listPath: ['body'], + keys: [['name'], ['id'], ['age']], + path: 'body/', + pathKeyList: ['name/', 'id/', 'age/'], + status: null, + }, + ], + 'green', + ), + ).toMatchSnapshot(); + }); + + test('object with nest array and sortNodeList', () => { + const object = { + responsestatustype: { + responsecode: 1, + responsedesc: 'String', + timestamp: 1, + }, + body: [ + { + name: 'String', + id: 1, + age: 1, + }, + ], + nestArray: [ + [ + { + name: 'String', + id: 1, + age: 1, + }, + ], + ], + }; + + expect( + getArrayNodes( + object, + '', + [ + { + id: '', + appId: '', + operationId: '', + modifiedTime: 0, + expirationType: 0, + expirationDate: 0, + listPath: ['body'], + keys: [['name'], ['id'], ['age']], + path: 'body/', + pathKeyList: ['name/', 'id/', 'age/'], + status: null, + }, + { + id: '', + appId: '', + operationId: '', + modifiedTime: 0, + expirationType: 0, + expirationDate: 0, + listPath: ['nestArray'], + keys: [['id'], ['age']], + path: 'nestArray/', + pathKeyList: ['id/', 'age/'], + status: null, + }, + ], + 'green', + ), + ).toMatchSnapshot(); + }); +}); diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.tsx new file mode 100644 index 000000000..76fd672ba --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayNodes.tsx @@ -0,0 +1,55 @@ +import { Badge } from 'antd'; +import { DataNode } from 'antd/lib/tree'; +import React from 'react'; + +import { getArrayValidElement } from '@/panes/AppSetting/CompareConfig/NodesSort/utils/getArrayValidElement'; +import { SortNode } from '@/services/ComparisonService'; + +/** + * 获取 object 的数组类型节点,并为已配置过的数组节点添加圆点提示 + * @param object 契约对象 + * @param basePath 节点路径 + * @param sortNodeList 已配置的节点列表 + * @param color 圆点颜色 + */ +export function getArrayNodes( + object: object, + basePath = '', + sortNodeList?: SortNode[], + color?: string, +): DataNode[] { + const entries = Object.entries(object); + return ( + entries + .map(([key, value]) => { + const losslessValue = value.isLosslessNumber ? value.value : value; + + const path = basePath + key + '/'; + return losslessValue && typeof losslessValue === 'object' + ? { + title: key, + key: path, + children: getArrayNodes( + Array.isArray(losslessValue) + ? getArrayValidElement(losslessValue) || {} + : losslessValue, + path, + sortNodeList, + color, + ), + disabled: !Array.isArray(losslessValue), + icon: sortNodeList?.find((node) => node.path === path)?.pathKeyList?.length && ( + // 已配置过的节点使用圆点进行提示 + ), + } + : { + title: key, + key: path, + value: losslessValue, + disabled: !Array.isArray(losslessValue), + }; + }) + // 过滤非数组子节点 + .filter((item) => item.children || Array.isArray(item.value)) + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.test.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.test.ts new file mode 100644 index 000000000..d6950dd16 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; + +import { getArrayValidElement } from './getArrayValidElement'; + +describe('getArrayValidElement', () => { + test('simple array', () => { + const array = [1, 2, 3]; + expect(getArrayValidElement(array)).toEqual(1); + }); + + test('complex array', () => { + const array = [ + { name: 'Helen', age: 20 }, + { name: 'Jesse', age: 22 }, + ]; + expect(getArrayValidElement(array)).toEqual({ name: 'Helen', age: 20 }); + }); + + test('nested array', () => { + const array = [ + [1, 2, 3], + [4, 5, 6], + ]; + expect(getArrayValidElement(array)).toEqual(1); + }); + + test('nested complex array', () => { + const array = [[{ name: 'Helen', age: 20 }], [{ name: 'Jesse', age: 22 }]]; + expect(getArrayValidElement(array)).toEqual({ name: 'Helen', age: 20 }); + }); + + test('empty array', () => { + const array: any[] = []; + expect(getArrayValidElement(array)).toEqual(undefined); + }); +}); diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.ts new file mode 100644 index 000000000..31a7f1c8d --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getArrayValidElement.ts @@ -0,0 +1,8 @@ +/** + * 获取一个数组中的有效元素(非数组类型的对象或者基本数据类型) + * @param array + */ +export function getArrayValidElement(array: any[]): any { + const sample = array[0]; + return Array.isArray(sample) ? getArrayValidElement(sample) : sample; +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortArray.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortArray.ts new file mode 100644 index 000000000..362f0a9e9 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortArray.ts @@ -0,0 +1,40 @@ +import { NodesSortProps } from '@/panes/AppSetting/CompareConfig/NodesSort'; +import { getArrayValidElement } from '@/panes/AppSetting/CompareConfig/NodesSort/utils/getArrayValidElement'; + +/** + * 根据 path获取 contract 的非数组元素节点 + * @param key + * @param contract + * @param caseSensitivity + */ +export function getSortArray( + key: string, + contract: NodesSortProps['contractParsed'], + caseSensitivity: boolean = true, +) { + let value: any = undefined; + console.log(key, contract, caseSensitivity); + key + .split('/') + .filter(Boolean) + .forEach((k, i) => { + value = + i === 0 + ? lowercaseObjectKeys(contract, caseSensitivity)?.[k] + : Array.isArray(value) + ? lowercaseObjectKeys(getArrayValidElement(value), caseSensitivity)?.[k] + : lowercaseObjectKeys(value, caseSensitivity)?.[k]; + }); + + console.log({ value }); + return value; +} + +function lowercaseObjectKeys(obj?: Record, disabled: boolean = false) { + if (disabled) return obj; + const newObj: Record = {}; + for (const key in obj) { + newObj[key.toLowerCase()] = obj[key]; + } + return newObj; +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortNodes.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortNodes.ts new file mode 100644 index 000000000..5f585c19a --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/getSortNodes.ts @@ -0,0 +1,31 @@ +import { DataNode } from 'antd/lib/tree'; + +import { getArrayValidElement } from '@/panes/AppSetting/CompareConfig/NodesSort/utils/getArrayValidElement'; + +export function getSortNodes(data?: any[] | object, basePath = ''): DataNode[] { + if (!data || (Array.isArray(data) && !data?.length)) return []; + + const sample = Array.isArray(data) ? getArrayValidElement(data) : data; + + if (['number', 'string'].includes(typeof sample)) + return [{ title: '%value%', key: basePath + '%value%/' }]; + + const entries = Object.entries(sample); + return entries.map(([key, value]) => { + const losslessValue = value.isLosslessNumber ? value.value : value; + + const path = basePath + key + '/'; + return losslessValue && typeof losslessValue === 'object' + ? { + title: key, + key: path, + children: getSortNodes( + Array.isArray(losslessValue) + ? getArrayValidElement(losslessValue) || [] + : losslessValue, + path, + ), + } + : { title: key, key: path, value: path }; + }); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/index.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/index.ts new file mode 100644 index 000000000..638e7f23b --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/ListSort/utils/index.ts @@ -0,0 +1,4 @@ +export { getArrayNodes } from './getArrayNodes'; +export { getArrayValidElement } from './getArrayValidElement'; +export { getSortArray } from './getSortArray'; +export { getSortNodes } from './getSortNodes'; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnorePathInput.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnorePathInput.tsx new file mode 100644 index 000000000..748c16450 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnorePathInput.tsx @@ -0,0 +1,92 @@ +import { DownOutlined } from '@ant-design/icons'; +import { + css, + styled, + TooltipButton, + tryParseJsonString, + useTranslation, +} from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Collapse, Input, InputProps, Space, Typography } from 'antd'; +import React, { useMemo, useState } from 'react'; + +import { ReportService } from '@/services'; +import { DependencyParams } from '@/services/ComparisonService'; + +import IgnoreTree from './IgnoreTree'; + +const IgnoreTreeWrapper = styled.div<{ lineThrough?: boolean }>` + .ant-tree-node-selected { + text-decoration: ${(props) => (props.lineThrough ? 'line-through' : 'none')}; + } +`; + +export type ExclusionPathInputProps = Omit & { + contract?: Record; + onChange?: (value: string) => void; + loadingContract?: boolean; +}; + +const IgnorePathInput = (props: ExclusionPathInputProps) => { + const { contract, loadingContract, ...inputProps } = props; + const { t } = useTranslation(); + + const [expand, setExpand] = useState(false); + + const [selectedKeys, setSelectedKeys] = useState(); + + return ( + + + { + props.onChange?.(e.target.value); + }} + /> + } + textProps={{ style: { height: '10px' } }} + onClick={() => setExpand(!expand)} + /> + + + { + setSelectedKeys(keys); + props.onChange?.(keys.join('/')); + }} + treeData={contract} + /> + ), + }, + ]} + /> + + ); +}; + +export default IgnorePathInput; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnoreTree.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnoreTree.tsx new file mode 100644 index 000000000..7190b457f --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnoreTree.tsx @@ -0,0 +1,40 @@ +import { EmptyWrapper, styled, useTranslation } from '@arextest/arex-core'; +import { Card, Tree } from 'antd'; +import { TreeProps } from 'antd/es'; +import React, { FC, useMemo } from 'react'; + +import { getIgnoreNodes } from './utils'; + +type IgnoreTreeProps = Omit & { + title?: React.ReactNode; + loading?: boolean; + treeData?: Record; + lineThrough?: boolean; +}; + +const IgnoreTreeWrapper = styled.div<{ lineThrough?: boolean }>` + .ant-tree-node-selected { + text-decoration: ${(props) => (props.lineThrough ? 'line-through' : 'none')}; + } +`; + +const IgnoreTree: FC = (props) => { + const { t } = useTranslation(['components', 'common']); + const treeData = useMemo(() => getIgnoreNodes(props.treeData, ''), [props.treeData]); + + return ( + + + + + + + + ); +}; + +export default IgnoreTree; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/index.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/index.tsx new file mode 100644 index 000000000..eac6fcf2a --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/index.tsx @@ -0,0 +1,243 @@ +import { CloseOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { App, Button, Form, Pagination, Popconfirm } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; + +import { SearchHighLight } from '@/components'; +import { CompareConfigNewProps } from '@/panes/AppSetting/CompareConfigNew'; +import AddConfigModal, { + AddConfigModalProps, + AddConfigModalRef, + parseDependency, +} from '@/panes/AppSetting/CompareConfigNew/AddConfigModal'; +import IgnorePathInput from '@/panes/AppSetting/CompareConfigNew/NodeIgnore/IgnorePathInput'; +import { ComparisonService } from '@/services'; +import { IgnoreNodeBase, QueryIgnoreSearchParams } from '@/services/ComparisonService'; + +import ConfigInfoTable, { CONFIG_INFO_TABLE_MODE, ConfigColumnsType } from '../ConfigInfoTable'; +import { ExclusionInfo } from '../type'; + +type NodeIgnorePrivate = { + exclusions: string; +}; + +export type NodeIgnoreProps = CompareConfigNewProps & + Pick, 'operationList'>; + +const PAGE_SIZE = { + SIZE_10: 10, + SIZE_20: 20, + SIZE_30: 30, +}; + +const pageSizeOptions = Object.values(PAGE_SIZE); + +export default function NodeIgnore(props: NodeIgnoreProps) { + const { t } = useTranslation(); + const { message } = App.useApp(); + + const addConfigModalRef = useRef(null); + + const [tableMode, setTableMode] = useState( + CONFIG_INFO_TABLE_MODE.DISPLAY, + ); + + const [pagination, setPagination] = useState({ + current: 1, + pageSize: PAGE_SIZE.SIZE_10, + }); + + const [searchParams, setSearchParams] = useState({}); + + useEffect(() => { + handleSearch({ + operationName: props.operation?.operationName, + dependencyName: props.dependency?.operationName, + }); + }, [props.operation, props.dependency, props.operationList]); + + const [selectedRows, setSelectedRows] = useState([]); + + const columns: ConfigColumnsType = [ + { + title: t('components:appSetting.path'), + dataIndex: 'exclusionPath', + search: true, + render: (path: string[]) => ( + + ), + }, + ]; + + const { + data = { totalCount: 0, exclusions: [] }, + loading, + run: queryAggregateIgnoreNode, + } = useRequest( + () => + ComparisonService.queryAggregateIgnoreNode({ + appId: props.appId, + pageSize: pagination.pageSize, + pageIndex: pagination.current, + ...searchParams, + }), + { + refreshDeps: [pagination, searchParams], + }, + ); + + /** + * 批量删除 IgnoreNode + */ + const { run: batchDeleteIgnoreNode } = useRequest(ComparisonService.batchDeleteIgnoreNode, { + manual: true, + onSuccess(success) { + if (success) { + message.success(t('common:message.delSuccess')); + setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY); + queryAggregateIgnoreNode(); + } else { + message.error(t('common:message.delFailed')); + } + }, + }); + + const { run: insertIgnoreNode } = useRequest(ComparisonService.insertIgnoreNode, { + manual: true, + onSuccess(success) { + if (success) { + message.success(t('message.updateSuccess', { ns: 'common' })); + addConfigModalRef.current?.close(); + queryAggregateIgnoreNode(); + } else { + message.error(t('message.updateFailed', { ns: 'common' })); + } + }, + }); + + function handleSearch(search: Record) { + if (pagination.current !== 1) { + setPagination({ current: 1, pageSize: pagination.pageSize }); + } + + setSearchParams({ + keyOfOperationName: search.operationName, + keyOfDependencyName: search.dependencyName, + keyOfExclusionPath: search.exclusionPath, + }); + } + + function handleDelete() { + batchDeleteIgnoreNode(selectedRows.map((row) => ({ id: row.id }))); + } + + const TableFooter = () => ( + + + {data.totalCount > pagination.pageSize && ( + { + setPagination({ + current: page, + pageSize, + }); + }} + /> + )} + + + + {tableMode === CONFIG_INFO_TABLE_MODE.DISPLAY ? ( + <> + } + onClick={() => addConfigModalRef.current?.open()} + > + {t('common:add')} + + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.EDIT)} + > + {t('common:edit')} + + > + ) : ( + // tableMode === CONFIG_INFO_TABLE_MODE.EDIT + <> + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY)} + > + {t('common:cancel')} + + + } disabled={!selectedRows?.length}> + {t('common:delete')} + + + > + )} + + + ); + + const handleAddIgnore: AddConfigModalProps['onSubmit'] = (form) => + form.validateFields().then((res) => { + const { dependency, exclusions, ...rest } = res; + const params = { + ...rest, + ...parseDependency(dependency), + exclusions: exclusions?.split('/').filter(Boolean), + } as IgnoreNodeBase; + insertIgnoreNode(params); + }); + + return ( + + + rowKey='id' + requestSearch + loading={loading} + columns={columns} + dataSource={data?.exclusions} + footer={TableFooter} + rowSelection={ + tableMode !== CONFIG_INFO_TABLE_MODE.DISPLAY + ? { + onSelect: (record, selected, selectedRows) => { + setSelectedRows(selectedRows.filter(Boolean)); + }, + } + : undefined + } + onSearch={handleSearch} + /> + + + ref={addConfigModalRef} + title={t('components:appSetting.addNodesIgnore')} + appId={props.appId} + operationList={props.operationList} + field={({ contract, loadingContract }) => ( + + + + )} + onSubmit={handleAddIgnore} + /> + + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/__snapshots__/getIgnoreNodes.test.ts.snap b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/__snapshots__/getIgnoreNodes.test.ts.snap new file mode 100644 index 000000000..2246fac6c --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/__snapshots__/getIgnoreNodes.test.ts.snap @@ -0,0 +1,78 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`convert sort tree > complex object 1`] = ` +[ + { + "key": "name/", + "title": "name", + "value": "Helen", + }, + { + "key": "age/", + "title": "age", + "value": 20, + }, + { + "key": "hobby/", + "title": "hobby", + "value": [ + "music", + "football", + ], + }, + { + "children": [ + { + "key": "assets/house/", + "title": "house", + "value": "shanghai", + }, + { + "key": "assets/car/", + "title": "car", + "value": [ + "BMW", + "Benz", + ], + }, + { + "key": "assets/savings/", + "title": "savings", + "value": 1000000, + }, + ], + "key": "assets/", + "title": "assets", + }, +] +`; + +exports[`convert sort tree > lossless json string 1`] = ` +[ + { + "key": "name/", + "title": "name", + "value": "Helen", + }, + { + "key": "age/", + "title": "age", + "value": "20.00000000", + }, +] +`; + +exports[`convert sort tree > simple object 1`] = ` +[ + { + "key": "name/", + "title": "name", + "value": "Helen", + }, + { + "key": "age/", + "title": "age", + "value": 20, + }, +] +`; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.test.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.test.ts new file mode 100644 index 000000000..78641beae --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.test.ts @@ -0,0 +1,30 @@ +import { tryParseJsonString } from '@arextest/arex-core'; +import { describe, expect, test } from 'vitest'; + +import { getIgnoreNodes } from './getIgnoreNodes'; + +describe('convert sort tree', () => { + test('simple object', () => { + const objectData = { name: 'Helen', age: 20 }; + expect(getIgnoreNodes(objectData)).toMatchSnapshot(); + }); + + test('complex object', () => { + const objectData = { + name: 'Helen', + age: 20, + hobby: ['music', 'football'], + assets: { + house: 'shanghai', + car: ['BMW', 'Benz'], + savings: 1000000, + }, + }; + expect(getIgnoreNodes(objectData)).toMatchSnapshot(); + }); + + test('lossless json string', () => { + const jsonString = '{"name":"Helen","age":20.00000000}'; + expect(getIgnoreNodes(tryParseJsonString(jsonString) as object)).toMatchSnapshot(); + }); +}); diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.ts new file mode 100644 index 000000000..8b1d38219 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/getIgnoreNodes.ts @@ -0,0 +1,35 @@ +import { DataNode } from 'antd/lib/tree'; + +import { getArrayValidElement } from '@/panes/AppSetting/CompareConfig/NodesSort/utils'; + +/** + * 获取 object 的节点信息 + * @param object + * @param basePath + */ +export function getIgnoreNodes(object?: object, basePath = ''): DataNode[] { + if (!object) return []; + + const entries = Object.entries(object); + return entries.map(([key, value]) => { + const losslessValue = value.isLosslessNumber ? value.value : value; + const isSimpleArray = + Array.isArray(losslessValue) && ['number', 'string'].includes(typeof losslessValue[0]); + const isObject = typeof losslessValue === 'object'; + + const path = basePath + key + '/'; + + return losslessValue && isObject && !isSimpleArray + ? { + title: key, + key: path, + children: getIgnoreNodes( + Array.isArray(losslessValue) + ? getArrayValidElement(losslessValue) || {} + : losslessValue, + path, + ), + } + : { title: key, key: path, value: losslessValue }; + }); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/index.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/index.ts new file mode 100644 index 000000000..1d721d9f5 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodeIgnore/utils/index.ts @@ -0,0 +1 @@ +export { getIgnoreNodes } from './getIgnoreNodes'; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/RootTransformInput.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/RootTransformInput.tsx new file mode 100644 index 000000000..c1d8d4c91 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/RootTransformInput.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Select } from 'antd'; +import React, { FC, useMemo } from 'react'; + +import { ComparisonService } from '@/services'; + +export type RootTransformInputProps = { + appId: string; + value?: string; + onChange?: (method: string) => void; +}; + +const RootTransformInput: FC = (props) => { + const { appId, ...selectProps } = props; + const { t } = useTranslation(); + + const { data: methods = [] } = useRequest(ComparisonService.getTransformMethod, { + ready: !!appId, + defaultParams: [appId], + }); + + const methodsOptions = useMemo( + () => + methods.map((method) => ({ + value: method, + label: method, + })), + [methods], + ); + + return ( + + ); +}; + +export default RootTransformInput; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/index.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/index.tsx new file mode 100644 index 000000000..dac32c3f0 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/NodesTransform/index.tsx @@ -0,0 +1,244 @@ +import { CloseOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; +import { useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { App, Button, Pagination, Popconfirm } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; + +import { CompareConfigNewProps } from '@/panes/AppSetting/CompareConfigNew'; +import { + AddConfigModalProps, + AddConfigModalRef, + parseDependency, +} from '@/panes/AppSetting/CompareConfigNew/AddConfigModal'; +import ConfigInfoTable, { + CONFIG_INFO_TABLE_MODE, + ConfigColumnsType, +} from '@/panes/AppSetting/CompareConfigNew/ConfigInfoTable'; +import { RootTransformInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { ComparisonService } from '@/services'; +import { + QueryRootTransformSearchParams, + UpdateTransformRootNodeReq, +} from '@/services/ComparisonService'; + +const PAGE_SIZE = { + SIZE_10: 10, + SIZE_20: 20, + SIZE_30: 30, +}; + +const pageSizeOptions = Object.values(PAGE_SIZE); + +export type NodeTransformProps = CompareConfigNewProps & + Pick, 'operationList'>; + +const NodesTransform = (props: NodeTransformProps) => { + const { t } = useTranslation(); + const { message } = App.useApp(); + + const addConfigModalRef = useRef(null); + + const [tableMode, setTableMode] = useState( + CONFIG_INFO_TABLE_MODE.DISPLAY, + ); + + const [selectedRows, setSelectedRows] = useState([]); + + const [pagination, setPagination] = useState({ + current: 1, + pageSize: PAGE_SIZE.SIZE_10, + }); + + const [searchParams, setSearchParams] = useState({}); + + useEffect(() => { + handleSearch({ + operationName: props.operation?.operationName, + dependencyName: props.dependency?.operationName, + }); + }, [props.operation, props.dependency, props.operationList]); + + const { + data = { rootTransformInfos: [], totalCount: 0 }, + loading, + run: queryAggregateRootTransform, + } = useRequest( + () => + ComparisonService.queryAggregateRootTransform({ + appId: props.appId, + pageSize: pagination.pageSize, + pageIndex: pagination.current, + ...searchParams, + }), + { + refreshDeps: [pagination, searchParams], + }, + ); + + const { run: updateTransformNode } = useRequest(ComparisonService.updateTransformRootNode, { + manual: true, + onSuccess: (success) => { + if (success) { + queryAggregateRootTransform(); + message.success(t('common:message.updateSuccess')); + } else message.error(t('common:message.updateFailed')); + }, + }); + + const { run: deleteTransformRootNode } = useRequest(ComparisonService.deleteTransformRootNode, { + manual: true, + onSuccess: (success) => { + if (success) { + setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY); + queryAggregateRootTransform(); + message.success(t('common:message.updateSuccess')); + } else message.error(t('common:message.updateFailed')); + }, + }); + + const columns: ConfigColumnsType = [ + { + dataIndex: 'transformMethodName', + title: t('components:appSetting.transformMethodName'), + search: true, + }, + ]; + + const TableFooter = () => ( + + + {data.totalCount > pagination.pageSize && ( + { + setPagination({ + current: page, + pageSize, + }); + }} + /> + )} + + + + {tableMode === CONFIG_INFO_TABLE_MODE.DISPLAY ? ( + <> + {/*}*/} + {/* onClick={() => addConfigModalRef.current?.open()}*/} + {/*>*/} + {/* {t('common:add')}*/} + {/**/} + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.EDIT)} + > + {t('common:edit')} + + > + ) : ( + // tableMode === CONFIG_INFO_TABLE_MODE.EDIT + <> + } + onClick={() => setTableMode(CONFIG_INFO_TABLE_MODE.DISPLAY)} + > + {t('common:cancel')} + + + } disabled={!selectedRows?.length}> + {t('common:delete')} + + + > + )} + + + ); + + const handleSubmit: AddConfigModalProps['onSubmit'] = (form) => + form.validateFields().then(({ dependency, ...rest }) => { + const params = { + ...parseDependency(dependency), + ...rest, + } as UpdateTransformRootNodeReq; + updateTransformNode(params); + }); + + function handleSearch(search: Record) { + if (pagination.current !== 1) { + setPagination({ current: 1, pageSize: pagination.pageSize }); + } + + setSearchParams({ + keyOfOperationName: search.operationName, + keyOfDependencyName: search.dependencyName, + keyOfMethodName: search.transformMethodName, + }); + } + + function handleDelete() { + const id = selectedRows[0]?.id; + if (id) deleteTransformRootNode(id); + } + + return ( + <> + + rowKey='id' + requestSearch + loading={loading} + columns={columns} + dataSource={data.rootTransformInfos} + footer={TableFooter} + rowSelection={ + tableMode !== CONFIG_INFO_TABLE_MODE.DISPLAY + ? { + type: 'radio', + onSelect: (record, selected, selectedRows) => { + setSelectedRows(selectedRows.filter(Boolean)); + }, + } + : undefined + } + onSearch={handleSearch} + /> + + {/**/} + {/* ref={addConfigModalRef}*/} + {/* title={t('components:appSetting.addTransformNode')}*/} + {/* appId={props.appId}*/} + {/* operationList={props.operationList}*/} + {/* rules={{*/} + {/* operationId: [*/} + {/* {*/} + {/* required: true,*/} + {/* },*/} + {/* ],*/} + {/* }}*/} + {/* field={(fieldProps) => (*/} + {/* */} + {/* */} + {/* */} + {/* )}*/} + {/* onSubmit={handleSubmit}*/} + {/*/>*/} + > + ); +}; + +export default NodesTransform; diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/index.tsx b/packages/arex/src/panes/AppSetting/CompareConfigNew/index.tsx new file mode 100644 index 000000000..14ca6c2a0 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/index.tsx @@ -0,0 +1,113 @@ +import { Label, useTranslation } from '@arextest/arex-core'; +import { useRequest } from 'ahooks'; +import { Tabs, TabsProps, Tag } from 'antd'; +import React, { useState } from 'react'; + +import CategoryIgnore from '@/panes/AppSetting/CompareConfigNew/CategoryIgnore'; +import NodesTransform from '@/panes/AppSetting/CompareConfigNew/NodesTransform'; +import { ApplicationService } from '@/services'; + +import ListSort from './ListSort'; +import NodeIgnore from './NodeIgnore'; + +export type CompareConfigNewProps = { + appId: string; + operation?: { + operationId?: string; + operationName?: string; + }; + dependency?: { + operationName?: string; + operationType?: string; + }; +}; + +export default function CompareConfigNew(props: CompareConfigNewProps) { + const { t } = useTranslation('components'); + + const [operation, setOperation] = useState(props.operation); + const [dependency, setDependency] = useState(props.dependency); + + /** + * 请求 InterfacesList + */ + const { data: operationList = [] } = useRequest( + () => ApplicationService.queryInterfacesList<'Interface'>({ appId: props.appId as string }), + { + ready: !!props.appId, + }, + ); + + const compareConfigProps = { + appId: props.appId, + operation, + dependency, + operationList, + }; + + const compareConfigItems: TabsProps['items'] = [ + { + key: 'nodeIgnore', + label: t('appSetting.nodesIgnore'), + children: , + }, + { + key: 'nodeSort', + label: t('appSetting.listSort'), + children: , + }, + { + key: 'categoryIgnore', + label: t('appSetting.categoryIgnore'), + children: , + }, + { + key: 'nodeTransform', + label: t('appSetting.nodesTransform'), + children: , + }, + ]; + + return ( + + + {(operation || dependency?.operationName) && ( + {t('common:filter')} + )} + + {operation && !dependency?.operationName && ( + { + setOperation(undefined); + }} + > + {t('components:appSetting.entryPoint')}: {props.operation?.operationName} + + )} + + {dependency?.operationName && ( + { + setOperation(undefined); + setDependency(undefined); + }} + > + {t('components:appSetting.dependency')}: + {`${props.dependency?.operationName} - ${props.dependency?.operationType}`} + + )} + + } + /> + + ); +} diff --git a/packages/arex/src/panes/AppSetting/CompareConfigNew/type.ts b/packages/arex/src/panes/AppSetting/CompareConfigNew/type.ts new file mode 100644 index 000000000..3893367e7 --- /dev/null +++ b/packages/arex/src/panes/AppSetting/CompareConfigNew/type.ts @@ -0,0 +1,38 @@ +import { IgnoreCategory } from '@/services/ComparisonService'; + +export enum CONFIG_TARGET { + GLOBAL, + INTERFACE, + DEPENDENCY, +} + +export enum ExpirationType { + PINNED_NEVER_EXPIRED, + SOFT_TIME_EXPIRED, +} + +export type ComparisonConfigInfo = { + id: string; + operationName: string; + dependencyName: string; + dependencyType: string; + expirationDate: number; + expirationType: ExpirationType; +}; + +export interface ExclusionInfo extends ComparisonConfigInfo { + exclusionPath: string[]; +} + +export interface ListSortInfo extends ComparisonConfigInfo { + listPath: string[]; + keys: string[][]; +} + +export interface IgnoreCategoryInfo extends ComparisonConfigInfo { + ignoreCategoryDetail: IgnoreCategory; +} + +export interface RootTransformInfo extends ComparisonConfigInfo { + transformMethodName: string; +} diff --git a/packages/arex/src/services/ComparisonService/index.ts b/packages/arex/src/services/ComparisonService/index.ts index 5cb0f0330..55ada8889 100644 --- a/packages/arex/src/services/ComparisonService/index.ts +++ b/packages/arex/src/services/ComparisonService/index.ts @@ -11,6 +11,10 @@ export * from './insertIgnoreCategory'; export * from './insertIgnoreNode'; export * from './insertSortNode'; export * from './insertTransformNode'; +export * from './queryAggregateIgnoreCategory'; +export * from './queryAggregateIgnoreNode'; +export * from './queryAggregateRootTransform'; +export * from './queryAggregateSortNode'; export * from './queryCategoryType'; export * from './queryEncryptionNode'; export * from './queryIgnoreCategory'; diff --git a/packages/arex/src/services/ComparisonService/insertIgnoreCategory.ts b/packages/arex/src/services/ComparisonService/insertIgnoreCategory.ts index f394b98d8..8467c96e0 100644 --- a/packages/arex/src/services/ComparisonService/insertIgnoreCategory.ts +++ b/packages/arex/src/services/ComparisonService/insertIgnoreCategory.ts @@ -5,6 +5,7 @@ export type IgnoreCategory = { operationType: string; operationName?: string; }; + export interface InsertIgnoreCategoryReq extends QueryNodeReq<'Interface'> { ignoreCategoryDetail: IgnoreCategory; } diff --git a/packages/arex/src/services/ComparisonService/queryAggregateIgnoreCategory.ts b/packages/arex/src/services/ComparisonService/queryAggregateIgnoreCategory.ts new file mode 100644 index 000000000..d9a491400 --- /dev/null +++ b/packages/arex/src/services/ComparisonService/queryAggregateIgnoreCategory.ts @@ -0,0 +1,34 @@ +import { IgnoreCategoryInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { PagingResponse } from '@/services/type'; +import { request } from '@/utils'; + +import { PageQueryComparisonReq } from './queryAggregateIgnoreNode'; + +export type QueryIgnoreCategorySearchParams = { + // operationIds?: (string | null)[]; // use null search for global + // dependencyIds?: (string | null)[]; + keyOfOperationName?: string; + keyOfIgnoreOperationType?: string; + keyOfIgnoreOperationName?: string; +}; + +export type PageQueryIgnoreCategoryComparisonReq = + PageQueryComparisonReq; + +export type PageQueryIgnoreCategoryComparisonRes = PagingResponse<{ + ignoreCategories: IgnoreCategoryInfo[]; +}>; + +export async function queryAggregateIgnoreCategory(params: PageQueryIgnoreCategoryComparisonReq) { + const { pageIndex = 1, pageSize = 10, needTotal = true, ...restParams } = params; + const res = await request.post( + '/webApi/config/comparison/ignoreCategory/pageQueryComparisonConfig', + { + pageIndex, + pageSize, + needTotal, + ...restParams, + }, + ); + return res.body; +} diff --git a/packages/arex/src/services/ComparisonService/queryAggregateIgnoreNode.ts b/packages/arex/src/services/ComparisonService/queryAggregateIgnoreNode.ts new file mode 100644 index 000000000..c3a623293 --- /dev/null +++ b/packages/arex/src/services/ComparisonService/queryAggregateIgnoreNode.ts @@ -0,0 +1,34 @@ +import { ExclusionInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { PagingRequest, PagingResponse } from '@/services/type'; +import { request } from '@/utils'; + +export type PageQueryComparisonReq> = PagingRequest<{ + appId: string; +}> & + T; + +export type QueryIgnoreSearchParams = { + // operationIds?: (string | null)[]; // use null search for global + // dependencyIds?: (string | null)[]; + keyOfOperationName?: string; + keyOfDependencyName?: string; + keyOfExclusionPath?: string; +}; + +export type PageQueryIgnoreComparisonReq = PageQueryComparisonReq; + +export type PageQueryIgnoreComparisonRes = PagingResponse<{ exclusions: ExclusionInfo[] }>; + +export async function queryAggregateIgnoreNode(params: PageQueryIgnoreComparisonReq) { + const { pageIndex = 1, pageSize = 10, needTotal = true, ...restParams } = params; + const res = await request.post( + '/webApi/config/comparison/exclusions/pageQueryComparisonConfig', + { + pageIndex, + pageSize, + needTotal, + ...restParams, + }, + ); + return res.body; +} diff --git a/packages/arex/src/services/ComparisonService/queryAggregateRootTransform.ts b/packages/arex/src/services/ComparisonService/queryAggregateRootTransform.ts new file mode 100644 index 000000000..fc50c85f4 --- /dev/null +++ b/packages/arex/src/services/ComparisonService/queryAggregateRootTransform.ts @@ -0,0 +1,33 @@ +import { RootTransformInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { PagingResponse } from '@/services/type'; +import { request } from '@/utils'; + +import { PageQueryComparisonReq } from './queryAggregateIgnoreNode'; + +export type QueryRootTransformSearchParams = { + // operationIds?: (string | null)[]; // use null search for global + // dependencyIds?: (string | null)[]; + keyOfOperationName?: string; + keyOfDependencyName?: string; + keyOfMethodName?: string; +}; + +export type PageQueryIgnoreComparisonReq = PageQueryComparisonReq; + +export type PageQueryRootTransformComparisonRes = PagingResponse<{ + rootTransformInfos: RootTransformInfo[]; +}>; + +export async function queryAggregateRootTransform(params: PageQueryIgnoreComparisonReq) { + const { pageIndex = 1, pageSize = 10, needTotal = true, ...restParams } = params; + const res = await request.post( + '/webApi/config/comparison/rootTransform/pageQueryComparisonConfig', + { + pageIndex, + pageSize, + needTotal, + ...restParams, + }, + ); + return res.body; +} diff --git a/packages/arex/src/services/ComparisonService/queryAggregateSortNode.ts b/packages/arex/src/services/ComparisonService/queryAggregateSortNode.ts new file mode 100644 index 000000000..74468f220 --- /dev/null +++ b/packages/arex/src/services/ComparisonService/queryAggregateSortNode.ts @@ -0,0 +1,31 @@ +import { ListSortInfo } from '@/panes/AppSetting/CompareConfigNew/type'; +import { PagingResponse } from '@/services/type'; +import { request } from '@/utils'; + +import { PageQueryComparisonReq } from './queryAggregateIgnoreNode'; + +export type QuerySortSearchParams = { + // operationIds?: (string | null)[]; // use null search for global + // dependencyIds?: (string | null)[]; + keyOfOperationName?: string; + keyOfDependencyName?: string; + keyOfValue?: string; + keyOfListPath?: string; +}; + +export type PageQuerySortComparisonReq = PageQueryComparisonReq; +export type PageQuerySortComparisonRes = PagingResponse<{ listSorts: ListSortInfo[] }>; + +export async function queryAggregateSortNode(params: PageQuerySortComparisonReq) { + const { pageIndex = 1, pageSize = 10, needTotal = true, ...restParams } = params; + const res = await request.post( + '/webApi/config/comparison/listsort/pageQueryComparisonConfig', + { + pageIndex, + pageSize, + needTotal, + ...restParams, + }, + ); + return res.body; +} diff --git a/packages/arex/src/services/ComparisonService/transformRootNode/updateTransformRootNode.ts b/packages/arex/src/services/ComparisonService/transformRootNode/updateTransformRootNode.ts index dc6d1387b..b1f0bc126 100644 --- a/packages/arex/src/services/ComparisonService/transformRootNode/updateTransformRootNode.ts +++ b/packages/arex/src/services/ComparisonService/transformRootNode/updateTransformRootNode.ts @@ -3,7 +3,9 @@ import { request } from '@/utils'; import { TransformRootNodeParams } from './queryTransformRootNode'; -interface UpdateTransformRootNodeReq extends QueryNodeReq<'Global'>, TransformRootNodeParams {} +export interface UpdateTransformRootNodeReq + extends QueryNodeReq<'Global'>, + TransformRootNodeParams {} export async function updateTransformRootNode(params: UpdateTransformRootNodeReq) { const res = await request.post( diff --git a/packages/arex/src/services/type.ts b/packages/arex/src/services/type.ts new file mode 100644 index 000000000..5ddb6f45c --- /dev/null +++ b/packages/arex/src/services/type.ts @@ -0,0 +1,9 @@ +export type PagingRequest = { + pageIndex?: number; + pageSize?: number; + needTotal?: boolean; +} & T; + +export type PagingResponse = { + totalCount: number; +} & T; diff --git a/packages/arex/src/utils/index.ts b/packages/arex/src/utils/index.ts index e24310779..600d00fb7 100644 --- a/packages/arex/src/utils/index.ts +++ b/packages/arex/src/utils/index.ts @@ -113,3 +113,40 @@ export function download(content: string, filename: string) { // a标签从body移除 document.body.removeChild(eleLink); } + +/** + * 根据路径获取对象的值 + * @param obj + * @param path + */ +export function getValueByPath(obj: object | undefined, path: string | string[]): any { + if (typeof path === 'string') { + path = path.split('.'); + } + return path.reduce>((o, k) => (o || {})[k], obj || {}); +} + +/** + * 根据路径设置对象的值 + * @param obj + * @param path + * @param value + */ +export function setValueByPath(obj: object, path: string | string[], value: any) { + // path 如果是 string 则直接赋值;如果是数组则遍历赋值,如果不存在则创建 + if (typeof path === 'string') { + path = path.split('.'); + } + path.reduce>((o, k, i) => { + if (i === path.length - 1) { + o[k] = value; + } else { + if (typeof o[k] === 'undefined') { + o[k] = {}; + } + } + return o[k]; + }, obj); + + return obj; +}