Skip to content

Commit 1b5496e

Browse files
authored
feat(adapter): add getDefaultServiceConfig for proactive network health checks (#322)
* refactor(common): rename contracts-ui-builder directory to ui-builder * chore(common): clean up * feat(adapter): add getDefaultServiceConfig for proactive network health checks Add getDefaultServiceConfig method to all adapters enabling the UI to proactively test network service connectivity when a network is selected. This allows displaying user-friendly error banners before users attempt operations that would fail due to service outages. Changes: - Add getDefaultServiceConfig to EVM, Stellar, Solana, Polkadot, Midnight adapters - Add useNetworkServiceHealthCheck hook for automatic service testing - Enhance ContractLoadingErrors with NetworkServiceErrorBanner integration - Update @OpenZeppelin/ui-* packages to latest versions * fix(docs): restore legacy-to-new package name mappings in spec files Address PR review comments by restoring the correct package name mappings in the rename spec documentation. The mappings should show legacy contracts-ui-builder-* names mapping to new ui-builder-* names.
1 parent 4d9d5c8 commit 1b5496e

File tree

50 files changed

+1166
-183
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1166
-183
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@openzeppelin/ui-builder-adapter-evm': minor
3+
'@openzeppelin/ui-builder-adapter-stellar': minor
4+
'@openzeppelin/ui-builder-adapter-solana': minor
5+
'@openzeppelin/ui-builder-adapter-polkadot': minor
6+
'@openzeppelin/ui-builder-adapter-midnight': minor
7+
---
8+
9+
Add `getDefaultServiceConfig` method to all adapters for proactive network service health checks
10+
11+
This new required method enables the UI to proactively test network service connectivity (RPC, indexers, explorers) when a network is selected, displaying user-friendly error banners before users attempt operations that would fail.
12+
13+
**New method: `getDefaultServiceConfig(serviceId: string): Record<string, unknown> | null`**
14+
15+
Returns the default configuration values for a network service, extracted from the network config. This allows health check functionality without requiring user configuration.
16+
17+
Implementation per adapter:
18+
19+
- **EVM**: Returns `rpcUrl` for 'rpc' service, `explorerUrl` for 'explorer' service
20+
- **Stellar**: Returns `sorobanRpcUrl` for 'rpc' service, `indexerUri`/`indexerWsUri` for 'indexer' service
21+
- **Solana**: Returns `rpcEndpoint` for 'rpc' service
22+
- **Polkadot**: Returns `rpcUrl` for 'rpc' service, `explorerUrl` for 'explorer' service
23+
- **Midnight**: Returns `httpUrl`/`wsUrl` (from `indexerUri`/`indexerWsUri`) for 'indexer' service

.eslint/rules/no-extra-adapter-methods.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ module.exports = {
9191
'prepareArtifactsForFunction',
9292
'getAccessControlService',
9393
'getTypeMappingInfo',
94+
'getDefaultServiceConfig',
9495
];
9596

9697
// Common standard methods and properties that are allowed

.pnpmfile.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
*
1515
* Expected directory structure:
1616
* ~/dev/
17-
* ├── contracts-ui-builder/ # This repo
17+
* ├── ui-builder/ # This repo
1818
* └── openzeppelin-ui/ # UI Kit repo (sibling directory)
1919
*
2020
* Custom path:

apps/builder/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@
2020
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" --config ../../.prettierrc.cjs",
2121
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" --config ../../.prettierrc.cjs",
2222
"ui:add": "npx shadcn-ui@latest add",
23-
"test": "vitest run",
24-
"test:watch": "vitest",
25-
"test:coverage": "vitest run --coverage",
26-
"test:report": "vitest run --reporter=json --outputFile=./test-results/export-tests.json src/export/__tests__/",
23+
"test": "NODE_OPTIONS='--max-old-space-size=8192' vitest run",
24+
"test:watch": "NODE_OPTIONS='--max-old-space-size=8192' vitest",
25+
"test:coverage": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --coverage",
26+
"test:report": "NODE_OPTIONS='--max-old-space-size=8192' vitest run --reporter=json --outputFile=./test-results/export-tests.json src/export/__tests__/",
2727
"export-app": "node src/export/cli/export-app.cjs",
2828
"storybook": "cd ../.. && pnpm storybook",
2929
"build-storybook": "cd ../.. && pnpm build-storybook"
@@ -37,13 +37,13 @@
3737
"@openzeppelin/ui-builder-adapter-polkadot": "workspace:*",
3838
"@openzeppelin/ui-builder-adapter-solana": "workspace:*",
3939
"@openzeppelin/ui-builder-adapter-stellar": "workspace:*",
40-
"@openzeppelin/ui-components": "^1.0.4",
40+
"@openzeppelin/ui-components": "^1.2.0",
4141
"@openzeppelin/ui-react": "^1.1.0",
4242
"@openzeppelin/ui-renderer": "^1.0.2",
4343
"@openzeppelin/ui-storage": "^1.0.0",
4444
"@openzeppelin/ui-styles": "^1.0.0",
45-
"@openzeppelin/ui-types": "^1.3.0",
46-
"@openzeppelin/ui-utils": "^1.1.0",
45+
"@openzeppelin/ui-types": "^1.5.0",
46+
"@openzeppelin/ui-utils": "^1.2.0",
4747
"@radix-ui/react-accordion": "^1.2.11",
4848
"@radix-ui/react-checkbox": "^1.3.2",
4949
"@radix-ui/react-dialog": "^1.1.14",

apps/builder/src/components/UIBuilder/StepContractDefinition/StepContractDefinition.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useStore } from 'zustand/react';
22
import { useShallow } from 'zustand/react/shallow';
33
import { useCallback, useMemo, useState } from 'react';
44

