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
157 changes: 115 additions & 42 deletions src/Components/Header/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { useEffect, useRef, useState } from "react";
import { Col, Row, Button } from "react-bootstrap";
import { Col, Row, Button, Spinner } from "react-bootstrap";
import Translate from "../Translate/Translate";
import PropTypes from "prop-types";
import { norCalResistNumber } from "../Rights/content";
import { shareHandler } from "../../utils";
import { isOnline, onNetworkChange } from "../../utils/network";
import { cacheResources, isCached } from "../../utils/cache";

function Header({ title, lead, disableTranslate } = {}) {
const deferredPromptRef = useRef(null);
const [hideSaveButton, setHideSaveButton] = useState(false);
const [online, setOnline] = useState(isOnline());
const [caching, setCaching] = useState(false);
const [cacheComplete, setCacheComplete] = useState(isCached());

useEffect(() => {
function handleBeforeInstallPrompt(event) {
Expand All @@ -22,15 +27,107 @@ function Header({ title, lead, disableTranslate } = {}) {
}

window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);

// Setup network change listener
const cleanupNetwork = onNetworkChange(setOnline);

return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
cleanupNetwork();
};
}, []);

const handleSaveClick = async () => {
// If already installed, trigger offline caching
if (window.matchMedia("(display-mode: standalone)").matches) {
if (cacheComplete) {
alert("App is installed and offline resources are cached!");
return;
}

if (!online) {
alert("Please connect to the internet to download offline resources.");
return;
}

// Cache resources for offline use
setCaching(true);
try {
const result = await cacheResources();
if (result.success && result.failed === 0) {
setCacheComplete(true);
alert(`Successfully cached ${result.cached} resources for offline use!`);
} else {
alert(`Cached ${result.cached} resources. ${result.failed} failed to cache.`);
}
} catch (error) {
alert(`Failed to cache resources: ${error.message}`);
} finally {
setCaching(false);
}
return;
}

// Otherwise, show install prompt
const deferredPrompt = deferredPromptRef.current || window.beforeInstallPrompt;
if (!deferredPrompt) {
alert("To install this app, please use your browser's 'Add to Home Screen' option.");
return;
}

// Installation must be done by a user gesture.
// Hide the UI that shows our A2HS button (matches the referenced snippet behavior).
setHideSaveButton(true);

// Show the prompt
deferredPrompt.prompt();

// Wait for the user to respond to the prompt
if (deferredPrompt.userChoice && typeof deferredPrompt.userChoice.then === "function") {
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult?.outcome === "accepted") {
console.log("User accepted the A2HS prompt");
// Keep the button hidden after successful install intent.
} else {
console.log("User dismissed the A2HS prompt");
// Re-show the button if the user dismissed the prompt.
setHideSaveButton(false);
}

deferredPromptRef.current = null;
window.beforeInstallPrompt = null;
});
} else {
deferredPromptRef.current = null;
window.beforeInstallPrompt = null;
setHideSaveButton(false);
}
};

