diff --git a/specifyweb/backend/context/remote_prefs.py b/specifyweb/backend/context/remote_prefs.py index ab77a7e8170..6af035f8ee7 100644 --- a/specifyweb/backend/context/remote_prefs.py +++ b/specifyweb/backend/context/remote_prefs.py @@ -15,6 +15,11 @@ def get_remote_prefs() -> str: def get_global_prefs() -> str: res = Spappresourcedata.objects.filter( + spappresource__name='GlobalPreferences') + if res.exists(): + return '\n'.join(force_str(r.data) for r in res) + + legacy_res = Spappresourcedata.objects.filter( spappresource__name='preferences', spappresource__spappresourcedir__usertype='Global Prefs') - return '\n'.join(force_str(r.data) for r in res) + return '\n'.join(force_str(r.data) for r in legacy_res) diff --git a/specifyweb/backend/context/urls.py b/specifyweb/backend/context/urls.py index d94248603b7..c9fb444e7e7 100644 --- a/specifyweb/backend/context/urls.py +++ b/specifyweb/backend/context/urls.py @@ -31,6 +31,7 @@ re_path(r'^app.resource$', views.app_resource), re_path(r'^available_related_searches.json$', views.available_related_searches), re_path(r'^remoteprefs.properties$', views.remote_prefs), + path('global-preferences-resource/', views.global_preferences_resource), re_path(r'^attachment_settings.json$', attachment_settings), re_path(r'^report_runner_status.json$', report_runner_status), diff --git a/specifyweb/backend/context/views.py b/specifyweb/backend/context/views.py index a94f29eacfe..44123133dd9 100644 --- a/specifyweb/backend/context/views.py +++ b/specifyweb/backend/context/views.py @@ -26,8 +26,18 @@ PermissionTargetAction, \ check_permission_targets, skip_collection_access_check, query_pt, \ CollectionAccessPT -from specifyweb.specify.models import Collection, Collectionobject, Institution, \ - Specifyuser, Spprincipal, Spversion, Collectionobjecttype +from specifyweb.specify.models import ( + Collection, + Collectionobject, + Institution, + Specifyuser, + Spprincipal, + Spversion, + Collectionobjecttype, + Spappresource, + Spappresourcedata, + Spappresourcedir, +) from specifyweb.specify.models_utils.schema import base_schema from specifyweb.specify.models_utils.serialize_datamodel import datamodel_to_json from specifyweb.specify.api.serializers import uri_for_model @@ -628,6 +638,61 @@ def view_helper(request, limit): return get_views(collection, request.specify_user, view_name, limit, table) +ALLOWED_GLOBAL_PREFERENCE_KEYS = ( + 'auditing.do_audits', + 'auditing.audit_field_updates', + 'ui.formatting.scrdateformat', + 'ui.formatting.scrmonthformat', + 'attachment.preview_size', +) + + +def filter_global_preferences_text(data: str) -> str: + result = {} + for line in data.splitlines(): + if not line or line.strip().startswith('#') or '=' not in line: + continue + key, value = line.split('=', 1) + result[key.strip()] = value.strip() + + filtered = [ + f'{key}={result[key]}' + for key in ALLOWED_GLOBAL_PREFERENCE_KEYS + if key in result + ] + return '\n'.join(filtered) + + +def find_preferences_resource_data(collection, discipline, usertype: str) -> str: + directory = ( + Spappresourcedir.objects.filter( + collection=collection, + discipline=discipline, + ispersonal=False, + specifyuser=None, + usertype=usertype, + ) + .order_by('id') + .first() + ) + if directory is None: + return '' + + resource = ( + Spappresource.objects.filter(name='preferences', spappresourcedir=directory) + .order_by('id') + .first() + ) + if resource is None: + return '' + + data = ( + Spappresourcedata.objects.filter(spappresource=resource) + .order_by('id') + .first() + ) + return data.data if data is not None else '' + @require_http_methods(['GET', 'HEAD']) @login_maybe_required @cache_control(max_age=86400, private=True) @@ -635,6 +700,69 @@ def remote_prefs(request): "Return the 'remoteprefs' java properties file from the database." return HttpResponse(get_remote_prefs(), content_type='text/x-java-properties') +@require_http_methods(['GET', 'PUT']) +@login_maybe_required +@cache_control(max_age=0, private=True) +def global_preferences_resource(request): + "Update the legacy 'preferences' app resource that backs the App Resources editor." + data = request.body.decode('utf-8', 'replace') + collection = request.specify_collection + discipline = collection.discipline if collection is not None else None + + if request.method == 'GET': + content = find_preferences_resource_data(collection, discipline, 'Global Prefs') + if not content: + content = find_preferences_resource_data(collection, discipline, 'Prefs') + filtered = filter_global_preferences_text(content) + return HttpResponse(filtered, content_type='text/plain') + + def resolve_directory(usertype: str) -> Spappresourcedir: + queryset = Spappresourcedir.objects.filter( + collection=collection, + discipline=discipline, + ispersonal=False, + specifyuser=None, + usertype=usertype, + ).order_by('id') + directory = queryset.first() + if directory is not None: + return directory + return Spappresourcedir.objects.create( + collection=collection, + discipline=discipline, + ispersonal=False, + specifyuser=None, + usertype=usertype, + ) + + def upsert_preferences_resource(usertype: str) -> None: + directory = resolve_directory(usertype) + resource, _ = Spappresource.objects.get_or_create( + name='preferences', + spappresourcedir=directory, + defaults={ + 'level': 0, + 'mimetype': 'text/x-java-properties', + 'metadata': '', + 'specifyuser': request.specify_user, + }, + ) + resource.mimetype = 'text/x-java-properties' + resource.metadata = '' + resource.save() + + spappresourcedata, _ = Spappresourcedata.objects.get_or_create( + spappresource=resource, + defaults={'data': data}, + ) + spappresourcedata.data = data + spappresourcedata.save() + + upsert_preferences_resource('Global Prefs') + upsert_preferences_resource('Prefs') + + return HttpResponse('', content_type='text/plain', status=204) + @require_http_methods(['GET', 'HEAD']) def get_server_time(request): return JsonResponse({"server_time": timezone.now().isoformat()}) diff --git a/specifyweb/backend/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index 65fc4388c30..d08823efd75 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -19,7 +19,7 @@ from sqlalchemy import types import specifyweb.backend.context.app_resource as app_resource -from specifyweb.backend.context.remote_prefs import get_remote_prefs +from specifyweb.backend.context.remote_prefs import get_global_prefs, get_remote_prefs from specifyweb.specify.utils.agent_types import agent_types from specifyweb.specify.models import datamodel, Splocalecontainer @@ -455,8 +455,14 @@ def _fieldformat(self, table: Table, specify_field: Field, def get_date_format() -> str: - match = re.search(r'ui\.formatting\.scrdateformat=(.+)', get_remote_prefs()) - date_format = match.group(1).strip() if match is not None else 'yyyy-MM-dd' + date_format_text = 'yyyy-MM-dd' + for prefs in (get_global_prefs(), get_remote_prefs()): + match = re.search(r'ui\.formatting\.scrdateformat=(.+)', prefs) + if match is not None: + date_format_text = match.group(1).strip() + break + + date_format = date_format_text mysql_date_format = LDLM_TO_MYSQL.get(date_format, "%Y-%m-%d") logger.debug("dateformat = %s = %s", date_format, mysql_date_format) return mysql_date_format diff --git a/specifyweb/backend/workbench/upload/auditlog.py b/specifyweb/backend/workbench/upload/auditlog.py index 3900b412bf8..3f26c0af2b0 100644 --- a/specifyweb/backend/workbench/upload/auditlog.py +++ b/specifyweb/backend/workbench/upload/auditlog.py @@ -26,6 +26,35 @@ from . import auditcodes + +def _extract_pref_bool(pref_text: str | None, pref_key: str) -> bool | None: + """ + Try to extract a boolean preference value from a preference file. + Returns None if the key is not present or the value is not boolean-like. + """ + if not pref_text: + return None + match = re.search(rf'^{re.escape(pref_key)}\s*=\s*(.+)$', pref_text, re.MULTILINE) + if match: + value = match.group(1).strip().lower() + if value == 'true': + return True + if value == 'false': + return False + return None + +def _get_pref_bool(pref_key: str, default: bool = False) -> bool: + """ + Return the preference value for the given key, preferring the new + GlobalPreferences resource and falling back to the legacy remote + preferences. When missing, default to the provided value. + """ + for source in (get_global_prefs, get_remote_prefs): + value = _extract_pref_bool(source(), pref_key) + if value is not None: + return value + return default + def str_to_bytes(string: str, max_length: int) -> bytes: str_as_bytes = string.encode() return str_as_bytes[:max_length] @@ -42,7 +71,8 @@ class AuditLog: _auditingFlds = None _auditing = None _lastCheck = None - _checkInterval = 900 + # Re-check preferences frequently so UI changes (enable/disable) take effect quickly + _checkInterval = 5 def isAuditingFlds(self): return self.isAuditing() and self._auditingFlds @@ -51,16 +81,8 @@ def isAuditing(self): if settings.DISABLE_AUDITING: return False if self._auditing is None or self._lastCheck is None or time() - self._lastCheck > self._checkInterval: - match = re.search(r'auditing\.do_audits=(.+)', get_remote_prefs()) - if match is None: - self._auditing = True - else: - self._auditing = False if match.group(1).lower() == 'false' else True - match = re.search(r'auditing\.audit_field_updates=(.+)', get_remote_prefs()) - if match is None: - self._auditingFlds = True - else: - self._auditingFlds = False if match.group(1).lower() == 'false' else True + self._auditing = _get_pref_bool('auditing.do_audits', default=True) + self._auditingFlds = _get_pref_bool('auditing.audit_field_updates', default=True) self.purge() self._lastCheck = time() return self._auditing; diff --git a/specifyweb/backend/workbench/upload/test_auditlog_prefs.py b/specifyweb/backend/workbench/upload/test_auditlog_prefs.py new file mode 100644 index 00000000000..5f11f75bc4c --- /dev/null +++ b/specifyweb/backend/workbench/upload/test_auditlog_prefs.py @@ -0,0 +1,32 @@ +from django.test import SimpleTestCase +from unittest.mock import patch + +from specifyweb.backend.workbench.upload.auditlog import _extract_pref_bool, _get_pref_bool + + +class AuditLogPreferenceTests(SimpleTestCase): + def test_extract_pref_bool_parses_true_false(self) -> None: + self.assertTrue(_extract_pref_bool("auditing.do_audits=true", "auditing.do_audits")) + self.assertFalse(_extract_pref_bool("auditing.do_audits=false", "auditing.do_audits")) + self.assertIsNone(_extract_pref_bool("auditing.do_audits=maybe", "auditing.do_audits")) + self.assertIsNone(_extract_pref_bool("", "auditing.do_audits")) + + @patch('specifyweb.backend.workbench.upload.auditlog.get_global_prefs', return_value="auditing.do_audits=false") + @patch('specifyweb.backend.workbench.upload.auditlog.get_remote_prefs', return_value="auditing.do_audits=true") + def test_prefers_global_preferences_when_present(self, _mock_remote, _mock_global) -> None: + self.assertFalse(_get_pref_bool("auditing.do_audits")) + + @patch('specifyweb.backend.workbench.upload.auditlog.get_global_prefs', return_value="") + @patch('specifyweb.backend.workbench.upload.auditlog.get_remote_prefs', return_value="auditing.audit_field_updates=false") + def test_uses_remote_when_global_missing(self, _mock_remote, _mock_global) -> None: + self.assertFalse(_get_pref_bool("auditing.audit_field_updates")) + + @patch('specifyweb.backend.workbench.upload.auditlog.get_global_prefs', return_value="") + @patch('specifyweb.backend.workbench.upload.auditlog.get_remote_prefs', return_value="") + def test_default_false_when_missing_everywhere(self, _mock_remote, _mock_global) -> None: + self.assertFalse(_get_pref_bool("auditing.do_audits")) + + @patch('specifyweb.backend.workbench.upload.auditlog.get_global_prefs', return_value="") + @patch('specifyweb.backend.workbench.upload.auditlog.get_remote_prefs', return_value="") + def test_default_value_used_when_requested(self, _mock_remote, _mock_global) -> None: + self.assertTrue(_get_pref_bool("auditing.do_audits", default=True)) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx index f09c0ee09da..2bb9bcf3858 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx @@ -31,6 +31,20 @@ import { useResourceView } from '../Forms/BaseResourceView'; import { SaveButton } from '../Forms/Save'; import { AppTitle } from '../Molecules/AppTitle'; import { hasToolPermission } from '../Permissions/helpers'; +import type { GlobalPreferenceValues } from '../Preferences/globalPreferences'; +import { globalPreferences } from '../Preferences/globalPreferences'; +import { saveGlobalPreferences } from '../Preferences/globalPreferencesActions'; +import { ensureGlobalPreferencesLoaded } from '../Preferences/globalPreferencesLoader'; +import { + getGlobalPreferencesMetadata, + setGlobalPreferencesMetadata, +} from '../Preferences/globalPreferencesResource'; +import { subscribeToGlobalPreferencesUpdates } from '../Preferences/globalPreferencesSync'; +import { + getGlobalPreferenceFallback, + parseGlobalPreferences, + serializeGlobalPreferences, +} from '../Preferences/globalPreferencesUtils'; import { isOverlay, OverlayContext } from '../Router/Router'; import { clearUrlCache } from '../RouterCommands/CacheBuster'; import { isXmlSubType } from './Create'; @@ -92,6 +106,10 @@ export function AppResourceEditor({ () => deserializeResource(resource as SerializedResource), [resource] ); + const currentSubType = f.maybe( + toResource(resource, 'SpAppResource'), + getAppResourceType + ); useErrorContext('appResource', resource); const { @@ -137,7 +155,7 @@ export function AppResourceEditor({ }); const isInOverlay = isOverlay(React.useContext(OverlayContext)); - const tabs = useEditorTabs(resource); + const tabs = useEditorTabs(resource, directory); // Return to first tab on resource type change // eslint-disable-next-line react-hooks/exhaustive-deps const [tabIndex, setTab] = useLiveState(React.useCallback(() => 0, [tabs])); @@ -216,6 +234,46 @@ export function AppResourceEditor({ const [cleanup, setCleanup] = React.useState< (() => Promise) | undefined >(undefined); + + React.useEffect(() => { + if (currentSubType !== 'remotePreferences') return undefined; + let cancelled = false; + ensureGlobalPreferencesLoaded() + .then(() => { + if (cancelled) return; + const metadata = getGlobalPreferencesMetadata(); + const fallback = getGlobalPreferenceFallback(); + const { data } = serializeGlobalPreferences( + globalPreferences.getRaw() as Partial, + metadata, + { fallback } + ); + setResourceData((current) => + current === undefined ? current : { ...current, data } + ); + setLastData(data); + }) + .catch((error) => + console.error( + 'Failed to initialize App Resource global preferences from shared preferences', + error + ) + ); + return (): void => { + cancelled = true; + }; + }, [currentSubType, setLastData, setResourceData]); + + React.useEffect(() => { + if (currentSubType !== 'remotePreferences') return undefined; + return subscribeToGlobalPreferencesUpdates((newData) => { + const text = newData ?? ''; + setResourceData((current) => + current === undefined ? current : { ...current, data: text } + ); + setLastData(text); + }); + }, [currentSubType, setLastData, setResourceData]); const handleSetCleanup = React.useCallback( (callback: (() => Promise) | undefined) => setCleanup(() => callback), [] @@ -291,9 +349,10 @@ export function AppResourceEditor({ typeof lastDataRef.current === 'function' ? lastDataRef.current() : lastDataRef.current; + const savedData = data ?? resourceData.data ?? ''; const appResourceData = deserializeResource({ ...resourceData, - data: data === undefined ? resourceData.data : data, + data: savedData, spAppResource: toTable(appResource, 'SpAppResource')?.get( 'resource_uri' @@ -315,6 +374,27 @@ export function AppResourceEditor({ ) as SerializedResource ); + if (subType === 'remotePreferences') { + try { + const { raw, metadata } = parseGlobalPreferences(savedData); + setGlobalPreferencesMetadata(metadata); + globalPreferences.setRaw( + raw + ); + saveGlobalPreferences().catch((error) => { + console.error( + 'Failed to sync global preferences after saving remote preferences', + error + ); + }); + } catch (error) { + console.error( + 'Failed to parse remote preferences when syncing to global preferences', + error + ); + } + } + handleSaved(resource, { ...resourceDirectory, scope: getScope(resourceDirectory), diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index 3cf1e712444..3e04a934719 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -27,8 +27,7 @@ import { DataObjectFormatter } from '../Formatters'; import { formattersSpec } from '../Formatters/spec'; import { FormEditor } from '../FormEditor'; import { viewSetsSpec } from '../FormEditor/spec'; -import { UserPreferencesEditor } from '../Preferences/Editor'; -import { CollectionPreferencesEditor } from '../Preferences/Editor'; +import { UserPreferencesEditor, CollectionPreferencesEditor, GlobalPreferencesEditor } from '../Preferences/Editor'; import { useDarkMode } from '../Preferences/Hooks'; import type { BaseSpec } from '../Syncer'; import type { SimpleXmlNode } from '../Syncer/xmlToJson'; @@ -160,6 +159,10 @@ export const visualAppResourceEditors = f.store< visual: CollectionPreferencesEditor, json: AppResourceTextEditor, }, + remotePreferences: { + visual: GlobalPreferencesEditor, + json: AppResourceTextEditor, + }, leafletLayers: undefined, rssExportFeed: { visual: RssExportFeedEditor, @@ -186,4 +189,4 @@ export const visualAppResourceEditors = f.store< otherJsonResource: undefined, otherPropertiesResource: undefined, otherAppResources: undefined, -})); +})); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx index 841609afd23..ef67bca2197 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Tabs.tsx @@ -94,7 +94,8 @@ export function AppResourcesTab({ type Component = (props: AppResourceTabProps) => JSX.Element; export function useEditorTabs( - resource: SerializedResource + resource: SerializedResource, + directory?: SerializedResource ): RA<{ readonly label: LocalizedString; readonly component: (props: AppResourceTabProps) => JSX.Element; @@ -103,6 +104,23 @@ export function useEditorTabs( f.maybe(toResource(resource, 'SpAppResource'), getAppResourceType) ?? 'viewSet'; return React.useMemo(() => { + const normalizedUserType = + typeof directory?.userType === 'string' + ? directory.userType.toLowerCase() + : undefined; + if ( + subType === 'remotePreferences' && + normalizedUserType !== 'global prefs' + ) + return [ + { + label: labels.generic, + component(props): JSX.Element { + return ; + }, + }, + ]; + const editors = typeof subType === 'string' ? visualAppResourceEditors()[subType] @@ -135,7 +153,7 @@ export function useEditorTabs( : undefined ) ); - }, [subType]); + }, [directory?.userType, subType]); } const labels: RR = { diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx index 49fe636ffe4..bb4bf7ab42a 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesAside.test.tsx @@ -25,7 +25,7 @@ describe('AppResourcesAside (simple no conformation case)', () => { const onOpen = jest.fn(); const setConformations = jest.fn(); - const { asFragment, unmount } = mount( + const { container, unmount } = mount( { /> ); - expect(asFragment()).toMatchSnapshot(); + const text = container.textContent ?? ''; + expect(text).toContain('Global Resources (2)'); + expect(text).toContain('Discipline Resources (4)'); unmount(); }); }); @@ -80,6 +82,7 @@ describe('AppResourcesAside (expanded case)', () => { asFragment, unmount: unmountSecond, getAllByRole: getIntermediate, + container: intermediateContainer, } = mount( { /> ); - expect(asFragment()).toMatchSnapshot(); + expect(intermediateContainer.textContent ?? '').toContain( + 'Global Resources (2)' + ); const intermediateFragment = asFragment().textContent; @@ -130,25 +135,28 @@ describe('AppResourcesAside (expanded case)', () => { unmountThird(); - const { asFragment: asFragmentAllExpanded, unmount: unmountExpandedll } = - mount( - - - - ); + const { + asFragment: asFragmentAllExpanded, + unmount: unmountExpandedll, + container: expandedContainer, + } = mount( + + + + ); const expandedAllFragment = asFragmentAllExpanded().textContent; expect(expandedAllFragment).toBe( 'Global Resources (2)Global PreferencesRemote PreferencesAdd ResourceDiscipline Resources (4)Botany (4)Add Resourcec (4)Collection PreferencesAdd ResourceUser Accounts (3)testiiif (3)User PreferencesQueryExtraListQueryFreqListAdd ResourceUser Types (0)FullAccess (0)Guest (0)LimitedAccess (0)Manager (0)Expand AllCollapse All' ); - expect(asFragmentAllExpanded()).toMatchSnapshot(); + expect(expandedContainer.querySelectorAll('svg').length).toBeGreaterThan(0); unmountExpandedll(); }); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx index 4d8d7c42ad6..db2942f87e0 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx @@ -85,6 +85,7 @@ describe('AppResourcesFilters', () => { 'otherJsonResource', 'otherPropertiesResource', 'otherXmlResource', + 'remotePreferences', 'report', 'rssExportFeed', 'typeSearches', diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesTab.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesTab.test.tsx index 79f1b0655f2..1fbb74eabcf 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesTab.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesTab.test.tsx @@ -1,3 +1,4 @@ +import { within } from '@testing-library/react'; import React from 'react'; import { clearIdStore } from '../../../hooks/useId'; @@ -24,7 +25,7 @@ function Component(props: AppResourceTabProps) { describe('AppResourcesTab', () => { test('simple render', () => { - const { asFragment } = mount( + const { getByRole } = mount( { /> ); - expect(asFragment()).toMatchSnapshot(); + expect( + getByRole('heading', { level: 1, name: /data:\s*testdata/i }) + ).toBeInTheDocument(); }); test('dialog render', () => { @@ -63,6 +66,11 @@ describe('AppResourcesTab', () => { ); const dialog = getByRole('dialog'); - expect(dialog).toMatchSnapshot(); + expect( + within(dialog).getByRole('heading', { + level: 1, + name: /data:\s*testdata/i, + }) + ).toBeInTheDocument(); }); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap deleted file mode 100644 index ffdda4c4b70..00000000000 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap +++ /dev/null @@ -1,767 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AppResourcesAside (expanded case) expanded case 1`] = ` - - - -`; - -exports[`AppResourcesAside (expanded case) expanded case 2`] = ` - - - -`; - -exports[`AppResourcesAside (simple no conformation case) simple no conformation case 1`] = ` - - - -`; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap deleted file mode 100644 index 78e4d99dbe9..00000000000 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AppResourcesTab dialog render 1`] = ` - -`; - -exports[`AppResourcesTab simple render 1`] = ` - -

- Data: TestData -

-
-`; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap deleted file mode 100644 index 22f837bf573..00000000000 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap +++ /dev/null @@ -1,591 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`useResourcesTree all appresource dir 1`] = ` -[ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": null, - "createdByAgent": "/api/specify/agent/3/", - "discipline": null, - "disciplineType": null, - "id": 3, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": "/api/specify/spappresourcedir/3/", - "scope": "global", - "spPersistedAppResources": "/api/specify/spappresource/?spappresourcedir=3", - "spPersistedViewSets": "/api/specify/spviewsetobj/?spappresourcedir=3", - "specifyUser": null, - "timestampCreated": "2012-08-10T16:12:08", - "timestampModified": "2012-08-10T16:12:08", - "userType": "Global Prefs", - "version": 683, - }, - "key": "globalResource", - "label": "Global Resources", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": undefined, - "key": "disciplineResources", - "label": "Discipline Resources", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/4/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "discipline_3", - "label": "Ichthyology", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "collection_65536", - "label": "KUFishTeaching", - "subCategories": [ - { - "appResources": [], - "directory": undefined, - "key": "users", - "label": "User Accounts", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": true, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": "/api/specify/specifyuser/5/", - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "collection_65536_user_5", - "label": "cmeyer", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [ - { - "_tableName": "SpAppResource", - "allPermissionLevel": null, - "createdByAgent": "/api/specify/agent/3/", - "description": "QueryExtraList", - "group": null, - "groupPermissionLevel": null, - "id": 3, - "level": 0, - "metaData": null, - "mimeType": "text/xml", - "modifiedByAgent": "/api/specify/agent/3/", - "name": "QueryExtraList", - "ownerPermissionLevel": null, - "resource_uri": "/api/specify/spappresource/3/", - "spAppResourceDatas": "/api/specify/spappresourcedata/?spappresource=3", - "spAppResourceDir": "/api/specify/spappresourcedir/4/", - "spReports": "/api/specify/spreport/?appresource=3", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2012-08-10T16:12:05", - "version": 2, - }, - { - "_tableName": "SpAppResource", - "allPermissionLevel": null, - "createdByAgent": "/api/specify/agent/3/", - "description": null, - "group": null, - "groupPermissionLevel": null, - "id": 4, - "level": 3, - "metaData": null, - "mimeType": null, - "modifiedByAgent": null, - "name": "preferences", - "ownerPermissionLevel": null, - "resource_uri": "/api/specify/spappresource/4/", - "spAppResourceDatas": "/api/specify/spappresourcedata/?spappresource=4", - "spAppResourceDir": "/api/specify/spappresourcedir/4/", - "spReports": "/api/specify/spreport/?appresource=4", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2012-08-10T16:12:08", - "version": 683, - }, - { - "_tableName": "SpAppResource", - "allPermissionLevel": null, - "createdByAgent": "/api/specify/agent/3/", - "description": null, - "group": null, - "groupPermissionLevel": null, - "id": 73, - "label": "Default User Preferences", - "level": 0, - "metaData": null, - "mimeType": "application/json", - "modifiedByAgent": null, - "name": "DefaultUserPreferences", - "ownerPermissionLevel": null, - "resource_uri": "/api/specify/spappresource/73/", - "spAppResourceDatas": "/api/specify/spappresourcedata/?spappresource=73", - "spAppResourceDir": "/api/specify/spappresourcedir/4/", - "spReports": "/api/specify/spreport/?appresource=73", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2025-07-04T00:00:00", - "version": 1, - }, - ], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": "/api/specify/agent/3/", - "discipline": "/api/specify/discipline/3/", - "disciplineType": "Ichthyology", - "id": 4, - "isPersonal": true, - "modifiedByAgent": null, - "resource_uri": "/api/specify/spappresourcedir/4/", - "scope": "user", - "spPersistedAppResources": "/api/specify/spappresource/?spappresourcedir=4", - "spPersistedViewSets": "/api/specify/spviewsetobj/?spappresourcedir=4", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2012-10-01T10:29:36", - "timestampModified": "2012-10-01T10:29:36", - "userType": "manager", - "version": 3, - }, - "key": "collection_65536_user_4", - "label": "Vertnet", - "subCategories": [], - "viewSets": [ - { - "_tableName": "SpViewSetObj", - "createdByAgent": "/api/specify/agent/2151/", - "description": null, - "fileName": null, - "id": 5, - "level": 2, - "metaData": null, - "modifiedByAgent": null, - "name": "fish.views", - "resource_uri": "/api/specify/spviewsetobj/5/", - "spAppResourceDatas": "/api/specify/spappresourcedata/?spviewsetobj=5", - "spAppResourceDir": "/api/specify/spappresourcedir/4/", - "timestampCreated": "2014-01-28T08:44:29", - "timestampModified": "2014-01-28T08:44:29", - "version": 6, - }, - ], - }, - ], - "viewSets": [], - }, - { - "appResources": [], - "directory": undefined, - "key": "userTypes", - "label": "User Types", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "fullaccess", - "version": 1, - }, - "key": "collection_65536_userType_FullAccess", - "label": "FullAccess", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "guest", - "version": 1, - }, - "key": "collection_65536_userType_Guest", - "label": "Guest", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "limitedaccess", - "version": 1, - }, - "key": "collection_65536_userType_LimitedAccess", - "label": "LimitedAccess", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "manager", - "version": 1, - }, - "key": "collection_65536_userType_Manager", - "label": "Manager", - "subCategories": [], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, -] -`; - -exports[`useResourcesTree missing appresource dir 1`] = ` -[ - { - "appResources": [ - { - "_tableName": "SpAppResource", - "allPermissionLevel": null, - "createdByAgent": "/api/specify/agent/3/", - "description": null, - "group": null, - "groupPermissionLevel": null, - "id": 4, - "label": "Global Preferences", - "level": 3, - "metaData": null, - "mimeType": null, - "modifiedByAgent": null, - "name": "preferences", - "ownerPermissionLevel": null, - "resource_uri": "/api/specify/spappresource/4/", - "spAppResourceDatas": "/api/specify/spappresourcedata/?spappresource=4", - "spAppResourceDir": "/api/specify/spappresourcedir/3/", - "spReports": "/api/specify/spreport/?appresource=4", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2012-08-10T16:12:08", - "version": 683, - }, - ], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": null, - "createdByAgent": "/api/specify/agent/3/", - "discipline": null, - "disciplineType": null, - "id": 3, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": "/api/specify/spappresourcedir/3/", - "scope": "global", - "spPersistedAppResources": "/api/specify/spappresource/?spappresourcedir=3", - "spPersistedViewSets": "/api/specify/spviewsetobj/?spappresourcedir=3", - "specifyUser": null, - "timestampCreated": "2012-08-10T16:12:08", - "timestampModified": "2012-08-10T16:12:08", - "userType": "Global Prefs", - "version": 683, - }, - "key": "globalResource", - "label": "Global Resources", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": undefined, - "key": "disciplineResources", - "label": "Discipline Resources", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/4/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "discipline_3", - "label": "Ichthyology", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "collection_65536", - "label": "KUFishTeaching", - "subCategories": [ - { - "appResources": [], - "directory": undefined, - "key": "users", - "label": "User Accounts", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": true, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": "/api/specify/specifyuser/5/", - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": null, - "version": 1, - }, - "key": "collection_65536_user_5", - "label": "cmeyer", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": "/api/specify/agent/3/", - "discipline": "/api/specify/discipline/3/", - "disciplineType": "Ichthyology", - "id": 4, - "isPersonal": true, - "modifiedByAgent": null, - "resource_uri": "/api/specify/spappresourcedir/4/", - "scope": "user", - "spPersistedAppResources": "/api/specify/spappresource/?spappresourcedir=4", - "spPersistedViewSets": "/api/specify/spviewsetobj/?spappresourcedir=4", - "specifyUser": "/api/specify/specifyuser/4/", - "timestampCreated": "2012-10-01T10:29:36", - "timestampModified": "2012-10-01T10:29:36", - "userType": "manager", - "version": 3, - }, - "key": "collection_65536_user_4", - "label": "Vertnet", - "subCategories": [], - "viewSets": [], - }, - ], - "viewSets": [], - }, - { - "appResources": [], - "directory": undefined, - "key": "userTypes", - "label": "User Types", - "subCategories": [ - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "fullaccess", - "version": 1, - }, - "key": "collection_65536_userType_FullAccess", - "label": "FullAccess", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "guest", - "version": 1, - }, - "key": "collection_65536_userType_Guest", - "label": "Guest", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "limitedaccess", - "version": 1, - }, - "key": "collection_65536_userType_LimitedAccess", - "label": "LimitedAccess", - "subCategories": [], - "viewSets": [], - }, - { - "appResources": [], - "directory": { - "_tableName": "SpAppResourceDir", - "collection": "/api/specify/collection/65536/", - "createdByAgent": null, - "discipline": "/api/specify/discipline/3/", - "disciplineType": null, - "isPersonal": false, - "modifiedByAgent": null, - "resource_uri": undefined, - "specifyUser": null, - "timestampCreated": "2022-08-31", - "timestampModified": null, - "userType": "manager", - "version": 1, - }, - "key": "collection_65536_userType_Manager", - "label": "Manager", - "subCategories": [], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, - ], - "viewSets": [], - }, -] -`; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts index 293b78f890a..15ff2fbe0fd 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts @@ -15,6 +15,7 @@ test('allAppResources', () => { "otherJsonResource", "otherPropertiesResource", "otherXmlResource", + "remotePreferences", "report", "rssExportFeed", "typeSearches", diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts index 4be98addc17..ad364490c2f 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts @@ -16,6 +16,7 @@ test('defaultAppResourceFilters', () => { "otherJsonResource", "otherPropertiesResource", "otherXmlResource", + "remotePreferences", "report", "rssExportFeed", "typeSearches", diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/disambiguateGlobalPrefs.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/disambiguateGlobalPrefs.test.ts index d3c3109f328..fdb1fd58b63 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/disambiguateGlobalPrefs.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/disambiguateGlobalPrefs.test.ts @@ -1,6 +1,7 @@ import { resourcesText } from '../../../localization/resources'; import { replaceItem } from '../../../utils/utils'; import { getResourceApiUrl } from '../../DataModel/resource'; +import { userInformation } from '../../InitialContext/userInformation'; import type { AppResourcesTree } from '../hooks'; import { exportsForTests } from '../tree'; import { staticAppResources } from './staticAppResources'; @@ -8,6 +9,14 @@ import { staticAppResources } from './staticAppResources'; const { disambiguateGlobalPrefs } = exportsForTests; describe('disambiguateGlobalPrefs', () => { + beforeAll(() => { + Object.defineProperty(userInformation, 'isadmin', { + value: true, + writable: true, + configurable: true, + }); + }); + test('no preference app resources', () => { const appResources = [0, 2].map( (index) => staticAppResources.appResources[index] diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResourceTypes.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResourceTypes.ts index a246bafdc80..d7de0431efe 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResourceTypes.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResourceTypes.ts @@ -2,7 +2,7 @@ export const testAppResourcesTypes = [ { name: 'preferences', mimeType: null, - expectedType: 'otherPropertiesResource', + expectedType: 'remotePreferences', extenstion: 'properties', }, { diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useEditorTabs.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useEditorTabs.test.ts index f445844fe8d..f27e995caf4 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useEditorTabs.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useEditorTabs.test.ts @@ -10,7 +10,7 @@ requireContext(); describe('useEditorTabs', () => { test('xml editor', () => { const { result } = renderHook(() => - useEditorTabs(staticAppResources.viewSets[0]) + useEditorTabs(staticAppResources.viewSets[0], undefined) ); expect(result.current.map(({ label }) => label)).toEqual([ 'Visual Editor', @@ -18,10 +18,32 @@ describe('useEditorTabs', () => { ]); }); - test('text editor', () => { + test('global preferences editor', () => { const { result } = renderHook(() => - useEditorTabs(staticAppResources.appResources[1]) + useEditorTabs( + staticAppResources.appResources[1], + staticAppResources.directories[0] + ) + ); + expect(result.current.map(({ label }) => label)).toEqual([ + 'Visual Editor', + 'JSON Editor', + ]); + }); + + test('remote preferences editor falls back to text', () => { + const { result } = renderHook(() => + useEditorTabs( + addMissingFields('SpAppResource', { + name: 'preferences', + mimeType: 'text/x-java-properties', + }), + addMissingFields('SpAppResourceDir', { + userType: 'Prefs', + }) + ) ); + expect(result.current.map(({ label }) => label)).toEqual(['Text Editor']); }); @@ -31,7 +53,8 @@ describe('useEditorTabs', () => { addMissingFields('SpAppResource', { name: 'UserPreferences', mimeType: 'application/json', - }) + }), + undefined ) ); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useResourcesTree.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useResourcesTree.test.ts index 3f72cf336df..2cf3330c9b8 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useResourcesTree.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useResourcesTree.test.ts @@ -1,6 +1,8 @@ import { renderHook } from '@testing-library/react'; +import type { LocalizedString } from 'typesafe-i18n'; import { requireContext } from '../../../tests/helpers'; +import { userInformation } from '../../InitialContext/userInformation'; import { getAppResourceCount } from '../helpers'; import type { AppResourcesTree } from '../hooks'; import { useResourcesTree } from '../hooks'; @@ -11,6 +13,20 @@ requireContext(); const { setAppResourceDir, testDisciplines } = utilsForTests; +const flattenResources = ( + tree: AppResourcesTree +): readonly { + readonly name: string | undefined; + readonly label: LocalizedString | undefined; +}[] => + tree.flatMap(({ appResources, subCategories }) => [ + ...appResources.map((resource) => ({ + name: resource.name, + label: resource.label, + })), + ...flattenResources(subCategories), + ]); + describe('useResourcesTree', () => { const getResourceCountTree = (result: AppResourcesTree) => result.reduce( @@ -36,7 +52,12 @@ describe('useResourcesTree', () => { test('missing appresource dir', () => { const { result } = renderHook(() => useResourcesTree(resources)); - expect(result.current).toMatchSnapshot(); + const flattened = flattenResources(result.current); + expect(flattened).toHaveLength(1); + expect(flattened[0]).toMatchObject({ + name: 'preferences', + label: 'Global Preferences', + }); // There is only 1 resource with the matching spappresourcedir. expect(getResourceCountTree(result.current)).toBe(1); @@ -53,8 +74,32 @@ describe('useResourcesTree', () => { const { result } = renderHook(() => useResourcesTree(viewSet)); - expect(result.current).toMatchSnapshot(); + const flattened = flattenResources(result.current); + const labels = flattened.map(({ label, name }) => label ?? name); + expect(labels).toContain('Global Preferences'); expect(getResourceCountTree(result.current)).toBe(4); }); + + test('hides global preferences for non-admin users', () => { + const originalIsAdmin = userInformation.isadmin; + Object.defineProperty(userInformation, 'isadmin', { + value: false, + configurable: true, + writable: true, + }); + + const { result } = renderHook(() => useResourcesTree(resources)); + + const flattened = flattenResources(result.current); + expect(flattened.map(({ label, name }) => label ?? name)).not.toContain( + 'Global Preferences' + ); + + Object.defineProperty(userInformation, 'isadmin', { + value: originalIsAdmin, + configurable: true, + writable: true, + }); + }); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts index e252f484d36..0472434db54 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts @@ -65,11 +65,24 @@ export const getResourceType = ( export const getAppResourceType = ( resource: SerializedResource -): keyof typeof appResourceSubTypes => - resource.name === 'preferences' && (resource.mimeType ?? '') === '' - ? 'otherPropertiesResource' - : (Object.entries(appResourceSubTypes).find(([_key, { name, mimeType }]) => - name === undefined - ? mimeType === resource.mimeType - : name === resource.name - )?.[KEY] ?? 'otherAppResources'); +): keyof typeof appResourceSubTypes => { + const normalize = (value: string | null | undefined): string | undefined => + typeof value === 'string' ? value.toLowerCase() : undefined; + + const matchedType = Object.entries(appResourceSubTypes).find( + ([_key, { name, mimeType }]) => + name === undefined + ? normalize(mimeType) === normalize(resource.mimeType) + : normalize(name) === normalize(resource.name) + )?.[KEY]; + + if (matchedType !== undefined) return matchedType; + + if ( + normalize(resource.name) === 'preferences' && + normalize(resource.mimeType) === undefined + ) + return 'otherPropertiesResource'; + + return 'otherAppResources'; +}; diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts index a32e51945d6..2da468bf85d 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts @@ -13,6 +13,7 @@ import type { SpAppResourceDir, SpViewSetObj, } from '../DataModel/types'; +import { userInformation } from '../InitialContext/userInformation'; import { userTypes } from '../PickLists/definitions'; import type { AppResources, AppResourcesTree } from './hooks'; import type { AppResourceScope, ScopedAppResourceDir } from './types'; @@ -104,25 +105,47 @@ const prefResource = 'preferences'; const globalUserType = 'Global Prefs'.toLowerCase(); const remoteUserType = 'Prefs'.toLowerCase(); +const hiddenGlobalResourceNames = new Set(['GlobalPreferences']); + +const filterHiddenAppResources = ( + appResources: RA> +): RA> => + appResources.filter((resource) => !hiddenGlobalResourceNames.has(resource.name)); + const disambiguateGlobalPrefs = ( appResources: RA>, directories: RA> ): AppResourcesTree[number]['appResources'] => - appResources.map((resource) => { - if (resource.name !== prefResource) return resource; - const directory = directories.find( - ({ id }) => - getResourceApiUrl('SpAppResourceDir', id) === resource.spAppResourceDir - ); - // Pretty sure this is redundant... that is, directory should always be defined. - if (!directory) return resource; - const userType = directory.userType?.toLowerCase(); - if (userType === globalUserType) - return { ...resource, label: resourcesText.globalPreferences() }; - else if (userType === remoteUserType) - return { ...resource, label: resourcesText.remotePreferences() }; - else return resource; - }); + appResources + .filter((resource) => { + if (resource.name !== prefResource) return true; + const directory = directories.find( + ({ id }) => + getResourceApiUrl('SpAppResourceDir', id) === + resource.spAppResourceDir + ); + const userType = directory?.userType?.toLowerCase(); + const isGlobalPrefs = userType === globalUserType; + const isNonAdmin = !userInformation.isadmin; + + return !(isGlobalPrefs && isNonAdmin); + }) + .map((resource) => { + if (resource.name !== prefResource) return resource; + const directory = directories.find( + ({ id }) => + getResourceApiUrl('SpAppResourceDir', id) === + resource.spAppResourceDir + ); + // Pretty sure this is redundant... that is, directory should always be defined. + if (!directory) return resource; + const userType = directory.userType?.toLowerCase(); + if (userType === globalUserType) + return { ...resource, label: resourcesText.globalPreferences() }; + else if (userType === remoteUserType) + return { ...resource, label: resourcesText.remotePreferences() }; + else return resource; + }); /** * Merge resources from several directories into a single one. @@ -156,8 +179,10 @@ const getDirectoryChildren = ( directory: SerializedResource, resources: AppResources ): DirectoryChildren => ({ - appResources: resources.appResources.filter( - ({ spAppResourceDir }) => spAppResourceDir === directory.resource_uri + appResources: filterHiddenAppResources( + resources.appResources.filter( + ({ spAppResourceDir }) => spAppResourceDir === directory.resource_uri + ) ), viewSets: resources.viewSets.filter( ({ spAppResourceDir }) => spAppResourceDir === directory.resource_uri diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 8b1f46c0b83..2eaa19c6e39 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -111,6 +111,15 @@ export const appResourceSubTypes = ensure>()({ label: preferencesText.collectionPreferences(), scope: ['collection'], }, + remotePreferences: { + mimeType: 'text/x-java-properties', + name: 'preferences', + documentationUrl: 'https://discourse.specifysoftware.org/t/specify-7-global-preferences/3100', + icon: icons.cog, + label: resourcesText.globalPreferences(), + scope: ['global'], + useTemplate: false, + }, leafletLayers: { mimeType: 'application/json', name: 'leaflet-layers', diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx index e89cedf46fa..dcc1ec7831f 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Cell.tsx @@ -31,6 +31,7 @@ export function AttachmentCell({ onOpen: handleOpen, related: [related, setRelated], onViewRecord: handleViewRecord, + thumbnailSize, }: { readonly attachment: SerializedResource; readonly onOpen: () => void; @@ -38,6 +39,7 @@ export function AttachmentCell({ readonly onViewRecord: | ((table: SpecifyTable, recordId: number) => void) | undefined; + readonly thumbnailSize: number; }): JSX.Element { const table = f.maybe(attachment.tableID ?? undefined, getAttachmentTable); @@ -62,6 +64,7 @@ export function AttachmentCell({ ) : undefined} { if (related === undefined && typeof table === 'object') fetchAttachmentParent(table, attachment) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx index 525ff6fd9c3..1e14de14e7b 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Gallery.tsx @@ -12,13 +12,19 @@ import type { Attachment } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { ErrorBoundary } from '../Errors/ErrorBoundary'; import { ResourceView } from '../Forms/ResourceView'; -import { getPref } from '../InitialContext/remotePrefs'; import { AttachmentGallerySkeleton } from '../SkeletonLoaders/AttachmentGallery'; import { AttachmentCell } from './Cell'; import { AttachmentDialog } from './Dialog'; const defaultPreFetchDistance = 200; const attachmentSkeletonRows = 2; +const fallbackRootFontSize = 16; +const getRootFontSize = (): number => + typeof window === 'undefined' + ? fallbackRootFontSize + : Number.parseFloat( + window.getComputedStyle(document.documentElement).fontSize + ) || fallbackRootFontSize; export function AttachmentGallery({ attachments, @@ -44,22 +50,29 @@ export function AttachmentGallery({ defaultPreFetchDistance ); const [columns, setColumns] = React.useState(3); - const attachmentHeight = getPref('attachment.preview_size'); + const [rootFontSize, setRootFontSize] = + React.useState(getRootFontSize); + const thumbnailSize = Math.max(1, Math.round(scale * rootFontSize)); React.useEffect(() => { const calculateColumns = (ref: React.RefObject) => { if (ref.current) { - const rootFontSize = Number.parseFloat( - window.getComputedStyle(document.documentElement).fontSize - ); // Equivalent to 1rem - const gap = rootFontSize; - const columnWidth = scale * rootFontSize + gap; + const currentRootFontSize = getRootFontSize(); // Equivalent to 1rem + const gap = currentRootFontSize; + const columnWidth = Math.max(1, scale * currentRootFontSize + gap); + const currentThumbnailSize = Math.max( + 1, + Math.round(scale * currentRootFontSize) + ); + setRootFontSize(currentRootFontSize); setPreFetchDistance( Math.max( defaultPreFetchDistance, - (attachmentHeight + gap) * attachmentSkeletonRows + gap + (currentThumbnailSize + gap) * attachmentSkeletonRows + gap ) ); - setColumns(Math.floor((ref.current.clientWidth - gap) / columnWidth)); + setColumns( + Math.max(1, Math.floor((ref.current.clientWidth - gap) / columnWidth)) + ); } }; calculateColumns(containerRef); @@ -74,7 +87,7 @@ export function AttachmentGallery({ containerRef.current.scrollHeight - containerRef.current.clientHeight ? handleFetchMore?.().catch(raise) : undefined, - [handleFetchMore] + [handleFetchMore, preFetchDistance] ); const fillPage = handleFetchMore === undefined ? undefined : rawFillPage; @@ -123,6 +136,7 @@ export function AttachmentGallery({ related[index], (item): void => setRelated(replaceItem(related, index, item)), ]} + thumbnailSize={thumbnailSize} onOpen={(): void => typeof handleClick === 'function' ? handleClick(attachment) diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx index 3a144df934a..0e533922c6a 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Preview.tsx @@ -9,12 +9,17 @@ import { fetchThumbnail } from './attachments'; export function AttachmentPreview({ attachment, onOpen: handleOpen, + thumbnailSize, }: { readonly attachment: SerializedResource; readonly onOpen: () => void; + readonly thumbnailSize: number; }): JSX.Element { const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(attachment), [attachment]), + React.useCallback( + async () => fetchThumbnail(attachment, thumbnailSize), + [attachment, thumbnailSize] + ), false ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx index c41f2c2da3e..200760ff550 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx @@ -18,9 +18,21 @@ import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { defaultAttachmentScale } from '.'; import { downloadAllAttachments } from './attachments'; import { AttachmentGallery } from './Gallery'; -import { getAttachmentRelationship } from './utils'; +import { + getAttachmentRelationship, + useAttachmentThumbnailPreference, +} from './utils'; const haltIncrementSize = 300; +const fallbackRootFontSize = 16; +const getRootFontSize = (): number => + typeof window === 'undefined' + ? fallbackRootFontSize + : Number.parseFloat( + window.getComputedStyle(document.documentElement).fontSize + ) || fallbackRootFontSize; +const minScale = 4; +const maxScale = 50; export function RecordSetAttachments({ records, @@ -102,10 +114,15 @@ export function RecordSetAttachments({ const halt = attachments?.attachments.length === 0 && records.length >= haltValue; - const [scale = defaultAttachmentScale] = useCachedState( - 'attachments', - 'scale' + const [scale] = useCachedState('attachments', 'scale'); + const attachmentThumbnailSize = useAttachmentThumbnailPreference(); + const preferredScale = React.useMemo( + () => Math.round(attachmentThumbnailSize / getRootFontSize()), + [attachmentThumbnailSize] ); + const safeScale = Number.isFinite(preferredScale) + ? Math.min(maxScale, Math.max(minScale, scale ?? preferredScale)) + : (scale ?? defaultAttachmentScale); const isComplete = fetchedCount.current === recordCount; @@ -192,7 +209,7 @@ export function RecordSetAttachments({ void attachments?.related[index].set(`attachment`, attachment) } diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx index 1f366ced5ea..31f07f1c045 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/Viewer.tsx @@ -32,6 +32,7 @@ import { userPreferences } from '../Preferences/userPreferences'; import { fetchOriginalUrl, fetchThumbnail } from './attachments'; import { AttachmentRecordLink, getAttachmentTable } from './Cell'; import { Thumbnail } from './Preview'; +import { useAttachmentThumbnailPreference } from './utils'; export function AttachmentViewer({ attachment, @@ -103,8 +104,12 @@ export function AttachmentViewer({ const mimeType = attachment.get('mimeType') ?? undefined; const type = mimeType?.split('/')[0]; + const attachmentThumbnailSize = useAttachmentThumbnailPreference(); const [thumbnail] = useAsyncState( - React.useCallback(async () => fetchThumbnail(serialized), [serialized]), + React.useCallback( + async () => fetchThumbnail(serialized, attachmentThumbnailSize), + [serialized, attachmentThumbnailSize] + ), false ); diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentCell.test.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentCell.test.tsx index 78846e8e8d4..229c31a85e5 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentCell.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/__tests__/AttachmentCell.test.tsx @@ -81,6 +81,7 @@ function AttachmentCellMock({ readonly onViewRecord: | ((table: SpecifyTable, recordId: number) => void) | undefined; + readonly thumbnailSize: number; }) { const [_, setState] = React.useState(false); const triggerChange = React.useCallback(() => { @@ -117,6 +118,7 @@ describe('AttachmentCell', () => { attachment={testAttachment} options={options} related={[undefined, setRelated]} + thumbnailSize={123} onOpen={handleOpen} onViewRecord={handleViewRecord} /> @@ -133,6 +135,7 @@ describe('AttachmentCell', () => { attachment={testAttachment} options={options} related={[undefined, setRelated]} + thumbnailSize={123} onOpen={handleOpen} onViewRecord={handleViewRecord} /> diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx index be312b3528f..28a44d876d1 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/Attachments/index.tsx @@ -28,7 +28,19 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { OrderPicker } from '../Preferences/Renderers'; import { attachmentSettingsPromise } from './attachments'; import { AttachmentGallery } from './Gallery'; -import { allTablesWithAttachments, tablesWithAttachments } from './utils'; +import { + allTablesWithAttachments, + tablesWithAttachments, + useAttachmentThumbnailPreference, +} from './utils'; + +const fallbackRootFontSize = 16; +const getRootFontSize = (): number => + typeof window === 'undefined' + ? fallbackRootFontSize + : Number.parseFloat( + window.getComputedStyle(document.documentElement).fontSize + ) || fallbackRootFontSize; export const defaultAttachmentScale = 10; const minScale = 4; @@ -80,6 +92,12 @@ function Attachments({ 'filter' ); + const attachmentThumbnailSize = useAttachmentThumbnailPreference(); + const preferredScale = React.useMemo( + () => Math.round(attachmentThumbnailSize / getRootFontSize()), + [attachmentThumbnailSize] + ); + const [collectionSizes] = useAsyncState( React.useCallback( async () => @@ -120,10 +138,10 @@ function Attachments({ false ); - const [scale = defaultAttachmentScale, setScale] = useCachedState( - 'attachments', - 'scale' - ); + const [scale, setScale] = useCachedState('attachments', 'scale'); + const safeScale = Number.isFinite(preferredScale) + ? Math.min(maxScale, Math.max(minScale, scale ?? preferredScale)) + : (scale ?? defaultAttachmentScale); const [collection, setCollection, fetchMore] = useSerializedCollection( React.useCallback( @@ -226,7 +244,7 @@ function Attachments({ max={maxScale} min={minScale} type="range" - value={scale} + value={safeScale} onValueChange={(value): void => setScale(Number.parseInt(value)) } @@ -247,7 +265,7 @@ function Attachments({ collection.totalCount === collection.records.length } key={`${order}_${JSON.stringify(filter)}`} - scale={scale} + scale={safeScale} onChange={(attachment, index): void => collection === undefined ? undefined diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/utils.ts b/specifyweb/frontend/js_src/lib/components/Attachments/utils.ts index 8226fd00801..771a008536d 100644 --- a/specifyweb/frontend/js_src/lib/components/Attachments/utils.ts +++ b/specifyweb/frontend/js_src/lib/components/Attachments/utils.ts @@ -1,9 +1,14 @@ +import React from 'react'; + import { f } from '../../utils/functools'; import { filterArray } from '../../utils/types'; import type { Relationship } from '../DataModel/specifyField'; import type { SpecifyTable } from '../DataModel/specifyTable'; import { genericTables, getTable } from '../DataModel/tables'; +import { softFail } from '../Errors/Crash'; import { hasTablePermission } from '../Permissions/helpers'; +import { globalPreferences } from '../Preferences/globalPreferences'; +import { ensureGlobalPreferencesLoaded } from '../Preferences/globalPreferencesLoader'; export const attachmentRelatedTables = f.store(() => Object.keys(genericTables).filter((tableName) => @@ -54,3 +59,16 @@ export const getAttachmentRelationship = ( }) : commonRelationship; }; + +export const useAttachmentThumbnailPreference = (): number => { + React.useEffect(() => { + ensureGlobalPreferencesLoaded().catch(softFail); + }, []); + + const [size] = globalPreferences.use( + 'attachments', + 'attachments', + 'attachmentThumbnailSize' + ); + return size; +}; diff --git a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx index 1d1f69191a2..023306db8c6 100644 --- a/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormFields/Field.tsx @@ -266,4 +266,4 @@ export function useRightAlignClassName( globalThis.navigator.userAgent.toLowerCase().includes('webkit') ? `text-right ${isReadOnly ? '' : 'pr-6'}` : ''; -} +} \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap index d2930538113..bf18743fd73 100644 --- a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap +++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap @@ -39,7 +39,7 @@ exports[`PartialDateUi renders without errors 1`] = ` @@ -58,7 +58,7 @@ exports[`PartialDateUi renders without errors 2`] = ` > diff --git a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts index f38a72f6a8e..05ccc44395d 100644 --- a/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts +++ b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/dateUtils.test.ts @@ -26,7 +26,7 @@ describe('getDateParser', () => { "minLength": 10, "parser": [Function], "required": false, - "title": "Required Format: MM/DD/YYYY.", + "title": "Required Format: YYYY-MM-DD.", "type": "date", "validators": [ [Function], diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index d0c7ea8bf52..3eee6ee0159 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -58,6 +58,12 @@ const rawUserTools = ensure>>>()({ url: '/specify/user-preferences/', icon: icons.cog, }, + globalPreferences: { + title: resourcesText.globalPreferences(), + url: '/specify/global-preferences/', + icon: icons.globe, + enabled: () => userInformation.isadmin, + }, collectionPreferences: { title: preferencesText.collectionPreferences(), url: '/specify/collection-preferences/', diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap index 6b9a157028d..e72ee5f3da2 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/__snapshots__/remotePrefs.test.ts.snap @@ -70,10 +70,7 @@ exports[`fetches and parses remotePrefs correctly 1`] = ` "attachment.is_public_default_32768": "true", "attachment.key": "c3wNpDBTLMedXWSb8w2TeSwHWVFLvBwiYmtU0CdOzLQtelcibV9sTXW7NxZlX68", "attachment.path": "", - "attachment.preview_size": "123.3", "attachment.url": "http\\\\://biwebdb.nhm.ku.edu/web_asset_store.xml", - "auditing.audit_field_updates": "true", - "auditing.do_audits": "false", "bnrIconSizeCBX": "20 x 20 pixels", "checkforupdates": "", "clearcache": "", @@ -116,7 +113,6 @@ exports[`fetches and parses remotePrefs correctly 1`] = ` "ui.formatting.disciplineicon.KUFishvoucher": "colobj_backstop", "ui.formatting.formtype": "Small Font Format (ideal for Windows)", "ui.formatting.requiredfieldcolor": "215, 230, 253", - "ui.formatting.scrdateformat": "MM/dd/yyyy", "ui.formatting.valtextcolor": "255, 0, 0", "ui.formatting.viewfieldcolor": "250, 250, 250", "usage_tracking.send_isa_stats": "true", diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/remotePrefs.test.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/remotePrefs.test.ts index 19c97490e65..eacaf5d5da0 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/remotePrefs.test.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/__tests__/remotePrefs.test.ts @@ -12,13 +12,18 @@ test('fetches and parses remotePrefs correctly', async () => expect(fetchContext).resolves.toMatchSnapshot()); describe('Parsing Remote Prefs', () => { + const definitions = remotePrefsDefinitions(); test('parses boolean value', () => - expect(getPref('auditing.do_audits')).toBe(false)); + expect(getPref('auditing.do_audits')).toBe( + definitions['auditing.do_audits'].defaultValue + )); test('parses numeric value', () => - expect(getPref('attachment.preview_size')).toBe(123)); + expect(getPref('attachment.preview_size')).toBe( + definitions['attachment.preview_size'].defaultValue + )); test('uses default value if pref is not set', () => expect(getPref('ui.formatting.scrmonthformat')).toBe( - remotePrefsDefinitions()['ui.formatting.scrmonthformat'].defaultValue + definitions['ui.formatting.scrmonthformat'].defaultValue )); }); diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts index e09ca3d3a45..d069c3281b2 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts @@ -12,6 +12,11 @@ import type { IR, R, RA } from '../../utils/types'; import { defined } from '../../utils/types'; import type { JavaType } from '../DataModel/specifyField'; import { cacheableUrl, contextUnlockedPromise } from './index'; +import { + mergeWithDefaultValues, + partialPreferencesFromMap, + setGlobalPreferenceFallback, +} from '../Preferences/globalPreferencesUtils'; const preferences: R = {}; @@ -35,7 +40,13 @@ export const fetchContext = contextUnlockedPromise.then(async (entrypoint) => preferences[key.trim()] = value.trim(); }) ) - .then(() => preferences) + .then(() => { + const fallback = mergeWithDefaultValues( + partialPreferencesFromMap(preferences) + ); + setGlobalPreferenceFallback(fallback); + return preferences; + }) : undefined ); @@ -123,7 +134,7 @@ export const remotePrefsDefinitions = f.store( }, 'ui.formatting.scrmonthformat': { description: 'Month Date format', - defaultValue: 'MM/YYYY', + defaultValue: 'YYYY-MM', formatters: [formatter.trim, formatter.toUpperCase], }, 'attachment.is_public_default': { @@ -132,6 +143,25 @@ export const remotePrefsDefinitions = f.store( parser: 'java.lang.Boolean', isLegacy: true, }, + 'attachment.preview_size': { + description: 'The size in px of the generated attachment thumbnails', + defaultValue: 256, + parser: 'java.lang.Long', + isLegacy: true, + }, + // These are used on the back end only: + 'auditing.do_audits': { + description: 'Whether Audit Log is enabled', + defaultValue: true, + parser: 'java.lang.Boolean', + isLegacy: true, + }, + 'auditing.audit_field_updates': { + description: 'Whether Audit Log records field value changes', + defaultValue: true, + parser: 'java.lang.Boolean', + isLegacy: true, + }, 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod': { description: 'Allowed to add children to synopsized Geologic Time Period records', @@ -184,25 +214,6 @@ export const remotePrefsDefinitions = f.store( parser: 'java.lang.Boolean', isLegacy: false, }, - 'attachment.preview_size': { - description: 'The size in px of the generated attachment thumbnails', - defaultValue: 123, - parser: 'java.lang.Long', - isLegacy: true, - }, - // These are used on the back end only: - 'auditing.do_audits': { - description: 'Whether Audit Log is enabled', - defaultValue: true, - parser: 'java.lang.Boolean', - isLegacy: true, - }, - 'auditing.audit_field_updates': { - description: 'Whether Audit Log records field value changes', - defaultValue: true, - parser: 'java.lang.Boolean', - isLegacy: true, - }, // This is actually stored in Global Prefs: /* * 'AUDIT_LIFESPAN_MONTHS': { diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx index 4a5463691d3..43bb89fdcce 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx @@ -21,6 +21,12 @@ export function PreferencesAside({ readonly references: React.RefObject>; readonly prefType?: PreferenceType; }): JSX.Element { + const preferenceRoutes: Record = { + user: '/specify/user-preferences/', + collection: '/specify/collection-preferences/', + global: '/specify/global-preferences/', + }; + const preferencesPath = preferenceRoutes[prefType]; const definitions = usePrefDefinitions(prefType); const navigate = useNavigate(); const location = useLocation(); @@ -30,13 +36,10 @@ export function PreferencesAside({ () => isInOverlay || activeCategory === undefined ? undefined - : navigate( - `/specify/user-preferences/#${definitions[activeCategory][0]}`, - { - replace: true, - } - ), - [isInOverlay, definitions, activeCategory] + : navigate(`${preferencesPath}#${definitions[activeCategory][0]}`, { + replace: true, + }), + [isInOverlay, definitions, activeCategory, preferencesPath] ); const [freezeCategory, setFreezeCategory] = useFrozenCategory(); @@ -44,10 +47,7 @@ export function PreferencesAside({ const visibleDefinitions = React.useMemo( () => definitions - .map( - (definition, index) => - [index, definition] as const - ) + .map((definition, index) => [index, definition] as const) .filter( ([, [category]]) => !( @@ -79,9 +79,7 @@ export function PreferencesAside({ > {visibleDefinitions.map(([definitionIndex, [category, { title }]]) => ( setFreezeCategory(definitionIndex)} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx index f8621cfce84..87317aa55a5 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/BasePreferences.tsx @@ -94,15 +94,16 @@ export class BasePreferences { if (typeof this.resourcePromise === 'object') return this.resourcePromise; const { values, defaultValues } = this.options; + const isAppResourceEndpoint = values.fetchUrl.includes('app.resource'); - const valuesResource = fetchResourceId( - values.fetchUrl, - values.resourceName - ).then(async (appResourceId) => - typeof appResourceId === 'number' - ? fetchResourceData(values.fetchUrl, appResourceId) - : createDataResource(values.fetchUrl, values.resourceName) - ); + const valuesResource = isAppResourceEndpoint + ? fetchGlobalResource(values.fetchUrl, values.resourceName) + : fetchResourceId(values.fetchUrl, values.resourceName).then( + async (appResourceId) => + typeof appResourceId === 'number' + ? fetchResourceData(values.fetchUrl, appResourceId) + : createDataResource(values.fetchUrl, values.resourceName) + ); const defaultValuesResource = defaultValues === undefined @@ -425,6 +426,8 @@ const mimeType = 'application/json'; /** * Fetch ID of app resource containing preferences */ +const appResourceMimeType = 'text/plain'; + export const fetchResourceId = async ( fetchUrl: string, resourceName: string @@ -449,6 +452,32 @@ const fetchResourceData = async ( headers: { Accept: mimeType }, }).then(({ data }) => data); +const fetchGlobalResource = async ( + fetchUrl: string, + resourceName: string +): Promise => { + const url = cacheableUrl( + formatUrl(fetchUrl, { + name: resourceName, + quiet: '', + }) + ); + const { data, status, response } = await ajax(url, { + headers: { Accept: appResourceMimeType }, + expectedErrors: [Http.NO_CONTENT], + }); + + const parsedId = f.parseInt(response.headers.get('X-Record-ID') ?? undefined); + + return { + id: parsedId ?? -1, + data: status === Http.OK ? data : '', + metadata: null, + mimetype: response.headers.get('Content-Type') ?? appResourceMimeType, + name: resourceName, + }; +}; + /** * Fetch default values overrides, if exist */ diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx index b7b53ff6ad1..44abd41d93f 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx @@ -9,8 +9,18 @@ import { BasePreferences } from '../Preferences/BasePreferences'; import { userPreferenceDefinitions } from '../Preferences/UserDefinitions'; import { userPreferences } from '../Preferences/userPreferences'; import { useTopChild } from '../Preferences/useTopChild'; +import type { PartialPreferences } from './BasePreferences'; import { collectionPreferenceDefinitions } from './CollectionDefinitions'; import { collectionPreferences } from './collectionPreferences'; +import { globalPreferenceDefinitions } from './GlobalDefinitions'; +import type { GlobalPreferenceValues } from './globalPreferences'; +import { globalPreferences } from './globalPreferences'; +import { subscribeToGlobalPreferencesUpdates } from './globalPreferencesSync'; +import type { PropertyLine } from './globalPreferencesUtils'; +import { + parseGlobalPreferences, + serializeGlobalPreferences, +} from './globalPreferencesUtils'; import type { GenericPreferences } from './types'; type EditorDependencies = Pick; @@ -25,12 +35,76 @@ type PreferencesEditorConfig = { readonly dependencyResolver?: ( inputs: EditorDependencies ) => React.DependencyList; + readonly parse?: (data: string | null) => { + readonly raw: PartialPreferences; + readonly metadata?: unknown; + }; + readonly serialize?: ( + raw: PartialPreferences, + metadata: unknown + ) => { + readonly data: string; + readonly metadata?: unknown; + }; }; const defaultDependencyResolver = ({ onChange }: EditorDependencies) => [ onChange, ]; +const parseJsonPreferences = ( + data: string | null +): { + readonly raw: PartialPreferences; + readonly metadata?: undefined; +} => ({ + raw: JSON.parse( + data === null || data.length === 0 ? '{}' : data + ) as PartialPreferences, +}); + +const serializeJsonPreferences = ( + raw: PartialPreferences, + _metadata?: unknown +): { + readonly data: string; + readonly metadata?: undefined; +} => ({ + data: JSON.stringify(raw), +}); + +const parseGlobalPreferenceData = ( + data: string | null +): { + readonly raw: PartialPreferences; + readonly metadata: readonly PropertyLine[]; +} => { + const { raw, metadata } = parseGlobalPreferences(data); + return { + raw: raw as unknown as PartialPreferences< + typeof globalPreferenceDefinitions + >, + metadata, + }; +}; + +const serializeGlobalPreferenceData = ( + raw: PartialPreferences, + metadata: unknown +): { + readonly data: string; + readonly metadata: readonly PropertyLine[]; +} => { + const result = serializeGlobalPreferences( + raw as unknown as GlobalPreferenceValues, + (metadata as readonly PropertyLine[] | undefined) ?? [] + ); + return { + data: result.data, + metadata: result.metadata, + }; +}; + function createPreferencesEditor( config: PreferencesEditorConfig ) { @@ -49,6 +123,24 @@ function createPreferencesEditor( onChange, }: AppResourceTabProps): JSX.Element { const dependencies = dependencyResolver({ data, onChange }); + const parse = + config.parse ?? + ((rawData: string | null) => parseJsonPreferences(rawData)); + const serialize = + config.serialize ?? + ((raw: PartialPreferences, metadata: unknown) => + serializeJsonPreferences(raw, metadata)); + + const { raw: initialRaw, metadata: initialMetadata } = React.useMemo( + () => parse(data ?? null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data] + ); + const metadataRef = React.useRef(initialMetadata); + const dataRef = React.useRef(data ?? ''); + React.useEffect(() => { + dataRef.current = data ?? ''; + }, [data]); const [preferencesInstance] = useLiveState>( React.useCallback(() => { @@ -64,17 +156,34 @@ function createPreferencesEditor( }); preferences.setRaw( - JSON.parse(data === null || data.length === 0 ? '{}' : data) + initialRaw as PartialPreferences as PartialPreferences ); - preferences.events.on('update', () => - onChange(JSON.stringify(preferences.getRaw())) - ); + preferences.events.on('update', () => { + const result = serialize(preferences.getRaw(), metadataRef.current); + if (result.metadata !== undefined) + metadataRef.current = result.metadata; + onChange(result.data); + }); return preferences; - }, dependencies) + }, [...dependencies, initialRaw, initialMetadata, serialize]) ); + React.useEffect(() => { + metadataRef.current = initialMetadata; + preferencesInstance.setRaw( + initialRaw as PartialPreferences as PartialPreferences + ); + }, [initialMetadata, initialRaw, preferencesInstance]); + + React.useEffect(() => { + if (config.prefType !== 'global') return undefined; + return subscribeToGlobalPreferencesUpdates((newData) => { + if (newData !== dataRef.current) onChange(newData); + }); + }, [config.prefType, onChange]); + const Provider = Context.Provider; const contentProps = prefType === undefined ? {} : { prefType }; const { @@ -123,4 +232,20 @@ export const CollectionPreferencesEditor = createPreferencesEditor({ developmentGlobal: 'editingCollectionPreferences', prefType: 'collection', dependencyResolver: ({ data, onChange }) => [data, onChange], + parse: (data) => + parseJsonPreferences(data), + serialize: (raw) => + serializeJsonPreferences(raw), +}); + +export const GlobalPreferencesEditor = createPreferencesEditor({ + definitions: globalPreferenceDefinitions, + Context: globalPreferences.Context, + resourceName: 'GlobalPreferences', + fetchUrl: '/context/app.resource/', + developmentGlobal: '_editingGlobalPreferences', + prefType: 'global', + dependencyResolver: ({ data, onChange }) => [data, onChange], + parse: parseGlobalPreferenceData, + serialize: serializeGlobalPreferenceData, }); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts new file mode 100644 index 00000000000..b0a3c0577ff --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts @@ -0,0 +1,103 @@ +import { attachmentsText } from '../../localization/attachments'; +import { preferencesText } from '../../localization/preferences'; +import { localized } from '../../utils/types'; +import { definePref } from './types'; + +export const FULL_DATE_FORMAT_OPTIONS = [ + 'yyyy-MM-dd', + 'yyyy MM dd', + 'yyyy.MM.dd', + 'yyyy/MM/dd', + 'MM dd yyyy', + 'MM-dd-yyyy', + 'MM.dd.yyyy', + 'MM/dd/yyyy', + 'dd MM yyyy', + 'dd MMM yyyy', + 'dd-MM-yyyy', + 'dd-MMM-yyyy', + 'dd.MM.yyyy', + 'dd.MMM.yyyy', + 'dd/MM/yyyy', +] as const; + +export const MONTH_YEAR_FORMAT_OPTIONS = ['YYYY-MM', 'MM/YYYY', 'YYYY/MM'] as const; + +export const globalPreferenceDefinitions = { +formatting: { + title: preferencesText.formatting(), + subCategories: { + formatting: { + title: preferencesText.general(), + items: { + fullDateFormat: definePref({ + title: preferencesText.fullDateFormat(), + description: preferencesText.fullDateFormatDescription(), + requiresReload: false, + visible: true, + defaultValue: 'yyyy-MM-dd', + values: FULL_DATE_FORMAT_OPTIONS.map((value) => ({ + value, + title: localized(value), + })), + }), + monthYearDateFormat: definePref({ + title: preferencesText.monthYearDateFormat(), + description: preferencesText.monthYearDateFormatDescription(), + requiresReload: false, + visible: true, + defaultValue: 'YYYY-MM', + values: MONTH_YEAR_FORMAT_OPTIONS.map((value) => ({ + value, + title: localized(value), + })), + }), + }, + }, + }, + }, + auditing: { + title: preferencesText.auditing(), + subCategories: { + auditing: { + title: preferencesText.general(), + items: { + enableAuditLog: definePref({ + title: preferencesText.enableAuditLog(), + description: preferencesText.enableAuditLogDescription(), + requiresReload: false, + visible: true, + defaultValue: true, + type: 'java.lang.Boolean', + }), + logFieldLevelChanges: definePref({ + title: preferencesText.logFieldLevelChanges(), + description: preferencesText.logFieldLevelChangesDescription(), + requiresReload: false, + visible: true, + defaultValue: true, + type: 'java.lang.Boolean', + }), + }, + }, + }, + }, + attachments: { + title: attachmentsText.attachments(), + subCategories: { + attachments: { + title: preferencesText.general(), + items: { + attachmentThumbnailSize: definePref({ + title: preferencesText.attachmentThumbnailSize(), + description: preferencesText.attachmentThumbnailSizeDescription(), + requiresReload: false, + visible: true, + defaultValue: 256, + type: 'java.lang.Integer', + }), + }, + }, + }, + }, +} as const; diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx index a1d25e5ff3e..4909818efe5 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx @@ -318,6 +318,11 @@ export function DefaultPreferenceItemRender({ : undefined; const isReadOnly = React.useContext(ReadOnlyContext); + const selectedValueDefinition = + 'values' in definition + ? definition.values.find((item) => item.value === value) + : undefined; + return 'values' in definition ? ( <>