Skip to content
Merged
Binary file added public/image/logos/jsonapi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { render, screen } from '@testing-library/react';

jest.mock('@/App', () => ({
__esModule: true,
CommonStateContext: React.createContext({
datasourceList: [],
datasourceCateOptions: [],
isPlus: false,
}),
}));
jest.mock('react-i18next', () => ({
__esModule: true,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh_CN' } }),
}));
jest.mock('@/components/AdvancedWrap/utils', () => ({
__esModule: true,
getCateDisplayLabel: () => undefined,
}));
jest.mock('../EmptyDatasourcePopover', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

import DatasourceSelectV3 from './index';

describe('DatasourceSelectV3', () => {
it('keeps an injected datasource variable value instead of marking it as deleted', () => {
render(
<DatasourceSelectV3
value='${datasource}'
datasourceList={[]}
datasourceCateList={[]}
ajustDatasourceList={() => [
{
id: '${datasource}',
name: '${datasource}',
plugin_type: 'prometheus',
},
]}
showEmptyDatasourcePopover={false}
/>,
);

expect(screen.getByText('${datasource}')).toBeInTheDocument();
expect(screen.queryByText(/deleted/)).not.toBeInTheDocument();
});
});
236 changes: 181 additions & 55 deletions src/components/DatasourceSelect/DatasourceSelectV3/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useContext } from 'react';
import { WarningOutlined } from '@ant-design/icons';
import { Select, Space, Tag } from 'antd';
import { SelectProps } from 'antd/lib/select';
import classNames from 'classnames';
import _ from 'lodash';
import { useTranslation } from 'react-i18next';

Expand All @@ -11,87 +13,211 @@ import EmptyDatasourcePopover from '../EmptyDatasourcePopover';

import './style.less';