return (
<header>
<Row>
<Col style={{ textAlign: "center" }}>
{/* Offline/Online Indicator */}
{/* <div
style={{
position: 'absolute',
display: 'none',
top: '10px',
right: '10px',
fontSize: '1.5rem',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: '5px'
}}
title={online ? 'Online' : 'Offline'}
aria-label={online ? 'Online' : 'Offline'}
role="status"
>
{online ? '🟢' : '🔴'}
</div> */}

<Button
href={
`tel:${norCalResistNumber.replace(/[^0-9]/g, "")}`
Expand Down Expand Up @@ -60,48 +157,24 @@ function Header({ title, lead, disableTranslate } = {}) {
<Button
variant="outline-primary"
size="lg"
onClick={() => {
if (window.matchMedia("(display-mode: standalone)").matches) {
alert("This app is already installed.");
return;
}

const deferredPrompt = deferredPromptRef.current || window.beforeInstallPrompt;
if (!deferredPrompt) {
alert("To install this app, please use your browser's 'Add to Home Screen' option.");
return;
}

// Installation must be done by a user gesture.
// Hide the UI that shows our A2HS button (matches the referenced snippet behavior).
setHideSaveButton(true);

// Show the prompt
deferredPrompt.prompt();

// Wait for the user to respond to the prompt
if (deferredPrompt.userChoice && typeof deferredPrompt.userChoice.then === "function") {
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult?.outcome === "accepted") {
console.log("User accepted the A2HS prompt");
// Keep the button hidden after successful install intent.
} else {
console.log("User dismissed the A2HS prompt");
// Re-show the button if the user dismissed the prompt.
setHideSaveButton(false);
}

deferredPromptRef.current = null;
window.beforeInstallPrompt = null;
});
} else {
deferredPromptRef.current = null;
window.beforeInstallPrompt = null;
setHideSaveButton(false);
}
}}
onClick={handleSaveClick}
disabled={caching}
>
Save
{caching ? (
<>
<Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
className="me-2"
/>
Saving...
</>
) : (
'Save'
)}
</Button>
)}
<Button variant="outline-primary" size="lg" onClick={async () => {
Expand Down
170 changes: 167 additions & 3 deletions src/Components/Header/header.test.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { test, expect, describe, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import Header from "./Header";
import { norCalResistNumber } from "../Rights/content";
import * as utils from "../../utils";
import * as cacheUtils from "../../utils/cache";

describe("Header", () => {
let scrollIntoViewMock;
Expand Down Expand Up @@ -118,20 +119,151 @@ describe("Header", () => {
});

describe("Save button", () => {
test("shows alert when app is already installed", () => {
test("shows already cached message when app installed and cache complete", async () => {
matchMediaMock.mockReturnValue({
matches: true,
media: "(display-mode: standalone)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

// Mock cache as complete
vi.spyOn(cacheUtils, 'isCached').mockReturnValue(true);

const { container } = render(<Header />);
const buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');

fireEvent.click(saveButton);

expect(alertMock).toHaveBeenCalledWith("App is installed and offline resources are cached!");
});

test("shows offline warning when app is installed and offline", async () => {
matchMediaMock.mockReturnValue({
matches: true,
media: "(display-mode: standalone)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

// Mock online state and caches API not available in test environment
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);

const { container } = render(<Header />);
const buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');

fireEvent.click(saveButton);

// Wait for async handling
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith("Please connect to the internet to download offline resources.");
});
});

test("shows loading spinner during caching", async () => {
matchMediaMock.mockReturnValue({
matches: true,
media: "(display-mode: standalone)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

// Mock online state
Object.defineProperty(navigator, 'onLine', {
writable: true,
value: true,
});

// Mock cacheResources to delay so we can see the spinner
let resolveCaching;
const cachingPromise = new Promise((resolve) => {
resolveCaching = resolve;
});
vi.spyOn(cacheUtils, 'cacheResources').mockReturnValue(cachingPromise);
vi.spyOn(cacheUtils, 'isCached').mockReturnValue(false);

const { container, rerender } = render(<Header />);
const buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');

fireEvent.click(saveButton);

// Check for spinner during caching
await waitFor(() => {
const spinnerButton = Array.from(container.querySelectorAll('.share-bar button'))
.find(btn => btn.textContent.includes('Saving'));
expect(spinnerButton).toBeDefined();
expect(spinnerButton.querySelector('[role="status"]')).toBeDefined();
});

// Resolve the caching
resolveCaching({ success: true, cached: 10, failed: 0 });
await cachingPromise;
rerender(<Header />);
});

test("shows partial success alert when some resources fail to cache", async () => {
matchMediaMock.mockReturnValue({
matches: true,
media: "(display-mode: standalone)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

// Mock online state
Object.defineProperty(navigator, 'onLine', {
writable: true,
value: true,
});

// Mock cacheResources with partial failure
vi.spyOn(cacheUtils, 'cacheResources').mockResolvedValue({
success: false,
cached: 8,
failed: 2
});
vi.spyOn(cacheUtils, 'isCached').mockReturnValue(false);

const { container } = render(<Header />);
const buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');

fireEvent.click(saveButton);

expect(alertMock).toHaveBeenCalledWith("This app is already installed.");
await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith("Cached 8 resources. 2 failed to cache.");
});
});

test("shows error alert when caching completely fails", async () => {
matchMediaMock.mockReturnValue({
matches: true,
media: "(display-mode: standalone)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});

// Mock online state
Object.defineProperty(navigator, 'onLine', {
writable: true,
value: true,
});

// Mock cacheResources to throw error
vi.spyOn(cacheUtils, 'cacheResources').mockRejectedValue(new Error("Network failed"));
vi.spyOn(cacheUtils, 'isCached').mockReturnValue(false);

const { container } = render(<Header />);
const buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');

fireEvent.click(saveButton);

await waitFor(() => {
expect(alertMock).toHaveBeenCalledWith("Failed to cache resources: Network failed");
});
});

test("shows alert when beforeinstallprompt not available", () => {
Expand Down Expand Up @@ -240,6 +372,38 @@ describe("Header", () => {
const saveButtonAfter = Array.from(buttons).find(btn => btn.textContent === 'Save');
expect(saveButtonAfter).toBeDefined();
});

test("handles install prompt without userChoice property", () => {
const mockPrompt = vi.fn();
const mockDeferredPrompt = {
prompt: mockPrompt,
// No userChoice property
};

const { rerender, container } = render(<Header />);
const event = new Event("beforeinstallprompt");
event.preventDefault = vi.fn();
Object.defineProperty(event, "prompt", {
value: mockPrompt,
});
// Don't set userChoice
window.dispatchEvent(event);

rerender(<Header />);

let buttons = container.querySelectorAll('.share-bar button');
const saveButton = Array.from(buttons).find(btn => btn.textContent === 'Save');
fireEvent.click(saveButton);

expect(mockPrompt).toHaveBeenCalled();

// Button should be hidden after click (then re-shown due to no userChoice)
rerender(<Header />);
buttons = container.querySelectorAll('.share-bar button');
const saveButtonAfter = Array.from(buttons).find(btn => btn.textContent === 'Save');
// Without userChoice, button gets re-shown
expect(saveButtonAfter).toBeDefined();
});
});

describe("Share button", () => {
Expand Down
Loading