diff --git a/deployables/app/app/components/wallet/ConnectWalletFallback.tsx b/deployables/app/app/components/wallet/ConnectWalletFallback.tsx new file mode 100644 index 00000000..c748da45 --- /dev/null +++ b/deployables/app/app/components/wallet/ConnectWalletFallback.tsx @@ -0,0 +1,9 @@ +import { Labeled, PrimaryButton } from '@zodiac/ui' + +export const ConnectWalletFallback = () => ( + + + Loading wallet support... + + +) diff --git a/deployables/app/app/components/wallet/WalletProvider.tsx b/deployables/app/app/components/wallet/WalletProvider.tsx index 0ad6363f..c24a2460 100644 --- a/deployables/app/app/components/wallet/WalletProvider.tsx +++ b/deployables/app/app/components/wallet/WalletProvider.tsx @@ -1,6 +1,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { getDefaultConfig } from 'connectkit' -import { useEffect, useState, type PropsWithChildren } from 'react' +import { + useEffect, + useState, + type PropsWithChildren, + type ReactNode, +} from 'react' import { createConfig, injected, WagmiProvider, type Config } from 'wagmi' import { arbitrum, @@ -19,13 +24,16 @@ const queryClient = new QueryClient() const WALLETCONNECT_PROJECT_ID = '0f8a5e2cf60430a26274b421418e8a27' const isServer = typeof document === 'undefined' -export type WalletProviderProps = PropsWithChildren +export type WalletProviderProps = PropsWithChildren<{ fallback?: ReactNode }> -export const WalletProvider = ({ children }: WalletProviderProps) => { +export const WalletProvider = ({ + children, + fallback = null, +}: WalletProviderProps) => { const config = useConfig() if (config == null) { - return null + return fallback } return ( diff --git a/deployables/app/app/components/wallet/index.ts b/deployables/app/app/components/wallet/index.ts index 2c29fefd..6f81d754 100644 --- a/deployables/app/app/components/wallet/index.ts +++ b/deployables/app/app/components/wallet/index.ts @@ -17,3 +17,5 @@ export const WalletProvider: ComponentType = lazy( return { default: WalletProvider } }, ) + +export { ConnectWalletFallback } from './ConnectWalletFallback' diff --git a/deployables/app/app/routes.ts b/deployables/app/app/routes.ts index bd7b36cf..1d13bc94 100644 --- a/deployables/app/app/routes.ts +++ b/deployables/app/app/routes.ts @@ -7,6 +7,7 @@ import { export default [ index('routes/index.tsx'), + route('/connect', 'routes/connect.tsx'), route('/new-route', 'routes/new-route.ts'), route('/edit-route/:data', 'routes/edit-route.$data.tsx'), ...prefix('/:account/:chainId', [ diff --git a/deployables/app/app/routes/connect.tsx b/deployables/app/app/routes/connect.tsx new file mode 100644 index 00000000..6cb2f159 --- /dev/null +++ b/deployables/app/app/routes/connect.tsx @@ -0,0 +1,14 @@ +import { PilotType, ZodiacOsPlain } from '@zodiac/ui' + +const Connect = () => { + return ( +
+
+ + +
+
+ ) +} + +export default Connect diff --git a/deployables/app/app/routes/edit-route.$data.spec.ts b/deployables/app/app/routes/edit-route.$data.spec.ts index 8d3e6c3b..54b04b75 100644 --- a/deployables/app/app/routes/edit-route.$data.spec.ts +++ b/deployables/app/app/routes/edit-route.$data.spec.ts @@ -27,11 +27,12 @@ import { randomPrefixedAddress, } from '@zodiac/test-utils' import { formatPrefixedAddress, type ChainId } from 'ser-kit' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSafesByOwner } = vi.hoisted(() => ({ - mockGetSafesByOwner: - vi.fn['getSafesByOwner']>(), + mockGetSafesByOwner: vi + .fn['getSafesByOwner']>() + .mockResolvedValue({ safes: [] }), })) vi.mock('@zodiac/safe', async (importOriginal) => { @@ -79,6 +80,10 @@ vi.mock('@/utils', async (importOriginal) => { const mockDryRun = vi.mocked(dryRun) describe('Edit route', () => { + beforeEach(() => { + mockFetchZodiacModules.mockResolvedValue([]) + }) + describe('Label', () => { it('shows the name of a route', async () => { const route = createMockExecutionRoute({ label: 'Test route' }) @@ -333,7 +338,7 @@ describe('Edit route', () => { await render(`/edit-route/${btoa(JSON.stringify(route))}`) await userEvent.click( - screen.getByRole('combobox', { name: 'Zodiac Mod' }), + await screen.findByRole('combobox', { name: 'Zodiac Mod' }), ) await userEvent.click(screen.getByRole('option', { name: 'Roles v2' })) @@ -403,7 +408,7 @@ describe('Edit route', () => { await render(`/edit-route/${btoa(JSON.stringify(route))}`) await userEvent.click( - screen.getByRole('combobox', { name: 'Zodiac Mod' }), + await screen.findByRole('combobox', { name: 'Zodiac Mod' }), ) await userEvent.click(screen.getByRole('option', { name: 'Roles v1' })) @@ -520,7 +525,7 @@ describe('Edit route', () => { await render(`/edit-route/${btoa(JSON.stringify(route))}`) await userEvent.click( - screen.getByRole('combobox', { name: 'Zodiac Mod' }), + await screen.findByRole('combobox', { name: 'Zodiac Mod' }), ) await userEvent.click(screen.getByRole('option', { name: 'Roles v2' })) diff --git a/deployables/app/app/routes/edit-route.$data.tsx b/deployables/app/app/routes/edit-route.$data.tsx index d071125f..625d4f4d 100644 --- a/deployables/app/app/routes/edit-route.$data.tsx +++ b/deployables/app/app/routes/edit-route.$data.tsx @@ -2,11 +2,12 @@ import { AvatarInput, ChainSelect, ConnectWallet, + ConnectWalletFallback, WalletProvider, ZodiacMod, } from '@/components' import { dryRun, editRoute, jsonRpcProvider, parseRouteData } from '@/utils' -import { invariant } from '@epic-web/invariant' +import { invariant, invariantResponse } from '@epic-web/invariant' import { Chain, getChainId, verifyChainId, ZERO_ADDRESS } from '@zodiac/chains' import { formData, @@ -43,12 +44,11 @@ import { PilotType, PrimaryButton, SecondaryButton, - Section, TextInput, ZodiacOsPlain, } from '@zodiac/ui' -import { lazy, Suspense, useState } from 'react' -import { Form, useNavigation, useSubmit } from 'react-router' +import { lazy, Suspense, useEffect, useState } from 'react' +import { Form, useLoaderData, useNavigation, useSubmit } from 'react-router' import type { Route } from './+types/edit-route.$data' import { Intent } from './intents' @@ -80,6 +80,13 @@ export const action = async ({ request, params }: Route.ActionArgs) => { const route = parseRouteData(params.data) const data = await request.formData() + const intent = getString(data, 'intent') + + invariantResponse( + intent === Intent.UpdateModule, + `Invalid intent "${intent}" received in server action`, + ) + const module = zodiacModuleSchema.parse(JSON.parse(getString(data, 'module'))) const updatedRoute = updateRolesWaypoint(route, { @@ -169,21 +176,16 @@ export const clientAction = async ({ } const EditRoute = ({ - loaderData: { chainId, label, avatar, providerType, waypoints }, + loaderData: { chainId, label, avatar, waypoints }, actionData, }: Route.ComponentProps) => { const submit = useSubmit() - - const [optimisticConnection, setOptimisticConnection] = useState({ - pilotAddress: getPilotAddress(waypoints), - chainId, - providerType, - }) + const optimisticRoute = useOptimisticRoute() const { state } = useNavigation() return ( -
+
@@ -197,25 +199,13 @@ const EditRoute = ({
- - Connect wallet - - } - > - + }> + }> { - setOptimisticConnection({ - pilotAddress: account, - chainId, - providerType, - }) - submit( formData({ intent: Intent.ConnectWallet, @@ -227,12 +217,6 @@ const EditRoute = ({ ) }} onDisconnect={() => { - setOptimisticConnection({ - pilotAddress: ZERO_ADDRESS, - chainId: Chain.ETH, - providerType: undefined, - }) - submit(formData({ intent: Intent.DisconnectWallet }), { method: 'POST', }) @@ -270,9 +254,15 @@ const EditRoute = ({ avatar={avatar} waypoints={waypoints} onSelect={(module) => { - submit(formData({ module: JSON.stringify(module) }), { - method: 'POST', - }) + submit( + formData({ + intent: Intent.UpdateModule, + module: JSON.stringify(module), + }), + { + method: 'POST', + }, + ) }} /> @@ -315,6 +305,57 @@ const EditRoute = ({ export default EditRoute +const useOptimisticRoute = () => { + const { waypoints, chainId, providerType } = useLoaderData() + const pilotAddress = getPilotAddress(waypoints) + + const { formData } = useNavigation() + + const [optimisticConnection, setOptimisticConnection] = useState({ + pilotAddress, + chainId, + providerType, + }) + + useEffect(() => { + setOptimisticConnection({ pilotAddress, chainId, providerType }) + }, [chainId, pilotAddress, providerType]) + + useEffect(() => { + if (formData == null) { + return + } + + const intent = getOptionalString(formData, 'intent') + + if (intent == null) { + return + } + + switch (intent) { + case Intent.DisconnectWallet: { + setOptimisticConnection({ + pilotAddress: ZERO_ADDRESS, + chainId: Chain.ETH, + providerType: undefined, + }) + + break + } + + case Intent.ConnectWallet: { + setOptimisticConnection({ + pilotAddress: getHexString(formData, 'account'), + chainId: verifyChainId(getInt(formData, 'chainId')), + providerType: verifyProviderType(getInt(formData, 'providerType')), + }) + } + } + }, [formData]) + + return optimisticConnection +} + const getPilotAddress = (waypoints?: Waypoints) => { if (waypoints == null) { return null diff --git a/deployables/app/app/routes/intents.ts b/deployables/app/app/routes/intents.ts index 55aae1c2..9b868544 100644 --- a/deployables/app/app/routes/intents.ts +++ b/deployables/app/app/routes/intents.ts @@ -1,5 +1,6 @@ export enum Intent { Save = 'Save', + UpdateModule = 'UpdateModule', UpdateChain = 'UpdateChain', UpdateAvatar = 'UpdateAvatar', RemoveAvatar = 'RemoveAvatar', diff --git a/deployables/app/e2e/utils/index.ts b/deployables/app/e2e/utils/index.ts index 14b06bfb..0d993e3c 100644 --- a/deployables/app/e2e/utils/index.ts +++ b/deployables/app/e2e/utils/index.ts @@ -1,4 +1,2 @@ export { expect, test } from './fixture' export { loadExtension } from './loadExtension' -export { defaultMockAccount, mockWeb3 } from './mockWeb3' -export { waitFor } from './waitFor' diff --git a/deployables/app/tsconfig.cloudflare.json b/deployables/app/tsconfig.cloudflare.json index a0c4e26f..7d5f7ddc 100644 --- a/deployables/app/tsconfig.cloudflare.json +++ b/deployables/app/tsconfig.cloudflare.json @@ -3,6 +3,7 @@ "include": [ ".react-router/types/**/*", "vitest.setup.ts", + "e2e/**/*", "app/**/*", "app/**/.server/**/*", "app/**/.client/**/*", diff --git a/deployables/extension/manifest-util.ts b/deployables/extension/manifest-util.ts index 779542c6..ff532cef 100644 --- a/deployables/extension/manifest-util.ts +++ b/deployables/extension/manifest-util.ts @@ -13,10 +13,6 @@ config() // // node manifest-util.js ./public/manifest.json -const getIframeUrl = () => { - return `${getCompanionAppUrl()}/` -} - const updateManifest = (templateFileName, outFileName, version) => { try { console.log(chalk.white.bold('Manifest template file:')) @@ -25,7 +21,7 @@ const updateManifest = (templateFileName, outFileName, version) => { const data = fs .readFileSync(templateFileName) .toString() - .replaceAll('', getIframeUrl()) + .replaceAll('', getCompanionAppUrl()) const manifest = JSON.parse(data) manifest['version'] = version.replace('v', '') diff --git a/deployables/extension/src/connect/contentScripts/dApp.ts b/deployables/extension/src/connect/contentScripts/dApp.ts index 6c8947a7..5e79a50d 100644 --- a/deployables/extension/src/connect/contentScripts/dApp.ts +++ b/deployables/extension/src/connect/contentScripts/dApp.ts @@ -5,16 +5,16 @@ import { type ConnectedWalletMessage, } from '@zodiac/messages' -const companionAppUrl = getCompanionAppUrl() +const connectUrl = `${getCompanionAppUrl()}/connect` const ensureIframe = () => { let node: HTMLIFrameElement | null = document.querySelector( - `iframe[src="${companionAppUrl}"]`, + `iframe[src="${connectUrl}"]`, ) if (!node) { node = document.createElement('iframe') - node.src = companionAppUrl + node.src = connectUrl node.style.display = 'none' const parent = document.body || document.documentElement @@ -40,7 +40,7 @@ chrome.runtime.onConnect.addListener((port) => { 'cannot access connect iframe window', ) - iframe.contentWindow.postMessage(message, companionAppUrl) + iframe.contentWindow.postMessage(message, connectUrl) // wait for response const handleResponse = (event: MessageEvent) => { diff --git a/deployables/extension/src/manifest.template.json b/deployables/extension/src/manifest.template.json index cffa09ec..27cfbfb6 100644 --- a/deployables/extension/src/manifest.template.json +++ b/deployables/extension/src/manifest.template.json @@ -34,22 +34,26 @@ "content_scripts": [ { "matches": [""], - "exclude_globs": ["*", "about:*", "chrome:*"], + "exclude_globs": ["/*", "about:*", "chrome:*"], "run_at": "document_start", "js": ["build/connect/contentScripts/dApp.js"] }, { - "matches": ["*"], + "matches": ["/connect"], "run_at": "document_start", "all_frames": true, - "js": [ - "build/connect/contentScripts/connectIframe.js", - "build/companion/contentScripts/main.js" - ] + "js": ["build/connect/contentScripts/connectIframe.js"] + }, + { + "matches": ["/*"], + "exclude_globs": ["/connect"], + "run_at": "document_start", + "all_frames": true, + "js": ["build/companion/contentScripts/main.js"] }, { "matches": [""], - "exclude_globs": ["", "about:*", "chrome:*"], + "exclude_globs": ["/*", "about:*", "chrome:*"], "run_at": "document_start", "js": ["build/monitor/contentScript/main.js"] } diff --git a/packages/modules/src/removeAvatar.spec.ts b/packages/modules/src/removeAvatar.spec.ts index 10bddc09..4c46b560 100644 --- a/packages/modules/src/removeAvatar.spec.ts +++ b/packages/modules/src/removeAvatar.spec.ts @@ -2,7 +2,9 @@ import { Chain, ZERO_ADDRESS } from '@zodiac/chains' import { createMockEndWaypoint, createMockExecutionRoute, + createMockRoleWaypoint, createMockStartingWaypoint, + createMockWaypoints, randomAddress, } from '@zodiac/test-utils' import { formatPrefixedAddress } from 'ser-kit' @@ -34,4 +36,17 @@ describe('removeAvatar', () => { formatPrefixedAddress(Chain.GNO, ZERO_ADDRESS), ) }) + + it('removes the role waypoint', () => { + const rolesWaypoint = createMockRoleWaypoint() + + const route = createMockExecutionRoute({ + avatar: formatPrefixedAddress(Chain.GNO, randomAddress()), + waypoints: createMockWaypoints({ waypoints: [rolesWaypoint] }), + }) + + const updatedRoute = removeAvatar(route) + + expect(updatedRoute.waypoints).not.toContain(rolesWaypoint) + }) }) diff --git a/packages/test-utils/src/e2e/mockWeb3.ts b/packages/test-utils/src/e2e/mockWeb3.ts index 1ddf70d4..b3f05891 100644 --- a/packages/test-utils/src/e2e/mockWeb3.ts +++ b/packages/test-utils/src/e2e/mockWeb3.ts @@ -1,4 +1,3 @@ -import { invariant } from '@epic-web/invariant' import type { Page } from '@playwright/test' import { readFileSync } from 'fs' import type { Ref } from 'react' @@ -30,12 +29,12 @@ export const mockWeb3 = async ( return { lockWallet() { - return getConnectFrame(page).evaluate(() => { + return page.evaluate(() => { Web3Mock.trigger('accountsChanged', []) }) }, loadAccounts(accounts: string[]) { - return getConnectFrame(page).evaluate( + return page.evaluate( ([accounts]) => { Web3Mock.trigger('accountsChanged', accounts) }, @@ -43,7 +42,7 @@ export const mockWeb3 = async ( ) }, switchChain(chainId: ChainId) { - return getConnectFrame(page).evaluate( + return page.evaluate( ([chainId]) => { Web3Mock.trigger('chainChanged', `0x${chainId}`) }, @@ -53,20 +52,6 @@ export const mockWeb3 = async ( } } -const getConnectFrame = (page: Page) => { - if (process.env.COMPANION_APP_URL == null) { - return page - } - - const frame = page.frame({ - url: process.env.COMPANION_APP_URL, - }) - - invariant(frame != null, 'Connect iframe not found') - - return frame -} - const getLibraryCode = () => { if (web3Content.current == null) { web3Content.current = readFileSync(