- {mediaLoading
+ {mediaLoading || mediaResults === null
?
+ : mediaResults === undefined
+ ?
Could not search firmware.
: (mediaResults.length === 0
?
No .bin firmware found
diff --git a/app/src/components/Flasher.test.js b/app/src/components/Flasher.test.js
index 2873c8e7..7d69b48d 100644
--- a/app/src/components/Flasher.test.js
+++ b/app/src/components/Flasher.test.js
@@ -290,6 +290,18 @@ describe('FlasherPage', () => {
expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0);
});
+ // "No .bin firmware found" may only appear once the server searched and found none.
+ it('shows an error, not "no firmware found", when the firmware search failed', async () => {
+ Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
+ // The api helper returns undefined on a non-OK response.
+ flasherSearch.mockResolvedValue(undefined);
+ render();
+ await waitFor(() => expect(flasherSearch).toHaveBeenCalled());
+ switchTab('Choose from your WROLPi');
+ expect((await screen.findAllByText(/Could not search firmware/)).length).toBeGreaterThan(0);
+ expect(screen.queryAllByText(/firmware found/)).toHaveLength(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/MapSearchView.js b/app/src/components/MapSearchView.js
index 6df499ff..bd9ed8c8 100644
--- a/app/src/components/MapSearchView.js
+++ b/app/src/components/MapSearchView.js
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
import {Link, useNavigate} from "react-router";
import {getMapSearchStatus, searchMap} from "../api";
import {Card, CardGroup, Header, Loading, Panel} from "./ui";
-import {Paginator} from "./Common";
+import {ErrorMessage, Paginator} from "./Common";
import {useLatestRequest, usePages} from "../hooks/customHooks";
import {QueryContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
@@ -68,16 +68,24 @@ export function MapSearchView() {
const {offset, limit, activePage, setPage, totalPages, setTotal} = usePages(12);
// useLatestRequest discards stale responses and catches errors, so rapid pagination
// can't show out-of-order results and a network failure can't leave the spinner stuck.
- const {data, sendRequest, loading} = useLatestRequest(300);
+ // Loading from the first render: a query of two or more characters always sends a request (below),
+ // so this settles, and the shorter-query branches render before the loading check.
+ const {data, sendRequest, loading} = useLatestRequest(300, true);
+ // null = unknown (pending, or the status could not be fetched), otherwise whether any map is
+ // installed. Only a real status may claim that no maps are installed.
const [hasMaps, setHasMaps] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
- const status = await getMapSearchStatus();
- if (!cancelled) {
- const installed = (status.indexed || []).length + (status.missing || []).length;
- setHasMaps(installed > 0);
+ try {
+ const status = await getMapSearchStatus();
+ if (!cancelled && status) {
+ const installed = (status.indexed || []).length + (status.missing || []).length;
+ setHasMaps(installed > 0);
+ }
+ } catch (e) {
+ console.error(e);
}
})();
return () => {
@@ -106,12 +114,22 @@ export function MapSearchView() {
;
}
+ if (!searchStr) {
+ return Enter a search query to find map locations.;
+ }
+
+ if (searchStr.length < 2) {
+ return Enter at least two characters to find map locations.;
+ }
+
if (loading) {
return ;
}
- if (!searchStr) {
- return Enter a search query to find map locations.;
+ // Settled with no data: the request failed (the api helper returns undefined on a non-OK
+ // response, and useLatestRequest leaves null when it throws).
+ if (data == null) {
+ return Could not search the map.;
}
if (results.length === 0) {
diff --git a/app/src/components/MapSearchView.test.js b/app/src/components/MapSearchView.test.js
new file mode 100644
index 00000000..01e02884
--- /dev/null
+++ b/app/src/components/MapSearchView.test.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import {render, screen} from '../test-utils';
+import {MapSearchView} from './MapSearchView';
+import {getMapSearchStatus, searchMap} from '../api';
+
+// The map search tab says "No map locations found" only when the server searched and found none,
+// and "No maps are installed" only when the server said so. A failed request is an error.
+
+jest.mock('../api', () => ({
+ ...jest.requireActual('../api'),
+ getMapSearchStatus: jest.fn(),
+ searchMap: jest.fn(),
+}));
+
+const NOT_FOUND = /No map locations found/;
+const NO_MAPS = /No maps are installed/;
+const FAILED = /Could not search the map/;
+
+const renderAt = (q) => render(, {query: {searchParams: new URLSearchParams({q})}});
+
+beforeEach(() => {
+ getMapSearchStatus.mockReset();
+ searchMap.mockReset();
+ getMapSearchStatus.mockResolvedValue({indexed: ['utah'], missing: []});
+});
+
+test('found nothing: says so', async () => {
+ searchMap.mockResolvedValue({results: [], total: 0});
+ renderAt('camp');
+ expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
+});
+
+test('a failed search shows an error, not "No map locations found"', async () => {
+ searchMap.mockResolvedValue(undefined);
+ renderAt('camp');
+ expect(await screen.findByText(FAILED)).toBeInTheDocument();
+ expect(screen.queryByText(NOT_FOUND)).not.toBeInTheDocument();
+});
+
+test('a failed status check does not claim no maps are installed', async () => {
+ getMapSearchStatus.mockResolvedValue(undefined);
+ searchMap.mockResolvedValue({results: [], total: 0});
+ renderAt('camp');
+ expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
+ expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
+});
+
+test('a thrown status check does not claim no maps are installed', async () => {
+ getMapSearchStatus.mockRejectedValue(new Error('down'));
+ searchMap.mockResolvedValue({results: [], total: 0});
+ renderAt('camp');
+ expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
+ expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
+});
+
+test('the server saying no maps are installed is shown', async () => {
+ getMapSearchStatus.mockResolvedValue({indexed: [], missing: []});
+ searchMap.mockResolvedValue({results: [], total: 0});
+ renderAt('camp');
+ expect(await screen.findByText(NO_MAPS)).toBeInTheDocument();
+});
diff --git a/app/src/components/MapViewer.js b/app/src/components/MapViewer.js
index f00b519d..888bb78a 100644
--- a/app/src/components/MapViewer.js
+++ b/app/src/components/MapViewer.js
@@ -421,10 +421,13 @@ function MapSearch({map}) {
lon = center.lng;
}
+ // A failed search (undefined) shows no dropdown, same as finding nothing: this is an
+ // as-you-type suggestion list, not a results page.
searchMap(q, 8, 0, lat, lon).then(data => {
- setResults(data.results || []);
- setShowResults((data.results || []).length > 0);
- });
+ const found = data?.results || [];
+ setResults(found);
+ setShowResults(found.length > 0);
+ }).catch(e => console.error(e));
}, [map]);
const handleInput = useCallback((e) => {
diff --git a/app/src/components/admin/ConfigBackups.test.js b/app/src/components/admin/ConfigBackups.test.js
new file mode 100644
index 00000000..6dd8a4ad
--- /dev/null
+++ b/app/src/components/admin/ConfigBackups.test.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import {render, screen} from '../../test-utils';
+import {BackupsModal} from './Configs';
+import {getConfigBackups} from '../../api';
+
+// The config backups modal says "No backups available" only once the server confirmed there are
+// none; a failed fetch is an error, and a thrown one must not leave the loader up forever.
+
+jest.mock('../../api', () => ({
+ ...jest.requireActual('../../api'),
+ getConfigBackups: jest.fn(),
+ postConfigBackupPreview: jest.fn(() => new Promise(() => {
+ })),
+}));
+
+const NONE = /No backups available/;
+const FAILED = /Could not fetch backups/;
+
+const renderModal = () => render(
+ ,
+);
+
+beforeEach(() => getConfigBackups.mockReset());
+
+test('empty: says there are no backups', async () => {
+ getConfigBackups.mockResolvedValue({dates: []});
+ renderModal();
+ expect(await screen.findByText(NONE)).toBeInTheDocument();
+});
+
+test('failed: shows an error, not "No backups available"', async () => {
+ getConfigBackups.mockResolvedValue(undefined);
+ renderModal();
+ expect(await screen.findByText(FAILED)).toBeInTheDocument();
+ expect(screen.queryByText(NONE)).not.toBeInTheDocument();
+});
+
+test('thrown: shows an error, not a loader forever', async () => {
+ getConfigBackups.mockRejectedValue(new Error('down'));
+ renderModal();
+ expect(await screen.findByText(FAILED)).toBeInTheDocument();
+ expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument();
+});
diff --git a/app/src/components/admin/Configs.js b/app/src/components/admin/Configs.js
index 106921eb..98cbe8ae 100644
--- a/app/src/components/admin/Configs.js
+++ b/app/src/components/admin/Configs.js
@@ -1,4 +1,4 @@
-import {APIButton} from "../Common";
+import {APIButton, ErrorMessage} from "../Common";
import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui";
import React, {useEffect, useState} from "react";
import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api";
@@ -117,9 +117,11 @@ function BackupDateRow({date, previews, previewsLoaded, onSelect}) {
;
}
-function BackupsModal({open, onClose, fileName, fetchConfigs}) {
+export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
const [dates, setDates] = useState([]);
const [datesLoading, setDatesLoading] = useState(true);
+ // The backups could not be fetched; distinct from the server confirming there are none.
+ const [datesFailed, setDatesFailed] = useState(false);
const [previews, setPreviews] = useState({});
const [previewsLoaded, setPreviewsLoaded] = useState({});
const [selectedAction, setSelectedAction] = useState(null); // {date, mode, preview}
@@ -130,17 +132,30 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) {
if (!open) return;
let cancelled = false;
setDatesLoading(true);
+ setDatesFailed(false);
setDates([]);
setPreviews({});
setPreviewsLoaded({});
setSelectedAction(null);
setApplying(false);
- getConfigBackups(fileName).then(result => {
- if (cancelled) return;
- setDates(result?.dates || []);
- setDatesLoading(false);
- });
+ getConfigBackups(fileName)
+ .then(result => {
+ if (cancelled) return;
+ // The api helper returns undefined on a non-OK response.
+ if (result) {
+ setDates(result.dates || []);
+ } else {
+ setDatesFailed(true);
+ }
+ })
+ .catch(e => {
+ console.error(e);
+ if (!cancelled) setDatesFailed(true);
+ })
+ .finally(() => {
+ if (!cancelled) setDatesLoading(false);
+ });
return () => { cancelled = true; };
}, [open, fileName]);
@@ -253,6 +268,8 @@ function BackupsModal({open, onClose, fileName, fetchConfigs}) {
{datesLoading
? Loading backups...
+ : datesFailed
+ ? Could not fetch backups for this config.
: dates.length === 0
? No backups available for this config.
:
diff --git a/app/src/components/inventory/InventoryImportModal.js b/app/src/components/inventory/InventoryImportModal.js
index 7f7aa18d..ce20d27c 100644
--- a/app/src/components/inventory/InventoryImportModal.js
+++ b/app/src/components/inventory/InventoryImportModal.js
@@ -25,7 +25,7 @@ function ItemNameList({items}) {
* `onChanged` refreshes the page's inventory state after a successful action.
*/
export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
- const [dates, setDates] = useState(null); // null while loading
+ const [dates, setDates] = useState(null); // null while loading, undefined if the fetch failed
const [reimporting, setReimporting] = useState(false);
const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view
const [previewing, setPreviewing] = useState(null); // `${date}:${mode}` currently loading a preview
@@ -41,11 +41,19 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
setReimporting(false);
setApplying(false);
setPreviewing(null);
- getInventoryBackups(slug).then(result => {
- if (!cancelled) {
- setDates(result || []);
- }
- });
+ getInventoryBackups(slug)
+ .then(result => {
+ if (!cancelled) {
+ // The api helper returns undefined on a non-OK response.
+ setDates(result === undefined ? undefined : result || []);
+ }
+ })
+ .catch(e => {
+ console.error(e);
+ if (!cancelled) {
+ setDates(undefined);
+ }
+ });
return () => {
cancelled = true;
};
@@ -153,6 +161,8 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
{dates === null
?
Loading backups…
+ : dates === undefined
+ ?
Could not fetch backups for this inventory.
: dates.length === 0
?
No backups yet. A backup is saved automatically whenever this inventory changes.
:
diff --git a/app/src/components/inventory/InventoryImportModal.test.js b/app/src/components/inventory/InventoryImportModal.test.js
index 6ec7d604..751deecf 100644
--- a/app/src/components/inventory/InventoryImportModal.test.js
+++ b/app/src/components/inventory/InventoryImportModal.test.js
@@ -65,4 +65,25 @@ describe('InventoryImportModal', () => {
renderModal();
await waitFor(() => expect(getInventoryBackups).toHaveBeenCalledWith('food-storage'));
});
+
+ // "No backups yet" may only appear once the server confirmed there are none.
+ test('says there are no backups when the server returned none', async () => {
+ getInventoryBackups.mockResolvedValue([]);
+ renderModal();
+ expect(await screen.findByText(/No backups yet/)).toBeInTheDocument();
+ });
+
+ test('a failed backups fetch shows an error, not "No backups yet"', async () => {
+ getInventoryBackups.mockResolvedValue(undefined);
+ renderModal();
+ expect(await screen.findByText(/Could not fetch backups/)).toBeInTheDocument();
+ expect(screen.queryByText(/No backups yet/)).not.toBeInTheDocument();
+ });
+
+ test('a thrown backups fetch shows an error, not a loader forever', async () => {
+ getInventoryBackups.mockRejectedValue(new Error('down'));
+ renderModal();
+ expect(await screen.findByText(/Could not fetch backups/)).toBeInTheDocument();
+ expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument();
+ });
});