diff --git a/app/src/App.tsx b/app/src/App.tsx index e21ef608f..854939bad 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -22,6 +22,7 @@ import { Content, AuthProvider, Modal, + InfoRedirect, InvitePage, LoginPage, SignupPage, @@ -210,9 +211,7 @@ function App() { > {tagsApi && } - - - + @@ -258,6 +257,14 @@ function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/app/src/ModalContent.tsx b/app/src/ModalContent.tsx index c79a850b9..ec59cf1bd 100644 --- a/app/src/ModalContent.tsx +++ b/app/src/ModalContent.tsx @@ -4,7 +4,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ -import { useEffect, useState } from 'react' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { TextView } from 'utopia-ui' import { config } from './config' @@ -61,21 +62,13 @@ export function Welcome1({ clickAction1, map }: ChapterProps) { ) } -const close = () => { - const myModal = document.getElementById('my_modal_3') as HTMLDialogElement - myModal.close() -} - export const ModalContent = ({ map }: { map: any }) => { - useEffect(() => { - const myModal = document.getElementById('my_modal_3') as HTMLDialogElement - if (map.info_open) { - myModal.showModal() - } - }, [map.info_open]) + const navigate = useNavigate() + const [chapter] = useState(1) - const [chapter, setChapter] = useState(1) - // const setQuestsOpen = useSetQuestOpen() + const close = () => { + void navigate('/') + } const ActiveChapter = () => { switch (chapter) { @@ -85,10 +78,6 @@ export const ModalContent = ({ map }: { map: any }) => { map={map} clickAction1={() => { close() - setTimeout(() => { - // setQuestsOpen(true); - setChapter(1) - }, 1000) }} /> ) diff --git a/cypress/e2e/info-modal/info-modal.cy.ts b/cypress/e2e/info-modal/info-modal.cy.ts new file mode 100644 index 000000000..50bda6b8b --- /dev/null +++ b/cypress/e2e/info-modal/info-modal.cy.ts @@ -0,0 +1,50 @@ +/// + +/** + * Info Modal Route E2E Tests + * + * Validates the route-based info modal introduced by PR #657: + * - Route /info renders the modal (E1) + * - NavBar ? link navigates to /info (E2) + * - Content "Close" button navigates back to / (E3) + */ + +describe('Info Modal Route', () => { + it('E1: visiting /info renders the info modal', () => { + cy.visit('/info') + + cy.get('.tw\\:card', { timeout: 15000 }).should('be.visible') + cy.get('.tw\\:backdrop-brightness-75').should('exist') + cy.contains('Close').should('be.visible') + cy.location('pathname').should('eq', '/info') + }) + + it('E2: NavBar ? icon navigates to /info', () => { + cy.visit('/') + cy.waitForMapReady() + + // Dismiss auto-opened modal if info_open is true in backend + cy.get('body').then(($body) => { + if ($body.find('.tw\\:backdrop-brightness-75').length > 0) { + cy.get('.tw\\:card button').contains('✕').click() + cy.location('pathname').should('eq', '/') + } + }) + + cy.get('a[href="/info"]').should('be.visible').click() + + cy.location('pathname').should('eq', '/info') + cy.get('.tw\\:card', { timeout: 10000 }).should('be.visible') + }) + + it('E3: content "Close" button closes modal and navigates to /', () => { + cy.visit('/info') + cy.get('.tw\\:card', { timeout: 15000 }).should('be.visible') + + cy.contains('label', 'Close').click() + + cy.location('pathname').should('eq', '/') + cy.get('.tw\\:backdrop-brightness-75').should('not.exist') + }) +}) + diff --git a/lib/src/Components/AppShell/InfoRedirect.spec.tsx b/lib/src/Components/AppShell/InfoRedirect.spec.tsx new file mode 100644 index 000000000..4f0f62497 --- /dev/null +++ b/lib/src/Components/AppShell/InfoRedirect.spec.tsx @@ -0,0 +1,78 @@ +import { render, cleanup } from '@testing-library/react' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +import { InfoRedirect } from './InfoRedirect' + +// --- Mocks --- + +const mockNavigate = vi.fn() + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + } +}) + +// Save original location so we can restore it after each test +const originalLocation = window.location + +// Helper to set window.location.pathname for tests +function setPathname(pathname: string) { + Object.defineProperty(window, 'location', { + value: { pathname, search: '', hash: '', href: `http://localhost${pathname}` }, + writable: true, + configurable: true, + }) +} + +// --- Tests --- + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + setPathname('/') + }) + + afterEach(() => { + cleanup() + // Restore original window.location to avoid leaking into other test files + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }) + }) + + it('U1: navigates to /info when enabled and pathname is "/"', () => { + render() + + expect(mockNavigate).toHaveBeenCalledTimes(1) + expect(mockNavigate).toHaveBeenCalledWith('/info') + }) + + it('U2: does NOT navigate when enabled is false', () => { + render() + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('U3: does NOT navigate when pathname is not "/"', () => { + setPathname('/login') + + render() + + expect(mockNavigate).not.toHaveBeenCalled() + }) + + it('U4: only navigates once even if re-rendered', () => { + const { rerender } = render() + + expect(mockNavigate).toHaveBeenCalledTimes(1) + + rerender() + + expect(mockNavigate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/lib/src/Components/AppShell/InfoRedirect.tsx b/lib/src/Components/AppShell/InfoRedirect.tsx new file mode 100644 index 000000000..efd17ca54 --- /dev/null +++ b/lib/src/Components/AppShell/InfoRedirect.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' + +interface InfoRedirectProps { + /** If true, redirects to /info route on initial page load (once) */ + enabled?: boolean +} + +/** + * Redirects to /info route on initial page load when enabled. + * Only redirects once per session to avoid redirect loops. + * Place this component inside the Router context but outside of Routes. + * @category AppShell + */ +export function InfoRedirect({ enabled }: InfoRedirectProps) { + const navigate = useNavigate() + const hasRedirected = useRef(false) + + useEffect(() => { + if (enabled && window.location.pathname === '/' && !hasRedirected.current) { + hasRedirected.current = true + void navigate('/info') + } + }, [enabled, navigate]) + + return null +} diff --git a/lib/src/Components/AppShell/Modal.tsx b/lib/src/Components/AppShell/Modal.tsx new file mode 100644 index 000000000..d3f082dc8 --- /dev/null +++ b/lib/src/Components/AppShell/Modal.tsx @@ -0,0 +1,16 @@ +import { MapOverlayPage } from '#components/Templates' + +/** + * @category AppShell + */ +export function Modal({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/lib/src/Components/AppShell/NavBar.tsx b/lib/src/Components/AppShell/NavBar.tsx index 17eecc01a..307a96e77 100644 --- a/lib/src/Components/AppShell/NavBar.tsx +++ b/lib/src/Components/AppShell/NavBar.tsx @@ -50,14 +50,9 @@ export default function NavBar({ appName }: { appName: string }) { {appName} - + diff --git a/lib/src/Components/AppShell/index.tsx b/lib/src/Components/AppShell/index.tsx index 7f27e9d56..2ccadc04c 100644 --- a/lib/src/Components/AppShell/index.tsx +++ b/lib/src/Components/AppShell/index.tsx @@ -1,4 +1,6 @@ export * from './AppShell' export { SideBar } from './SideBar' export { Content } from './Content' +export { InfoRedirect } from './InfoRedirect' +export { Modal } from './Modal' export { default as SVG } from 'react-inlinesvg' diff --git a/lib/src/Components/Gaming/Modal.tsx b/lib/src/Components/Gaming/Modal.tsx deleted file mode 100644 index 32f660587..000000000 --- a/lib/src/Components/Gaming/Modal.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect } from 'react' - -/** - * @category Gaming - */ -export function Modal({ - children, - showOnStartup, -}: { - children: React.ReactNode - showOnStartup?: boolean -}) { - useEffect(() => { - if (showOnStartup) { - window.my_modal_3.showModal() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return ( - <> - {/* You can open the modal using ID.showModal() method */} - -
- - {children} -
-
- -
-
- - ) -} diff --git a/lib/src/Components/Gaming/index.tsx b/lib/src/Components/Gaming/index.tsx index 78c873a96..9fffb315c 100644 --- a/lib/src/Components/Gaming/index.tsx +++ b/lib/src/Components/Gaming/index.tsx @@ -1,2 +1 @@ -export { Modal } from './Modal' export { Quests } from './Quests' diff --git a/lib/src/index.tsx b/lib/src/index.tsx index e62e6af6f..6d6c3e14a 100644 --- a/lib/src/index.tsx +++ b/lib/src/index.tsx @@ -10,11 +10,3 @@ export * from './Components/Input' export * from './Components/Item' export * from './Components/Onboarding' export * from './Components/Profile' - -declare global { - interface Window { - my_modal_3: { - showModal(): void - } - } -}