diff --git a/e2e/chain-config-subscription.spec.ts b/e2e/chain-config-subscription.spec.ts index 00e67f671..0b21f5e56 100644 --- a/e2e/chain-config-subscription.spec.ts +++ b/e2e/chain-config-subscription.spec.ts @@ -72,7 +72,6 @@ test.describe('Chain-config subscription', () => { await page.click('button:has-text("保存")') const refreshButton = page.locator('button[aria-label="刷新订阅"]') - await refreshButton.click() await expect(page.getByText('BF Sub', { exact: true })).toBeVisible() const row = page.locator('div.px-4.py-3', { hasText: 'bf-sub' }) @@ -122,4 +121,3 @@ test.describe('Chain-config subscription', () => { expect(derived?.address.startsWith('c')).toBe(true) }) }) - diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 017a4e4eb..8f6b3393c 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -15,7 +15,13 @@ "title": "إضافة يدويًا", "description": "الصق JSON (كائن واحد أو مصفوفة)، ثم اضغط على إضافة.", "add": "إضافة", - "placeholder": "مثال: {\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}" + "placeholder": "مثال: {\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}", + "duplicate": { + "title": "معرّفات سلاسل مكررة", + "description": "هذه المعرّفات موجودة بالفعل. هل تريد استبدالها؟", + "replace": "استبدال", + "cancel": "إلغاء" + } }, "list": { "title": "قائمة السلاسل", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 79a04a548..70535392d 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -15,7 +15,13 @@ "title": "Add Manually", "description": "Paste JSON (an object or an array), then tap Add.", "add": "Add", - "placeholder": "e.g. {\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}" + "placeholder": "e.g. {\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}", + "duplicate": { + "title": "Duplicate chain IDs", + "description": "These chain IDs already exist. Do you want to replace them?", + "replace": "Replace", + "cancel": "Cancel" + } }, "list": { "title": "Chain List", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 8192146f0..dbc36f6d8 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -16,7 +16,13 @@ "title": "手动添加", "description": "粘贴 JSON(单个对象或数组),点击添加。", "add": "添加", - "placeholder": "例如:{\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}" + "placeholder": "例如:{\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}", + "duplicate": { + "title": "链 ID 已存在", + "description": "以下链 ID 已存在,是否替换?", + "replace": "替换", + "cancel": "取消" + } }, "list": { "title": "链列表", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 6931fb0b1..cac403778 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -15,7 +15,13 @@ "title": "手動新增", "description": "貼上 JSON(單一物件或陣列),點擊新增。", "add": "新增", - "placeholder": "例如:{\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}" + "placeholder": "例如:{\"id\":\"mychain\",\"version\":\"1.0\",\"type\":\"custom\",\"name\":\"MyChain\",\"symbol\":\"MC\",\"decimals\":8}", + "duplicate": { + "title": "鏈 ID 已存在", + "description": "以下鏈 ID 已存在,是否取代?", + "replace": "取代", + "cancel": "取消" + } }, "list": { "title": "鏈清單", diff --git a/src/pages/settings/chain-config.test.tsx b/src/pages/settings/chain-config.test.tsx index 38c51ee69..d20b42d4c 100644 --- a/src/pages/settings/chain-config.test.tsx +++ b/src/pages/settings/chain-config.test.tsx @@ -114,6 +114,35 @@ describe('ChainConfigPage', () => { expect(mockAddManualConfig).toHaveBeenCalledTimes(1) }) + it('warns when manual config id already exists and allows replace/cancel', async () => { + renderWithProviders() + + const textarea = screen.getByPlaceholderText( + '例如:{"id":"mychain","version":"1.0","type":"custom","name":"MyChain","symbol":"MC","decimals":8}' + ) + + const duplicateJson = + '{"id":"bfmeta","version":"1.0","type":"bioforest","name":"BFMeta Override","symbol":"BFT","decimals":8,"prefix":"c"}' + fireEvent.change(textarea, { + target: { value: duplicateJson }, + }) + + await userEvent.click(screen.getByRole('button', { name: '添加' })) + expect(mockAddManualConfig).not.toHaveBeenCalled() + + expect(screen.getByText('链 ID 已存在')).toBeInTheDocument() + expect(screen.getByText('以下链 ID 已存在,是否替换?')).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '取消' })) + expect(mockAddManualConfig).not.toHaveBeenCalled() + + // Re-open dialog and confirm replace + await userEvent.click(screen.getByRole('button', { name: '添加' })) + await userEvent.click(screen.getByRole('button', { name: '替换' })) + + await waitFor(() => expect(mockAddManualConfig).toHaveBeenCalledWith(duplicateJson)) + }) + it('displays error message', () => { mockError = 'Boom' renderWithProviders() diff --git a/src/pages/settings/chain-config.tsx b/src/pages/settings/chain-config.tsx index 0d4106830..ec7c5a17b 100644 --- a/src/pages/settings/chain-config.tsx +++ b/src/pages/settings/chain-config.tsx @@ -10,6 +10,16 @@ import { import { PageHeader } from '@/components/layout/page-header'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { useToast } from '@/services'; import { chainConfigActions, @@ -22,6 +32,24 @@ import { import { cn } from '@/lib/utils'; import type { ChainConfigSource, ChainConfigWarning } from '@/services/chain-config'; +function getIdFromUnknownConfig(input: unknown): string | null { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return null; + const id = (input as Record).id; + return typeof id === 'string' && id.trim() !== '' ? id : null; +} + +function getManualConfigIds(input: string): string[] { + try { + const json: unknown = JSON.parse(input) as unknown; + const ids = Array.isArray(json) + ? json.map(getIdFromUnknownConfig) + : [getIdFromUnknownConfig(json)]; + return Array.from(new Set(ids.filter((id): id is string => typeof id === 'string'))); + } catch { + return []; + } +} + function getSourceLabel(t: (key: string) => string, source: ChainConfigSource): string { switch (source) { case 'default': @@ -66,7 +94,11 @@ export function ChainConfigPage() { const [subscriptionUrl, setSubscriptionUrl] = useState('default'); const [manualJson, setManualJson] = useState(''); + const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false); + const [duplicateIds, setDuplicateIds] = useState([]); + const pendingToastRef = useRef(null); + const pendingManualJsonRef = useRef(null); const wasLoadingRef = useRef(false); useEffect(() => { @@ -108,15 +140,70 @@ export function ChainConfigPage() { }; const handleAddManual = async () => { + const ids = getManualConfigIds(manualJson); + const existing = new Set(configs.map((config) => config.id)); + const duplicates = ids.filter((id) => existing.has(id)); + + if (duplicates.length > 0) { + pendingManualJsonRef.current = manualJson; + setDuplicateIds(duplicates); + setDuplicateDialogOpen(true); + return; + } + pendingToastRef.current = 'manualAdded'; await chainConfigActions.addManualConfig(manualJson); }; + const handleDuplicateCancel = () => { + pendingManualJsonRef.current = null; + setDuplicateIds([]); + setDuplicateDialogOpen(false); + }; + + const handleDuplicateReplace = async () => { + const pendingJson = pendingManualJsonRef.current; + pendingManualJsonRef.current = null; + setDuplicateIds([]); + setDuplicateDialogOpen(false); + + if (!pendingJson) return; + + pendingToastRef.current = 'manualAdded'; + await chainConfigActions.addManualConfig(pendingJson); + }; + return (
navigate({ to: '/settings' })} />
+ { + if (!open) handleDuplicateCancel(); + else setDuplicateDialogOpen(open); + }} + > + + + {t('chainConfig.manual.duplicate.title')} + {t('chainConfig.manual.duplicate.description')} + +
+ {duplicateIds.join(', ')} +
+ + + {t('chainConfig.manual.duplicate.cancel')} + + + {t('chainConfig.manual.duplicate.replace')} + + +
+
+ {error && (
{error} diff --git a/src/stores/chain-config.test.ts b/src/stores/chain-config.test.ts index 9bfc60361..80e790e8f 100644 --- a/src/stores/chain-config.test.ts +++ b/src/stores/chain-config.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import 'fake-indexeddb/auto' import { resetChainConfigStorageForTests } from '@/services/chain-config/storage' @@ -45,11 +45,32 @@ describe('chain-config store', () => { it('sets subscription url and surfaces invalid url errors', async () => { await chainConfigActions.initialize() - await chainConfigActions.setSubscriptionUrl('https://example.com/chains.json') + + const subscriptionUrl = 'https://example.com/chains.json' + const baseFetch = fetch.bind(globalThis) + const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(async (input, init) => { + const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + if (requestUrl === subscriptionUrl) { + return new Response( + JSON.stringify([ + { id: 'remote-one', version: '1.0', type: 'custom', name: 'Remote One', symbol: 'R1', decimals: 8 }, + ]), + { + status: 200, + headers: { 'Content-Type': 'application/json', ETag: '"etag-1"' }, + }, + ) + } + return baseFetch(input, init) + }) + vi.stubGlobal('fetch', fetchMock) + + await chainConfigActions.setSubscriptionUrl(subscriptionUrl) const state1 = chainConfigStore.state - expect(chainConfigSelectors.getSubscription(state1)?.url).toBe('https://example.com/chains.json') + expect(chainConfigSelectors.getSubscription(state1)?.url).toBe(subscriptionUrl) expect(state1.error).toBeNull() + expect(chainConfigSelectors.getConfigs(state1).some((c) => c.id === 'remote-one' && c.source === 'subscription')).toBe(true) await chainConfigActions.setSubscriptionUrl('not-a-url') const state2 = chainConfigStore.state diff --git a/src/stores/chain-config.ts b/src/stores/chain-config.ts index 411c695a3..6d908dbbc 100644 --- a/src/stores/chain-config.ts +++ b/src/stores/chain-config.ts @@ -49,7 +49,23 @@ export const chainConfigActions = { }, setSubscriptionUrl: async (url: string): Promise => { - await runAndUpdate(async () => setSubscriptionUrl(url)) + chainConfigStore.setState((state) => ({ ...state, isLoading: true, error: null })) + + try { + const snapshot = await setSubscriptionUrl(url) + const nextUrl = snapshot.subscription?.url ?? 'default' + + if (nextUrl === 'default') { + chainConfigStore.setState((state) => ({ ...state, snapshot, isLoading: false, error: null })) + return + } + + const { result, snapshot: refreshedSnapshot } = await refreshSubscription() + const error = result.status === 'error' ? result.error : null + chainConfigStore.setState((state) => ({ ...state, snapshot: refreshedSnapshot, isLoading: false, error })) + } catch (error) { + chainConfigStore.setState((state) => ({ ...state, isLoading: false, error: toErrorMessage(error) })) + } }, refreshSubscription: async (): Promise => {