From fb4dd929cad764a702df9884159a8982980f4883 Mon Sep 17 00:00:00 2001 From: Fu Diwei Date: Thu, 4 Sep 2025 20:28:02 +0800 Subject: [PATCH] feat(ui): display both available and unavailable providers --- .../provider/DeploymentProviderPicker.tsx | 132 +++++++++--------- .../provider/NotificationProviderPicker.tsx | 125 ++++++++--------- ui/src/components/provider/_shared.ts | 75 +++++++++- ui/src/i18n/locales/en/nls.provider.json | 2 +- ui/src/i18n/locales/zh/nls.provider.json | 2 +- 5 files changed, 197 insertions(+), 139 deletions(-) diff --git a/ui/src/components/provider/DeploymentProviderPicker.tsx b/ui/src/components/provider/DeploymentProviderPicker.tsx index 68dbf767a..193bb6da0 100644 --- a/ui/src/components/provider/DeploymentProviderPicker.tsx +++ b/ui/src/components/provider/DeploymentProviderPicker.tsx @@ -1,17 +1,17 @@ -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMount } from "ahooks"; -import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; +import { Avatar, Card, Divider, Empty, Flex, Input, type InputRef, Tabs, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { DEPLOYMENT_CATEGORIES, type DeploymentProvider, deploymentProvidersMap } from "@/domain/provider"; -import { useZustandShallowSelector } from "@/hooks"; -import { useAccessesStore } from "@/stores/access"; import { mergeCls } from "@/utils/css"; -import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; +import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; -export interface DeploymentProviderPickerProps extends SharedPickerProps {} +export interface DeploymentProviderPickerProps extends SharedPickerProps { + showAvailable?: boolean; +} const DeploymentProviderPicker = ({ className, @@ -19,19 +19,15 @@ const DeploymentProviderPicker = ({ autoFocus, gap = "middle", placeholder, + showAvailable = true, showSearch = true, onFilter, onSelect, }: DeploymentProviderPickerProps) => { const { t } = useTranslation(); - const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); - const { wrapperElRef, cols } = usePickerWrapperCols(320); - const [isAvailableOnly, setIsAvailableOnly] = useState(true); - const [category, setCategory] = useState(DEPLOYMENT_CATEGORIES.ALL); const [keyword, setKeyword] = useState(); @@ -42,38 +38,47 @@ const DeploymentProviderPicker = ({ } }); - const providers = useMemo(() => { - return Array.from(deploymentProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } - - return true; - }) - .filter((provider) => { - if (isAvailableOnly) { - return provider.builtin || accesses.some((access) => access.provider === provider.provider); - } - - return true; - }) - .filter((provider) => { + const dataSources = usePickerDataSource({ + dataSource: Array.from(deploymentProvidersMap.values()), + filters: [ + (provider) => { if (category && category !== DEPLOYMENT_CATEGORIES.ALL) { return provider.category === category; } return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } + }, + ], + keyword: keyword, + onFilter: onFilter, + deps: [category], + }); - return true; - }); - }, [onFilter, accesses, isAvailableOnly, category, keyword]); + const renderOption = (provider: DeploymentProvider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
+
+
+
+
+ ); + }; const handleProviderTypeSelect = (value: string) => { onSelect?.(value); @@ -87,12 +92,6 @@ const DeploymentProviderPicker = ({ - - setIsAvailableOnly(!isAvailableOnly)}> - {t("provider.text.show_available_provider_only")} - - -
- 0} fallback={}> + 0} fallback={}>
- {providers.map((provider) => { - return ( -
- { - handleProviderTypeSelect(provider.type); - }} - > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - -
-
-
-
-
- ); - })} + {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
+ + 0}> + + + {t("provider.text.unavailable_options")} + + + +
+ {dataSources.unavailable.map((provider) => renderOption(provider))} +
+
diff --git a/ui/src/components/provider/NotificationProviderPicker.tsx b/ui/src/components/provider/NotificationProviderPicker.tsx index c7d4310be..2a6bdc45e 100644 --- a/ui/src/components/provider/NotificationProviderPicker.tsx +++ b/ui/src/components/provider/NotificationProviderPicker.tsx @@ -1,17 +1,17 @@ -import { useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMount } from "ahooks"; -import { Avatar, Card, Checkbox, Empty, Flex, Input, type InputRef, Tooltip, Typography } from "antd"; +import { Avatar, Card, Divider, Empty, Input, type InputRef, Tooltip, Typography } from "antd"; import Show from "@/components/Show"; import { type NotificationProvider, notificationProvidersMap } from "@/domain/provider"; -import { useZustandShallowSelector } from "@/hooks"; -import { useAccessesStore } from "@/stores/access"; import { mergeCls } from "@/utils/css"; -import { type SharedPickerProps, usePickerWrapperCols } from "./_shared"; +import { type SharedPickerProps, usePickerDataSource, usePickerWrapperCols } from "./_shared"; -export interface NotificationProviderPickerProps extends SharedPickerProps {} +export interface NotificationProviderPickerProps extends SharedPickerProps { + showAvailable?: boolean; +} const NotificationProviderPicker = ({ className, @@ -19,19 +19,15 @@ const NotificationProviderPicker = ({ autoFocus, gap = "middle", placeholder, + showAvailable = true, showSearch = true, onFilter, onSelect, }: NotificationProviderPickerProps) => { const { t } = useTranslation(); - const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); - useMount(() => fetchAccesses(false)); - const { wrapperElRef, cols } = usePickerWrapperCols(320); - const [isAvailableOnly, setIsAvailableOnly] = useState(true); - const [keyword, setKeyword] = useState(); const keywordInputRef = useRef(null); useMount(() => { @@ -40,31 +36,37 @@ const NotificationProviderPicker = ({ } }); - const providers = useMemo(() => { - return Array.from(notificationProvidersMap.values()) - .filter((provider) => { - if (onFilter) { - return onFilter(provider.type, provider); - } + const dataSources = usePickerDataSource({ + dataSource: Array.from(notificationProvidersMap.values()), + keyword: keyword, + onFilter: onFilter, + }); - return true; - }) - .filter((provider) => { - if (isAvailableOnly) { - return accesses.some((access) => access.provider === provider.provider); - } - - return true; - }) - .filter((provider) => { - if (keyword) { - const value = keyword.toLowerCase(); - return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); - } - - return true; - }); - }, [onFilter, accesses, isAvailableOnly, keyword]); + const renderOption = (provider: NotificationProvider) => { + return ( +
+ { + handleProviderTypeSelect(provider.type); + }} + > +
+ } shape="square" size={28} /> +
+
+ + {t(provider.name) || "\u00A0"} + +
+
+
+
+
+ ); + }; const handleProviderTypeSelect = (value: string) => { onSelect?.(value); @@ -78,13 +80,7 @@ const NotificationProviderPicker = ({ - - setIsAvailableOnly(!isAvailableOnly)}> - {t("provider.text.show_available_provider_only")} - - - - 0} fallback={}> + 0} fallback={}>
- {providers.map((provider) => { - return ( -
- { - handleProviderTypeSelect(provider.type); - }} - > -
- } shape="square" size={28} /> -
-
- - {t(provider.name) || "\u00A0"} - -
-
-
-
-
- ); - })} + {(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
+ + 0}> + + + {t("provider.text.unavailable_options")} + + + +
+ {dataSources.unavailable.map((provider) => renderOption(provider))} +
+
); diff --git a/ui/src/components/provider/_shared.ts b/ui/src/components/provider/_shared.ts index 3c8f32482..ff345c880 100644 --- a/ui/src/components/provider/_shared.ts +++ b/ui/src/components/provider/_shared.ts @@ -1,7 +1,12 @@ import { useMemo, useRef } from "react"; -import { useSize } from "ahooks"; +import { useTranslation } from "react-i18next"; +import { useMount, useSize } from "ahooks"; + import { type SelectProps } from "antd"; +import { useZustandShallowSelector } from "@/hooks"; +import { useAccessesStore } from "@/stores/access"; + type Provider = { type: string; name: string }; export interface SharedSelectProps @@ -40,3 +45,71 @@ export const usePickerWrapperCols = (width: number) => { cols, }; }; + +export const usePickerDataSource = ({ + dataSource, + filters, + keyword, + onFilter, + deps, +}: { + dataSource: T[]; + filters?: Array<(option: T) => boolean>; + keyword?: string; + onFilter?: (value: string, option: T) => boolean; + deps?: React.DependencyList; +}) => { + const { t } = useTranslation(); + + const { accesses, fetchAccesses } = useAccessesStore(useZustandShallowSelector(["accesses", "fetchAccesses"])); + useMount(() => fetchAccesses(false)); + + const filteredDataSource = useMemo(() => { + return dataSource + .filter((provider) => { + if (onFilter) { + return onFilter(provider.type, provider); + } + + return true; + }) + .filter((provider) => { + if (filters) { + for (const filter of filters) { + if (!filter(provider)) return false; + } + } + + return true; + }) + .filter((provider) => { + if (keyword) { + const value = keyword.toLowerCase(); + return provider.type.toLowerCase().includes(value) || t(provider.name).toLowerCase().includes(value); + } + + return true; + }); + }, [dataSource, filters, keyword, onFilter, ...(deps ?? [])]); + + const availableDataSource = useMemo(() => { + return filteredDataSource.filter((provider) => { + return accesses.some((access) => { + if ("builtin" in provider && provider.builtin) return true; + if ("provider" in provider) return access.provider === provider.provider; + return access.provider === provider.type; + }); + }); + }, [accesses, filteredDataSource, ...(deps ?? [])]); + + const unavailableDataSource = useMemo(() => { + return filteredDataSource.filter((item) => !availableDataSource.includes(item)); + }, [filteredDataSource, availableDataSource, ...(deps ?? [])]); + + return { + all: dataSource, + filtered: filteredDataSource, + available: availableDataSource, + unavailable: unavailableDataSource, + }; +}; diff --git a/ui/src/i18n/locales/en/nls.provider.json b/ui/src/i18n/locales/en/nls.provider.json index 6cf80f38a..12b05b211 100644 --- a/ui/src/i18n/locales/en/nls.provider.json +++ b/ui/src/i18n/locales/en/nls.provider.json @@ -196,5 +196,5 @@ "provider.text.nodata": "No providers available", "provider.text.default_ca_provider": "(Default) Follow global settings", - "provider.text.show_available_provider_only": "Only display available providers (with added credentials)" + "provider.text.unavailable_options": "The following providers are not available (without added credentials)" } diff --git a/ui/src/i18n/locales/zh/nls.provider.json b/ui/src/i18n/locales/zh/nls.provider.json index f92dcbf0c..5ab9407cf 100644 --- a/ui/src/i18n/locales/zh/nls.provider.json +++ b/ui/src/i18n/locales/zh/nls.provider.json @@ -196,5 +196,5 @@ "provider.text.nodata": "暂无提供商", "provider.text.default_ca_provider": "(默认)不指定,跟随全局设置", - "provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)" + "provider.text.unavailable_options": "以下提供商不可用(即未添加过授权凭据)" }