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/stored_queries/format.py b/specifyweb/backend/stored_queries/format.py index 9a06926678c..5e985ec8af9 100644 --- a/specifyweb/backend/stored_queries/format.py +++ b/specifyweb/backend/stored_queries/format.py @@ -18,7 +18,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 @@ -439,8 +439,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/frontend/js_src/lib/components/AppResources/Editor.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx index f09c0ee09da..0dce21a5039 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/Editor.tsx @@ -137,7 +137,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])); 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 d661ae50a83..0450a42493f 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 @@ -15,7 +15,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(); }); }); @@ -70,6 +72,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; @@ -120,8 +125,11 @@ describe('AppResourcesAside (expanded case)', () => { unmountThird(); - const { asFragment: asFragmentAllExpanded, unmount: unmountExpandedll } = - mount( + const { + asFragment: asFragmentAllExpanded, + unmount: unmountExpandedll, + container: expandedContainer, + } = mount( { 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 9f67a920b45..de3656a0474 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 @@ -65,6 +65,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__/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..86493b68328 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,4 +1,5 @@ import { renderHook } from '@testing-library/react'; +import type { LocalizedString } from 'typesafe-i18n'; import { requireContext } from '../../../tests/helpers'; import { getAppResourceCount } from '../helpers'; @@ -11,6 +12,20 @@ requireContext(); const { setAppResourceDir, testDisciplines } = utilsForTests; +const flattenResources = ( + tree: AppResourcesTree +): ReadonlyArray<{ + 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 +51,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,7 +73,9 @@ 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); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts index 446eb29de05..e265a8aeb4c 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts @@ -62,11 +62,21 @@ 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..e971d58a6aa 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/tree.ts @@ -1,3 +1,4 @@ + import { resourcesText } from '../../localization/resources'; import { userText } from '../../localization/user'; import type { RA } from '../../utils/types'; 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/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap b/specifyweb/frontend/js_src/lib/components/FormPlugins/__tests__/__snapshots__/PartialDateUi.test.tsx.snap index d2930538113..07f8ea6632c 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 @@ -58,7 +58,7 @@ exports[`PartialDateUi renders without errors 2`] = ` > diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts index 3ed5ee2a4e3..d74d58da810 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts @@ -57,6 +57,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/remotePrefs.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts index 2574f60f53d..c4aa1e1c481 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], }, 'GeologicTimePeriod.treeview_sort_field': { @@ -225,7 +236,7 @@ export const remotePrefsDefinitions = f.store( }, 'attachment.preview_size': { description: 'The size in px of the generated attachment thumbnails', - defaultValue: 123, + defaultValue: 256, parser: 'java.lang.Long', isLegacy: true, }, diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx index 2e4e6cd813e..0917f744614 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(); 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..e86635a9c46 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx @@ -10,8 +10,17 @@ import { userPreferenceDefinitions } from '../Preferences/UserDefinitions'; import { userPreferences } from '../Preferences/userPreferences'; import { useTopChild } from '../Preferences/useTopChild'; import { collectionPreferenceDefinitions } from './CollectionDefinitions'; +import { globalPreferenceDefinitions } from './GlobalDefinitions'; import { collectionPreferences } from './collectionPreferences'; +import { globalPreferences } from './globalPreferences'; import type { GenericPreferences } from './types'; +import type { PartialPreferences } from './BasePreferences'; +import { + parseGlobalPreferences, + serializeGlobalPreferences, +} from './globalPreferencesUtils'; +import type { GlobalPreferenceValues } from './globalPreferences'; +import type { PropertyLine } from './globalPreferencesUtils'; type EditorDependencies = Pick; @@ -25,12 +34,74 @@ 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: ReadonlyArray; +} => { + const { raw, metadata } = parseGlobalPreferences(data); + return { + raw: raw as unknown as PartialPreferences, + metadata, + }; +}; + +const serializeGlobalPreferenceData = ( + raw: PartialPreferences, + metadata: unknown +): { + readonly data: string; + readonly metadata: ReadonlyArray; +} => { + const result = serializeGlobalPreferences( + raw as unknown as GlobalPreferenceValues, + (metadata as ReadonlyArray | undefined) ?? [] + ); + return { + data: result.data, + metadata: result.metadata, + }; +}; + function createPreferencesEditor( config: PreferencesEditorConfig ) { @@ -49,6 +120,21 @@ 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 [preferencesInstance] = useLiveState>( React.useCallback(() => { @@ -63,18 +149,28 @@ function createPreferencesEditor( syncChanges: false, }); - preferences.setRaw( - JSON.parse(data === null || data.length === 0 ? '{}' : data) - ); + preferences.setRaw(initialRaw as PartialPreferences as PartialPreferences); - preferences.events.on('update', () => - onChange(JSON.stringify(preferences.getRaw())) - ); + preferences.events.on('update', () => { + const result = serialize( + preferences.getRaw() as PartialPreferences, + 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]); + const Provider = Context.Provider; const contentProps = prefType === undefined ? {} : { prefType }; const { @@ -123,4 +219,18 @@ 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, +}); \ No newline at end of file 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..31ee461dbfd --- /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 0af5d46b5a2..407c46cf6ec 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx @@ -314,6 +314,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 ? ( <>