mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
[功能] 添加域名列表输入组件,支持动态建议和批量输入
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId, useMemo } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
|
||||||
|
function splitDomainRows(value: string) {
|
||||||
|
const rows = value.split(/\r?\n/);
|
||||||
|
return rows.length > 0 ? rows : [''];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSuggestionSources(domains: string[]) {
|
||||||
|
const values = new Set<string>();
|
||||||
|
|
||||||
|
for (const domain of domains) {
|
||||||
|
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.add(normalized);
|
||||||
|
|
||||||
|
const segments = normalized.split('.');
|
||||||
|
for (let index = 1; index < segments.length - 1; index += 1) {
|
||||||
|
values.add(segments.slice(index).join('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDomainSuggestions(input: string, sources: string[], rows: string[]) {
|
||||||
|
const normalizedInput = input.trim().toLowerCase();
|
||||||
|
if (!normalizedInput) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingDomains = new Set(
|
||||||
|
rows
|
||||||
|
.map((row) => row.trim().toLowerCase())
|
||||||
|
.filter((row) => row && row !== normalizedInput),
|
||||||
|
);
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
|
||||||
|
suggestions.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = normalizedInput.lastIndexOf('.');
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = normalizedInput.slice(0, separatorIndex);
|
||||||
|
const suffixInput = normalizedInput.slice(separatorIndex + 1);
|
||||||
|
|
||||||
|
if (!suffixInput || source.startsWith(suffixInput)) {
|
||||||
|
suggestions.push(`${prefix}.${source}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.filter((suggestion, index) => {
|
||||||
|
return (
|
||||||
|
suggestion !== normalizedInput &&
|
||||||
|
!existingDomains.has(suggestion) &&
|
||||||
|
suggestions.indexOf(suggestion) === index
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DomainListInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
suggestionSources = [],
|
||||||
|
placeholder = 'app.example.com',
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
suggestionSources?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const listId = useId();
|
||||||
|
const rows = splitDomainRows(value);
|
||||||
|
const normalizedSources = useMemo(
|
||||||
|
() => buildSuggestionSources(suggestionSources),
|
||||||
|
[suggestionSources],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateRows = (nextRows: string[]) => {
|
||||||
|
onChange(nextRows.join('\n'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row, index) => {
|
||||||
|
const suggestions = buildDomainSuggestions(row, normalizedSources, rows).slice(
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${index}-${rows.length}`} className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<ResourceInput
|
||||||
|
value={row}
|
||||||
|
list={`${listId}-${index}`}
|
||||||
|
aria-label={`域名 ${index + 1}`}
|
||||||
|
placeholder={index === 0 ? placeholder : 'www.example.com'}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextRows = rows.slice();
|
||||||
|
nextRows[index] = event.target.value;
|
||||||
|
updateRows(nextRows);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<datalist id={`${listId}-${index}`}>
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<option key={suggestion} value={suggestion} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
aria-label={`新增域名输入框 ${index + 1}`}
|
||||||
|
className="h-[46px] min-w-11 px-0"
|
||||||
|
onClick={() => {
|
||||||
|
const nextRows = rows.slice();
|
||||||
|
nextRows.splice(index + 1, 0, '');
|
||||||
|
updateRows(nextRows);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</SecondaryButton>
|
||||||
|
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
aria-label={`删除域名输入框 ${index + 1}`}
|
||||||
|
className="h-[46px] min-w-11 px-0"
|
||||||
|
disabled={rows.length === 1}
|
||||||
|
onClick={() => {
|
||||||
|
if (rows.length === 1) {
|
||||||
|
updateRows(['']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRows(rows.filter((_, rowIndex) => rowIndex !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
|
||||||
|
onClick={() => {
|
||||||
|
const nextRows = rows.slice();
|
||||||
|
nextRows[index] = suggestion;
|
||||||
|
updateRows(nextRows);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
@@ -14,19 +14,19 @@ import { InlineMessage } from '@/components/feedback/inline-message';
|
|||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||||
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
|
||||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
||||||
import {
|
import {
|
||||||
getProxyRoute,
|
getProxyRoute,
|
||||||
updateProxyRoute,
|
updateProxyRoute,
|
||||||
} from '@/features/proxy-routes/api/proxy-routes';
|
} from '@/features/proxy-routes/api/proxy-routes';
|
||||||
|
import { DomainListInput } from '@/features/proxy-routes/components/domain-list-input';
|
||||||
import {
|
import {
|
||||||
buildPayloadFromRoute,
|
buildPayloadFromRoute,
|
||||||
customHeadersToText,
|
customHeadersToText,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getWebsiteConfigSection,
|
getWebsiteConfigSection,
|
||||||
getWebsiteStatusBadges,
|
|
||||||
linesFromTextarea,
|
linesFromTextarea,
|
||||||
normalizeLimitRate,
|
normalizeLimitRate,
|
||||||
parseCustomHeadersText,
|
parseCustomHeadersText,
|
||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
validateLimitRate,
|
validateLimitRate,
|
||||||
validateOriginHost,
|
validateOriginHost,
|
||||||
websiteConfigSections,
|
websiteConfigSections,
|
||||||
type WebsiteConfigSectionKey,
|
|
||||||
} from '@/features/proxy-routes/helpers';
|
} from '@/features/proxy-routes/helpers';
|
||||||
import type {
|
import type {
|
||||||
ProxyRouteItem,
|
ProxyRouteItem,
|
||||||
@@ -53,7 +52,6 @@ import {
|
|||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
|
||||||
|
|
||||||
type FeedbackState = {
|
type FeedbackState = {
|
||||||
tone: 'success' | 'danger';
|
tone: 'success' | 'danger';
|
||||||
@@ -234,10 +232,12 @@ function DomainSettingsSection({
|
|||||||
route,
|
route,
|
||||||
saving,
|
saving,
|
||||||
onSave,
|
onSave,
|
||||||
|
suggestionSources,
|
||||||
}: {
|
}: {
|
||||||
route: ProxyRouteItem;
|
route: ProxyRouteItem;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onSave: SaveHandler;
|
onSave: SaveHandler;
|
||||||
|
suggestionSources: string[];
|
||||||
}) {
|
}) {
|
||||||
const form = useForm<DomainSettingsValues>({
|
const form = useForm<DomainSettingsValues>({
|
||||||
resolver: zodResolver(domainSettingsSchema),
|
resolver: zodResolver(domainSettingsSchema),
|
||||||
@@ -303,13 +303,20 @@ function DomainSettingsSection({
|
|||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="每行一个域名,第一行会作为主域名,并用于列表摘要和兼容镜像。"
|
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
|
||||||
error={form.formState.errors.domains_text?.message}
|
error={form.formState.errors.domains_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<Controller
|
||||||
className="min-h-36"
|
control={form.control}
|
||||||
placeholder={'app.example.com\nwww.example.com'}
|
name="domains_text"
|
||||||
{...form.register('domains_text')}
|
render={({ field }) => (
|
||||||
|
<DomainListInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
suggestionSources={suggestionSources}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
@@ -476,6 +483,7 @@ function ReverseProxySection({
|
|||||||
error={form.formState.errors.origin_urls_text?.message}
|
error={form.formState.errors.origin_urls_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
|
aria-label="上游地址"
|
||||||
className="min-h-40"
|
className="min-h-40"
|
||||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
@@ -743,6 +751,10 @@ export function ProxyRouteConfigPage({
|
|||||||
queryKey: ['tls-certificates', 'list'],
|
queryKey: ['tls-certificates', 'list'],
|
||||||
queryFn: getTlsCertificates,
|
queryFn: getTlsCertificates,
|
||||||
});
|
});
|
||||||
|
const managedDomainsQuery = useQuery({
|
||||||
|
queryKey: ['managed-domains'],
|
||||||
|
queryFn: getManagedDomains,
|
||||||
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
@@ -776,6 +788,12 @@ export function ProxyRouteConfigPage({
|
|||||||
() => certificatesQuery.data ?? [],
|
() => certificatesQuery.data ?? [],
|
||||||
[certificatesQuery.data],
|
[certificatesQuery.data],
|
||||||
);
|
);
|
||||||
|
const domainSuggestionSources = useMemo(() => {
|
||||||
|
return [
|
||||||
|
...(route?.domains ?? []),
|
||||||
|
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||||
|
];
|
||||||
|
}, [managedDomainsQuery.data, route?.domains]);
|
||||||
|
|
||||||
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
if (!Number.isFinite(numericRouteID) || numericRouteID <= 0) {
|
||||||
return (
|
return (
|
||||||
@@ -817,10 +835,6 @@ export function ProxyRouteConfigPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSectionMeta =
|
|
||||||
websiteConfigSections.find((section) => section.key === currentSection) ??
|
|
||||||
websiteConfigSections[0];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -887,6 +901,7 @@ export function ProxyRouteConfigPage({
|
|||||||
<DomainSettingsSection
|
<DomainSettingsSection
|
||||||
route={route}
|
route={route}
|
||||||
saving={saveMutation.isPending}
|
saving={saveMutation.isPending}
|
||||||
|
suggestionSources={domainSuggestionSources}
|
||||||
onSave={(payload, context) =>
|
onSave={(payload, context) =>
|
||||||
saveMutation.mutate({ payload, context })
|
saveMutation.mutate({ payload, context })
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-13
@@ -1,13 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { Drawer } from '@/components/ui/drawer';
|
import { Drawer } from '@/components/ui/drawer';
|
||||||
|
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||||
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
|
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
|
||||||
|
import { DomainListInput } from '@/features/proxy-routes/components/domain-list-input';
|
||||||
import {
|
import {
|
||||||
buildOriginUrl,
|
buildOriginUrl,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -20,6 +22,7 @@ import type { ProxyRouteItem } from '@/features/proxy-routes/types';
|
|||||||
import {
|
import {
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
ResourceField,
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
ResourceTextarea,
|
ResourceTextarea,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
@@ -69,15 +72,26 @@ export function ProxyRouteCreateDrawer({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
domainSuggestionSources = [],
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onCreated: (route: ProxyRouteItem) => void;
|
onCreated: (route: ProxyRouteItem) => void;
|
||||||
|
domainSuggestionSources?: string[];
|
||||||
}) {
|
}) {
|
||||||
const form = useForm<CreateWebsiteFormValues>({
|
const form = useForm<CreateWebsiteFormValues>({
|
||||||
resolver: zodResolver(createWebsiteSchema),
|
resolver: zodResolver(createWebsiteSchema),
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
const managedDomainsQuery = useQuery({
|
||||||
|
queryKey: ['managed-domains'],
|
||||||
|
queryFn: getManagedDomains,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const combinedDomainSuggestions = [
|
||||||
|
...domainSuggestionSources,
|
||||||
|
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (values: CreateWebsiteFormValues) => {
|
mutationFn: async (values: CreateWebsiteFormValues) => {
|
||||||
@@ -156,24 +170,31 @@ export function ProxyRouteCreateDrawer({
|
|||||||
>
|
>
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="站点标识"
|
label="站点标识"
|
||||||
hint="可选。留空时会自动使用第一行域名。"
|
hint="可选。留空时会自动使用第一个域名。"
|
||||||
error={form.formState.errors.site_name?.message}
|
error={form.formState.errors.site_name?.message}
|
||||||
>
|
>
|
||||||
<input
|
<ResourceInput
|
||||||
{...form.register('site_name')}
|
{...form.register('site_name')}
|
||||||
placeholder="marketing-site"
|
placeholder="marketing-site"
|
||||||
className="w-full rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-3 text-sm text-[var(--foreground-primary)] outline-none transition focus:border-[var(--border-strong)]"
|
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="域名列表"
|
label="域名列表"
|
||||||
hint="每行一个域名,第一行会作为主域名。"
|
hint="一个输入框填写一个域名,点击右侧 + 可以继续追加。输入时会优先提示已有域名后缀。"
|
||||||
error={form.formState.errors.domains_text?.message}
|
error={form.formState.errors.domains_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<Controller
|
||||||
placeholder={'app.example.com\nwww.example.com'}
|
control={form.control}
|
||||||
{...form.register('domains_text')}
|
name="domains_text"
|
||||||
|
render={({ field }) => (
|
||||||
|
<DomainListInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
suggestionSources={combinedDomainSuggestions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
@@ -183,6 +204,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
error={form.formState.errors.origin_urls_text?.message}
|
error={form.formState.errors.origin_urls_text?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea
|
||||||
|
aria-label="上游地址"
|
||||||
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
|
||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
/>
|
/>
|
||||||
@@ -201,10 +223,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
label="备注"
|
label="备注"
|
||||||
error={form.formState.errors.remark?.message}
|
error={form.formState.errors.remark?.message}
|
||||||
>
|
>
|
||||||
<ResourceTextarea
|
<ResourceTextarea placeholder="" {...form.register('remark')} />
|
||||||
placeholder=""
|
|
||||||
{...form.register('remark')}
|
|
||||||
/>
|
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
{createMutation.isError ? (
|
{createMutation.isError ? (
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
ResourceInput,
|
ResourceInput,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
|
||||||
|
|
||||||
type FeedbackState = {
|
type FeedbackState = {
|
||||||
tone: 'info' | 'success' | 'danger';
|
tone: 'info' | 'success' | 'danger';
|
||||||
@@ -388,6 +387,7 @@ export function ProxyRoutesPage() {
|
|||||||
<ProxyRouteCreateDrawer
|
<ProxyRouteCreateDrawer
|
||||||
open={isCreateOpen}
|
open={isCreateOpen}
|
||||||
onOpenChange={setIsCreateOpen}
|
onOpenChange={setIsCreateOpen}
|
||||||
|
domainSuggestionSources={routes.flatMap((route) => route.domains)}
|
||||||
onCreated={async (route) => {
|
onCreated={async (route) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||||
|
|||||||
@@ -232,6 +232,18 @@ describe('Proxy route website pages', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.includes('/managed-domains/')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -245,22 +257,28 @@ describe('Proxy route website pages', () => {
|
|||||||
const dialog = await screen.findByRole('dialog');
|
const dialog = await screen.findByRole('dialog');
|
||||||
expect(dialog).toBeInTheDocument();
|
expect(dialog).toBeInTheDocument();
|
||||||
|
|
||||||
const dialogTextboxes = within(dialog).getAllByRole('textbox');
|
await user.type(within(dialog).getByPlaceholderText('marketing-site'), 'launch-site');
|
||||||
await user.type(dialogTextboxes[0], 'launch-site');
|
|
||||||
|
const primaryDomainInput = within(dialog).getByLabelText('域名 1');
|
||||||
|
await user.type(primaryDomainInput, 'app.exam');
|
||||||
|
await user.click(await within(dialog).findByRole('button', { name: 'app.example.com' }));
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByLabelText('新增域名输入框 1'));
|
||||||
|
await user.type(within(dialog).getByLabelText('域名 2'), 'www.example.com');
|
||||||
|
|
||||||
await user.type(
|
await user.type(
|
||||||
dialogTextboxes[1],
|
within(dialog).getByLabelText('上游地址'),
|
||||||
'app.example.com{enter}www.example.com',
|
|
||||||
);
|
|
||||||
await user.type(
|
|
||||||
dialogTextboxes[2],
|
|
||||||
'https://origin-a.internal:443{enter}https://origin-b.internal:443',
|
'https://origin-a.internal:443{enter}https://origin-b.internal:443',
|
||||||
);
|
);
|
||||||
|
|
||||||
const submitButton = document.querySelector(
|
const submitButton = document.querySelector(
|
||||||
'button[form="create-website-form"]',
|
'button[form="create-website-form"]',
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(submitButton).not.toBeNull();
|
expect(submitButton).toBeInstanceOf(HTMLButtonElement);
|
||||||
await user.click(submitButton!);
|
if (!submitButton) {
|
||||||
|
throw new Error('missing create submit button');
|
||||||
|
}
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(pushMock).toHaveBeenCalledWith(
|
expect(pushMock).toHaveBeenCalledWith(
|
||||||
@@ -327,6 +345,18 @@ describe('Proxy route website pages', () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.includes('/managed-domains/')) {
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -338,21 +368,26 @@ describe('Proxy route website pages', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
|
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
|
||||||
|
|
||||||
const [siteNameInput, domainsText] = screen.getAllByRole('textbox');
|
const siteNameInput = screen.getByPlaceholderText('marketing-site');
|
||||||
await user.clear(siteNameInput);
|
await user.clear(siteNameInput);
|
||||||
await user.type(siteNameInput, 'brand-site');
|
await user.type(siteNameInput, 'brand-site');
|
||||||
|
|
||||||
await user.clear(domainsText);
|
const primaryDomainInput = screen.getByLabelText('域名 1');
|
||||||
await user.type(
|
await user.clear(primaryDomainInput);
|
||||||
domainsText,
|
await user.type(primaryDomainInput, 'brand.example.com');
|
||||||
'brand.example.com{enter}www.brand.example.com',
|
|
||||||
);
|
const secondaryDomainInput = screen.getByLabelText('域名 2');
|
||||||
|
await user.clear(secondaryDomainInput);
|
||||||
|
await user.type(secondaryDomainInput, 'www.brand.example.com');
|
||||||
|
|
||||||
const saveButton = document.querySelector(
|
const saveButton = document.querySelector(
|
||||||
'button[form="proxy-route-domains-form"]',
|
'button[form="proxy-route-domains-form"]',
|
||||||
) as HTMLButtonElement | null;
|
) as HTMLButtonElement | null;
|
||||||
expect(saveButton).not.toBeNull();
|
expect(saveButton).toBeInstanceOf(HTMLButtonElement);
|
||||||
await user.click(saveButton!);
|
if (!saveButton) {
|
||||||
|
throw new Error('missing domain save button');
|
||||||
|
}
|
||||||
|
await user.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(updateRequests).toHaveLength(1);
|
expect(updateRequests).toHaveLength(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user