diff --git a/react-ui/config/routes.ts b/react-ui/config/routes.ts index 189efc8a..c58b182e 100644 --- a/react-ui/config/routes.ts +++ b/react-ui/config/routes.ts @@ -160,10 +160,15 @@ export default [ component: './AutoML/Info/index', }, { - name: '创建自动机器学习', + name: '创建实验', path: 'create', component: './AutoML/Create/index', }, + { + name: '编辑实验', + path: 'edit/:id', + component: './AutoML/Create/index', + }, ], }, ], diff --git a/react-ui/src/enums/index.ts b/react-ui/src/enums/index.ts index e489515c..05e14b34 100644 --- a/react-ui/src/enums/index.ts +++ b/react-ui/src/enums/index.ts @@ -71,6 +71,7 @@ export enum DevEditorStatus { Unknown = 'Unknown', // 未启动 } +// 服务类型 export enum ServiceType { Video = 'video', Image = 'image', @@ -84,3 +85,26 @@ export const serviceTypeOptions = [ { label: '音频', value: ServiceType.Audio }, { label: '文本', value: ServiceType.Text }, ]; + +// 自动化任务类型 +export enum AutoMLTaskType { + Classification = 'classification', + Regression = 'regression', +} + +// 自动化任务集成策略 +export enum AutoMLEnsembleClass { + Default = 'default', + SingleBest = 'SingleBest', +} + +// 自动化任务重采样策略 +export enum AutoMLResamplingStrategy { + Holdout = 'holdout', + CrossValid = 'crossValid', +} + +export const resamplingStrategyOptions = [ + { label: 'holdout', value: AutoMLResamplingStrategy.Holdout }, + { label: 'crossValid', value: AutoMLResamplingStrategy.CrossValid }, +]; diff --git a/react-ui/src/pages/Authorize/index.tsx b/react-ui/src/pages/Authorize/index.tsx index f3624f32..e42a0f1b 100644 --- a/react-ui/src/pages/Authorize/index.tsx +++ b/react-ui/src/pages/Authorize/index.tsx @@ -22,7 +22,6 @@ function Authorize() { code, }; const [res] = await to(loginByOauth2Req(params)); - debugger; if (res && res.data) { const { access_token, expires_in } = res.data; setSessionToken(access_token, access_token, expires_in); diff --git a/react-ui/src/pages/AutoML/Create/index.tsx b/react-ui/src/pages/AutoML/Create/index.tsx index 9ac79d57..743d8377 100644 --- a/react-ui/src/pages/AutoML/Create/index.tsx +++ b/react-ui/src/pages/AutoML/Create/index.tsx @@ -4,147 +4,116 @@ * @Description: 创建服务版本 */ import PageTitle from '@/components/PageTitle'; -import { type ParameterInputObject } from '@/components/ResourceSelect'; -import { useComputingResource } from '@/hooks/resource'; -import { - createServiceVersionReq, - getServiceInfoReq, - updateServiceVersionReq, -} from '@/services/modelDeployment'; -import { changePropertyName } from '@/utils'; + +import { AutoMLTaskType } from '@/enums'; +import { addAutoMLReq, getDatasetInfoReq, updateAutoMLReq } from '@/services/autoML'; +import { parseJsonText, trimCharacter } from '@/utils'; +import { safeInvoke } from '@/utils/functional'; import { to } from '@/utils/promise'; -import SessionStorage from '@/utils/sessionStorage'; import { useNavigate, useParams } from '@umijs/max'; import { App, Button, Form } from 'antd'; -import { omit, pick } from 'lodash'; -import { useEffect, useState } from 'react'; +import { omit } from 'lodash'; +import { useEffect } from 'react'; import BasicConfig from '../components/CreateForm/BasicConfig'; +import DatasetConfig from '../components/CreateForm/DatasetConfig'; import ExecuteConfig from '../components/CreateForm/ExecuteConfig'; -import SearchConfig from '../components/CreateForm/SearchConfig'; import TrialConfig from '../components/CreateForm/TrialConfig'; -import { ServiceData, ServiceOperationType, ServiceVersionData } from '../types'; +import { AutoMLData, FormData } from '../types'; import styles from './index.less'; -// 表单数据 -export type FormData = { - service_name: string; // 服务名称 - version: string; // 服务版本 - description: string; // 描述 - model: ParameterInputObject; // 模型 - image: ParameterInputObject; // 镜像 - code_config: ParameterInputObject; // 代码 - resource: string; // 资源规格 - replicas: string; // 副本数量 - mount_path: string; // 模型路径 - env_variables: { key: string; value: string }[]; // 环境变量 -}; - function CreateAutoML() { const navigate = useNavigate(); const [form] = Form.useForm(); - const [resourceStandardList, filterResourceStandard] = useComputingResource(); - const [operationType, setOperationType] = useState(ServiceOperationType.Create); - const { message } = App.useApp(); - const [serviceInfo, setServiceInfo] = useState(undefined); - const [versionInfo, setVersionInfo] = useState(undefined); const params = useParams(); - const id = params.id; + const id = safeInvoke(Number)(params.id); useEffect(() => { - const res: - | (ServiceVersionData & { - operationType: ServiceOperationType; - }) - | undefined = SessionStorage.getItem(SessionStorage.serviceVersionInfoKey, true); - if (res) { - setOperationType(res.operationType); - - setVersionInfo(res); - let model, codeConfig, envVariables; - if (res.model && typeof res.model === 'object') { - model = changePropertyName(res.model, { show_value: 'showValue' }); - // 接口返回是数据没有 value 值,但是 form 需要 value - model.value = model.showValue; - } - if (res.code_config && typeof res.code_config === 'object') { - codeConfig = changePropertyName(res.code_config, { show_value: 'showValue' }); - // 接口返回是数据没有 value 值,但是 form 需要 value - codeConfig.value = codeConfig.showValue; - } - if (res.env_variables && typeof res.env_variables === 'object') { - envVariables = Object.entries(res.env_variables).map(([key, value]) => ({ - key, - value, - })); - } - - const formData = { - ...omit(res, 'model', 'code_config', 'env_variables'), - model: model, - code_config: codeConfig, - env_variables: envVariables, - }; - form.setFieldsValue(formData); + if (id) { + getAutoMLInfo(); } - return () => { - SessionStorage.removeItem(SessionStorage.serviceVersionInfoKey); - }; - }, []); + }, [id]); // 获取服务详情 - const getServiceInfo = async () => { - const [res] = await to(getServiceInfoReq(id)); + const getAutoMLInfo = async () => { + const [res] = await to(getDatasetInfoReq({ id })); if (res && res.data) { - setServiceInfo(res.data); - form.setFieldsValue({ - service_name: res.data.service_name, - }); + const autoMLInfo: AutoMLData = res.data; + const { + include_classifier: include_classifier_str, + include_feature_preprocessor: include_feature_preprocessor_str, + include_regressor: include_regressor_str, + exclude_classifier: exclude_classifier_str, + exclude_feature_preprocessor: exclude_feature_preprocessor_str, + exclude_regressor: exclude_regressor_str, + metrics: metrics_str, + } = autoMLInfo; + const include_classifier = include_classifier_str?.split(',').filter(Boolean); + const include_feature_preprocessor = include_feature_preprocessor_str + ?.split(',') + .filter(Boolean); + const include_regressor = include_regressor_str?.split(',').filter(Boolean); + const exclude_classifier = exclude_classifier_str?.split(',').filter(Boolean); + const exclude_feature_preprocessor = exclude_feature_preprocessor_str + ?.split(',') + .filter(Boolean); + const exclude_regressor = exclude_regressor_str?.split(',').filter(Boolean); + const metricsObj = safeInvoke(parseJsonText)(metrics_str) ?? {}; + const metrics = Object.entries(metricsObj).map(([key, value]) => ({ + name: key, + value, + })); + + const formData = { + ...autoMLInfo, + include_classifier, + include_feature_preprocessor, + include_regressor, + exclude_classifier, + exclude_feature_preprocessor, + exclude_regressor, + metrics, + }; + + form.setFieldsValue(formData); } }; // 创建版本 - const createServiceVersion = async (formData: FormData) => { - const envList = formData['env_variables'] ?? []; - const image = formData['image']; - const model = formData['model']; - const codeConfig = formData['code_config']; - const envVariables = envList.reduce((acc, cur) => { - acc[cur.key] = cur.value; + const createExperiment = async (formData: FormData) => { + const include_classifier = formData['include_classifier']?.join(','); + const include_feature_preprocessor = formData['include_feature_preprocessor']?.join(','); + const include_regressor = formData['include_regressor']?.join(','); + const exclude_classifier = formData['exclude_classifier']?.join(','); + const exclude_feature_preprocessor = formData['exclude_feature_preprocessor']?.join(','); + const exclude_regressor = formData['exclude_regressor']?.join(','); + const metrics = formData['metrics']?.reduce((acc, cur) => { + acc[cur.name] = cur.value; return acc; - }, {} as Record); + }, {} as Record); + const target_columns = trimCharacter(formData['target_columns'], ','); // 根据后台要求,修改表单数据 const object = { - ...omit(formData, ['replicas', 'env_variables', 'image', 'model', 'code_config']), - replicas: Number(formData.replicas), - env_variables: envVariables, - image: image.value, - model: changePropertyName( - pick(model, ['id', 'name', 'version', 'path', 'identifier', 'owner', 'showValue']), - { showValue: 'show_value' }, - ), - code_config: changePropertyName(pick(codeConfig, ['code_path', 'branch', 'showValue']), { - showValue: 'show_value', - }), - service_id: serviceInfo?.id, + ...omit(formData), + include_classifier, + include_feature_preprocessor, + include_regressor, + exclude_classifier, + exclude_feature_preprocessor, + exclude_regressor, + metrics: metrics ? JSON.stringify(metrics) : undefined, + target_columns, }; - const params = - operationType === ServiceOperationType.Create - ? object - : { - id: versionInfo?.id, - rerun: operationType === ServiceOperationType.Restart ? true : false, - deployment_name: versionInfo?.deployment_name, - ...object, - }; - - const request = - operationType === ServiceOperationType.Create - ? createServiceVersionReq - : updateServiceVersionReq; + const params = id + ? { + id: id, + ...object, + } + : object; + const request = id ? updateAutoMLReq : addAutoMLReq; const [res] = await to(request(params)); if (res) { message.success('操作成功'); @@ -154,7 +123,7 @@ function CreateAutoML() { // 提交 const handleSubmit = (values: FormData) => { - console.log('values', values); + createExperiment(values); }; // 取消 @@ -162,15 +131,12 @@ function CreateAutoML() { navigate(-1); }; - const disabled = operationType !== ServiceOperationType.Create; + const disabled = id !== null || id !== undefined; let buttonText = '新建'; - let title = '新增服务版本'; - if (operationType === ServiceOperationType.Update) { - title = '更新服务版本'; + let title = '新增实验'; + if (id) { + title = '更新实验'; buttonText = '更新'; - } else if (operationType === ServiceOperationType.Restart) { - title = '重启服务版本'; - buttonText = '重启'; } return ( @@ -180,22 +146,22 @@ function CreateAutoML() {
- + @@ -279,7 +267,7 @@ function AutoMLList() { diff --git a/react-ui/src/pages/AutoML/components/CreateForm/BasicConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/BasicConfig.tsx index e181d890..dbf32e32 100644 --- a/react-ui/src/pages/AutoML/components/CreateForm/BasicConfig.tsx +++ b/react-ui/src/pages/AutoML/components/CreateForm/BasicConfig.tsx @@ -1,5 +1,5 @@ import SubAreaTitle from '@/components/SubAreaTitle'; -import { Col, Form, Input, Row, Select } from 'antd'; +import { Col, Form, Input, Row } from 'antd'; function BasicConfig() { return ( <> @@ -11,43 +11,34 @@ function BasicConfig() { - + - + {/* - + */} ); } diff --git a/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx new file mode 100644 index 00000000..79984a41 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/DatasetConfig.tsx @@ -0,0 +1,59 @@ +import ResourceSelect, { + ResourceSelectorType, + requiredValidator, +} from '@/components/ResourceSelect'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { Col, Form, Input, Row } from 'antd'; + +function DatasetConfig() { + return ( + <> + + + + + + + + + + + + + + + + + ); +} + +export default DatasetConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx index 4ce0143e..e8ef7d65 100644 --- a/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx +++ b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfig.tsx @@ -1,19 +1,110 @@ -import KFIcon from '@/components/KFIcon'; import SubAreaTitle from '@/components/SubAreaTitle'; -import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; -import { Button, Col, Flex, Form, Input, Radio, Row } from 'antd'; -import ExecuteConfigDLC from './ExecuteConfigDLC'; -import ExecuteConfigMC from './ExecuteConfigMC'; -import styles from './index.less'; +import { + AutoMLEnsembleClass, + AutoMLResamplingStrategy, + AutoMLTaskType, + resamplingStrategyOptions, +} from '@/enums'; +import { Col, Form, Input, InputNumber, Radio, Row, Select, Switch } from 'antd'; -type ExecuteConfigProps = { - disabled?: boolean; -}; +// 分类算法 +const classificationAlgorithms = [ + 'adaboost', + 'bernoulli_nb', + 'decision_tree', + 'extra_trees', + 'gaussian_nb', + 'gradient_boosting', + 'k_nearest_neighbors', + 'lda', + 'liblinear_svc', + 'libsvm_svc', + 'mlp', + 'multinomial_nb', + 'passive_aggressive', + 'qda', + 'random_forest', + 'sgd', +].map((name) => ({ label: name, value: name })); -function ExecuteConfig({ disabled = false }: ExecuteConfigProps) { +// 回归算法 +const regressorAlgorithms = [ + 'adaboost', + 'ard_regression', + 'decision_tree', + 'extra_trees', + 'gaussian_process', + 'gradient_boosting', + 'k_nearest_neighbors', + 'liblinear_svr', + 'libsvm_svr', + 'mlp', + 'random_forest', + 'sgd', +].map((name) => ({ label: name, value: name })); + +// 特征预处理算法 +const featureAlgorithms = [ + 'densifier', + 'extra_trees_preproc_for_classification', + 'extra_trees_preproc_for_regression', + 'fast_ica', + 'feature_agglomeration', + 'kernel_pca', + 'kitchen_sinks', + 'liblinear_svc_preprocessor', + 'no_preprocessing', + 'nystroem_sampler', + 'pca', + 'polynomial', + 'random_trees_embedding', + 'select_percentile_classification', + 'select_percentile_regression', + 'select_rates_classification', + 'select_rates_regression', + 'truncatedSVD', +].map((name) => ({ label: name, value: name })); + +// 分类指标 +export const classificationMetrics = [ + 'accuracy', + 'balanced_accuracy', + 'roc_auc', + 'average_precision', + 'log_loss', + 'precision_macro', + 'precision_micro', + 'precision_samples', + 'precision_weighted', + 'recall_macro', + 'recall_micro', + 'recall_samples', + 'recall_weighted', + 'f1_macro', + 'f1_micro', + 'f1_samples', + 'f1_weighted', +].map((name) => ({ label: name, value: name })); + +// 回归指标 +export const regressionMetrics = [ + 'mean_absolute_error', + 'mean_squared_error', + 'root_mean_squared_error', + 'mean_squared_log_error', + 'median_absolute_error', + 'r2', +].map((name) => ({ label: name, value: name })); + +function ExecuteConfig() { const form = Form.useFormInstance(); - const image_type = Form.useWatch('image_type', form); - console.log(image_type); + const task_type = Form.useWatch('task_type', form); + const include_classifier = Form.useWatch('include_classifier', form); + const exclude_classifier = Form.useWatch('exclude_classifier', form); + const include_regressor = Form.useWatch('include_regressor', form); + const exclude_regressor = Form.useWatch('exclude_regressor', form); + const include_feature_preprocessor = Form.useWatch('include_feature_preprocessor', form); + const exclude_feature_preprocessor = Form.useWatch('exclude_feature_preprocessor', form); return ( <> @@ -26,27 +117,349 @@ function ExecuteConfig({ disabled = false }: ExecuteConfigProps) { - DLC - MaxCompute + 分类 + 回归 - + + + + + 0} + mode="multiple" + showSearch + /> + + + + + {({ getFieldValue }) => { - return getFieldValue('execute_type') === 'DLC' ? ( - + return getFieldValue('task_type') === AutoMLTaskType.Classification ? ( + <> + + + + 0} + showSearch + /> + + + + ) : ( - + <> + + + + 0} + showSearch + /> + + + + ); }} - + + + + + 集成模型 + 单一最佳模型 + + + + + + + {({ getFieldValue }) => { + return getFieldValue('ensemble_class') === AutoMLEnsembleClass.Default ? ( + <> + + + + + + + + + + + + + + + + + ) : null; + }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* {(fields, { add, remove }) => ( <> @@ -126,7 +539,7 @@ function ExecuteConfig({ disabled = false }: ExecuteConfigProps) {
)} - + */} ); } diff --git a/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfigDLC.tsx b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfigDLC.tsx index d96a28e6..6b2c63e6 100644 --- a/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfigDLC.tsx +++ b/react-ui/src/pages/AutoML/components/CreateForm/ExecuteConfigDLC.tsx @@ -1,5 +1,8 @@ import CodeSelect from '@/components/CodeSelect'; -import ResourceSelect, { ResourceSelectorType } from '@/components/ResourceSelect'; +import ResourceSelect, { + requiredValidator, + ResourceSelectorType, +} from '@/components/ResourceSelect'; import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { Button, Col, Flex, Form, Input, InputNumber, Radio, Row, Select } from 'antd'; import styles from './index.less'; @@ -62,12 +65,12 @@ function ExecuteConfigDLC({ disabled = false }: ExecuteConfigDLCProps) { -
- - - - - - - - - - - - - - - - - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...restField }, index) => ( - - - - - : - - - -
+ + + + + + + + + + + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }, index) => ( + + + - - - - - - - - - 越大越好 - 越小越好 - - - - -
+ )} +
+ + ))} + {fields.length === 0 && ( + + + + )} + + )} + +
+ + - - + + - + + 越大越好 + 越小越好 + diff --git a/react-ui/src/pages/AutoML/components/CreateForm/UploadConfig.tsx b/react-ui/src/pages/AutoML/components/CreateForm/UploadConfig.tsx new file mode 100644 index 00000000..429be535 --- /dev/null +++ b/react-ui/src/pages/AutoML/components/CreateForm/UploadConfig.tsx @@ -0,0 +1,80 @@ +import { getAccessToken } from '@/access'; +import KFIcon from '@/components/KFIcon'; +import SubAreaTitle from '@/components/SubAreaTitle'; +import { getFileListFromEvent } from '@/utils/ui'; +import { Button, Col, Form, Input, Row, Upload, type UploadProps } from 'antd'; +import { useState } from 'react'; +import styles from './index.less'; + +function UploadConfig() { + const [uuid] = useState(Date.now()); + // 上传组件参数 + const uploadProps: UploadProps = { + action: '/api/mmp/autoML/upload', + headers: { + Authorization: getAccessToken() || '', + }, + defaultFileList: [], + }; + + return ( + <> + + + + + + + + + + + + + + + + + + +
只允许上传 .csv 格式文件
+
+
+ + ); +} + +export default UploadConfig; diff --git a/react-ui/src/pages/AutoML/components/CreateForm/index.less b/react-ui/src/pages/AutoML/components/CreateForm/index.less index 57a44b79..8e8414a5 100644 --- a/react-ui/src/pages/AutoML/components/CreateForm/index.less +++ b/react-ui/src/pages/AutoML/components/CreateForm/index.less @@ -128,3 +128,14 @@ border: 1px dashed #e0e0e0; border-radius: 8px; } + +.upload-tip { + margin-top: 5px; + color: @text-color-secondary; + font-size: 14px; +} + +.upload-button { + height: 46px; + font-size: 15px; +} diff --git a/react-ui/src/pages/AutoML/types.ts b/react-ui/src/pages/AutoML/types.ts index 56582f9e..59496c64 100644 --- a/react-ui/src/pages/AutoML/types.ts +++ b/react-ui/src/pages/AutoML/types.ts @@ -1,6 +1,54 @@ +import { type ParameterInputObject } from '@/components/ResourceSelect'; + // 操作类型 -export enum ServiceOperationType { +export enum OperationType { Create = 'Create', // 创建 Update = 'Update', // 更新 - Restart = 'Restart', // 重启 } + +// 表单数据 +export type FormData = { + ml_name: string; // 实验名称 + ml_description: string; // 实验描述 + ensemble_class?: string; // 集成构建 + ensemble_nbest?: string; + ensemble_size?: number; + include_classifier?: string[]; + include_feature_preprocessor?: string[]; + include_regressor?: string[]; + exclude_classifier?: string[]; + exclude_feature_preprocessor?: string[]; + exclude_regressor?: string[]; + max_models_on_disc?: number; + memory_limit?: number; + metric_name?: string; + greater_is_better: boolean; + per_run_time_limit?: number; + resampling_strategy?: string; + scoring_functions?: string; + shuffle?: boolean; + seed?: number; + target_columns: string; + task_type: string; + test_size?: number; + train_size?: number; + time_left_for_this_task: number; + tmp_folder?: string; + metrics?: { name: string; value: number }[]; + dataset: ParameterInputObject; // 模型 +}; + +export type AutoMLData = { + id: string; + progress: number; + run_state: string; + state: number; + metrics?: string; + include_classifier?: string; + include_feature_preprocessor?: string; + include_regressor?: string; + exclude_classifier?: string; + exclude_feature_preprocessor?: string; + exclude_regressor?: string; + dataset?: string; +}; diff --git a/react-ui/src/services/autoML.js b/react-ui/src/services/autoML.js new file mode 100644 index 00000000..63f4cd70 --- /dev/null +++ b/react-ui/src/services/autoML.js @@ -0,0 +1,55 @@ +/* + * @Author: 赵伟 + * @Date: 2024-11-18 10:18:27 + * @Description: 自动机器学习请求 + */ + +import { request } from '@umijs/max'; + + +// 分页查询自动学习 +export function getAutoMLListReq(params) { + return request(`/api/mmp/autoML`, { + method: 'GET', + params, + }); +} + +// 查询自动学习详情 +export function getDatasetInfoReq(params) { + return request(`/api/mmp/autoML/getAutoMlDetail`, { + method: 'GET', + params, + }); +} + +// 新增自动学习 +export function addAutoMLReq(data) { + return request(`/api/mmp/autoML`, { + method: 'POST', + data, + }); +} + +// 编辑自动学习 +export function updateAutoMLReq(data) { + return request(`/api/mmp/autoML`, { + method: 'PUT', + data, + }); +} + +// 删除自动学习 +export function deleteAutoMLReq(id) { + return request(`/api/mmp/autoML/${id}`, { + method: 'DELETE', + }); +} + +// 运行自动学习 +export function runAutoMLReq(id) { + return request(`/api/mmp/autoML/${id}`, { + method: 'POST', + params, + }); +} \ No newline at end of file diff --git a/react-ui/src/utils/functional.ts b/react-ui/src/utils/functional.ts index 01514db7..6128c897 100644 --- a/react-ui/src/utils/functional.ts +++ b/react-ui/src/utils/functional.ts @@ -4,6 +4,16 @@ * @Description: 函数式编程 */ +/** + * Safely invokes a function with a given value, returning the result of the + * function or the provided value if it is `undefined` or `null`. + * + * @template T - The type of the input value. + * @template M - The type of the output value. + * @param {function} fn - The function to be invoked with the input value. + * @returns {function} A function that takes a value, invokes `fn` with it if + * it's not `undefined` or `null`, and returns the result or the original value. + */ export function safeInvoke( fn: (value: T) => M | undefined | null, ): (value: T | undefined | null) => M | undefined | null { diff --git a/react-ui/src/utils/index.ts b/react-ui/src/utils/index.ts index 67ca10d2..8feb6d51 100644 --- a/react-ui/src/utils/index.ts +++ b/react-ui/src/utils/index.ts @@ -241,3 +241,18 @@ export const tableSorter = (a: any, b: any) => { } return 0; }; + +/** + * Trim the given character from both ends of the given string. + * + * @param {string} ch - the character to trim + * @param {string} str - the string to trim + * @return {string} the trimmed string + */ +export const trimCharacter = (str: string, ch: string): string => { + if (str === null || str === undefined) { + return str; + } + const reg = new RegExp(`^${ch}|${ch}$`, 'g'); + return str.trim().replace(reg, ''); +};