mirror of
https://github.com/certimate-go/certimate.git
synced 2026-09-24 23:10:13 +08:00
feat(ui): display both available and unavailable providers
This commit is contained in:
@@ -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<DeploymentProvider> {}
|
||||
export interface DeploymentProviderPickerProps extends SharedPickerProps<DeploymentProvider> {
|
||||
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<string>(DEPLOYMENT_CATEGORIES.ALL);
|
||||
|
||||
const [keyword, setKeyword] = useState<string>();
|
||||
@@ -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 (
|
||||
<div key={provider.type}>
|
||||
<Card
|
||||
className="h-16 w-full overflow-hidden shadow"
|
||||
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
|
||||
hoverable
|
||||
onClick={() => {
|
||||
handleProviderTypeSelect(provider.type);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full items-center gap-4 overflow-hidden">
|
||||
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="line-clamp-2 max-w-full">
|
||||
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
|
||||
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleProviderTypeSelect = (value: string) => {
|
||||
onSelect?.(value);
|
||||
@@ -87,12 +92,6 @@ const DeploymentProviderPicker = ({
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Flex className="mb-4" justify="end">
|
||||
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
|
||||
{t("provider.text.show_available_provider_only")}
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
|
||||
<Flex>
|
||||
<Tabs
|
||||
defaultActiveKey={DEPLOYMENT_CATEGORIES.ALL}
|
||||
@@ -121,7 +120,7 @@ const DeploymentProviderPicker = ({
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
|
||||
<Show when={dataSources.filtered.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
|
||||
<div
|
||||
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
|
||||
"gap-4": gap === "large",
|
||||
@@ -130,32 +129,27 @@ const DeploymentProviderPicker = ({
|
||||
[`gap-${+gap || "2"}`]: typeof gap === "number",
|
||||
})}
|
||||
>
|
||||
{providers.map((provider) => {
|
||||
return (
|
||||
<div key={provider.type}>
|
||||
<Card
|
||||
className="h-16 w-full overflow-hidden shadow"
|
||||
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
|
||||
hoverable
|
||||
onClick={() => {
|
||||
handleProviderTypeSelect(provider.type);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full items-center gap-4 overflow-hidden">
|
||||
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="line-clamp-2 max-w-full">
|
||||
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
|
||||
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
|
||||
</div>
|
||||
|
||||
<Show when={showAvailable && dataSources.unavailable.length > 0}>
|
||||
<Divider size="small">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("provider.text.unavailable_options")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<div
|
||||
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
|
||||
"gap-4": gap === "large",
|
||||
"gap-2": gap === "middle",
|
||||
"gap-1": gap === "small",
|
||||
[`gap-${+gap || "2"}`]: typeof gap === "number",
|
||||
})}
|
||||
>
|
||||
{dataSources.unavailable.map((provider) => renderOption(provider))}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
@@ -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<NotificationProvider> {}
|
||||
export interface NotificationProviderPickerProps extends SharedPickerProps<NotificationProvider> {
|
||||
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<string>();
|
||||
const keywordInputRef = useRef<InputRef>(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 (
|
||||
<div key={provider.type}>
|
||||
<Card
|
||||
className="h-16 w-full overflow-hidden shadow"
|
||||
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
|
||||
hoverable
|
||||
onClick={() => {
|
||||
handleProviderTypeSelect(provider.type);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full items-center gap-4 overflow-hidden">
|
||||
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="line-clamp-2 max-w-full">
|
||||
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
|
||||
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleProviderTypeSelect = (value: string) => {
|
||||
onSelect?.(value);
|
||||
@@ -78,13 +80,7 @@ const NotificationProviderPicker = ({
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Flex className="mb-4" justify="end">
|
||||
<Checkbox checked={isAvailableOnly} onClick={() => setIsAvailableOnly(!isAvailableOnly)}>
|
||||
{t("provider.text.show_available_provider_only")}
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
|
||||
<Show when={providers.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
|
||||
<Show when={dataSources.filtered.length > 0} fallback={<Empty description={t("provider.text.nodata")} image={Empty.PRESENTED_IMAGE_SIMPLE} />}>
|
||||
<div
|
||||
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
|
||||
"gap-4": gap === "large",
|
||||
@@ -93,32 +89,27 @@ const NotificationProviderPicker = ({
|
||||
[`gap-${+gap || "2"}`]: typeof gap === "number",
|
||||
})}
|
||||
>
|
||||
{providers.map((provider) => {
|
||||
return (
|
||||
<div key={provider.type}>
|
||||
<Card
|
||||
className="h-16 w-full overflow-hidden shadow"
|
||||
styles={{ body: { height: "100%", padding: "0.5rem 1rem" } }}
|
||||
hoverable
|
||||
onClick={() => {
|
||||
handleProviderTypeSelect(provider.type);
|
||||
}}
|
||||
>
|
||||
<div className="flex size-full items-center gap-4 overflow-hidden">
|
||||
<Avatar className="bg-stone-100" icon={<img src={provider.icon} />} shape="square" size={28} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="line-clamp-2 max-w-full">
|
||||
<Tooltip title={t(provider.name)} mouseEnterDelay={1}>
|
||||
<Typography.Text>{t(provider.name) || "\u00A0"}</Typography.Text>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(showAvailable ? dataSources.available : dataSources.filtered).map((provider) => renderOption(provider))}
|
||||
</div>
|
||||
|
||||
<Show when={showAvailable && dataSources.unavailable.length > 0}>
|
||||
<Divider size="small">
|
||||
<Typography.Text className="text-xs font-normal" type="secondary">
|
||||
{t("provider.text.unavailable_options")}
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<div
|
||||
className={mergeCls("grid w-full gap-2", `grid-cols-${cols}`, {
|
||||
"gap-4": gap === "large",
|
||||
"gap-2": gap === "middle",
|
||||
"gap-1": gap === "small",
|
||||
[`gap-${+gap || "2"}`]: typeof gap === "number",
|
||||
})}
|
||||
>
|
||||
{dataSources.unavailable.map((provider) => renderOption(provider))}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<T extends Provider>
|
||||
@@ -40,3 +45,71 @@ export const usePickerWrapperCols = (width: number) => {
|
||||
cols,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePickerDataSource = <T extends Provider>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -196,5 +196,5 @@
|
||||
|
||||
"provider.text.nodata": "暂无提供商",
|
||||
"provider.text.default_ca_provider": "(默认)不指定,跟随全局设置",
|
||||
"provider.text.show_available_provider_only": "仅列出可用的提供商(即已添加过授权凭据的)"
|
||||
"provider.text.unavailable_options": "以下提供商不可用(即未添加过授权凭据)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user