diff --git a/app/src/api.js b/app/src/api.js index f80d9ced..57f5d255 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1361,7 +1361,9 @@ export async function getFlasherConfigs() { const data = await response.json(); return data['configurations'] || []; } - return []; + // Undefined, not [], so the page can tell a failed fetch from an empty list. + const message = await getErrorMessage(response, 'Could not get saved firmware configurations'); + toast({type: 'error', title: 'Flasher Error', description: message, time: 5000}); } export async function saveFlasherConfig(name, files, eraseAll) { diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js index 3360afac..9e547f3c 100644 --- a/app/src/components/Apps.js +++ b/app/src/components/Apps.js @@ -14,7 +14,8 @@ import {useStatistics} from "../hooks/customHooks"; import {CalculatorsPage} from "./Calculators"; import {BookmarksPage} from "./Bookmarks"; -function StatisticsPage() { +// `statistics` follows useStatistics: null = pending, undefined = fetch failed. +export function StatisticsPage() { useTitle('Statistics'); const {statistics} = useStatistics(); @@ -26,7 +27,7 @@ function StatisticsPage() { } - if (statistics['global_statistics']) { + if (statistics && statistics['global_statistics']) { let {global_statistics, file_statistics} = statistics; const { archive_count, diff --git a/app/src/components/Flasher.js b/app/src/components/Flasher.js index 9e6f1111..304c4374 100644 --- a/app/src/components/Flasher.js +++ b/app/src/components/Flasher.js @@ -3,7 +3,7 @@ import {Route, Routes} from "react-router"; import {ESPLoader, Transport} from "esptool-js"; import {useDropzone} from "react-dropzone"; import _ from "lodash"; -import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common"; +import {encodeMediaPath, ErrorMessage, humanFileSize, PageContainer, useTitle} from "./Common"; import { Button, Checkbox, @@ -17,6 +17,7 @@ import { Message, Modal, Panel, + Placeholder, Progress, Select, Stack, @@ -297,8 +298,12 @@ export function FlasherPage() { // Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer"). const [activeTab, setActiveTab] = useState(TAB_SAVED); - // Saved firmware configurations (flasher.yaml). - const [savedConfigs, setSavedConfigs] = useState([]); + // Saved firmware configurations (flasher.yaml). null = pending, undefined = fetch failed, + // [] = none saved; the "No saved configurations yet." message is only for the last. + const [savedConfigs, setSavedConfigs] = useState(null); + // Only the newest configs request may write state: a save or delete refetches while the mount + // request may still be in flight. + const savedConfigsGen = useRef(0); const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveName, setSaveName] = useState(''); @@ -424,7 +429,19 @@ export function FlasherPage() { // Saved firmware configurations. const fetchSavedConfigs = React.useCallback(async () => { - setSavedConfigs(await getFlasherConfigs()); + const gen = ++savedConfigsGen.current; + try { + const configs = await getFlasherConfigs(); + if (gen === savedConfigsGen.current) { + // The api helper returns undefined on a non-OK response. + setSavedConfigs(configs ?? undefined); + } + } catch (e) { + console.error(e); + if (gen === savedConfigsGen.current) { + setSavedConfigs(undefined); + } + } }, []); useEffect(() => { @@ -812,7 +829,9 @@ export function FlasherPage() { Configure the firmware files and offsets, then save them as a named set to re-flash later (e.g. a Meshtastic T-Deck: firmware at 0x0, littlefs at 0xc90000).

