diff --git a/specifyweb/backend/attachment_gw/urls.py b/specifyweb/backend/attachment_gw/urls.py index 08857760fc6..d7f007afad9 100644 --- a/specifyweb/backend/attachment_gw/urls.py +++ b/specifyweb/backend/attachment_gw/urls.py @@ -6,6 +6,7 @@ path('get_settings/', views.get_settings), path('get_upload_params/', views.get_upload_params), path('get_token/', views.get_token), + path('health/', views.health), path('proxy/', views.proxy), path('download_all/', views.download_all), path('dataset/', views.datasets), diff --git a/specifyweb/backend/attachment_gw/views.py b/specifyweb/backend/attachment_gw/views.py index fec4bed676a..943ad29dcc5 100644 --- a/specifyweb/backend/attachment_gw/views.py +++ b/specifyweb/backend/attachment_gw/views.py @@ -261,6 +261,20 @@ def test_key(): else: raise AttachmentError("Attachment key test failed.") +@login_maybe_required +@require_http_methods(['GET', 'HEAD']) +@never_cache +def health(request): + if server_urls is None: + return HttpResponse(status=503) + + try: + test_key() + except (AttachmentError, requests.RequestException): + return HttpResponse(status=503) + + return HttpResponse(status=204) + @openapi(schema={ "get": { "parameters": [ diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index e89cedf46fa..028a0476fc5 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -22,7 +22,7 @@ import { softFail } from '../Errors/Crash'; import { Dialog } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; -import { fetchOriginalUrl } from './attachments'; +import { fetchOriginalUrl, useAttachmentServerStatus } from './attachments'; import { AttachmentPreview } from './Preview'; import { getAttachmentRelationship, tablesWithAttachments } from './utils'; @@ -39,10 +39,17 @@ export function AttachmentCell({ | ((table: SpecifyTable, recordId: number) => void) | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const table = f.maybe(attachment.tableID ?? undefined, getAttachmentTable); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(attachment), [attachment]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchOriginalUrl(attachment) + : undefined, + [attachment, attachmentServerStatus] + ), false ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index ff4ac242654..41611ee1b83 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -4,7 +4,11 @@ import { useAsyncState } from '../../hooks/useAsyncState'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { Attachment } from '../DataModel/types'; import type { AttachmentThumbnail } from './attachments'; -import { fetchThumbnail } from './attachments'; +import { + fetchThumbnail, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from './attachments'; export function AttachmentPreview({ attachment, @@ -13,8 +17,15 @@ export function AttachmentPreview({ readonly attachment: SerializedResource; readonly onOpen: () => void; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(attachment), [attachment]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchThumbnail(attachment) + : undefined, + [attachment, attachmentServerStatus] + ), false ); @@ -56,6 +67,11 @@ export function Thumbnail({ width: `${thumbnail.width}px`, height: `${thumbnail.height}px`, }} + onError={ + thumbnail.src.startsWith('http') + ? reportAttachmentServerFailure + : undefined + } /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index 1f366ced5ea..dd8072fe841 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -29,7 +29,12 @@ import { } from '../Forms/useViewDefinition'; import { loadingGif } from '../Molecules'; import { userPreferences } from '../Preferences/userPreferences'; -import { fetchOriginalUrl, fetchThumbnail } from './attachments'; +import { + fetchOriginalUrl, + fetchThumbnail, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from './attachments'; import { AttachmentRecordLink, getAttachmentTable } from './Cell'; import { Thumbnail } from './Preview'; @@ -48,12 +53,19 @@ export function AttachmentViewer({ | ((table: SpecifyTable, recordId: number) => void) | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); const serialized = React.useMemo( () => serializeResource(attachment), [attachment] ); const [originalUrl] = useAsyncState( - React.useCallback(async () => fetchOriginalUrl(serialized), [serialized]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchOriginalUrl(serialized) + : undefined, + [attachmentServerStatus, serialized] + ), false ); @@ -104,7 +116,13 @@ export function AttachmentViewer({ const type = mimeType?.split('/')[0]; const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(serialized), [serialized]), + React.useCallback( + async () => + attachmentServerStatus !== 'unavailable' + ? fetchThumbnail(serialized) + : undefined, + [attachmentServerStatus, serialized] + ), false ); @@ -136,7 +154,9 @@ export function AttachmentViewer({ return ( <>
- {displayOriginal === 'full' && !isTiffImage ? ( + {attachmentServerStatus === 'unavailable' ? ( + + ) : displayOriginal === 'full' && !isTiffImage ? ( originalUrl === undefined ? ( loadingGif ) : type === 'image' ? ( @@ -192,6 +212,7 @@ export function AttachmentViewer({ alt={title} className="h-full w-full object-scale-down" src={thumbnail?.src} + onError={reportAttachmentServerFailure} /> ) @@ -229,25 +250,29 @@ export function AttachmentViewer({ {typeof originalUrl === 'string' && (
- - {notificationsText.download()} - - - {commonText.openInNewTab()} - + {attachmentServerStatus !== 'unavailable' && ( + <> + + {notificationsText.download()} + + + {commonText.openInNewTab()} + + + )} {typeof table === 'object' && typeof handleViewRecord === 'function' ? ( ) => { - if (typeof thumbnail === 'string') { + if ( + !thumbnailFallbackAttempted.current && + typeof thumbnail === 'string' + ) { + thumbnailFallbackAttempted.current = true; const image = event.currentTarget; - image.onerror = null; image.src = thumbnail; + } else { + setImageFailed(true); + reportAttachmentServerFailure(); } }, [thumbnail] @@ -310,13 +343,17 @@ function ImageTransformContent({ wrapperClass="flex h-full w-full items-center justify-center" wrapperStyle={{ height: '100%', width: '100%' }} > - {alt} + {imageFailed ? ( + + ) : ( + {alt} + )} {showControls ? (
+ {attachmentsText.attachmentServerUnavailable()} + {attachmentsText.attachmentServerUnavailableDescription()} +
+ ); +} + function ZoomControls({ canToggleSidebar, isSidebarExpanded, diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx new file mode 100644 index 00000000000..fe86b4042df --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentsView.test.tsx @@ -0,0 +1,143 @@ +import { act, waitFor } from '@testing-library/react'; +import React from 'react'; +import * as Router from 'react-router-dom'; + +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { commonText } from '../../../localization/common'; +import { attachmentsText } from '../../../localization/attachments'; +import { Http } from '../../../utils/ajax/definitions'; +import { SetMenuContext } from '../../Header/MenuContext'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, + overrideAttachmentSettings, +} from '../attachments'; +import { AttachmentsView } from '..'; + +/* + * Bypass the paginated attachment fetches (a pre-existing, unrelated bug in + * useAsyncState's real implementation crashes when exercised here); only the + * status-driven rendering is under test + */ +jest.mock('../../../hooks/useAsyncState', () => { + const ReactModule = require('react'); + return { + __esModule: true, + useAsyncState: () => ReactModule.useState(undefined), + usePromise: (promise: Promise) => { + const [state, setState] = ReactModule.useState(undefined); + ReactModule.useEffect(() => { + let ignore = false; + promise.then((value: unknown) => { + if (!ignore) setState(value); + }); + return () => { + ignore = true; + }; + }, [promise]); + return [state, setState]; + }, + }; +}); + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +function TestAttachmentsView(): JSX.Element { + return ( + + + + + + ); +} + +describe('AttachmentsView', () => { + let consoleError: jest.SpiedFunction; + let consoleWarn: jest.SpiedFunction; + + beforeEach(async () => { + await attachmentSettingsPromise; + overrideAttachmentSettings(testSettings); + overrideAttachmentServerStatus('available'); + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); + consoleWarn.mockRestore(); + }); + + describe('when the health check reports the server is unreachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('replaces the gallery with a single unavailable message and disables Import', async () => { + const { findByRole, unmount } = mount(); + + await findByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + expect(importButton).toBeDisabled(); + expect(importButton).toHaveAttribute( + 'title', + attachmentsText.attachmentServerUnavailable() + ); + + unmount(); + }); + }); + + describe('when the health check reports the server is reachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + + test('shows the gallery and an enabled Import button when available', async () => { + const { findByRole, queryByRole, unmount } = mount( + + ); + + const importButton = await findByRole('button', { + name: commonText.import(), + }); + await waitFor(() => expect(importButton).toBeEnabled()); + expect( + queryByRole('heading', { + name: attachmentsText.attachmentServerUnavailable(), + }) + ).not.toBeInTheDocument(); + + await act(() => { + unmount(); + }); + }); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap index 61638ec3ada..c527650144e 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/__snapshots__/AttachmentCell.test.tsx.snap @@ -66,40 +66,8 @@ exports[`AttachmentCell simple render 1`] = ` > + />
- - -
`; diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts new file mode 100644 index 00000000000..d29a3d27631 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/attachmentServerStatus.test.ts @@ -0,0 +1,128 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { Http } from '../../../utils/ajax/definitions'; +import { + attachmentSettingsPromise, + overrideAttachmentServerStatus, + overrideAttachmentSettings, + reportAttachmentServerFailure, + useAttachmentServerStatus, +} from '../attachments'; + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +// Silences (and lets tests assert on) the connection-loss/restoration logging +let consoleError: jest.SpiedFunction; +let consoleWarn: jest.SpiedFunction; + +beforeEach(async () => { + await attachmentSettingsPromise; + overrideAttachmentSettings(testSettings); + overrideAttachmentServerStatus('available'); + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + overrideAttachmentSettings(undefined); + consoleError.mockRestore(); + consoleWarn.mockRestore(); +}); + +describe('reportAttachmentServerFailure', () => { + describe('when a health check confirms the server is reachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + + test('a single caller error does not mark the server unavailable', async () => { + overrideAttachmentServerStatus('unknown'); + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('unknown'); + + act(() => reportAttachmentServerFailure()); + + await waitFor(() => expect(result.current).toBe('available')); + unmount(); + }); + }); + + describe('when a health check confirms the server is unreachable', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('marks the server unavailable', async () => { + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('available'); + + act(() => reportAttachmentServerFailure()); + + await waitFor(() => expect(result.current).toBe('unavailable')); + unmount(); + }); + }); + + test('does nothing when no settings are configured', () => { + overrideAttachmentSettings(undefined); + expect(() => reportAttachmentServerFailure()).not.toThrow(); + }); +}); + +describe('connection loss/restoration logging', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.SERVER_ERROR }); + + test('logs connection loss exactly once, and only once further failures are reported', async () => { + const { result, unmount } = renderHook(() => useAttachmentServerStatus()); + expect(result.current).toBe('available'); + + act(() => reportAttachmentServerFailure()); + await waitFor(() => expect(result.current).toBe('unavailable')); + + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleWarn).not.toHaveBeenCalled(); + + act(() => reportAttachmentServerFailure()); + await waitFor(() => expect(consoleError).toHaveBeenCalledTimes(1)); + + unmount(); + }); +}); + +describe('useAttachmentServerStatus polling', () => { + overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('keeps the shared interval running until the last subscriber unmounts', async () => { + const first = renderHook(() => useAttachmentServerStatus()); + const second = renderHook(() => useAttachmentServerStatus()); + + // Let the immediate on-mount health check settle before tearing down + await act(async () => { + await Promise.resolve(); + }); + + expect(jest.getTimerCount()).toBe(1); + + first.unmount(); + expect(jest.getTimerCount()).toBe(1); + + second.unmount(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts index 8b30bfff2b8..e9d0be67c45 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import { commonText } from '../../localization/common'; import { ajax } from '../../utils/ajax'; import { Http } from '../../utils/ajax/definitions'; @@ -41,15 +43,114 @@ type AttachmentSettings = { }; let settings: AttachmentSettings | undefined; +export type AttachmentServerStatus = 'unknown' | 'available' | 'unavailable'; + +let serverStatus: AttachmentServerStatus = 'unknown'; +const serverStatusListeners = new Set<() => void>(); +let healthCheckTimer: ReturnType | undefined; + +const setAttachmentServerStatus = (newStatus: AttachmentServerStatus): void => { + if (serverStatus === newStatus) return; + const previousStatus = serverStatus; + serverStatus = newStatus; + // Only log actual connection loss/restoration, not the initial unknown state + if (previousStatus === 'available' && newStatus === 'unavailable') + console.error( + `[${new Date().toISOString()}] Attachment server connection lost` + ); + else if (previousStatus === 'unavailable' && newStatus === 'available') + console.warn( + `[${new Date().toISOString()}] Attachment server connection restored` + ); + serverStatusListeners.forEach((listener) => listener()); +}; + export const attachmentSettingsPromise = load>( '/context/attachment_settings.json', 'application/json' ).then((data) => { - if (Object.keys(data).length > 0) settings = data as AttachmentSettings; + if (Object.keys(data).length > 0) { + settings = data as AttachmentSettings; + checkAttachmentServer().catch(() => { + setAttachmentServerStatus('unavailable'); + }); + } else { + settings = undefined; + setAttachmentServerStatus('unavailable'); + } return attachmentsAvailable(); }); export const attachmentsAvailable = (): boolean => typeof settings === 'object'; + +const checkAttachmentServer = async (): Promise => { + if (settings === undefined) return; + const { status } = await ajax('/attachment_gw/health/', { + cache: 'no-store', + errorMode: 'silent', + expectedErrors: Object.values(Http), + headers: { Accept: 'text/plain' }, + }); + setAttachmentServerStatus( + status !== Http.MISDIRECTED && status < Http.SERVER_ERROR + ? 'available' + : 'unavailable' + ); +}; + +/* + * A single caller error (e.g. a missing or corrupt attachment) doesn't mean + * the server is down, so confirm with a health check before marking it unavailable + */ +export const reportAttachmentServerFailure = (): void => { + if (settings === undefined) return; + checkAttachmentServer().catch(() => setAttachmentServerStatus('unavailable')); +}; + +const stopAttachmentServerHealthPolling = (): void => { + if (serverStatusListeners.size === 0 && healthCheckTimer !== undefined) { + clearInterval(healthCheckTimer); + healthCheckTimer = undefined; + } +}; + +const startAttachmentServerHealthPolling = (): (() => void) => { + const poll = (): void => { + checkAttachmentServer().catch(() => + setAttachmentServerStatus('unavailable') + ); + }; + if (healthCheckTimer === undefined) { + poll(); + + healthCheckTimer = setInterval(() => { + poll(); + }, 30_000); + } + return stopAttachmentServerHealthPolling; +}; + +export const useAttachmentServerStatus = (): AttachmentServerStatus => { + const subscribe = React.useCallback((listener: () => void) => { + serverStatusListeners.add(listener); + const stopPolling = startAttachmentServerHealthPolling(); + return () => { + serverStatusListeners.delete(listener); + stopPolling(); + }; + }, []); + const getSnapshot = React.useCallback(() => serverStatus, []); + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +}; + +/* + * This function is only used in automated tests. + */ +export const overrideAttachmentServerStatus = ( + newStatus: AttachmentServerStatus +): void => { + serverStatus = newStatus; +}; const uploadTimeoutMilliseconds = 30 * 60 * 1000; /* diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index 32d32acdd1d..60be6fa54d3 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -27,6 +27,7 @@ import { Dialog } from '../Molecules/Dialog'; import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; +import { useAttachmentServerStatus } from './attachments'; import { AttachmentGallery } from './Gallery'; import { allTablesWithAttachments, tablesWithAttachments } from './utils'; @@ -65,6 +66,7 @@ function Attachments({ readonly onClick?: (attachment: SerializedResource) => void; }): JSX.Element { useMenuItem('attachments'); + const attachmentServerStatus = useAttachmentServerStatus(); const isInDialog = React.useContext(DialogContext); @@ -234,6 +236,12 @@ function Attachments({ /> navigate('/specify/overlay/attachments/import/')} > {commonText.import()} @@ -241,25 +249,38 @@ function Attachments({ )} - - collection === undefined - ? undefined - : setCollection({ - records: replaceItem(collection.records, index, attachment), - totalCount: collection.totalCount, - }) - } - onClick={onClick} - onFetchMore={collection === undefined ? undefined : fetchMore} - /> + {attachmentServerStatus === 'unavailable' ? ( + + ) : ( + + collection === undefined + ? undefined + : setCollection({ + records: replaceItem(collection.records, index, attachment), + totalCount: collection.totalCount, + }) + } + onClick={onClick} + onFetchMore={collection === undefined ? undefined : fetchMore} + /> + )} ); } + +function AttachmentServerUnavailable(): JSX.Element { + return ( +
+

{attachmentsText.attachmentServerUnavailable()}

+

{attachmentsText.attachmentServerUnavailableDescription()}

+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx new file mode 100644 index 00000000000..8f07bea9157 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Header/__tests__/Header.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import * as Router from 'react-router-dom'; +import { waitFor } from '@testing-library/react'; + +import { commonText } from '../../../localization/common'; +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { Http } from '../../../utils/ajax/definitions'; +import { SetMenuContext } from '../MenuContext'; +import { + overrideAttachmentServerStatus, + overrideAttachmentSettings, +} from '../../Attachments/attachments'; +import { HeaderItems } from '..'; + +requireContext(); + +const mockReadUrl = '/mockAssetServer/fileget'; + +const testSettings = { + collection: 'Test Collection', + delete: '/mockAssetServer/filedelete', + getmetadata: '/mockAssetServer/getmetadata', + read: mockReadUrl, + testkey: '/mockAssetServer/testkey', + // eslint-disable-next-line @typescript-eslint/naming-convention + token_required_for_get: false, + write: '/mockAssetServer/fileupload', +}; + +overrideAjax(mockReadUrl, '', { responseCode: Http.OK }); + +function TestHeaderItems(): JSX.Element { + return ( + + + , + url: '/specify/attachments/', + }, + { + name: 'search', + title: commonText.search(), + icon: , + url: '/specify/overlay/express-search/', + }, + ]} + isCollapsed={false} + activeMenuItem={undefined} + /> + + + ); +} + +describe('HeaderItems', () => { + // The immediate on-mount health check legitimately logs a status transition + beforeEach(() => { + overrideAttachmentSettings(testSettings); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + overrideAttachmentSettings(undefined); + overrideAttachmentServerStatus('unknown'); + jest.restoreAllMocks(); + }); + + test('disables attachments item when server is unavailable', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest( + 'span[aria-disabled]' + ); + expect(attachmentsItem).toHaveAttribute('aria-disabled', 'true'); + expect(attachmentsItem).toHaveClass('cursor-not-allowed'); + expect(attachmentsItem).toHaveClass('opacity-50'); + }); + }); + + test('does not disable non-attachments items', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const searchItem = getByTestId('search-icon').closest('a'); + expect(searchItem).toBeEnabled(); + expect(searchItem).toHaveAttribute( + 'href', + '/specify/overlay/express-search/' + ); + }); + }); + + test('renders disabled attachments item as non-interactive', async () => { + overrideAttachmentServerStatus('unavailable'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest( + 'span[aria-disabled]' + ); + expect(attachmentsItem).toBeInTheDocument(); + expect(attachmentsItem).not.toHaveAttribute('href'); + }); + }); + + test('keeps attachments enabled when server is available', async () => { + overrideAttachmentServerStatus('available'); + const { getByTestId } = mount(); + + await waitFor(() => { + const attachmentsItem = getByTestId('attachments-icon').closest('a'); + expect(attachmentsItem).toBeEnabled(); + expect(attachmentsItem).toHaveAttribute('href', '/specify/attachments/'); + }); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Header/index.tsx b/specifyweb/frontend/js_src/lib/components/Header/index.tsx index d5d9971865f..a6192f5df13 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Header/index.tsx @@ -7,10 +7,12 @@ import { useLocation } from 'react-router-dom'; import type { LocalizedString } from 'typesafe-i18n'; import { useCachedState } from '../../hooks/useCachedState'; +import { attachmentsText } from '../../localization/attachments'; import { commonText } from '../../localization/common'; import { listen } from '../../utils/events'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; +import { useAttachmentServerStatus } from '../Attachments/attachments'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { icons } from '../Atoms/Icons'; @@ -160,7 +162,7 @@ export function Header({ ); } -function HeaderItems({ +export function HeaderItems({ menuItems, isCollapsed, activeMenuItem, @@ -169,17 +171,28 @@ function HeaderItems({ readonly isCollapsed: boolean; readonly activeMenuItem: MenuItemName | undefined; }): JSX.Element { + const attachmentServerStatus = useAttachmentServerStatus(); return ( <> - {menuItems.map(({ url, name, ...menuItem }) => ( - - ))} + {menuItems.map(({ url, name, ...menuItem }) => { + const isAttachmentsUnavailable = + name === 'attachments' && attachmentServerStatus === 'unavailable'; + return ( + + ); + })} ); } @@ -190,6 +203,8 @@ export function MenuButton({ isActive = false, isCollapsed, preventOverflow = false, + disabled = false, + disabledTitle, onClick: handleClick, props: extraProps, }: { @@ -198,6 +213,8 @@ export function MenuButton({ readonly isCollapsed: boolean; readonly isActive?: boolean; readonly preventOverflow?: boolean; + readonly disabled?: boolean; + readonly disabledTitle?: LocalizedString; readonly onClick: string | (() => void); readonly props?: Omit & TagProps<'button'>, 'aria-label'>; }): JSX.Element | null { @@ -205,6 +222,7 @@ export function MenuButton({ const [isSideBarLight] = userPreferences.use('general', 'ui', 'sidebarTheme'); const isDarkMode = useDarkMode(); const isSideBarDark = isDarkMode || isSideBarLight === 'dark'; + const descriptionId = React.useId(); const getClassName = (isActive: boolean): string => ` p-[1.4vh] ${ @@ -224,7 +242,10 @@ export function MenuButton({ [titlePosition]: position === 'left' ? 'right' : position === 'right' ? 'left' : undefined, 'aria-current': isActive ? 'page' : undefined, - title: isCollapsed ? title : undefined, + 'aria-disabled': disabled ? true : undefined, + 'aria-describedby': + disabled && typeof disabledTitle === 'string' ? descriptionId : undefined, + title: disabled ? disabledTitle : isCollapsed ? title : undefined, } as const; const children = ( @@ -239,9 +260,24 @@ export function MenuButton({ ) : ( {title} )} + {disabled && typeof disabledTitle === 'string' ? ( + + {disabledTitle} + + ) : undefined} ); + if (disabled) + return ( + + {children} + + ); + return typeof handleClick === 'string' ? ( >>()({ url: '/specify/attachments/', title: attachmentsText.attachments(), icon: icons.photos, - async enabled(): Promise { - if (!hasTablePermission('Attachment', 'read')) return false; - await attachmentSettingsPromise; - return attachmentsAvailable(); - }, + /* + * Asset server availability is checked at render time so the item can be + * disabled and re-enabled without a page reload. See useAttachmentServerStatus + */ + enabled: () => hasTablePermission('Attachment', 'read'), }, statistics: { url: '/specify/stats',