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
Empty file removed .codex
Empty file.
57 changes: 30 additions & 27 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Coverage: App-level GrowthBook popup routing by URL tab/screen, trigger payload variants, false-positive mismatch blocking, and malformed/null payload negatives.
import { waitFor } from '@testing-library/react';
import { act } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { renderWithProviders } from './tests/test-utils';
import App from './App';
import PopupManager from './components/GenericPopUp/GenericPopUpManager';
import * as growthbookModule from '@growthbook/growthbook-react';
import { GENERIC_POP_UP } from './common/constants';

jest.mock('@growthbook/growthbook-react', () => ({
GrowthBookProvider: ({ children }: any) => children,
Expand Down Expand Up @@ -50,11 +50,7 @@ describe('App Component', () => {

act(() => {
mockGrowthbook.getFeatureValue.mockReturnValue(null);
const result = renderWithProviders(
<BrowserRouter>
<App />
</BrowserRouter>,
);
const result = renderWithProviders(<App />);
unmount = result.unmount;
});

Expand All @@ -65,6 +61,16 @@ describe('App Component', () => {
});
});

// Covers: rewrites legacy pathname deep links into hash-router URLs before router mount.
it('rewrites legacy pathname deep links to hash routes on startup', () => {
window.history.replaceState({}, '', '/join-class?classCode=123');

renderWithProviders(<App />);

expect(window.location.pathname).toBe('/');
expect(window.location.hash).toBe('#/join-class?classCode=123');
});

const popupConfigBase = {
id: 'gb-popup-app-1',
isActive: true,
Expand Down Expand Up @@ -117,14 +123,13 @@ describe('App Component', () => {
triggers: trigger,
};

mockGrowthbook.getFeatureValue.mockReturnValue(popupConfig);
window.history.replaceState({}, '', `/?tab=${tab}`);

renderWithProviders(
<BrowserRouter>
<App />
</BrowserRouter>,
(growthbookModule.useFeatureValue as jest.Mock).mockImplementation(
(key, defaultValue) =>
key === GENERIC_POP_UP ? popupConfig : defaultValue,
);
window.history.replaceState({}, '', `/#/?tab=${tab}`);

renderWithProviders(<App />);

await waitFor(() =>
expect(PopupManager.onAppOpen).toHaveBeenCalledWith(popupConfig),
Expand All @@ -144,14 +149,13 @@ describe('App Component', () => {
triggers: { type: 'APP_OPEN', value: 1 },
};

mockGrowthbook.getFeatureValue.mockReturnValue(popupConfig);
window.history.replaceState({}, '', '/?tab=home');

renderWithProviders(
<BrowserRouter>
<App />
</BrowserRouter>,
(growthbookModule.useFeatureValue as jest.Mock).mockImplementation(
(key, defaultValue) =>
key === GENERIC_POP_UP ? popupConfig : defaultValue,
);
window.history.replaceState({}, '', '/#/?tab=home');

renderWithProviders(<App />);
await new Promise((resolve) => setTimeout(resolve, 50));

expect(PopupManager.onAppOpen).not.toHaveBeenCalled();
Expand All @@ -165,15 +169,14 @@ describe('App Component', () => {
])(
'does not route popup when growthbook payload is malformed: $name',
async ({ payload }) => {
mockGrowthbook.getFeatureValue.mockReturnValue(payload);
(growthbookModule.useFeatureValue as jest.Mock).mockImplementation(
(key, defaultValue) =>
key === GENERIC_POP_UP ? payload : defaultValue,
);

window.history.replaceState({}, '', '/?tab=leaderboard');
window.history.replaceState({}, '', '/#/?tab=leaderboard');

renderWithProviders(
<BrowserRouter>
<App />
</BrowserRouter>,
);
renderWithProviders(<App />);
await new Promise((resolve) => setTimeout(resolve, 50));

expect(PopupManager.onAppOpen).not.toHaveBeenCalled();
Expand Down
59 changes: 11 additions & 48 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import { IonApp, setupIonicReact } from '@ionic/react';
import { IonReactHashRouter } from '@ionic/react-router';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
Expand All @@ -39,37 +39,21 @@ import './theme/variables.css';
import './App.css';

import React from 'react';
import { BASE_NAME } from './common/constants';
import TermsGate from './components/termsandconditons/TermsGate';
import { HardwareBackButtonHandler } from './common/backButtonRegistry';
import { useAppSelector } from './redux/hooks';
import { useNavigationHandler } from './helper/navigation/NavigationHandler';
import AppRoutes from './app/AppRoutes';
import AppOverlays from './app/AppOverlays';
import { useGenericPopup } from './hooks/useGenericPopup';

import { useGlobalBrowserEffects } from './hooks/useGlobalBrowserEffects';
import { useGrowthBookFeatureCache } from './hooks/useGrowthBookFeatureCache';
import { useHotUpdate } from './hooks/useHotUpdate';
import { useNativeAppListeners } from './hooks/useNativeAppListeners';
import { useOpsConsoleBodyClass } from './hooks/useOpsConsoleBodyClass';
import { useRemoteAssetFlags } from './hooks/useRemoteAssetFlags';
import { useRouteAudioCleanup } from './hooks/useRouteAudioCleanup';
import { useUsageLimitModal } from './hooks/useUsageLimitModal';
import { normalizeInitialHashRouteEntry } from './utility/routerLocation';

setupIonicReact();
import AppContent from './app/AppContent';
import { BASE_NAME } from './common/constants';

const AppRouteEffects = () => {
useNavigationHandler();
useOpsConsoleBodyClass();
useRouteAudioCleanup();
return null;
};
setupIonicReact();

const App: React.FC = () => {
const isGlobalLoading = useAppSelector((state) => state.auth.globalLoading);
const popup = useGenericPopup();
const usageLimit = useUsageLimitModal();

normalizeInitialHashRouteEntry();
useGrowthBookFeatureCache();
useRemoteAssetFlags();
useGlobalBrowserEffects();
Expand All @@ -78,30 +62,9 @@ const App: React.FC = () => {

return (
<IonApp>
<IonReactRouter basename={BASE_NAME}>
<AppRouteEffects />
<TermsGate />
<HardwareBackButtonHandler
popupDataRef={popup.popupDataRef}
setPopupData={popup.setPopupData}
popupManager={popup.popupManager}
showModalRef={usageLimit.showModalRef}
setShowModal={usageLimit.setShowModal}
/>
<IonRouterOutlet>
<AppRoutes />
</IonRouterOutlet>
<AppOverlays
isGlobalLoading={isGlobalLoading}
popupData={popup.popupData}
onPopupClose={popup.closePopup}
onPopupAction={popup.actOnPopup}
showBreakModal={usageLimit.showModal}
onContinueFromBreak={usageLimit.continueAfterBreak}
showBreakToast={usageLimit.showToast}
onDismissBreakToast={() => usageLimit.setShowToast(false)}
/>
</IonReactRouter>
<IonReactHashRouter basename={BASE_NAME}>
<AppContent />
</IonReactHashRouter>
</IonApp>
);
};
Expand Down
7 changes: 4 additions & 3 deletions src/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Loading from './components/Loading';
import { ServiceConfig } from './services/ServiceConfig';
import { logAuthDebug } from './utility/authDebug';
import { isRecoverableStorageError } from './utility/recoverableStorageError';
import { getAppPathname } from './utility/routerLocation';

type ProtectedRouteProps = RouteProps & {
children: ReactNode;
Expand Down Expand Up @@ -48,7 +49,7 @@ export default function ProtectedRoute({
reason: !isUserLoggedIn
? 'is_user_logged_in_false'
: 'current_user_missing',
from_page: window.location.pathname,
from_page: getAppPathname(),
to_page: PAGES.LOGIN,
});
}
Expand All @@ -66,7 +67,7 @@ export default function ProtectedRoute({
source: 'ProtectedRoute.checkAuth',
reason: 'recoverable_auth_dependency_error',
attempt,
from_page: window.location.pathname,
from_page: getAppPathname(),
},
);
lifecycle.timeoutId = window.setTimeout(() => {
Expand All @@ -77,7 +78,7 @@ export default function ProtectedRoute({
logAuthDebug('ProtectedRoute redirecting to login after auth error.', {
source: 'ProtectedRoute.checkAuth',
reason: 'auth_check_exception',
from_page: window.location.pathname,
from_page: getAppPathname(),
to_page: PAGES.LOGIN,
});
if (lifecycle.cancelled) return;
Expand Down
9 changes: 5 additions & 4 deletions src/analytics/clickUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Util } from '../utility/util';
import { EVENTS, PAGES } from '../common/constants';
import { RoleType } from '../interface/modelInterfaces';
import { SupabaseAuth } from '../services/auth/SupabaseAuth';
import { getAppHref, getAppPathname } from '../utility/routerLocation';

const storedStudent: {
id?: string;
Expand Down Expand Up @@ -59,7 +60,7 @@ const handleClick = async (event: MouseEvent) => {
if (textContent) break;
target = target.parentElement as HTMLElement;
}
if (PAGES.EDIT_STUDENT === window.location.pathname) {
if (PAGES.EDIT_STUDENT === getAppPathname()) {
textContent = target
.getAttribute('src')
?.trim()
Expand Down Expand Up @@ -100,9 +101,9 @@ const handleClick = async (event: MouseEvent) => {
user_type: storedStudent.type,
click_value: textContent,
click_identifier: id || className || 'null',
page_name: window.location.pathname.replace('/', ''),
page_path: window.location.pathname,
complete_path: window.location.href,
page_name: getAppPathname().replace('/', ''),
page_path: getAppPathname(),
complete_path: getAppHref(),
action_type: event.type,
};

Expand Down
7 changes: 4 additions & 3 deletions src/analytics/profileClickUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Util } from '../utility/util';
import { EVENTS } from '../common/constants';
import { RoleType } from '../interface/modelInterfaces';
import { ServiceConfig } from '../services/ServiceConfig';
import { getAppHref, getAppPathname } from '../utility/routerLocation';

const storedStudent: {
id?: string;
Expand Down Expand Up @@ -92,9 +93,9 @@ export const logProfileClick = async (event: React.MouseEvent<HTMLElement>) => {
user_gender: storedStudent.gender,
user_type: storedStudent.type,
click_identifier: id || className || 'null',
page_name: window.location.pathname.replace('/', ''),
page_path: window.location.pathname,
complete_path: window.location.href,
page_name: getAppPathname().replace('/', ''),
page_path: getAppPathname(),
complete_path: getAppHref(),
action_type: event.type,
click_value: textContent,
input_value: (event.target as HTMLInputElement).value || 'null',
Expand Down
54 changes: 54 additions & 0 deletions src/app/AppContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import { IonRouterOutlet } from '@ionic/react';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The newly added AppContent component uses React.FC but does not import React. This will cause a TypeScript compilation error (Cannot find name 'React'). Please import React at the top of the file.

import React from 'react';\nimport { IonRouterOutlet } from '@ionic/react';

import { HardwareBackButtonHandler } from '../common/backButtonRegistry';
import TermsGate from '../components/termsandconditons/TermsGate';
import { useNavigationHandler } from '../helper/navigation/NavigationHandler';
import { useGenericPopup } from '../hooks/useGenericPopup';
import { useOpsConsoleBodyClass } from '../hooks/useOpsConsoleBodyClass';
import { useRouteAudioCleanup } from '../hooks/useRouteAudioCleanup';
import { useUsageLimitModal } from '../hooks/useUsageLimitModal';
import { useAppSelector } from '../redux/hooks';
import AppRoutes from './AppRoutes';
import AppOverlays from './AppOverlays';

const AppRouteEffects = () => {
useNavigationHandler();
useOpsConsoleBodyClass();
useRouteAudioCleanup();
return null;
};

const AppContent: React.FC = () => {
const isGlobalLoading = useAppSelector((state) => state.auth.globalLoading);
const popup = useGenericPopup();
const usageLimit = useUsageLimitModal();

return (
<>
<AppRouteEffects />
<TermsGate />
<HardwareBackButtonHandler
popupDataRef={popup.popupDataRef}
setPopupData={popup.setPopupData}
popupManager={popup.popupManager}
showModalRef={usageLimit.showModalRef}
setShowModal={usageLimit.setShowModal}
/>
<IonRouterOutlet>
<AppRoutes />
</IonRouterOutlet>
<AppOverlays
isGlobalLoading={isGlobalLoading}
popupData={popup.popupData}
onPopupClose={popup.closePopup}
onPopupAction={popup.actOnPopup}
showBreakModal={usageLimit.showModal}
onContinueFromBreak={usageLimit.continueAfterBreak}
showBreakToast={usageLimit.showToast}
onDismissBreakToast={() => usageLimit.setShowToast(false)}
/>
</>
);
};

export default AppContent;
3 changes: 3 additions & 0 deletions src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ import KidsAppLocation from '../teachers-module/pages/KidsAppLocation';

const AppRoutes = () => (
<Switch>
<Route path={PAGES.ROOT} exact={true}>
<HotUpdate />
</Route>
<Route path={PAGES.APP_UPDATE} exact={true}>
<HotUpdate />
</Route>
Expand Down
3 changes: 2 additions & 1 deletion src/common/backButtonRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from 'react';
import { Capacitor } from '@capacitor/core';
import { App as CapApp } from '@capacitor/app';
import { getAppPathname } from '../utility/routerLocation';

export type BackButtonHandler = () => boolean | void | Promise<boolean | void>;

Expand All @@ -30,7 +31,7 @@ const normalizePath = (path: string) => {

const getCurrentPath = () => {
if (typeof window === 'undefined') return '/';
return normalizePath(window.location?.pathname || '/');
return normalizePath(getAppPathname());
};

const isActiveForPath = (record: BackButtonRecord, path: string) => {
Expand Down
2 changes: 2 additions & 0 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ export enum PAGES {
COLORING_BOARD = '/coloring-board',
STICKER_BOOK = '/sticker-book',
STREAK_PAGE = '/streak-page',
ROOT = '/#',
}

export const enum ASSIGNMENT_TYPE {
Expand Down Expand Up @@ -1077,6 +1078,7 @@ export const CURRENT_AVATAR_SUGGESTION_NO = 'currentAvatarSuggestion';
export const SHOW_DAILY_PROGRESS_FLAG = 'showAvatarDailyProgress';

export const CURRENT_SQLITE_VERSION = 'currentSqliteVersion';
export const BUNDLED_IMPORT_APP_VERSION_KEY = 'bundledImportAppVersion';
export const CAMPAIGN_SEQUENCE_FINISHED = 'CAMPAIGN_SEQUENCE_FINISHED';
export const LIDO_COMMON_AUDIO_DIR = 'Lido-CommonAudios';
export const LIDO_COMMON_AUDIO_LANG_KEY = 'lido_common_audio_language';
Expand Down
Loading
Loading