mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(ui): hide dev-only features in production, clean up homepage (#3157)
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import PillTabBar from '../PillTabBar';
|
||||
import ConnectionPathTab from './ConnectionPathTab';
|
||||
import DiagramViewerTab from './DiagramViewerTab';
|
||||
@@ -39,17 +40,18 @@ export default function MemorySection({ onToast }: MemorySectionProps) {
|
||||
const { t } = useT();
|
||||
const [activeSubTab, setActiveSubTab] = useState<MemorySubTab>('memoryTree');
|
||||
|
||||
const subTabs: { id: MemorySubTab; label: string }[] = [
|
||||
const allSubTabs: { id: MemorySubTab; label: string; devOnly?: boolean }[] = [
|
||||
{ id: 'memoryTree', label: t('memory.tab.memoryTree') },
|
||||
{ id: 'diagram', label: t('memory.tab.diagram') },
|
||||
{ id: 'centrality', label: t('memory.tab.centrality') },
|
||||
{ id: 'cohesion', label: t('memory.tab.cohesion') },
|
||||
{ id: 'associations', label: t('memory.tab.associations') },
|
||||
{ id: 'freshness', label: t('memory.tab.freshness') },
|
||||
{ id: 'timeline', label: t('memory.tab.timeline') },
|
||||
{ id: 'paths', label: t('memory.tab.path') },
|
||||
{ id: 'namespaces', label: t('memory.tab.namespaces') },
|
||||
{ id: 'diagram', label: t('memory.tab.diagram'), devOnly: true },
|
||||
{ id: 'centrality', label: t('memory.tab.centrality'), devOnly: true },
|
||||
{ id: 'cohesion', label: t('memory.tab.cohesion'), devOnly: true },
|
||||
{ id: 'associations', label: t('memory.tab.associations'), devOnly: true },
|
||||
{ id: 'freshness', label: t('memory.tab.freshness'), devOnly: true },
|
||||
{ id: 'timeline', label: t('memory.tab.timeline'), devOnly: true },
|
||||
{ id: 'paths', label: t('memory.tab.path'), devOnly: true },
|
||||
{ id: 'namespaces', label: t('memory.tab.namespaces'), devOnly: true },
|
||||
];
|
||||
const subTabs = allSubTabs.filter(tab => !tab.devOnly || IS_DEV);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
selectCustomSecondaryColor,
|
||||
selectMascotColor,
|
||||
} from '../../store/mascotSlice';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import { CustomGifMascot, getMascotPalette, hexToArgbInt, RiveMascot } from './Mascot';
|
||||
import { useHumanMascot } from './useHumanMascot';
|
||||
|
||||
@@ -72,18 +73,20 @@ const HumanPage = () => {
|
||||
{t('voice.pushToTalk')}
|
||||
</label>
|
||||
|
||||
{/* "Send OpenHuman to a meeting" — opens the Flow A modal which spawns
|
||||
an off-screen CEF webview pointed at the Meet URL with the mascot
|
||||
canvas as the outbound camera and synthesized speech as the
|
||||
outbound mic. The user's OS mic is never wired to the meeting. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJoinMeetingOpen(true)}
|
||||
data-testid="human-join-meeting-pill"
|
||||
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
|
||||
<span aria-hidden="true">📞</span>
|
||||
{t('skills.meetingBots.modalTitle')}
|
||||
</button>
|
||||
{/* "Send OpenHuman to a meeting" — dev-only; opens the Flow A modal
|
||||
which spawns an off-screen CEF webview pointed at the Meet URL with
|
||||
the mascot canvas as the outbound camera and synthesized speech as
|
||||
the outbound mic. The user's OS mic is never wired to the meeting. */}
|
||||
{IS_DEV && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setJoinMeetingOpen(true)}
|
||||
data-testid="human-join-meeting-pill"
|
||||
className="absolute top-4 left-44 z-10 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-primary-500 text-white text-xs font-medium shadow-soft hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-300">
|
||||
<span aria-hidden="true">📞</span>
|
||||
{t('skills.meetingBots.modalTitle')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{joinMeetingOpen && <MeetingBotsModal onClose={() => setJoinMeetingOpen(false)} />}
|
||||
|
||||
|
||||
+1
-73
@@ -1,15 +1,12 @@
|
||||
import createDebug from 'debug';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import ConnectionIndicator from '../components/ConnectionIndicator';
|
||||
import {
|
||||
DiscordBanner,
|
||||
EarlyBirdyBanner,
|
||||
PromotionalCreditsBanner,
|
||||
UsageLimitBanner,
|
||||
} from '../components/home/HomeBanners';
|
||||
import { dismissBanner, shouldShowBanner } from '../components/upsell/upsellDismissState';
|
||||
import { useUsageState } from '../hooks/useUsageState';
|
||||
import { useUser } from '../hooks/useUser';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
@@ -18,9 +15,6 @@ import { selectBlockingState } from '../store/connectivitySelectors';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import { resolveTheme, setThemeMode, type ThemeMode } from '../store/themeSlice';
|
||||
import { APP_VERSION } from '../utils/config';
|
||||
import { openhumanCronList } from '../utils/tauriCommands';
|
||||
|
||||
const homeLog = createDebug('app:home');
|
||||
|
||||
export function resolveHomeUserName(user: unknown): string {
|
||||
if (!user || typeof user !== 'object') return 'User';
|
||||
@@ -57,16 +51,6 @@ const Home = () => {
|
||||
user?.subscription?.plan === 'FREE' || !user?.subscription?.hasActiveSubscription;
|
||||
const showPromoBanner = isFreeTier && promoCredits > 0.01;
|
||||
|
||||
// Early birdy banner: once dismissed it stays gone (cooldown longer than any realistic session).
|
||||
const [showEarlyBirdy, setShowEarlyBirdy] = useState(() =>
|
||||
shouldShowBanner('home-earlybirdy', Number.MAX_SAFE_INTEGER)
|
||||
);
|
||||
|
||||
const handleDismissEarlyBirdy = () => {
|
||||
dismissBanner('home-earlybirdy');
|
||||
setShowEarlyBirdy(false);
|
||||
};
|
||||
|
||||
const welcomeVariants = useMemo(
|
||||
() => [`Welcome, ${userName} 👋`, `Let's cook, ${userName} 🧑🍳.`, `Time to Zone In 🧘🏻`],
|
||||
[userName]
|
||||
@@ -81,21 +65,6 @@ const Home = () => {
|
||||
const [isRestartingCore, setIsRestartingCore] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
|
||||
// Routine count for the card on home.
|
||||
const [activeRoutineCount, setActiveRoutineCount] = useState<number | null>(null);
|
||||
const loadRoutineCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await openhumanCronList();
|
||||
const activeCount = response.result.filter(j => j.enabled).length;
|
||||
setActiveRoutineCount(activeCount);
|
||||
} catch (err) {
|
||||
homeLog('failed to load routine count', err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void loadRoutineCount();
|
||||
}, [loadRoutineCount]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.theme.mode) as ThemeMode;
|
||||
const resolvedTheme = resolveTheme(themeMode);
|
||||
@@ -276,47 +245,6 @@ const Home = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Routines card */}
|
||||
{activeRoutineCount !== null && (
|
||||
<button
|
||||
onClick={() => navigate('/routines')}
|
||||
className="mt-3 w-full bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-4 flex items-center gap-3 text-left hover:border-primary-300 dark:hover:border-primary-500/40 transition-colors animate-fade-up">
|
||||
<div className="w-9 h-9 rounded-full bg-primary-50 dark:bg-primary-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-4.5 h-4.5 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t('home.routinesCard')}
|
||||
</div>
|
||||
<div className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{activeRoutineCount > 0
|
||||
? t('home.routinesActive').replace('{count}', String(activeRoutineCount))
|
||||
: t('routines.emptyHint')}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-stone-400 dark:text-neutral-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showEarlyBirdy && <EarlyBirdyBanner onDismiss={handleDismissEarlyBirdy} />}
|
||||
|
||||
<DiscordBanner />
|
||||
|
||||
{/* Next steps — compact directory of where to go next */}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
ToastNotification,
|
||||
} from '../types/intelligence';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import AgentWorkflows from './AgentWorkflows';
|
||||
|
||||
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'council';
|
||||
@@ -24,7 +25,7 @@ type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'coun
|
||||
export default function Intelligence() {
|
||||
const { t } = useT();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<IntelligenceTab>('tasks');
|
||||
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
|
||||
|
||||
// The legacy header pills (system-status + Ingesting/Queued chips) were
|
||||
// sourced from `useConsciousItems` + `useMemoryIngestionStatus`. They are
|
||||
@@ -87,18 +88,30 @@ export default function Intelligence() {
|
||||
}
|
||||
}, [socketConnected, socketManager]);
|
||||
|
||||
const tabs: { id: IntelligenceTab; label: string; description?: string; comingSoon?: boolean }[] =
|
||||
[
|
||||
{ id: 'tasks', label: t('memory.tab.tasks'), description: t('memory.tab.tasksDescription') },
|
||||
{ id: 'memory', label: t('memory.tab.memory') },
|
||||
{ id: 'subconscious', label: t('memory.tab.subconscious') },
|
||||
{
|
||||
id: 'workflows',
|
||||
label: t('memory.tab.workflows'),
|
||||
description: t('memory.tab.workflowsDescription'),
|
||||
},
|
||||
{ id: 'council', label: t('memory.tab.council') },
|
||||
];
|
||||
const allTabs: {
|
||||
id: IntelligenceTab;
|
||||
label: string;
|
||||
description?: string;
|
||||
comingSoon?: boolean;
|
||||
devOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
id: 'tasks',
|
||||
label: t('memory.tab.tasks'),
|
||||
description: t('memory.tab.tasksDescription'),
|
||||
devOnly: true,
|
||||
},
|
||||
{ id: 'memory', label: t('memory.tab.memory') },
|
||||
{ id: 'subconscious', label: t('memory.tab.subconscious') },
|
||||
{
|
||||
id: 'workflows',
|
||||
label: t('memory.tab.workflows'),
|
||||
description: t('memory.tab.workflowsDescription'),
|
||||
devOnly: true,
|
||||
},
|
||||
{ id: 'council', label: t('memory.tab.council'), devOnly: true },
|
||||
];
|
||||
const tabs = allTabs.filter(tab => !tab.devOnly || IS_DEV);
|
||||
const activeTabDef = tabs.find(tab => tab.id === activeTab);
|
||||
|
||||
return (
|
||||
|
||||
+23
-11
@@ -355,7 +355,8 @@ export default function Skills() {
|
||||
const initialTab: ConnectionsTab = (() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const t = params.get('tab');
|
||||
if (t === 'runners' || t === 'composio' || t === 'channels' || t === 'mcp') return t;
|
||||
if (t === 'runners') return IS_DEV ? 'runners' : 'composio';
|
||||
if (t === 'composio' || t === 'channels' || t === 'mcp') return t;
|
||||
return 'composio';
|
||||
})();
|
||||
const [activeTab, setActiveTab] = useState<ConnectionsTab>(initialTab);
|
||||
@@ -659,7 +660,9 @@ export default function Skills() {
|
||||
for (const { meta } of composioGridEntries) {
|
||||
cats.add(meta.category);
|
||||
}
|
||||
return SKILL_CATEGORY_ORDER.filter(c => c !== 'Channels' && cats.has(c));
|
||||
return SKILL_CATEGORY_ORDER.filter(
|
||||
c => c !== 'Channels' && cats.has(c) && (IS_DEV || c !== 'Other')
|
||||
);
|
||||
}, [allItems, composioGridEntries]);
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
@@ -690,7 +693,7 @@ export default function Skills() {
|
||||
return items.length > 0 ? { category: 'Channels' as SkillCategory, items } : undefined;
|
||||
}, [allItems]);
|
||||
const otherGroups = useMemo(
|
||||
() => groupedItems.filter(g => g.category !== 'Channels'),
|
||||
() => groupedItems.filter(g => g.category !== 'Channels' && (IS_DEV || g.category !== 'Other')),
|
||||
[groupedItems]
|
||||
);
|
||||
|
||||
@@ -941,12 +944,12 @@ export default function Skills() {
|
||||
{ value: 'composio', label: t('skills.tabs.composio') },
|
||||
{ value: 'channels', label: t('skills.tabs.channels') },
|
||||
{ value: 'mcp', label: t('skills.tabs.mcp') },
|
||||
{ value: 'runners', label: t('skills.tabs.runners') },
|
||||
...(IS_DEV ? [{ value: 'runners' as const, label: t('skills.tabs.runners') }] : []),
|
||||
]}
|
||||
/>
|
||||
{
|
||||
<>
|
||||
{activeTab === 'runners' && (
|
||||
{IS_DEV && activeTab === 'runners' && (
|
||||
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-6 shadow-soft animate-fade-up">
|
||||
{/* The Runners sub-tab IS the scheduled-skills dashboard:
|
||||
header + [+ Create a Skill] + [▷ Run a Skill] CTAs
|
||||
@@ -1102,12 +1105,21 @@ export default function Skills() {
|
||||
{t('channels.mcp.description')}
|
||||
</p>
|
||||
</div>
|
||||
{/* The real browse/install/manage surface (issue #3039).
|
||||
McpServersTab manages its own height via h-full, so it
|
||||
needs a sized container to fill. */}
|
||||
<div className="h-[72vh] min-h-[480px]">
|
||||
<McpServersTab />
|
||||
</div>
|
||||
{IS_DEV ? (
|
||||
<div className="h-[72vh] min-h-[480px]">
|
||||
<McpServersTab />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-3xl mb-3">🔌</div>
|
||||
<p className="text-sm font-medium text-stone-700 dark:text-neutral-300">
|
||||
{t('misc.comingSoon')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
|
||||
{t('channels.mcp.description')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -61,15 +61,6 @@ vi.mock('../../services/coreProcessControl', () => ({
|
||||
restartCoreProcess: () => restartCoreProcessMock(),
|
||||
}));
|
||||
|
||||
const mockShouldShowBanner = vi.fn<() => boolean>(() => true);
|
||||
const mockDismissBanner = vi.fn<(id: string) => void>();
|
||||
|
||||
vi.mock('../../components/upsell/upsellDismissState', () => ({
|
||||
shouldShowBanner: (...args: Parameters<typeof mockShouldShowBanner>) =>
|
||||
mockShouldShowBanner(...args),
|
||||
dismissBanner: (...args: Parameters<typeof mockDismissBanner>) => mockDismissBanner(...args),
|
||||
}));
|
||||
|
||||
describe('resolveHomeUserName', () => {
|
||||
it('uses camelCase name fields when present', () => {
|
||||
expect(resolveHomeUserName({ firstName: 'Ada', lastName: 'Lovelace' })).toBe('Ada Lovelace');
|
||||
@@ -111,7 +102,7 @@ describe('resolveHomeUserName', () => {
|
||||
describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
it('shows "Restart Core" button when blocking=core-unreachable (lines 194, 200)', async () => {
|
||||
useAppSelectorMock.mockReturnValue('core-unreachable');
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
|
||||
@@ -120,7 +111,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
|
||||
it('does NOT show "Restart Core" button when blocking=ok (line 194)', async () => {
|
||||
useAppSelectorMock.mockReturnValue('ok');
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
|
||||
@@ -129,7 +120,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
|
||||
it('handleRestartCore calls restartCoreProcess and resets state on success (lines 78-81, 85)', async () => {
|
||||
useAppSelectorMock.mockReturnValue('core-unreachable');
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
restartCoreProcessMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
@@ -150,7 +141,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
|
||||
it('handleRestartCore shows error message when restartCoreProcess throws (lines 78-83, 202)', async () => {
|
||||
useAppSelectorMock.mockReturnValue('core-unreachable');
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
restartCoreProcessMock.mockRejectedValueOnce(new Error('sidecar not found'));
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
@@ -164,7 +155,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
|
||||
it('handleRestartCore shows string error when restartCoreProcess throws a non-Error (lines 83)', async () => {
|
||||
useAppSelectorMock.mockReturnValue('core-unreachable');
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
restartCoreProcessMock.mockRejectedValueOnce('raw string error');
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
@@ -177,40 +168,11 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Home page — EarlyBirdy banner integration', () => {
|
||||
it('shows the EarlyBirdy banner when shouldShowBanner returns true', async () => {
|
||||
mockShouldShowBanner.mockReturnValue(true);
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
expect(screen.getByText('The first 1,000 users get 60% off.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the EarlyBirdy banner when shouldShowBanner returns false', async () => {
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
expect(screen.queryByText('The first 1,000 users get 60% off.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses the EarlyBirdy banner and calls dismissBanner when the X button is clicked', async () => {
|
||||
mockShouldShowBanner.mockReturnValue(true);
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
|
||||
const dismissBtn = screen.getByRole('button', { name: /dismiss early bird banner/i });
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
expect(mockDismissBanner).toHaveBeenCalledWith('home-earlybirdy');
|
||||
expect(screen.queryByText('The first 1,000 users get 60% off.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Home page — theme toggle', () => {
|
||||
it('renders "Switch to dark mode" in light mode and dispatches setThemeMode("dark") on click', async () => {
|
||||
themeModeProbe.current = 'light';
|
||||
const dispatch = vi.fn();
|
||||
useAppDispatchMock.mockReturnValue(dispatch);
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
@@ -228,7 +190,6 @@ describe('Home page — theme toggle', () => {
|
||||
themeModeProbe.current = 'dark';
|
||||
const dispatch = vi.fn();
|
||||
useAppDispatchMock.mockReturnValue(dispatch);
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
@@ -248,7 +209,6 @@ describe('Home page — budget completed banner', () => {
|
||||
// Covers line 151: UsageLimitBanner render when shouldShowBudgetCompletedMessage=true
|
||||
it('renders UsageLimitBanner when shouldShowBudgetCompletedMessage=true', async () => {
|
||||
mockUseUsageState.mockReturnValueOnce({ shouldShowBudgetCompletedMessage: true });
|
||||
mockShouldShowBanner.mockReturnValue(false);
|
||||
|
||||
const { default: Home } = await import('../Home');
|
||||
render(<Home />);
|
||||
|
||||
Reference in New Issue
Block a user