From dc076d32df63b02798182db1d72663150a9644e5 Mon Sep 17 00:00:00 2001 From: jsers Date: Tue, 25 Aug 2026 18:09:26 +0800 Subject: [PATCH 1/8] feat(datasource-select): migrate pages to DatasourceSelectV3 Extend DatasourceSelectV3 with filterKey/showHost/className passthrough, array-aware onChange, and a styled default tag. Migrate alert rules, history events, shield, subscribe, metrics-builtin and alert-rule form off the legacy per-group DatasourceSelect. --- .../DatasourceSelectV3/index.tsx | 63 ++++++++++++++----- .../DatasourceSelectV3/style.less | 15 ++++- .../DatasourceValueSelect/index.tsx | 45 +++++++------ src/pages/alertRules/List/ListNG.tsx | 19 ++++-- src/pages/historyEvents/ListNG/index.tsx | 16 ++++- .../components/Filters/FormModal.tsx | 14 +---- src/pages/warning/shield/index.tsx | 16 ++++- src/pages/warning/subscribe/ListNG.tsx | 16 ++++- 8 files changed, 139 insertions(+), 65 deletions(-) diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 0076f62001..581c5f0bc0 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -1,6 +1,7 @@ import React, { useContext } from 'react'; import { Select, Space, Tag } from 'antd'; import { SelectProps } from 'antd/lib/select'; +import classNames from 'classnames'; import _ from 'lodash'; import { useTranslation } from 'react-i18next'; @@ -23,23 +24,32 @@ interface Props { type?: 'metric' | 'logging'; datasourceCateList: Cate[]; ajustDatasourceList?: (list: DatasourceItem[]) => DatasourceItem[]; - onChange?: (value: string | number, datasourceCate: string) => void; + onChange?: (value: string | number | Array, datasourceCate: string) => void; onClear?: () => void; additionalOptions?: SelectProps['options']; + filterKey?: string; + showHost?: boolean; } export default function index(props: SelectProps & Props) { - const { type, datasourceCateList, ajustDatasourceList, onChange, onClear, additionalOptions } = props; + const { type, datasourceCateList, ajustDatasourceList, onChange, onClear, additionalOptions, filterKey, showHost, className } = props; const { i18n } = useTranslation(); - const { datasourceList } = useContext(CommonStateContext); - const currentDatasourceList = ajustDatasourceList ? ajustDatasourceList(datasourceList) : datasourceList; + const { datasourceList, datasourceCateOptions, isPlus } = useContext(CommonStateContext); + const currentDatasourceList = (ajustDatasourceList ? ajustDatasourceList(datasourceList) : datasourceList).filter((item) => { + if (!filterKey) { + return true; + } + const datasourceCate = _.find(datasourceCateOptions, { value: item.plugin_type }); + const supported = (datasourceCate as any)?.[filterKey]; + return isPlus ? supported : supported && !datasourceCate?.alertPro; + }); return ( _.filter(list, (item) => _.some(datasourceList, { id: item.id }))} + additionalOptions={ + cate === 'prometheus' || cate === 'loki' + ? [ + { + filter: '$all', + label: '$all', + optionLabel: '$all', + value: DATASOURCE_ALL, + }, + ] + : undefined + } onChange={(v: number[] | number) => { if (_.isArray(v)) { const curVal = _.last(v); @@ -164,16 +169,8 @@ export default function index({ setFieldsValue, cate, datasourceList, mode, requ }} maxTagCount='responsive' disabled={disabled} - showSearch - optionFilterProp='children' style={{ width: '100%' }} - > - {_.map(curDatasourceList, (item) => ( - - {item.name} - - ))} - + /> {showExtra && ( diff --git a/src/pages/alertRules/List/ListNG.tsx b/src/pages/alertRules/List/ListNG.tsx index f219206f41..b0178d48ba 100644 --- a/src/pages/alertRules/List/ListNG.tsx +++ b/src/pages/alertRules/List/ListNG.tsx @@ -12,7 +12,7 @@ import { CommonStateContext } from '@/App'; import { updateAlertRules, deleteStrategy } from '@/services/warning'; import { allCates, getCateDisplayLabel } from '@/components/AdvancedWrap/utils'; import RefreshIcon from '@/components/RefreshIcon'; -import DatasourceSelect from '@/components/DatasourceSelect/DatasourceSelect'; +import { DatasourceSelectV3 } from '@/components/DatasourceSelect'; import TableColumnSelect, { getDefaultColumnsConfigs, setDefaultColumnsConfigs, buildColumnOptions } from '@/components/TableColumnSelect'; import usePagination from '@/components/usePagination'; import Tags from '@/components/TableTags/Tags'; @@ -62,7 +62,7 @@ interface Props { export default function AlertRules(props: Props) { const { t, i18n } = useTranslation('alertRules'); - const { busiGroups, datasourceList, feats } = useContext(CommonStateContext); + const { busiGroups, datasourceList, datasourceCateOptions, feats } = useContext(CommonStateContext); const { hideBusinessGroupColumn, showRowSelection, readonly, headerExtra, data, loading, setRefreshFlag, linkTarget, emptyGuide, gids, groupSwitchCount } = props; const history = useHistory(); const location = useLocation(); @@ -406,11 +406,14 @@ export default function AlertRules(props: Props) { fetchData(); }} /> - { const newFilter = { @@ -419,6 +422,12 @@ export default function AlertRules(props: Props) { }; handleFilterChange(newFilter); }} + onClear={() => { + handleFilterChange({ + ...filter, + datasourceIds: undefined, + }); + }} /> - { setFilter({ @@ -304,6 +308,12 @@ const Event = (props: Props) => { datasource_ids: val, }); }} + onClear={() => { + setFilter({ + ...filter, + datasource_ids: undefined, + }); + }} /> - { const { t, i18n } = useTranslation('alertSubscribes'); const history = useHistory(); const location = useLocation(); - const { datasourceList, busiGroups, darkMode } = useContext(CommonStateContext); + const { datasourceList, datasourceCateOptions, busiGroups, darkMode } = useContext(CommonStateContext); const { hideBusinessGroupColumn, readonly, canCreate, headerExtra, data, loading, setRefreshFlag, linkTarget, gids, groupSwitchCount } = props; const [visibleColumns, setVisibleColumns] = useState(() => getDefaultColumnsConfigs(defaultColumnsConfigs, LOCAL_STORAGE_KEY)); const columnOptions = buildColumnOptions(defaultColumnsConfigs, t); @@ -361,9 +361,13 @@ const Subscribe = (props: Props) => { refreshList(); }} /> - { setDatasourceIds(val); @@ -371,6 +375,12 @@ const Subscribe = (props: Props) => { saveState({ datasourceIds: val }); history.replace({ pathname: location.pathname, search: setPageInSearch(location.search, 1) }); }} + onClear={() => { + setDatasourceIds(undefined); + setCurrent(1); + saveState({ datasourceIds: undefined }); + history.replace({ pathname: location.pathname, search: setPageInSearch(location.search, 1) }); + }} /> Date: Mon, 31 Aug 2026 15:39:34 +0800 Subject: [PATCH 2/8] fix(datasource-select): merge external className and truncate option label - merge props.className into base class and omit it from the rest spread to avoid duplicate className on Select - switch optionLabel to flex layout with overflow-hidden / truncate so long datasource names no longer overflow the selected display --- .../DatasourceSelect/DatasourceSelectV3/index.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 581c5f0bc0..9291dc2e23 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -86,11 +86,9 @@ export default function index(props: SelectProps & Props) { filter: [item.plugin_type, displayLabel, item.name].join(' '), originLabel: item.name, optionLabel: ( -
- - {displayLabel} - {item.name} - +
+ {displayLabel} + {item.name}
), label: ( From 5eb72e9425c8594f621374356c30c5fb81b1e10c Mon Sep 17 00:00:00 2001 From: guguji5 <604821528@qq.com> Date: Wed, 2 Sep 2026 20:40:02 +0800 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=E6=94=AF=E6=8C=81=E4=BA=86?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E6=95=B0=E6=8D=AE=E6=BA=90=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=9A=84=E4=BC=A0=E5=85=A5=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DatasourceSelectV3/index.tsx | 211 +++++++++++------- src/components/DatasourceSelect/style.less | 4 +- 2 files changed, 131 insertions(+), 84 deletions(-) diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 9291dc2e23..956f3ddb64 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -16,113 +16,160 @@ 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 | Array, 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; } export default function index(props: SelectProps & Props) { - const { type, datasourceCateList, ajustDatasourceList, onChange, onClear, additionalOptions, filterKey, showHost, className } = props; + const { + type, + datasourceList: providedDatasourceList, + datasourceCateList, + ajustDatasourceList, + onChange, + onClear, + additionalOptions, + filterKey, + showHost, + renderOptionIcon, + isOptionDisabled, + renderOptionExtra, + showEmptyDatasourcePopover = true, + className, + } = props; const { i18n } = useTranslation(); - const { datasourceList, datasourceCateOptions, isPlus } = useContext(CommonStateContext); - const currentDatasourceList = (ajustDatasourceList ? ajustDatasourceList(datasourceList) : datasourceList).filter((item) => { + const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext); + const resolvedDatasourceList = providedDatasourceList ?? contextDatasourceList; + const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions; + const currentDatasourceList = (ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList).filter((item) => { if (!filterKey) { return true; } - const datasourceCate = _.find(datasourceCateOptions, { value: item.plugin_type }); + 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']); - return ( - - { + // 根据空格分词进行过滤,取交集 + 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: ( - {displayLabel} - {item.name} + Host + Host - - {item.is_default && default} - - {displayLabel} - + ), + optionLabel: ( + + Host + Host -
- ), - value: item.id, - }; - }), - ]} - onChange={(value) => { - if (onChange) { - const curValue = Array.isArray(value) ? _.last(value) : value; - const curCate = _.find(currentDatasourceList, { id: curValue })?.plugin_type; - onChange(value, curCate ?? (value === 'mixed' ? 'mixed' : '')); - } - }} - onClear={onClear} - allowClear={!!onClear} - /> + ), + 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 ? {displayLabel} : null); + return { + filter: [item.plugin_type, item.plugin_type_name, displayLabel, item.name].filter(Boolean).join(' '), + originLabel: item.name, + optionLabel: ( +
+ {optionIcon} + {item.name} +
+ ), + label: ( +
+ + {optionIcon} + {item.name} + + + {item.is_default && default} + + {displayLabel} + + {renderOptionExtra?.(item)} + +
+ ), + value: item.id, + disabled: isOptionDisabled?.(item), + }; + }), + ]} + onChange={(value) => { + if (onChange) { + const curValue = Array.isArray(value) ? _.last(value) : value; + const curCate = _.find(currentDatasourceList, { id: curValue })?.plugin_type; + onChange(value, curCate ?? (value === 'mixed' ? 'mixed' : '')); + } + }} + onClear={onClear} + allowClear={!!onClear} + /> + ); + + if (!showEmptyDatasourcePopover) return select; + + return ( + + {select} ); } diff --git a/src/components/DatasourceSelect/style.less b/src/components/DatasourceSelect/style.less index 8f7fb3a0de..1bc0eb38fc 100644 --- a/src/components/DatasourceSelect/style.less +++ b/src/components/DatasourceSelect/style.less @@ -8,7 +8,7 @@ font-size: 12px; font-weight: bolder; line-height: 1; - transform: scale(0.8) + transform: scale(0.8); } .n9e-pro-tag-selected { border: 1px solid #fff; @@ -34,4 +34,4 @@ .n9e-db-cate-grid-item-selected { border: 1px solid var(--fc-primary-color); } -} \ No newline at end of file +} From 0aae632ae82633d2fae04924131875fbb8569839 Mon Sep 17 00:00:00 2001 From: guguji5 <604821528@qq.com> Date: Thu, 3 Sep 2026 09:58:38 +0800 Subject: [PATCH 4/8] refactor: fix type error --- src/components/DatasourceSelect/DatasourceSelectV3/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 956f3ddb64..8a84a20d1d 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -57,7 +57,7 @@ export default function index(props: SelectProps & Props) { } = props; const { i18n } = useTranslation(); const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext); - const resolvedDatasourceList = providedDatasourceList ?? contextDatasourceList; + const resolvedDatasourceList: DatasourceItem[] = providedDatasourceList ?? contextDatasourceList; const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions; const currentDatasourceList = (ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList).filter((item) => { if (!filterKey) { From 6451e1407e7d6020b9ef4b16d99d7c73a70baa91 Mon Sep 17 00:00:00 2001 From: jsers Date: Fri, 4 Sep 2026 16:26:55 +0800 Subject: [PATCH 5/8] feat(datasource-select): mark deleted selected value in V3 Render a warning label for selected datasource values that no longer exist in the list, while keeping the parent-facing onChange contract on primitive string|number values via labelInValue wrap/unwrap. --- .../DatasourceSelectV3/index.tsx | 48 +++++++++++++++++-- .../DatasourceSelectV3/style.less | 9 ++++ .../DatasourceSelect/locale/en_US.ts | 1 + .../DatasourceSelect/locale/ja_JP.ts | 1 + .../DatasourceSelect/locale/ru_RU.ts | 1 + .../DatasourceSelect/locale/zh_CN.ts | 1 + .../DatasourceSelect/locale/zh_HK.ts | 1 + 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 8a84a20d1d..1c4ef68484 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -1,4 +1,5 @@ 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'; @@ -38,6 +39,12 @@ interface Props { 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, @@ -55,7 +62,7 @@ export default function index(props: SelectProps & Props) { showEmptyDatasourcePopover = true, className, } = props; - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation('datasourceSelect'); const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext); const resolvedDatasourceList: DatasourceItem[] = providedDatasourceList ?? contextDatasourceList; const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions; @@ -68,6 +75,34 @@ export default function index(props: SelectProps & Props) { 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'); + // additionalOptions 中的伪值(如 mixed)并非数据源,回填时保留其原有展示。 + const preservedOptionValues = new Set([...additionalOptionValues, ...(showHost ? [-999] : [])]); + const normalizeSelectedValue = (value: SelectProps['value']) => { + if (value == null) return undefined; + + const normalizeValue = (item: DatasourceValue | { value: DatasourceValue }) => { + const rawValue = getRawValue(item); + // 业务过滤仅影响下拉可选项,不能将原始数据源列表中仍存在的数据源标记为已删除。 + const exists = _.some(resolvedDatasourceList, { id: rawValue }) || preservedOptionValues.has(rawValue); + + return exists + ? { value: rawValue } + : { + value: rawValue, + label: ( + + + id: {rawValue} {t('deleted')} + + ), + }; + }; + + return Array.isArray(value) ? value.map(normalizeValue) : normalizeValue(value); + }; const select = ( { - return { - value: item.id, - label: item.name, - }; - })} - optionFilterProp='label' - /> + {match_type === 0 ? ( + + ) : ( + Date: Thu, 10 Sep 2026 16:34:53 +0800 Subject: [PATCH 8/8] fix(datasource-select): keep injected datasource variable options in V3 ajustDatasourceList may inject datasource variable options (e.g. from dashboard). The deleted-value check only looked at the original list, so an injected selected value was wrongly flagged as deleted. Also check the adjusted list before marking a value as deleted, and add a regression test. --- .../DatasourceSelectV3/index.test.tsx | 51 +++++++++++++++++++ .../DatasourceSelectV3/index.tsx | 9 ++-- 2 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx new file mode 100644 index 0000000000..3489abf10e --- /dev/null +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx @@ -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( + [ + { + id: '${datasource}', + name: '${datasource}', + plugin_type: 'prometheus', + }, + ]} + showEmptyDatasourcePopover={false} + />, + ); + + expect(screen.getByText('${datasource}')).toBeInTheDocument(); + expect(screen.queryByText(/deleted/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index d872b39784..e6ec6ab5f7 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -66,7 +66,9 @@ export default function index(props: SelectProps & Props) { const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext); const resolvedDatasourceList: DatasourceItem[] = providedDatasourceList ?? contextDatasourceList; const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions; - const currentDatasourceList = (ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList).filter((item) => { + // 调整后的列表可能包含仪表盘注入的数据源变量选项,不能只用原始列表判断当前值是否已删除。 + const adjustedDatasourceList = ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList; + const currentDatasourceList = adjustedDatasourceList.filter((item) => { if (!filterKey) { return true; } @@ -87,8 +89,9 @@ export default function index(props: SelectProps & Props) { const normalizeValue = (item: DatasourceValue | { value: DatasourceValue }) => { const rawValue = getRawValue(item); - // 业务过滤仅影响下拉可选项,不能将原始数据源列表中仍存在的数据源标记为已删除。 - const exists = _.some(resolvedDatasourceList, { id: rawValue }) || preservedOptionValues.has(rawValue); + // 业务过滤仅影响下拉可选项,不能将原始数据源列表或调用方注入的选项标记为已删除。 + const exists = + _.some(resolvedDatasourceList, { id: rawValue }) || _.some(adjustedDatasourceList, { id: rawValue }) || preservedOptionValues.has(rawValue); return exists ? { value: rawValue }