interface DatasourceItem {
export interface DatasourceItem {
id: number | string;
name: string;
plugin_type: string;
is_default: boolean;
plugin_type_name?: string;
is_default?: boolean;
identifier?: string;
weight?: number;
}

interface Props {
type?: 'metric' | 'logging';
datasourceCateList: Cate[];
datasourceList?: DatasourceItem[];
datasourceCateList?: Cate[];
ajustDatasourceList?: (list: DatasourceItem[]) => DatasourceItem[];
onChange?: (value: string | number, datasourceCate: string) => void;
onChange?: (value: string | number | Array<string | number>, datasourceCate: string) => void;
onClear?: () => void;
additionalOptions?: SelectProps['options'];
filterKey?: string;
showHost?: boolean;
renderOptionIcon?: (item: DatasourceItem) => React.ReactNode;
isOptionDisabled?: (item: DatasourceItem) => boolean;
renderOptionExtra?: (item: DatasourceItem) => React.ReactNode;
showEmptyDatasourcePopover?: boolean;
}

type DatasourceValue = string | number;

function getRawValue(value: DatasourceValue | { value: DatasourceValue }): DatasourceValue {
return typeof value === 'object' ? value.value : value;
}

export default function index(props: SelectProps & Props) {
const { type, datasourceCateList, ajustDatasourceList, onChange, onClear, additionalOptions } = props;
const { i18n } = useTranslation();
const { datasourceList } = useContext(CommonStateContext);
const currentDatasourceList = ajustDatasourceList ? ajustDatasourceList(datasourceList) : datasourceList;
const {
type,
datasourceList: providedDatasourceList,
datasourceCateList,
ajustDatasourceList,
onChange,
onClear,
additionalOptions,
filterKey,
showHost,
renderOptionIcon,
isOptionDisabled,
renderOptionExtra,
showEmptyDatasourcePopover = true,
className,
} = props;
const { t, i18n } = useTranslation('datasourceSelect');
const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext);
const resolvedDatasourceList: DatasourceItem[] = providedDatasourceList ?? contextDatasourceList;
const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions;
// 调整后的列表可能包含仪表盘注入的数据源变量选项,不能只用原始列表判断当前值是否已删除。
const adjustedDatasourceList = ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList;
const currentDatasourceList = adjustedDatasourceList.filter((item) => {
if (!filterKey) {
return true;
}
const datasourceCate = _.find(resolvedDatasourceCateList, { value: item.plugin_type });
const supported = (datasourceCate as any)?.[filterKey];
return isPlus ? supported : supported && !datasourceCate?.alertPro;
});
const orderedDatasourceList = providedDatasourceList ? currentDatasourceList : _.orderBy(currentDatasourceList, ['is_default', 'plugin_type', 'weight'], ['desc', 'asc', 'asc']);
const additionalOptionValues = (additionalOptions ?? [])
.map((option) => option?.value)
.filter((value): value is DatasourceValue => typeof value === 'string' || typeof value === 'number');
const selectedValue = props.value ?? props.defaultValue;
const hasSelectedValue = Array.isArray(selectedValue) ? selectedValue.length > 0 : selectedValue != null;
// additionalOptions 中的伪值(如 mixed)并非数据源,回填时保留其原有展示。
const preservedOptionValues = new Set<DatasourceValue>([...additionalOptionValues, ...(showHost ? [-999] : [])]);
const normalizeSelectedValue = (value: SelectProps['value']) => {
if (value == null) return undefined;

return (
<EmptyDatasourcePopover type={type} datasourceList={currentDatasourceList}>
<Select
className='n9e-datasource-select-v3'
dropdownMatchSelectWidth={false}
{..._.omit(props, ['type', 'datasourceCateList', 'ajustDatasourceList', 'additionalOptions'])}
showSearch
optionLabelProp='optionLabel'
filterOption={(inputValue, option) => {
// 根据空格分词进行过滤,取交集
const keywords = _.filter(_.split(inputValue, ' '), (kw) => kw) as string[];
return _.every(keywords, (kw) => _.includes(_.toLower(option?.filter), _.toLower(kw)));
}}
options={[
...(additionalOptions ?? []),
..._.map(_.orderBy(currentDatasourceList, ['is_default', 'plugin_type', 'weight'], ['desc', 'asc', 'asc']), (item) => {
const datasourceCate = _.find(datasourceCateList, { value: item.plugin_type });
const displayLabel = getCateDisplayLabel(datasourceCate, i18n.language);
return {
filter: [item.plugin_type, displayLabel, item.name].join(' '),
originLabel: item.name,
optionLabel: (
<div>
const normalizeValue = (item: DatasourceValue | { value: DatasourceValue }) => {
const rawValue = getRawValue(item);
// 业务过滤仅影响下拉可选项,不能将原始数据源列表或调用方注入的选项标记为已删除。
const exists =
_.some(resolvedDatasourceList, { id: rawValue }) || _.some(adjustedDatasourceList, { id: rawValue }) || preservedOptionValues.has(rawValue);

return exists
? { value: rawValue }
: {
value: rawValue,
label: (
<span className='n9e-datasource-select-v3-deleted-value'>
<WarningOutlined />
id: {rawValue} {t('deleted')}
</span>
),
};
};

return Array.isArray(value) ? value.map(normalizeValue) : normalizeValue(value);
};

const select = (
<Select
className={classNames('n9e-datasource-select-v3', { 'n9e-datasource-select-v3-has-value': hasSelectedValue }, className)}
dropdownMatchSelectWidth={false}
{..._.omit(props, [
'type',
'datasourceList',
'datasourceCateList',
'ajustDatasourceList',
'additionalOptions',
'filterKey',
'showHost',
'renderOptionIcon',
'isOptionDisabled',
'renderOptionExtra',
'showEmptyDatasourcePopover',
'className',
'value',
'defaultValue',
'labelInValue',
])}
showSearch
labelInValue
menuItemSelectedIcon={null}
optionLabelProp='optionLabel'
value={normalizeSelectedValue(props.value)}
defaultValue={normalizeSelectedValue(props.defaultValue)}
filterOption={(inputValue, option) => {
// 根据空格分词进行过滤,取交集
const keywords = _.filter(_.split(inputValue, ' '), (kw) => kw) as string[];
return _.every(keywords, (kw) => _.includes(_.toLower(option?.filter), _.toLower(kw)));
}}
options={[
...(additionalOptions ?? []),
...(showHost
? [
{
filter: 'host',
label: (
<Space>
<img src={datasourceCate?.logo} alt={displayLabel} height={16} />
{item.name}
<img src='/image/logos/host.png' alt='Host' height={16} />
Host
</Space>
</div>
),
label: (
<div className='flex items-center gap-2 justify-between'>
),
optionLabel: (
<Space>
<img src={datasourceCate?.logo} alt={displayLabel} height={16} />
{item.name}
{item.is_default && <Tag color='var(--fc-fill-primary)'>default</Tag>}
<img src='/image/logos/host.png' alt='Host' height={16} />
Host
</Space>
),
value: -999,
},
]
: []),
..._.map(orderedDatasourceList, (item) => {
const datasourceCate = _.find(resolvedDatasourceCateList, { value: item.plugin_type });
const displayLabel = getCateDisplayLabel(datasourceCate, i18n.language) || item.plugin_type_name || item.plugin_type;
const optionIcon = renderOptionIcon?.(item) ?? (datasourceCate?.logo ? <img src={datasourceCate.logo} alt={displayLabel} height={16} className='shrink-0' /> : null);
return {
filter: [item.plugin_type, item.plugin_type_name, displayLabel, item.name].filter(Boolean).join(' '),
originLabel: item.name,
optionLabel: (
<div className='flex items-center gap-2 overflow-hidden'>
{optionIcon}
<span className='truncate'>{item.name}</span>
</div>
),
label: (
<div className='flex items-center gap-2 justify-between'>
<Space>
{optionIcon}
{item.name}
</Space>
<Space size={4}>
{item.is_default && <Tag className='n9e-datasource-select-v3-default-tag'>{t('default')}</Tag>}
<span
style={{
color: 'var(--fc-text-4)',
}}
>
{displayLabel}
</span>
</div>
),
value: item.id,
};
}),
]}
onChange={(value) => {
if (onChange) {
const curCate = _.find(currentDatasourceList, { id: value })?.plugin_type;
onChange(value, curCate ?? (value === 'mixed' ? 'mixed' : ''));
}
}}
onClear={onClear}
allowClear={!!onClear}
/>
{renderOptionExtra?.(item)}
</Space>
</div>
),
value: item.id,
disabled: isOptionDisabled?.(item),
};
}),
]}
onChange={(value) => {
if (onChange) {
const selectedValue = Array.isArray(value) ? value.map(getRawValue) : getRawValue(value);
const curValue = Array.isArray(selectedValue) ? _.last(selectedValue) : selectedValue;
const curCate = _.find(currentDatasourceList, { id: curValue })?.plugin_type;
onChange(selectedValue, curCate ?? (selectedValue === 'mixed' ? 'mixed' : ''));
Comment on lines +205 to +208
}
}}
onClear={onClear}
allowClear={!!onClear}
/>
);

if (!showEmptyDatasourcePopover) return select;

return (
<EmptyDatasourcePopover type={type} datasourceList={currentDatasourceList}>
{select}
</EmptyDatasourcePopover>
);
}
28 changes: 27 additions & 1 deletion src/components/DatasourceSelect/DatasourceSelectV3/style.less
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
.n9e-datasource-select-v3 {
&-default-tag.ant-tag {
margin-right: 0;
color: var(--fc-violet-11);
background-color: var(--fc-violet-3);
border: none;
border-radius: 4px;
}

.ant-select-selection-search-input {
text-indent: 0 !important;
}

&-has-value .ant-select-selection-search-input {
text-indent: 24px !important;
}

.ant-select-selector .ant-select-selection-item {
transition: unset !important;
}
}

&.ant-select-open .ant-select-selection-item img {
opacity: 0.45;
transition: opacity 0.3s;
}

&-deleted-value {
color: var(--fc-red-11);
text-decoration: line-through;

.anticon {
margin-right: 4px;
}
}
}
2 changes: 2 additions & 0 deletions src/components/DatasourceSelect/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import DatasourceSelect from './DatasourceSelect';
import DatasourceCateSelectV2 from './DatasourceCateSelectV2';
import DatasourceSelectV2 from './DatasourceSelectV2';
import DatasourceSelectV3 from './DatasourceSelectV3';
import type { DatasourceItem } from './DatasourceSelectV3';

import './locale';
export { DatasourceCateSelect, ProSvg, ProdSelect, DatasourceSelect, DatasourceCateSelectV2, DatasourceSelectV2, DatasourceSelectV3 };
export type { DatasourceItem };
2 changes: 2 additions & 0 deletions src/components/DatasourceSelect/locale/en_US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ const en_US = {
type: 'Data source type',
id: 'Data source',
allCates: 'All types',
deleted: 'Deleted',
default: 'Default',
};
export default en_US;
2 changes: 2 additions & 0 deletions src/components/DatasourceSelect/locale/ja_JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ const ja_JP = {
type: 'データソースタイプ',
id: 'データソースID',
allCates: 'すべてのタイプ',
deleted: '削除済み',
default: 'デフォルト',
};
export default ja_JP;
2 changes: 2 additions & 0 deletions src/components/DatasourceSelect/locale/ru_RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const ru_RU = {
type: 'Тип источника данных',
id: 'Связанный источник данных',
allCates: 'Все типы',
deleted: 'Удален',
default: 'По умолчанию',
};

export default ru_RU;
Loading