Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions e2e/chain-config-subscription.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -122,4 +121,3 @@ test.describe('Chain-config subscription', () => {
expect(derived?.address.startsWith('c')).toBe(true)
})
})

8 changes: 7 additions & 1 deletion src/i18n/locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "قائمة السلاسل",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "链列表",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/zh-TW/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "鏈清單",
Expand Down
29 changes: 29 additions & 0 deletions src/pages/settings/chain-config.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,35 @@ describe('ChainConfigPage', () => {
expect(mockAddManualConfig).toHaveBeenCalledTimes(1)
})

it('warns when manual config id already exists and allows replace/cancel', async () => {
renderWithProviders(<ChainConfigPage />)

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(<ChainConfigPage />)
Expand Down
87 changes: 87 additions & 0 deletions src/pages/settings/chain-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown>).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':
Expand Down Expand Up @@ -66,7 +94,11 @@ export function ChainConfigPage() {
const [subscriptionUrl, setSubscriptionUrl] = useState<string>('default');
const [manualJson, setManualJson] = useState<string>('');

const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [duplicateIds, setDuplicateIds] = useState<string[]>([]);

const pendingToastRef = useRef<null | 'subscriptionSaved' | 'subscriptionRefreshed' | 'manualAdded'>(null);
const pendingManualJsonRef = useRef<string | null>(null);
const wasLoadingRef = useRef(false);

useEffect(() => {
Expand Down Expand Up @@ -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 (
<div className="bg-muted/30 flex min-h-screen flex-col">
<PageHeader title={t('chainConfig.title')} onBack={() => navigate({ to: '/settings' })} />

<div className="flex-1 space-y-4 p-4">
<AlertDialog
open={duplicateDialogOpen}
onOpenChange={(open) => {
if (!open) handleDuplicateCancel();
else setDuplicateDialogOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('chainConfig.manual.duplicate.title')}</AlertDialogTitle>
<AlertDialogDescription>{t('chainConfig.manual.duplicate.description')}</AlertDialogDescription>
</AlertDialogHeader>
<div className="bg-muted/50 text-muted-foreground rounded-lg px-3 py-2 text-xs font-mono break-all">
{duplicateIds.join(', ')}
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleDuplicateCancel}>
{t('chainConfig.manual.duplicate.cancel')}
</AlertDialogCancel>
<AlertDialogAction onClick={handleDuplicateReplace} disabled={isLoading}>
{t('chainConfig.manual.duplicate.replace')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

{error && (
<div className="border-destructive/20 bg-destructive/5 text-destructive rounded-xl border px-4 py-3 text-sm">
{error}
Expand Down
27 changes: 24 additions & 3 deletions src/stores/chain-config.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<Response>>(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
Expand Down
18 changes: 17 additions & 1 deletion src/stores/chain-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,23 @@ export const chainConfigActions = {
},

setSubscriptionUrl: async (url: string): Promise<void> => {
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<void> => {
Expand Down