5+
import { NetworkServiceErrorBanner } from '@openzeppelin/ui-components';
6+
57
import { ActionBar } from '../../Common/ActionBar';
68
import { STEP_INDICES } from '../constants/stepIndices';
79
import {
@@ -15,7 +17,13 @@ import {
1517
ContractSuccessStatus,
1618
TrimmedArtifactsBanner,
1719
} from './components';
18-
import { useAutoContractLoad, useContractForm, useContractLoader, useFormSync } from './hooks';
20+
import {
21+
useAutoContractLoad,
22+
useContractForm,
23+
useContractLoader,
24+
useFormSync,
25+
useNetworkServiceHealthCheck,
26+
} from './hooks';
1927
import { StepContractDefinitionProps } from './types';
2028

2129
export function StepContractDefinition({
@@ -89,6 +97,12 @@ export function StepContractDefinition({
8997
ignoreProxy,
9098
});
9199

100+
// Proactive network service health check
101+
const { hasUnhealthyServices, unhealthyServices } = useNetworkServiceHealthCheck(
102+
adapter,
103+
networkConfig
104+
);
105+
92106
// Form-store synchronization
93107
useFormSync({
94108
debouncedManualDefinition,
@@ -148,6 +162,18 @@ export function StepContractDefinition({
148162
isWidgetExpanded={isWidgetExpanded}
149163
/>
150164

165+
{/* Show banners for unhealthy network services */}
166+
{hasUnhealthyServices &&
167+
unhealthyServices.map((service) => (
168+
<NetworkServiceErrorBanner
169+
key={service.serviceId}
170+
networkConfig={networkConfig}
171+
serviceType={service.serviceId}
172+
errorMessage={service.error}
173+
title={`${service.serviceLabel} Unavailable`}
174+
/>
175+
))}
176+
151177
{/* Show banner if artifacts have been trimmed */}
152178
{artifactsAreTrimmed && loadedConfigurationId && (
153179
<TrimmedArtifactsBanner
@@ -168,6 +194,7 @@ export function StepContractDefinition({
168194
contractDefinitionError={contractDefinitionError}
169195
circuitBreakerActive={circuitBreakerActive}
170196
loadedConfigurationId={loadedConfigurationId}
197+
networkConfig={networkConfig}
171198
/>
172199

173200
{hasContract && (
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Tests for useNetworkServiceHealthCheck hook.
5+
*/
6+
import { renderHook, waitFor } from '@testing-library/react';
7+
import { describe, expect, it, vi } from 'vitest';
8+
9+
import type { ContractAdapter, NetworkConfig, NetworkServiceForm } from '@openzeppelin/ui-types';
10+
11+
import { useNetworkServiceHealthCheck } from '../hooks/useNetworkServiceHealthCheck';
12+
13+
// Mock @openzeppelin/ui-utils to avoid heavy dependency loading during tests.
14+
// Vitest hoists vi.mock calls to the top of the file automatically.
15+
vi.mock('@openzeppelin/ui-utils', () => ({
16+
logger: {
17+
debug: vi.fn(),
18+
info: vi.fn(),
19+
warn: vi.fn(),
20+
error: vi.fn(),
21+
configure: vi.fn(),
22+
},
23+
userNetworkServiceConfigService: {
24+
get: vi.fn().mockReturnValue(null),
25+
},
26+
}));
27+
28+
// Helper to create mock network config
29+
function createMockNetworkConfig(): NetworkConfig {
30+
return {
31+
id: 'ethereum',
32+
exportConstName: 'ethereum',
33+
name: 'Ethereum',
34+
ecosystem: 'evm',
35+
network: 'mainnet',
36+
type: 'mainnet',
37+
isTestnet: false,
38+
} as NetworkConfig;
39+
}
40+
41+
// Helper to create mock service forms
42+
function createMockServiceForms(): NetworkServiceForm[] {
43+
return [
44+
{
45+
id: 'rpc',
46+
label: 'RPC Provider',
47+
supportsConnectionTest: true,
48+
fields: [
49+
{
50+
id: 'rpcUrl',
51+
name: 'rpcUrl',
52+
type: 'text',
53+
label: 'RPC URL',
54+
validation: { required: false },
55+
},
56+
],
57+
},
58+
];
59+
}
60+
61+
// Helper to create mock adapter
62+
function createMockAdapter(): Partial<ContractAdapter> {
63+
return {
64+
networkConfig: createMockNetworkConfig(),
65+
getNetworkServiceForms: vi.fn().mockReturnValue(createMockServiceForms()),
66+
getDefaultServiceConfig: vi.fn().mockImplementation((serviceId: string) => {
67+
if (serviceId === 'rpc') return { rpcUrl: 'https://eth.llamarpc.com' };
68+
return null;
69+
}),
70+
testNetworkServiceConnection: vi.fn().mockResolvedValue({
71+
success: true,
72+
latency: 150,
73+
}),
74+
};
75+
}
76+
77+
describe('useNetworkServiceHealthCheck', () => {
78+
it('should return empty state when adapter is null', () => {
79+
const { result } = renderHook(() => useNetworkServiceHealthCheck(null, null));
80+
81+
expect(result.current.isChecking).toBe(false);
82+
expect(result.current.hasUnhealthyServices).toBe(false);
83+
expect(result.current.unhealthyServices).toEqual([]);
84+
expect(result.current.allStatuses).toEqual([]);
85+
});
86+
87+
it('should report healthy services correctly', async () => {
88+
const mockAdapter = createMockAdapter();
89+
90+
const { result } = renderHook(() =>
91+
useNetworkServiceHealthCheck(mockAdapter as ContractAdapter, createMockNetworkConfig())
92+
);
93+
94+
await waitFor(() => {
95+
expect(result.current.isChecking).toBe(false);
96+
expect(result.current.allStatuses.length).toBeGreaterThan(0);
97+
});
98+
99+
expect(result.current.hasUnhealthyServices).toBe(false);
100+
expect(result.current.unhealthyServices).toEqual([]);
101+
});
102+
103+
it('should report unhealthy services correctly', async () => {
104+
const mockAdapter = createMockAdapter();
105+
mockAdapter.testNetworkServiceConnection = vi.fn().mockResolvedValue({
106+
success: false,
107+
error: 'Connection timeout',
108+
});
109+
110+
const { result } = renderHook(() =>
111+
useNetworkServiceHealthCheck(mockAdapter as ContractAdapter, createMockNetworkConfig())
112+
);
113+
114+
await waitFor(() => {
115+
expect(result.current.isChecking).toBe(false);
116+
expect(result.current.allStatuses.length).toBeGreaterThan(0);
117+
});
118+
119+
expect(result.current.hasUnhealthyServices).toBe(true);
120+
expect(result.current.unhealthyServices.length).toBeGreaterThan(0);
121+
});
122+
123+
it('should handle exceptions during health check gracefully', async () => {
124+
const mockAdapter = createMockAdapter();
125+
mockAdapter.testNetworkServiceConnection = vi
126+
.fn()
127+
.mockRejectedValue(new Error('Network failure'));
128+
129+
const { result } = renderHook(() =>
130+
useNetworkServiceHealthCheck(mockAdapter as ContractAdapter, createMockNetworkConfig())
131+
);
132+
133+
await waitFor(() => {
134+
expect(result.current.isChecking).toBe(false);
135+
expect(result.current.allStatuses.length).toBeGreaterThan(0);
136+
});
137+
138+
expect(result.current.hasUnhealthyServices).toBe(true);
139+
const rpcStatus = result.current.allStatuses.find((s) => s.serviceId === 'rpc');
140+
expect(rpcStatus?.isHealthy).toBe(false);
141+
expect(rpcStatus?.error).toBe('Network failure');
142+
});
143+
});

apps/builder/src/components/UIBuilder/StepContractDefinition/components/ContractLoadingErrors.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,44 @@
11
import { AlertTriangle } from 'lucide-react';
22

3-
import { Alert, AlertDescription, AlertTitle } from '@openzeppelin/ui-components';
3+
import {
4+
Alert,
5+
AlertDescription,
6+
AlertTitle,
7+
NetworkServiceErrorBanner,
8+
} from '@openzeppelin/ui-components';
9+
import type { NetworkConfig } from '@openzeppelin/ui-types';
10+
import { detectServiceType, isServiceConnectionError } from '@openzeppelin/ui-utils';
411

512
interface ContractLoadingErrorsProps {
613
validationError: string | null;
714
contractDefinitionError: string | null;
815
circuitBreakerActive: boolean;
916
loadedConfigurationId: string | null;
17+
/** Network configuration for showing service settings action */
18+
networkConfig?: NetworkConfig | null;
19+
/**
20+
* Optional hint about which service type failed.
21+
* If not provided, will attempt to detect from error message.
22+
*/
23+
failedServiceType?: string;
1024
}
1125

1226
/**
13-
* Displays all error states related to contract loading and validation
27+
* Displays all error states related to contract loading and validation.
28+
* Automatically detects network service connection errors and shows a specialized
29+
* banner with a call-to-action to configure the appropriate service settings.
1430
*/
1531
export function ContractLoadingErrors({
1632
validationError,
1733
contractDefinitionError,
1834
circuitBreakerActive,
1935
loadedConfigurationId,
36+
networkConfig,
37+
failedServiceType,
2038
}: ContractLoadingErrorsProps) {
39+
const isServiceError = isServiceConnectionError(contractDefinitionError);
40+
const serviceType = failedServiceType || detectServiceType(contractDefinitionError);
41+
2142
return (
2243
<div className="space-y-4">
2344
{/* Validation Error */}
@@ -29,8 +50,17 @@ export function ContractLoadingErrors({
2950
</Alert>
3051
)}
3152

32-
{/* Contract Loading Error */}
33-
{contractDefinitionError && !validationError && (
53+
{/* Network Service Connection Error - Show specialized banner */}
54+
{contractDefinitionError && !validationError && isServiceError && networkConfig && (
55+
<NetworkServiceErrorBanner
56+
networkConfig={networkConfig}
57+
serviceType={serviceType}
58+
errorMessage={contractDefinitionError}
59+
/>
60+
)}
61+
62+
{/* Contract Loading Error - Generic error (non-service related) */}
63+
{contractDefinitionError && !validationError && (!isServiceError || !networkConfig) && (
3464
<Alert variant="destructive">
3565
<AlertTriangle className="h-4 w-4" />
3666
<AlertTitle>Contract Loading Error</AlertTitle>

apps/builder/src/components/UIBuilder/StepContractDefinition/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ export { useContractForm } from './useContractForm';
22
export { useContractLoader } from './useContractLoader';
33
export { useFormSync } from './useFormSync';
44
export { useAutoContractLoad } from './useAutoContractLoad';
5+
export { useNetworkServiceHealthCheck } from './useNetworkServiceHealthCheck';
6+
export type { ServiceHealthStatus, NetworkHealthCheckResult } from './useNetworkServiceHealthCheck';

0 commit comments

Comments
 (0)