- {savedConfigs.length > 0 + {savedConfigs === null && } + {savedConfigs === undefined && Could not fetch saved configurations.} + {Array.isArray(savedConfigs) && (savedConfigs.length > 0 ? {savedConfigs.map((configuration, index) =>
handleDeleteConfig(configuration.name)}/>
)}
- :

No saved configurations yet.

} + :

No saved configurations yet.

)} ; return
diff --git a/app/src/components/Flasher.test.js b/app/src/components/Flasher.test.js index 34ca2301..2873c8e7 100644 --- a/app/src/components/Flasher.test.js +++ b/app/src/components/Flasher.test.js @@ -265,6 +265,31 @@ describe('FlasherPage', () => { } }); + // The Saved Firmwares tab is the default tab. "No saved configurations yet." may only appear once the + // server confirmed there are none; a pending or failed fetch must not read as an empty list. + it('shows a placeholder, not "no saved configurations", while the configs load', () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + getFlasherConfigs.mockReturnValue(new Promise(() => { + })); + const {container} = render(); + expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument(); + expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0); + }); + + it('shows an error, not "no saved configurations", when the configs fetch failed', async () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + getFlasherConfigs.mockResolvedValue(undefined); + render(); + expect((await screen.findAllByText(/Could not fetch saved configurations/)).length).toBeGreaterThan(0); + expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0); + }); + + it('shows "no saved configurations" once the server confirmed there are none', async () => { + Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true}); + render(); + expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0); + }); + // PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice. it('warns when Web Serial is unavailable', () => { delete global.navigator.serial; diff --git a/app/src/components/StatisticsPage.test.js b/app/src/components/StatisticsPage.test.js new file mode 100644 index 00000000..8aaae49e --- /dev/null +++ b/app/src/components/StatisticsPage.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import {act, render, screen} from '../test-utils'; +import {StatisticsPage} from './Apps'; +import {getStatistics} from '../api'; + +// What the Statistics page shows while the numbers are still loading, when the fetch failed, and +// once they arrive. "Failed to fetch statistics" may only appear for a failed fetch. + +jest.mock('../api', () => ({ + ...jest.requireActual('../api'), + getStatistics: jest.fn(), +})); + +const FAILED = /Failed to fetch statistics/; +const STATS = { + global_statistics: {db_size: 1024}, + file_statistics: { + archive_count: 1, audio_count: 2, ebook_count: 3, image_count: 4, pdf_count: 5, total_count: 21, + video_count: 6, zip_count: 0, total_size: 4096, tagged_files: 7, tagged_zims: 0, tags_count: 2, + }, +}; + +beforeEach(() => getStatistics.mockReset()); + +test('pending: shows a loader, not the error', () => { + getStatistics.mockReturnValue(new Promise(() => { + })); + const {container} = render(); + expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument(); + expect(screen.queryByText(FAILED)).not.toBeInTheDocument(); +}); + +test('failed: shows the error', async () => { + // The api helper toasts and returns undefined on a non-OK response. + getStatistics.mockResolvedValue(undefined); + render(); + await act(async () => { + }); + expect(screen.getByText(FAILED)).toBeInTheDocument(); +}); + +test('loaded: shows the numbers', async () => { + getStatistics.mockResolvedValue(STATS); + render(); + await act(async () => { + }); + expect(screen.getByText('All Files')).toBeInTheDocument(); + expect(screen.queryByText(FAILED)).not.toBeInTheDocument(); +}); diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 8836aab2..94c1c03f 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -1697,12 +1697,15 @@ export const collectLocations = (inventories) => { }; export const useStatistics = () => { - const [statistics, setStatistics] = useState(); + // null = pending, undefined = fetch failed (the api helper returns undefined on a non-OK + // response), an object = loaded. Never undefined on the first render: the page shows its + // error for undefined. + const [statistics, setStatistics] = useState(null); const fetchFileStatistics = async () => { try { const s = await getStatistics(); - setStatistics(s); + setStatistics(s ?? undefined); } catch (e) { console.error(e); setStatistics(undefined); @@ -1710,7 +1713,6 @@ export const useStatistics = () => { } useEffect(() => { - setStatistics({}); fetchFileStatistics(); }, []); diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index 0df9eaab..987467af 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -1,8 +1,10 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; import {render, screen} from '../test-utils'; -import {usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useVideoExtras} from './customHooks'; -import {getVideoComments, getVideoDescription, searchChannels} from '../api'; +import { + usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useStatistics, useVideoExtras, +} from './customHooks'; +import {getStatistics, getVideoComments, getVideoDescription, searchChannels} from '../api'; import {QueryContext, StatusContext} from '../contexts/contexts'; import {Paginator} from '../components/Common'; @@ -39,6 +41,7 @@ jest.mock('../api', () => ({ getVideoComments: jest.fn(), getVideoDescription: jest.fn(), searchChannels: jest.fn(), + getStatistics: jest.fn(), })); // Mock Media so Paginator renders both mobile + tablet variants synchronously. @@ -534,3 +537,46 @@ describe('useSearchChannels', () => { expect(result.current.loading).toBe(false); }); }); + +describe('useStatistics', () => { + // null = pending, undefined = fetch failed, an object = loaded. The Statistics page shows its + // error only for undefined, so the first render must not be undefined. + beforeEach(() => getStatistics.mockReset()); + + test('is pending (null) on the first render', () => { + getStatistics.mockReturnValue(new Promise(() => { + })); + const {result} = renderHook(() => useStatistics()); + expect(result.current.statistics).toBeNull(); + }); + + test('resolves to the statistics', async () => { + getStatistics.mockResolvedValue({global_statistics: {}, file_statistics: {}}); + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toEqual({global_statistics: {}, file_statistics: {}}); + }); + + test('a non-OK response yields undefined', async () => { + getStatistics.mockResolvedValue(undefined); + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toBeUndefined(); + }); + + test('a thrown request yields undefined', async () => { + getStatistics.mockRejectedValue(new Error('boom')); + const spy = jest.spyOn(console, 'error').mockImplementation(() => { + }); + try { + const {result} = renderHook(() => useStatistics()); + await act(async () => { + }); + expect(result.current.statistics).toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); +});