diff --git a/specifyweb/backend/trees/extras.py b/specifyweb/backend/trees/extras.py
index 456a46f3ceb..f780e849027 100644
--- a/specifyweb/backend/trees/extras.py
+++ b/specifyweb/backend/trees/extras.py
@@ -1,6 +1,8 @@
+import json
import re
from contextlib import contextmanager
import logging
+from typing import Iterable
from specifyweb.backend.trees.ranks import RankOperation, post_tree_rank_save, pre_tree_rank_deletion, \
verify_rank_parent_chain_integrity, pre_tree_rank_init, post_tree_rank_deletion
@@ -16,7 +18,97 @@
from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException
import specifyweb.specify.models as spmodels
-from specifyweb.backend.workbench.upload.auditcodes import TREE_BULK_MOVE, TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE
+from specifyweb.backend.workbench.upload.auditcodes import TREE_BULK_MOVE, TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE
+
+_SYNONYM_PREF_KEYS_BY_TABLE: dict[str, tuple[str, ...]] = {
+ 'GeologicTimePeriod': (
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronoStrat',
+ ),
+ 'ChronosStrat': (
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat',
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronoStrat',
+ ),
+ 'ChronoStrat': (
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronoStrat',
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat',
+ ),
+}
+
+
+def _synonym_pref_keys(node) -> tuple[str, ...]:
+ table_name = node.specify_model.name
+ base_key = f'sp7.allow_adding_child_to_synonymized_parent.{table_name}'
+ keys = _SYNONYM_PREF_KEYS_BY_TABLE.get(table_name)
+ if keys is None:
+ return (base_key,)
+
+ if keys and keys[0] == base_key:
+ return keys
+ return (base_key, *keys)
+
+
+def _collection_synonym_pref_enabled(keys: Iterable[str]) -> bool:
+ from specifyweb.specify.models import Spappresourcedata
+
+ qs = Spappresourcedata.objects.filter(
+ spappresource__name='CollectionPreferences'
+ ).values_list('data', flat=True)
+
+ for raw_data in qs:
+ if not raw_data:
+ continue
+ if isinstance(raw_data, memoryview):
+ raw_data = raw_data.tobytes()
+ if isinstance(raw_data, (bytes, bytearray)):
+ try:
+ raw_data = raw_data.decode('utf-8')
+ except UnicodeDecodeError:
+ continue
+ try:
+ prefs = json.loads(raw_data)
+ except (TypeError, ValueError):
+ continue
+
+ if not isinstance(prefs, dict):
+ continue
+
+ tree_management = prefs.get('treeManagement')
+ if not isinstance(tree_management, dict):
+ continue
+
+ synonymized = tree_management.get('synonymized')
+ if not isinstance(synonymized, dict):
+ continue
+
+ for key in keys:
+ value = synonymized.get(key)
+ if value is True:
+ return True
+
+ return False
+
+
+def _remote_synonym_pref_enabled(keys: Iterable[str]) -> bool:
+ from specifyweb.backend.context.remote_prefs import get_remote_prefs
+
+ prefs_text = get_remote_prefs()
+ for key in keys:
+ pattern = r'^' + re.escape(key) + r'(?:_\d+)?=(.+)'
+ override = re.search(pattern, prefs_text, re.MULTILINE)
+ if override is not None and override.group(1).strip().lower() == "true":
+ return True
+ return False
+
+
+def _synonym_override_enabled(node) -> bool:
+ """Return True when collection or remote prefs allow actions on synonymized parents."""
+
+ keys = _synonym_pref_keys(node)
+ return _collection_synonym_pref_enabled(keys) or _remote_synonym_pref_enabled(keys)
@contextmanager
def validate_node_numbers(table, revalidate_after=True):
@@ -208,11 +300,10 @@ def adding_node(node):
model = type(node)
parent = model.objects.select_for_update().get(id=node.parent.id)
if parent.accepted_id is not None:
- from specifyweb.backend.context.remote_prefs import get_remote_prefs
- # This business rule can be overriden by a remote pref.
- pattern = r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)'
- override = re.search(pattern, get_remote_prefs(), re.MULTILINE)
- if override is None or override.group(1).strip().lower() != "true":
+ if not _synonym_override_enabled(node):
+ node_children = [] if node.pk is None else list(node.children.values('id', 'fullname'))
+ parent_children = list(parent.children.values('id', 'fullname'))
+ parent_parent_id = parent.parent.id if parent.parent_id else None
raise TreeBusinessRuleException(
f'Adding node "{node.fullname}" to synonymized parent "{parent.fullname}"',
{"tree" : "Taxon",
@@ -223,14 +314,14 @@ def adding_node(node):
"rankid" : node.rankid,
"fullName" : node.fullname,
"parentid": node.parent.id,
- "children": list(node.children.values('id', 'fullname'))
+ "children": node_children
},
"parent" : {
"id" : parent.id,
"rankid" : parent.rankid,
"fullName" : parent.fullname,
- "parentid": parent.parent.id,
- "children": list(parent.children.values('id', 'fullname'))
+ "parentid": parent_parent_id,
+ "children": parent_children
}})
insertion_point = open_interval(model, parent.nodenumber, 1)
@@ -396,11 +487,8 @@ def synonymize(node, into, agent):
node.isaccepted = False
node.save()
- # This check can be disabled by a remote pref
- from specifyweb.backend.context.remote_prefs import get_remote_prefs
- pattern = r'^sp7\.allow_adding_child_to_synonymized_parent\.' + node.specify_model.name + '=(.+)'
- override = re.search(pattern, get_remote_prefs(), re.MULTILINE)
- if node.children.count() > 0 and (override is None or override.group(1).strip().lower() != "true"):
+ # This check can be disabled by a remote or collection preference override
+ if node.children.count() > 0 and not _synonym_override_enabled(node):
raise TreeBusinessRuleException(
f'Synonymizing node "{node.fullname}" which has children',
{"tree" : "Taxon",
diff --git a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py
index ef724e0c7fe..107c2d2b012 100644
--- a/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py
+++ b/specifyweb/backend/trees/tests/test_tree_extras/test_synonymize.py
@@ -1,11 +1,77 @@
+import json
+
from specifyweb.backend.businessrules.exceptions import TreeBusinessRuleException
-from specifyweb.specify.models import Determination, Taxon, Taxontreedef
+from specifyweb.specify.models import (
+ Determination,
+ Spappresource,
+ Spappresourcedata,
+ Spappresourcedir,
+ Taxon,
+ Taxontreedef,
+)
from specifyweb.backend.trees.tests.test_trees import GeographyTree
from specifyweb.backend.trees.extras import synonymize
class TestSynonymize(GeographyTree):
+ def _set_synonym_pref(self, key: str, value: bool = True) -> None:
+ app_dir = Spappresourcedir.objects.filter(
+ ispersonal=False,
+ collection=self.collection,
+ discipline=self.discipline,
+ specifyuser=self.specifyuser,
+ ).first()
+ if app_dir is None:
+ app_dir = Spappresourcedir.objects.create(
+ ispersonal=False,
+ collection=self.collection,
+ discipline=self.discipline,
+ specifyuser=self.specifyuser,
+ )
+
+ app_resource = Spappresource.objects.filter(
+ spappresourcedir=app_dir,
+ name="CollectionPreferences",
+ specifyuser=self.specifyuser,
+ ).first()
+ if app_resource is None:
+ app_resource = Spappresource.objects.create(
+ spappresourcedir=app_dir,
+ name="CollectionPreferences",
+ level=0,
+ metadata="",
+ mimetype="application/json",
+ specifyuser=self.specifyuser,
+ )
+
+ app_data = Spappresourcedata.objects.filter(
+ spappresource=app_resource
+ ).first()
+ if app_data is None:
+ app_data = Spappresourcedata.objects.create(
+ spappresource=app_resource,
+ data=json.dumps({}),
+ )
+
+ raw_data = app_data.data
+ if isinstance(raw_data, memoryview):
+ raw_data = raw_data.tobytes()
+ if isinstance(raw_data, (bytes, bytearray)):
+ raw_data = raw_data.decode('utf-8')
+
+ try:
+ prefs = json.loads(raw_data if raw_data else '{}')
+ except (TypeError, ValueError):
+ prefs = {}
+
+ tree_management = prefs.setdefault('treeManagement', {})
+ synonymized = tree_management.setdefault('synonymized', {})
+ synonymized[key] = value
+
+ app_data.data = json.dumps(prefs)
+ app_data.save()
+
def test_different_type(self):
with self.assertRaises(AssertionError) as context:
synonymize(self.na, self.collectionobjects[0], self.agent)
@@ -69,6 +135,19 @@ def test_synonymize_geography_target_children(self):
self.assertEqual(context.exception.args[1]['localizationKey'], "nodeSynonimizeWithChildren")
+ def test_synonymize_geography_target_children_with_collection_pref(self):
+ self._set_synonym_pref(
+ 'sp7.allow_adding_child_to_synonymized_parent.Geography',
+ True,
+ )
+
+ try:
+ synonymize(self.kansas, self.mo, self.agent)
+ except TreeBusinessRuleException:
+ self.fail(
+ 'synonymize raised TreeBusinessRuleException despite collection preference override'
+ )
+
def test_synonymize_taxon_no_target_children(self):
life = Taxon.objects.create(
@@ -141,4 +220,4 @@ def test_synonymize_taxon_no_target_children(self):
self.assertEqual(det_plantae_1.preferredtaxon_id, plantae.id)
self.assertEqual(det_plantae_2.preferredtaxon_id, plantae.id)
-
\ No newline at end of file
+
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx
index 1f4b82d129c..64e0c8e0f49 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/Create.tsx
@@ -31,6 +31,7 @@ import { formatUrl } from '../Router/queryString';
import type { AppResourcesTree } from './hooks';
import { useResourcesTree } from './hooks';
import type { AppResourcesOutlet } from './index';
+import { shouldShowCollectionPreferenceSubType } from './permissions';
import type { AppResourceType, ScopedAppResourceDir } from './types';
import { appResourceSubTypes, appResourceTypes } from './types';
@@ -60,6 +61,7 @@ export function CreateAppResource(): JSX.Element {
const [templateFile, setTemplateFile] = React.useState<
string | false | undefined
>(undefined);
+ const canSeeCollectionPreferences = shouldShowCollectionPreferenceSubType();
return directory === undefined ? (
) : type === undefined ? (
@@ -98,11 +100,16 @@ export function CreateAppResource(): JSX.Element {
- {Object.entries(appResourceSubTypes).map(
- ([
- key,
- { icon, mimeType, name = '', documentationUrl, label, ...rest },
- ]) =>
+ {Object.entries(appResourceSubTypes)
+ .filter(
+ ([key]) =>
+ key !== 'collectionPreferences' || canSeeCollectionPreferences
+ )
+ .map(
+ ([
+ key,
+ { icon, mimeType, name = '', documentationUrl, label, ...rest },
+ ]) =>
'scope' in rest &&
!f.includes(rest.scope, directory.scope) ? undefined : (
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx
index e5ddd5ded39..05ae7ac709d 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/Filters.tsx
@@ -22,6 +22,7 @@ import {
isAllAppResourceTypes,
} from './filtersHelpers';
import type { AppResources } from './hooks';
+import { shouldShowCollectionPreferenceSubType } from './permissions';
import { appResourceSubTypes, appResourceTypes } from './types';
export function AppResourcesFilters({
@@ -33,12 +34,23 @@ export function AppResourcesFilters({
'appResources',
'filters'
);
+ const canSeeCollectionPreferences = shouldShowCollectionPreferenceSubType();
+ const visibleAppResources = React.useMemo(
+ () =>
+ canSeeCollectionPreferences
+ ? allAppResources
+ : allAppResources.filter((type) => type !== 'collectionPreferences'),
+ [canSeeCollectionPreferences]
+ );
- const showAllResources = isAllAppResourceTypes(filters.appResources);
+ const showAllResources = isAllAppResourceTypes(
+ filters.appResources,
+ visibleAppResources
+ );
const handleToggleResources = (): void =>
setFilters({
...filters,
- appResources: showAllResources ? [] : allAppResources,
+ appResources: showAllResources ? [] : visibleAppResources,
});
const [isOpen, handleOpen, handleClose] = useBooleanState();
@@ -63,7 +75,9 @@ export function AppResourcesFilters({
setFilters({
viewSets: false,
appResources:
- filters.viewSets || !showAllResources ? allAppResources : [],
+ filters.viewSets || !showAllResources
+ ? visibleAppResources
+ : [],
})
}
>
@@ -116,48 +130,54 @@ export function AppResourcesFilters({
{commonText.countLine({
resource: resourcesText.appResources(),
count: countAppResources(initialResources, {
- appResources: allAppResources,
+ appResources: visibleAppResources,
viewSets: false,
}),
})}
- {Object.entries(appResourceSubTypes).map(
- ([key, { label, icon, documentationUrl }]): JSX.Element => (
- -
-
-
- setFilters({
- ...filters,
- appResources: toggleItem(
- filters.appResources,
- key
- ),
- })
- }
- />
- {icon}
- {commonText.countLine({
- resource: label,
- count: countAppResources(initialResources, {
- appResources: [key],
- viewSets: false,
- }),
- })}
- {typeof documentationUrl === 'string' && (
-
- )}
-
-
+ {Object.entries(appResourceSubTypes)
+ .filter(
+ ([key]) =>
+ key !== 'collectionPreferences' ||
+ canSeeCollectionPreferences
)
- )}
+ .map(
+ ([key, { label, icon, documentationUrl }]): JSX.Element => (
+ -
+
+
+ setFilters({
+ ...filters,
+ appResources: toggleItem(
+ filters.appResources,
+ key
+ ),
+ })
+ }
+ />
+ {icon}
+ {commonText.countLine({
+ resource: label,
+ count: countAppResources(initialResources, {
+ appResources: [key],
+ viewSets: false,
+ }),
+ })}
+ {typeof documentationUrl === 'string' && (
+
+ )}
+
+
+ )
+ )}
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx
index b44e76eea67..3cf1e712444 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx
@@ -28,6 +28,7 @@ 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 { useDarkMode } from '../Preferences/Hooks';
import type { BaseSpec } from '../Syncer';
import type { SimpleXmlNode } from '../Syncer/xmlToJson';
@@ -156,7 +157,7 @@ export const visualAppResourceEditors = f.store<
json: AppResourceTextEditor,
},
collectionPreferences: {
- // FEATURE: add visual editor
+ visual: CollectionPreferencesEditor,
json: AppResourceTextEditor,
},
leafletLayers: undefined,
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..49fe636ffe4 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
@@ -8,6 +8,16 @@ import type { AppResourcesConformation } from '../Aside';
import { AppResourcesAside } from '../Aside';
import { testAppResources } from './testAppResources';
+jest.mock('../permissions', () => {
+ const actual = jest.requireActual('../permissions');
+ return {
+ ...actual,
+ filterCollectionPreferencesResources: (resources: readonly any[]) =>
+ resources,
+ canAccessCollectionPreferencesResource: () => true,
+ };
+});
+
requireContext();
describe('AppResourcesAside (simple no conformation case)', () => {
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..4d8d7c42ad6 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
@@ -6,7 +6,27 @@ import { UnloadProtectsContext } from '../../Router/UnloadProtect';
import { AppResourcesFilters } from '../Filters';
import { testAppResources } from './testAppResources';
+const mockCanSeeCollectionPreferences = jest.fn(() => false);
+
+jest.mock('../permissions', () => {
+ const actual = jest.requireActual('../permissions');
+ return {
+ ...actual,
+ canAccessCollectionPreferencesResource: () =>
+ mockCanSeeCollectionPreferences(),
+ shouldShowCollectionPreferenceSubType: () =>
+ mockCanSeeCollectionPreferences(),
+ filterCollectionPreferencesResources: (resources: readonly any[]) =>
+ mockCanSeeCollectionPreferences()
+ ? resources
+ : resources.filter(
+ (resource) => resource?.name !== 'CollectionPreferences'
+ ),
+ };
+});
+
beforeEach(() => {
+ mockCanSeeCollectionPreferences.mockReturnValue(true);
setCache(
'appResources',
'filters',
@@ -99,4 +119,34 @@ describe('AppResourcesFilters', () => {
viewSets: true,
});
});
+
+ test('collection preferences option visible when permitted', async () => {
+ mockCanSeeCollectionPreferences.mockReturnValue(true);
+
+ const { getAllByRole, user } = mount(
+
+ );
+
+ const button = getAllByRole('button')[1];
+
+ await user.click(button);
+
+ const filters = getCache('appResources', 'filters');
+ expect(filters).toBeDefined();
+ expect(filters?.appResources).toContain('collectionPreferences');
+ });
+
+ test('collection preferences option hidden when not permitted', async () => {
+ mockCanSeeCollectionPreferences.mockReturnValue(false);
+
+ const { getAllByRole, user } = mount(
+
+ );
+
+ const button = getAllByRole('button')[1];
+ await user.click(button);
+
+ const filters = getCache('appResources', 'filters');
+ expect(filters?.appResources).not.toContain('collectionPreferences');
+ });
});
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts
index 446eb29de05..e252f484d36 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/filtersHelpers.ts
@@ -5,6 +5,7 @@ import { toResource } from '../DataModel/helpers';
import type { SerializedResource } from '../DataModel/helperTypes';
import type { SpAppResource, SpViewSetObj } from '../DataModel/types';
import type { AppResources } from './hooks';
+import { filterCollectionPreferencesResources } from './permissions';
import { appResourceSubTypes } from './types';
export const allAppResources = Array.from(
@@ -25,10 +26,11 @@ export type AppResourceFilters = {
* Determine if all app resource types are visible
*/
export const isAllAppResourceTypes = (
- appResources: RA
+ appResources: RA,
+ universe: RA = allAppResources
): boolean =>
JSON.stringify(Array.from(appResources).sort(sortFunction(f.id))) ===
- JSON.stringify(allAppResources);
+ JSON.stringify(universe);
export function countAppResources(
resources: AppResources,
@@ -44,14 +46,15 @@ export const filterAppResources = (
): AppResources => ({
...resources,
viewSets: filters.viewSets ? resources.viewSets : [],
- appResources:
+ appResources: filterCollectionPreferencesResources(
filters.appResources.length === 0
? []
: isAllAppResourceTypes(filters.appResources)
? resources.appResources
: resources.appResources.filter((resource) =>
filters.appResources.includes(getAppResourceType(resource))
- ),
+ )
+ ),
});
export const getResourceType = (
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/permissions.ts b/specifyweb/frontend/js_src/lib/components/AppResources/permissions.ts
new file mode 100644
index 00000000000..7105bbfa94e
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/permissions.ts
@@ -0,0 +1,78 @@
+import type { RA } from '../../utils/types';
+import type { SerializedResource } from '../DataModel/helperTypes';
+import { schema } from '../DataModel/schema';
+import type { SpAppResource, Tables } from '../DataModel/types';
+import { getOperationPermissions, getTablePermissions } from '../Permissions';
+import { toolDefinitions } from '../Security/registry';
+import { tableNameToResourceName } from '../Security/utils';
+
+const COLLECTION_PREFERENCES_NAME = 'CollectionPreferences';
+
+const getCollectionId = (): number => schema.domainLevelIds?.collection ?? -1;
+
+type OperationPermissionMap = Record>;
+type TablePermissionMap = Record>;
+
+const getOperationPermissionMap = (): OperationPermissionMap => {
+ const collectionId = getCollectionId();
+ return collectionId === -1
+ ? ({} as OperationPermissionMap)
+ : ((getOperationPermissions()[collectionId] ??
+ {}) as OperationPermissionMap);
+};
+
+const getTablePermissionMap = (): TablePermissionMap => {
+ const collectionId = getCollectionId();
+ return collectionId === -1
+ ? ({} as TablePermissionMap)
+ : ((getTablePermissions()[collectionId] ?? {}) as TablePermissionMap);
+};
+
+const hasOperationPermission = (resource: string, action: string): boolean => {
+ const permissions = getOperationPermissionMap();
+ return Boolean(permissions[resource]?.[action]);
+};
+
+const hasToolTablePermission = (
+ tool: keyof ReturnType,
+ action: 'create' | 'delete' | 'read' | 'update'
+): boolean => {
+ const tablePermissions = getTablePermissionMap();
+ const tables = toolDefinitions()[tool].tables as RA;
+ return tables.every((tableName) => {
+ const resourceName = tableNameToResourceName(tableName);
+ return Boolean(tablePermissions[resourceName]?.[action]);
+ });
+};
+
+export const canAccessCollectionPreferencesResource = (): boolean => {
+ const collectionId = getCollectionId();
+ if (collectionId === -1) return true;
+
+ const tablePermissions = getTablePermissionMap();
+ const operationPermissions = getOperationPermissionMap();
+ if (
+ Object.keys(tablePermissions).length === 0 ||
+ Object.keys(operationPermissions).length === 0
+ )
+ return true;
+
+ return (
+ hasToolTablePermission('resources', 'update') &&
+ hasOperationPermission('/preferences/collection', 'edit_collection')
+ );
+};
+
+export const filterCollectionPreferencesResources = <
+ RESOURCE extends SerializedResource,
+>(
+ resources: RA
+): RA =>
+ canAccessCollectionPreferencesResource()
+ ? resources
+ : resources.filter(
+ (resource) => resource.name !== COLLECTION_PREFERENCES_NAME
+ );
+
+export const shouldShowCollectionPreferenceSubType = (): boolean =>
+ canAccessCollectionPreferencesResource();
diff --git a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
index fa59a2bcc77..9ee3b99a0a2 100644
--- a/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Atoms/Icons.tsx
@@ -107,6 +107,7 @@ export const icons = {
minus: ,
minusCircle: ,
nonStrict: ,
+ office: ,
pencil: ,
pencilAt: ,
photos: ,
diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx
index 597e4ed3665..c41f2c2da3e 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/RecordSetAttachment.tsx
@@ -109,7 +109,8 @@ export function RecordSetAttachments({
const isComplete = fetchedCount.current === recordCount;
- const [showCreateRecordSetDialog, setShowCreateRecordSetDialog] = React.useState(false);
+ const [showCreateRecordSetDialog, setShowCreateRecordSetDialog] =
+ React.useState(false);
return (
<>
@@ -133,16 +134,17 @@ export function RecordSetAttachments({
- (recordSetId === undefined && !isComplete) ?
- setShowCreateRecordSetDialog(true)
- :
- loading(
- downloadAllAttachments(
- (recordSetId !== undefined && !isComplete) ? [] : attachmentsRef.current?.attachments ?? [],
- name,
- recordSetId,
- )
- )
+ recordSetId === undefined && !isComplete
+ ? setShowCreateRecordSetDialog(true)
+ : loading(
+ downloadAllAttachments(
+ recordSetId !== undefined && !isComplete
+ ? []
+ : (attachmentsRef.current?.attachments ?? []),
+ name,
+ recordSetId
+ )
+ )
}
>
{attachmentsText.downloadAll()}
@@ -157,15 +159,15 @@ export function RecordSetAttachments({
header={
attachmentsRef.current?.attachments === undefined
? attachmentsText.attachments()
- : (isComplete ?
- commonText.countLine({
- resource: attachmentsText.attachments(),
- count: attachmentsRef.current.attachments.length
- }) :
- commonText.countLineOrMore({
- resource: attachmentsText.attachments(),
- count: attachmentsRef.current.attachments.length
- }))
+ : isComplete
+ ? commonText.countLine({
+ resource: attachmentsText.attachments(),
+ count: attachmentsRef.current.attachments.length,
+ })
+ : commonText.countLineOrMore({
+ resource: attachmentsText.attachments(),
+ count: attachmentsRef.current.attachments.length,
+ })
}
onClose={handleHideAttachments}
>
@@ -215,13 +217,11 @@ function CreateRecordSetDialog({
}): JSX.Element {
return (
);
-}
\ No newline at end of file
+}
diff --git a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
index fde546546b0..ac94e79001c 100644
--- a/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
+++ b/specifyweb/frontend/js_src/lib/components/Attachments/attachments.ts
@@ -8,10 +8,15 @@ import type { UploadAttachmentSpec } from '../AttachmentsBulkImport/types';
import { getField } from '../DataModel/helpers';
import type { SerializedResource } from '../DataModel/helperTypes';
import type { SpecifyResource } from '../DataModel/legacyTypes';
+import { schema } from '../DataModel/schema';
import { tables } from '../DataModel/tables';
import type { Attachment } from '../DataModel/types';
import { load } from '../InitialContext';
-import { getPref } from '../InitialContext/remotePrefs';
+import {
+ ensureCollectionPreferencesLoaded,
+ getCollectionPref,
+ getPref,
+} from '../InitialContext/remotePrefs';
import { downloadFile } from '../Molecules/FilePicker';
import { formatUrl } from '../Router/queryString';
// Import SVG icons, but better than in Icons.tsx
@@ -284,15 +289,42 @@ export async function uploadFile(
}
})
);
+ const isPublicDefault = await getAttachmentPublicDefault();
+
return new tables.Attachment.Resource({
attachmentlocation: data.attachmentLocation,
mimetype: fixMimeType(file.type),
origfilename: file.name,
title: file.name,
- isPublic: getPref('attachment.is_public_default'),
+ isPublic: isPublicDefault,
});
}
+async function getAttachmentPublicDefault(): Promise {
+ const collectionPrefKey =
+ 'attachment.is_public_default' as const;
+ const collectionId = schema.domainLevelIds.collection;
+ try {
+ const collectionPreferences = await ensureCollectionPreferencesLoaded();
+ const rawValue =
+ collectionPreferences
+ .getRaw()
+ ?.general?.attachments?.['attachment.is_public_default'];
+ if (typeof rawValue === 'boolean') return rawValue;
+ return collectionPreferences.get(
+ 'general',
+ 'attachments',
+ 'attachment.is_public_default'
+ );
+ } catch {
+ try {
+ return getCollectionPref(collectionPrefKey, collectionId);
+ } catch {
+ return getPref(collectionPrefKey);
+ }
+ }
+}
+
/**
* A temporary workaround for mimeTypes for `.docx` and `.xlsx` files being
* longer than the length limit on the `Attachment.mimeType` field.
diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx
index 31b410f3be4..59cb682763b 100644
--- a/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/index.tsx
@@ -247,7 +247,6 @@ const containsSystemTables = (queryFieldSpec: QueryFieldSpec) => {
return Boolean(baseIsBlocked || pathHasBlockedSystem);
};
-
const hasHierarchyBaseTable = (queryFieldSpec: QueryFieldSpec) =>
Object.keys(schema.domainLevelIds).includes(
queryFieldSpec.baseTable.name.toLowerCase() as 'collection'
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
index 4ec08b2b697..1c982de08d2 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/__tests__/businessRules.test.ts
@@ -5,7 +5,6 @@ import { overrideAjax } from '../../../tests/ajax';
import { mockTime, requireContext } from '../../../tests/helpers';
import type { RA } from '../../../utils/types';
import { overwriteReadOnly } from '../../../utils/types';
-import { getPref } from '../../InitialContext/remotePrefs';
import { cogTypes } from '../helpers';
import type { SerializedResource } from '../helperTypes';
import { getResourceApiUrl } from '../resource';
@@ -13,6 +12,7 @@ import { useSaveBlockers } from '../saveBlockers';
import { schema } from '../schema';
import type { SpecifyTable } from '../specifyTable';
import { tables } from '../tables';
+import { getSynonymPreferenceForTree } from '../treeBusinessRules';
import type {
CollectingEvent,
CollectionObjectType,
@@ -864,14 +864,20 @@ describe('treeBusinessRules', () => {
expect(fieldChangeResult.current[0]).toStrictEqual(['Bad tree structure.']);
});
test('saveBlocker not on synonymized parent w/preference', async () => {
- const remotePrefs = await import('../../InitialContext/remotePrefs');
- jest
- .spyOn(remotePrefs, 'getPref')
- .mockImplementation((key) =>
- key === 'sp7.allow_adding_child_to_synonymized_parent.Taxon'
- ? true
- : getPref(key)
- );
+ const { collectionPreferences } = await import(
+ '../../Preferences/collectionPreferences'
+ );
+ const originalRaw = collectionPreferences.getRaw();
+ collectionPreferences.setRaw({
+ ...originalRaw,
+ treeManagement: {
+ ...originalRaw.treeManagement,
+ synonymized: {
+ ...originalRaw.treeManagement?.synonymized,
+ 'sp7.allow_adding_child_to_synonymized_parent.Taxon': true,
+ },
+ },
+ } as typeof originalRaw);
const taxon = new tables.Taxon.Resource({
name: 'dauricus',
@@ -887,5 +893,53 @@ describe('treeBusinessRules', () => {
useSaveBlockers(taxon, tables.Taxon.getField('parent'))
);
expect(result.current[0]).toStrictEqual([]);
+ collectionPreferences.setRaw(originalRaw);
+ });
+
+ test('getSynonymPreferenceForTree respects geologic time pref', async () => {
+ const { collectionPreferences } = await import(
+ '../../Preferences/collectionPreferences'
+ );
+ const originalRaw = collectionPreferences.getRaw();
+ collectionPreferences.setRaw({
+ ...originalRaw,
+ treeManagement: {
+ ...originalRaw.treeManagement,
+ synonymized: {
+ ...originalRaw.treeManagement?.synonymized,
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod':
+ true,
+ },
+ },
+ } as typeof originalRaw);
+
+ await expect(
+ getSynonymPreferenceForTree('GeologicTimePeriod')
+ ).resolves.toBe(true);
+
+ collectionPreferences.setRaw(originalRaw);
+ });
+
+ test('getSynonymPreferenceForTree handles chronostrat legacy key', async () => {
+ const { collectionPreferences } = await import(
+ '../../Preferences/collectionPreferences'
+ );
+ const originalRaw = collectionPreferences.getRaw();
+ collectionPreferences.setRaw({
+ ...originalRaw,
+ treeManagement: {
+ ...originalRaw.treeManagement,
+ synonymized: {
+ ...originalRaw.treeManagement?.synonymized,
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat': true,
+ },
+ },
+ } as typeof originalRaw);
+
+ await expect(
+ getSynonymPreferenceForTree('GeologicTimePeriod')
+ ).resolves.toBe(true);
+
+ collectionPreferences.setRaw(originalRaw);
});
});
diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts
index f738a71b256..8657be7e748 100644
--- a/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts
+++ b/specifyweb/frontend/js_src/lib/components/DataModel/treeBusinessRules.ts
@@ -1,15 +1,59 @@
import { treeText } from '../../localization/tree';
import { ajax } from '../../utils/ajax';
import { f } from '../../utils/functools';
-import { getPref } from '../InitialContext/remotePrefs';
+import {
+ ensureCollectionPreferencesLoaded,
+ getCollectionPref,
+ getPref,
+ remotePrefsDefinitions,
+} from '../InitialContext/remotePrefs';
import { fetchPossibleRanks } from '../PickLists/TreeLevelPickList';
import { formatUrl } from '../Router/queryString';
import type { BusinessRuleResult } from './businessRules';
import type { AnyTree, TableFields } from './helperTypes';
import type { SpecifyResource } from './legacyTypes';
import { idFromUrl } from './resource';
+import { schema } from './schema';
import type { Tables } from './types';
+const remoteSynonymPrefKeysByTable = {
+ Taxon: ['sp7.allow_adding_child_to_synonymized_parent.Taxon'] as const,
+ Geography: [
+ 'sp7.allow_adding_child_to_synonymized_parent.Geography',
+ ] as const,
+ Storage: ['sp7.allow_adding_child_to_synonymized_parent.Storage'] as const,
+ GeologicTimePeriod: [
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronoStrat',
+ ] as const,
+ LithoStrat: [
+ 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat',
+ ] as const,
+ TectonicUnit: [
+ 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit',
+ ] as const,
+} as const;
+
+type RemoteSynonymPrefKey =
+ (typeof remoteSynonymPrefKeysByTable)[keyof typeof remoteSynonymPrefKeysByTable][number];
+
+type CollectionSynonymPrefKey =
+ (typeof remoteSynonymPrefKeysByTable)[keyof typeof remoteSynonymPrefKeysByTable][0];
+
+export const expandSynonymPrefItemsByTable = remoteSynonymPrefKeysByTable;
+
+const collectionSynonymPrefKeyMap = Object.fromEntries(
+ (
+ Object.values(
+ remoteSynonymPrefKeysByTable
+ ) as readonly (readonly RemoteSynonymPrefKey[])[]
+ ).flatMap((keys) => {
+ const [primary, ...aliases] = keys;
+ return [[primary, primary], ...aliases.map((alias) => [alias, primary])];
+ })
+) as Record;
+
// eslint-disable-next-line unicorn/prevent-abbreviations
export type TreeDefItem =
Tables[`${TREE['tableName']}TreeDefItem`];
@@ -40,8 +84,8 @@ export const treeBusinessRules = async (
idFromUrl(parentDefItem.get('treeDef'))!
);
- const doExpandSynonymActionsPref = getPref(
- `sp7.allow_adding_child_to_synonymized_parent.${resource.specifyTable.name}`
+ const doExpandSynonymActionsPref = await getSynonymPreferenceForTree(
+ resource.specifyTable.name
);
const isParentSynonym = !parent.get('isAccepted');
@@ -89,6 +133,61 @@ export const treeBusinessRules = async (
);
});
+export async function getSynonymPreferenceForTree(
+ tableName: AnyTree['tableName']
+): Promise {
+ const preferenceKeys = expandSynonymPrefItemsByTable[tableName] ?? [];
+ const [primaryKey] = preferenceKeys;
+ if (typeof primaryKey !== 'string') return false;
+
+ const collectionId = schema.domainLevelIds.collection;
+
+ try {
+ const collectionPreferences = await ensureCollectionPreferencesLoaded();
+ const rawSynonymPrefs =
+ collectionPreferences.getRaw()?.treeManagement?.synonymized ?? {};
+
+ for (const key of preferenceKeys)
+ if (Object.hasOwn(rawSynonymPrefs, key)) {
+ const value = rawSynonymPrefs[key as keyof typeof rawSynonymPrefs];
+ if (typeof value === 'boolean') return value;
+ }
+
+ const defaultCollectionKey = preferenceKeys
+ .map((key) => collectionSynonymPrefKeyMap[key])
+ .find((key): key is CollectionSynonymPrefKey => key !== undefined);
+ if (defaultCollectionKey !== undefined)
+ return collectionPreferences.get(
+ 'treeManagement',
+ 'synonymized',
+ defaultCollectionKey
+ );
+ } catch {
+ /* Ignore and try fallbacks */
+ }
+
+ for (const key of preferenceKeys) {
+ const collectionKey = collectionSynonymPrefKeyMap[key];
+ if (collectionKey !== undefined)
+ try {
+ return getCollectionPref(collectionKey, collectionId);
+ } catch {
+ /* Continue */
+ }
+ }
+
+ const remoteDefinitions = remotePrefsDefinitions();
+ for (const key of preferenceKeys)
+ if (key in remoteDefinitions)
+ try {
+ return getPref(key as keyof ReturnType);
+ } catch {
+ /* Continue */
+ }
+
+ return false;
+}
+
const getRelatedTreeTables = async <
TREE extends AnyTree,
TREE_DEF_ITEM extends TreeDefItem,
diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
index 6855811b3b4..d0c7ea8bf52 100644
--- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
@@ -9,6 +9,7 @@ import { f } from '../../utils/functools';
import type { IR } from '../../utils/types';
import { ensure } from '../../utils/types';
import { toLowerCase } from '../../utils/utils';
+import { canAccessCollectionPreferencesResource } from '../AppResources/permissions';
import { icons } from '../Atoms/Icons';
import type { MenuItem } from '../Core/Main';
import { getDisciplineTrees } from '../InitialContext/treeRanks';
@@ -53,10 +54,16 @@ const rawUserTools = ensure>>>()({
},
[preferencesText.customization()]: {
userPreferences: {
- title: preferencesText.preferences(),
+ title: preferencesText.userPreferences(),
url: '/specify/user-preferences/',
icon: icons.cog,
},
+ collectionPreferences: {
+ title: preferencesText.collectionPreferences(),
+ url: '/specify/collection-preferences/',
+ icon: icons.office,
+ enabled: () => canAccessCollectionPreferencesResource(),
+ },
schemaConfig: {
title: schemaText.schemaConfig(),
url: '/specify/schema-config/',
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 0ba6373e6e3..6b9a157028d 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
@@ -67,7 +67,7 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"Treeeditor.TreeColColor2.LithoStrat": "151, 221, 255",
"Treeeditor.TreeColColor2.Storage": "128, 128, 0",
"Treeeditor.TreeColColor2.Taxon": "151, 221, 255",
- "attachment.is_public_default": "true",
+ "attachment.is_public_default_32768": "true",
"attachment.key": "c3wNpDBTLMedXWSb8w2TeSwHWVFLvBwiYmtU0CdOzLQtelcibV9sTXW7NxZlX68",
"attachment.path": "",
"attachment.preview_size": "123.3",
@@ -109,6 +109,8 @@ exports[`fetches and parses remotePrefs correctly 1`] = `
"settings.email.smtp": "authsmtp.ku.edu",
"settings.email.testconnection": "",
"settings.email.username": "abentley",
+ "sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod_32768": "true",
+ "sp7.allow_adding_child_to_synonymized_parent.Taxon_32768": "true",
"specify.bg.image": "",
"ui.formatting.disciplineicon.KUFishtissue": "colobj_backstop",
"ui.formatting.disciplineicon.KUFishvoucher": "colobj_backstop",
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 d545c838da1..19c97490e65 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
@@ -24,3 +24,22 @@ describe('Parsing Remote Prefs', () => {
test('can retrieve collection pref', () =>
expect(getCollectionPref('CO_CREATE_COA', 32_678)).toBe(false));
+
+test('parses collection boolean pref', () =>
+ expect(getCollectionPref('attachment.is_public_default', 32_768)).toBe(true));
+
+test('parses collection tree synonym pref', () =>
+ expect(
+ getCollectionPref(
+ 'sp7.allow_adding_child_to_synonymized_parent.Taxon',
+ 32_768
+ )
+ ).toBe(true));
+
+test('parses collection chronostrat synonym pref', () =>
+ expect(
+ getCollectionPref(
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod',
+ 32_768
+ )
+ ).toBe(true));
diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts
index 0ab66f5601c..e09ca3d3a45 100644
--- a/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts
+++ b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts
@@ -132,25 +132,6 @@ 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: 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,
- },
'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod': {
description:
'Allowed to add children to synopsized Geologic Time Period records',
@@ -158,8 +139,16 @@ export const remotePrefsDefinitions = f.store(
parser: 'java.lang.Boolean',
isLegacy: false,
},
- 'sp7.allow_adding_child_to_synonymized_parent.Taxon': {
- description: 'Allowed to add children to synopsized Taxon records',
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronosStrat': {
+ description:
+ 'Allowed to add children to synopsized Chronostratigraphy records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ isLegacy: false,
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.ChronoStrat': {
+ description:
+ 'Allowed to add children to synopsized Chronostratigraphy records',
defaultValue: false,
parser: 'java.lang.Boolean',
isLegacy: false,
@@ -182,6 +171,12 @@ export const remotePrefsDefinitions = f.store(
parser: 'java.lang.Boolean',
isLegacy: false,
},
+ 'sp7.allow_adding_child_to_synonymized_parent.Taxon': {
+ description: 'Allowed to add children to synopsized Taxon records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ isLegacy: false,
+ },
'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit': {
description:
'Allowed to add children to synopsized TectonicUnit records',
@@ -189,6 +184,25 @@ 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': {
@@ -227,6 +241,49 @@ export const collectionPrefsDefinitions = {
defaultValue: false,
parser: 'java.lang.Boolean',
},
+ 'attachment.is_public_default': {
+ separator: '_',
+ description: 'Whether new Attachments are public by default',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.Taxon': {
+ separator: '_',
+ description: 'Allowed to add children to synopsized Taxon records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.Geography': {
+ separator: '_',
+ description: 'Allowed to add children to synopsized Geography records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.Storage': {
+ separator: '_',
+ description: 'Allowed to add children to synopsized Storage records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod': {
+ separator: '_',
+ description:
+ 'Allowed to add children to synopsized Geologic Time Period records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat': {
+ separator: '_',
+ description: 'Allowed to add children to synopsized LithoStrat records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
+ 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit': {
+ separator: '_',
+ description: 'Allowed to add children to synopsized TectonicUnit records',
+ defaultValue: false,
+ parser: 'java.lang.Boolean',
+ },
sp7_scope_table_picklists: {
separator: '_',
description:
@@ -235,3 +292,22 @@ export const collectionPrefsDefinitions = {
parser: 'java.lang.Boolean',
},
} as const;
+
+let collectionPrefsFetchPromise: Promise | undefined;
+
+export async function ensureCollectionPreferencesLoaded(): Promise<
+ (typeof import('../Preferences/collectionPreferences'))['collectionPreferences']
+> {
+ const { collectionPreferences } = await import(
+ '../Preferences/collectionPreferences'
+ );
+ if (Object.keys(collectionPreferences.getRaw()).length === 0) {
+ if (collectionPrefsFetchPromise === undefined)
+ collectionPrefsFetchPromise = collectionPreferences
+ .fetch()
+ .catch(() => undefined)
+ .then(() => undefined);
+ await collectionPrefsFetchPromise;
+ }
+ return collectionPreferences;
+}
diff --git a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts
index a5ee87a2028..0045e7a884b 100644
--- a/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Permissions/definitions.ts
@@ -122,6 +122,7 @@ export const institutionPermissions = new Set([
export const frontEndPermissions = {
'/preferences/user': ['edit_protected'],
'/preferences/statistics': ['edit_shared'],
+ '/preferences/collection': ['edit_collection'],
} as const;
/**
diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/__tests__/fetch.test.ts b/specifyweb/frontend/js_src/lib/components/PickLists/__tests__/fetch.test.ts
index 7889b2f32a5..25f200f6147 100644
--- a/specifyweb/frontend/js_src/lib/components/PickLists/__tests__/fetch.test.ts
+++ b/specifyweb/frontend/js_src/lib/components/PickLists/__tests__/fetch.test.ts
@@ -18,6 +18,10 @@ const { unsafeFetchPickList, fetchPickListItems } = exportsForTests;
requireContext();
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
describe('unsafeFetchPickList', () => {
test('front-end pick list', async () => {
const resource = await unsafeFetchPickList('_AgentTypeComboBox');
@@ -113,7 +117,22 @@ describe('fetchPickListItems', () => {
},
objects: [{ id: 3, _tableName: 'Locality', localityname: 'abc' }],
});
+ overrideAjax('/api/specify/locality/?domainfilter=false&limit=0', {
+ meta: {
+ total_count: 2,
+ },
+ objects: [
+ { id: 3, _tableName: 'Locality', localityname: 'abc' },
+ { id: 4, _tableName: 'Locality', localityname: 'def' },
+ ],
+ });
test('pick list from entire table', async () => {
+ const remotePrefs = await import('../../InitialContext/remotePrefs');
+ jest
+ .spyOn(remotePrefs, 'ensureCollectionPreferencesLoaded')
+ .mockRejectedValue(new Error('no prefs'));
+ jest.spyOn(remotePrefs, 'getCollectionPref').mockReturnValue(true);
+
const pickList = deserializeResource(
addMissingFields('PickList', {
type: PickListTypes.TABLE,
@@ -130,6 +149,16 @@ describe('fetchPickListItems', () => {
]);
});
+ overrideAjax('/api/specify/locality/?limit=0', {
+ meta: {
+ total_count: 2,
+ },
+ objects: [
+ { id: 3, _tableName: 'Locality', localityname: 'abc' },
+ { id: 4, _tableName: 'Locality', localityname: 'def' },
+ ],
+ });
+
overrideAjax('/api/specify/collection/?domainfilter=true&limit=0', {
meta: {
total_count: 1,
@@ -137,6 +166,16 @@ describe('fetchPickListItems', () => {
objects: [{ id: 1, _tableName: 'Collection', collectionname: 'abc' }],
});
+ overrideAjax('/api/specify/collection/?domainfilter=false&limit=0', {
+ meta: {
+ total_count: 2,
+ },
+ objects: [
+ { id: 1, _tableName: 'Collection', collectionname: 'abc' },
+ { id: 2, _tableName: 'Collection', collectionname: 'cba' },
+ ],
+ });
+
overrideAjax('/api/specify/collection/?limit=0', {
meta: {
total_count: 2,
@@ -148,6 +187,12 @@ describe('fetchPickListItems', () => {
});
test('Picklistitems for Entire Table scoped by default', async () => {
+ const remotePrefs = await import('../../InitialContext/remotePrefs');
+ jest
+ .spyOn(remotePrefs, 'ensureCollectionPreferencesLoaded')
+ .mockRejectedValue(new Error('no prefs'));
+ jest.spyOn(remotePrefs, 'getCollectionPref').mockReturnValue(true);
+
const picklist = deserializeResource(
addMissingFields('PickList', {
type: PickListTypes.TABLE,
@@ -163,6 +208,9 @@ describe('fetchPickListItems', () => {
test('Picklistitems unscoped for sp7_scope_table_picklists', async () => {
const remotePrefs = await import('../../InitialContext/remotePrefs');
+ jest
+ .spyOn(remotePrefs, 'ensureCollectionPreferencesLoaded')
+ .mockRejectedValue(new Error('no prefs'));
jest
.spyOn(remotePrefs, 'getCollectionPref')
.mockImplementation(() => false);
diff --git a/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts b/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts
index 1b6bcf4b785..6a187a28675 100644
--- a/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts
+++ b/specifyweb/frontend/js_src/lib/components/PickLists/fetch.ts
@@ -24,7 +24,10 @@ import type { PickList, PickListItem, Tables } from '../DataModel/types';
import { softFail } from '../Errors/Crash';
import { format } from '../Formatters/formatters';
import type { PickListItemSimple } from '../FormFields/ComboBox';
-import { getCollectionPref } from '../InitialContext/remotePrefs';
+import {
+ ensureCollectionPreferencesLoaded,
+ getCollectionPref,
+} from '../InitialContext/remotePrefs';
import { hasTablePermission, hasToolPermission } from '../Permissions/helpers';
import {
createPickListItem,
@@ -118,18 +121,39 @@ async function fetchFromTable(
pickList: SpecifyResource,
limit: number
): Promise> {
- const tableName = strictGetTable(pickList.get('tableName')).name;
+ const specifyTable = strictGetTable(pickList.get('tableName'));
+ const tableName = specifyTable.name;
if (!hasTablePermission(tableName, 'read')) return [];
- const scopeTablePicklist = getCollectionPref(
- 'sp7_scope_table_picklists',
- schema.domainLevelIds.collection
- );
+ let scopeTablePicklist: boolean;
+ try {
+ const collectionPreferences = await ensureCollectionPreferencesLoaded();
+ const rawValue =
+ collectionPreferences.getRaw()?.general?.pickLists
+ ?.sp7_scope_table_picklists;
+ scopeTablePicklist =
+ typeof rawValue === 'boolean'
+ ? rawValue
+ : collectionPreferences.get(
+ 'general',
+ 'pickLists',
+ 'sp7_scope_table_picklists'
+ );
+ } catch {
+ scopeTablePicklist = getCollectionPref(
+ 'sp7_scope_table_picklists',
+ schema.domainLevelIds.collection
+ );
+ }
+ const tableHasScope = specifyTable.getScope() !== undefined;
+ const tableSupportsDomainFilter =
+ tableHasScope ||
+ !f.includes(Object.keys(schema.domainLevelIds), toLowerCase(tableName));
const { records } = await fetchCollection(tableName, {
- domainFilter: scopeTablePicklist
- ? true
- : !f.includes(Object.keys(schema.domainLevelIds), toLowerCase(tableName)),
+ domainFilter: tableSupportsDomainFilter
+ ? Boolean(scopeTablePicklist)
+ : undefined,
limit,
});
return Promise.all(
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx
index 806575fafad..4a5463691d3 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/Aside.tsx
@@ -7,18 +7,21 @@ import type { GetSet, WritableArray } from '../../utils/types';
import { Link } from '../Atoms/Link';
import { pathIsOverlay } from '../Router/UnloadProtect';
import { scrollIntoView } from '../TreeView/helpers';
+import type { PreferenceType } from './index';
import { usePrefDefinitions } from './index';
export function PreferencesAside({
activeCategory,
setActiveCategory,
references,
+ prefType = 'user',
}: {
readonly activeCategory: number | undefined;
readonly setActiveCategory: (activeCategory: number | undefined) => void;
readonly references: React.RefObject>;
+ readonly prefType?: PreferenceType;
}): JSX.Element {
- const definitions = usePrefDefinitions();
+ const definitions = usePrefDefinitions(prefType);
const navigate = useNavigate();
const location = useLocation();
const isInOverlay = pathIsOverlay(location.pathname);
@@ -38,6 +41,22 @@ export function PreferencesAside({
const [freezeCategory, setFreezeCategory] = useFrozenCategory();
const currentIndex = freezeCategory ?? activeCategory;
+ const visibleDefinitions = React.useMemo(
+ () =>
+ definitions
+ .map(
+ (definition, index) =>
+ [index, definition] as const
+ )
+ .filter(
+ ([, [category]]) =>
+ !(
+ prefType === 'collection' &&
+ category === 'catalogNumberParentInheritance'
+ )
+ ),
+ [definitions, prefType]
+ );
React.useEffect(() => {
const active = location.hash.replace('#', '').toLowerCase();
@@ -58,12 +77,14 @@ export function PreferencesAside({
overflow-y-auto md:sticky md:flex-1
`}
>
- {definitions.map(([category, { title }], index) => (
+ {visibleDefinitions.map(([definitionIndex, [category, { title }]]) => (
setFreezeCategory(index)}
+ onClick={(): void => setFreezeCategory(definitionIndex)}
>
{typeof title === 'function' ? title() : title}
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx
index ce8612a47fe..a8944be59fc 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx
@@ -1,80 +1,151 @@
+/**
+ * Definitions for Collection preferences
+ */
+
+import type { LocalizedString } from 'typesafe-i18n';
+
+import { attachmentsText } from '../../localization/attachments';
import { preferencesText } from '../../localization/preferences';
import { queryText } from '../../localization/query';
import { specifyNetworkText } from '../../localization/specifyNetwork';
import { statsText } from '../../localization/stats';
+import { treeText } from '../../localization/tree';
import { f } from '../../utils/functools';
import type { RA } from '../../utils/types';
-import { ensure, localized } from '../../utils/types';
-import type { QueryView } from '../QueryBuilder/Header';
+import { ensure } from '../../utils/types';
+import { camelToHuman } from '../../utils/utils';
+import { getField } from '../DataModel/helpers';
+import { genericTables } from '../DataModel/tables';
+import { tables } from '../DataModel/tables';
+import type { Tables } from '../DataModel/types';
import type { StatLayout } from '../Statistics/types';
import type { GenericPreferences } from './types';
import { definePref } from './types';
+const tableLabel = (tableName: keyof Tables): LocalizedString =>
+ genericTables[tableName]?.label ?? camelToHuman(tableName);
+
+const specifyNetworkItems = {
+ publishingOrganization: definePref({
+ title: specifyNetworkText.publishingOrganizationKey(),
+ description: specifyNetworkText.publishingOrganizationKeyDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: undefined,
+ type: 'java.lang.String',
+ }),
+ collectionKey: definePref({
+ title: specifyNetworkText.collectionKey(),
+ description: specifyNetworkText.collectionKeyDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: undefined,
+ type: 'java.lang.String',
+ }),
+} as const;
+
export const collectionPreferenceDefinitions = {
- statistics: {
- title: statsText.statistics(),
+ general: {
+ title: preferencesText.general(),
subCategories: {
- appearance: {
- title: preferencesText.appearance(),
+ pickLists: {
+ title: preferencesText.filterPickLists(),
items: {
- layout: definePref | undefined>({
- title: localized('_Defines the layout of the stats page'),
+ sp7_scope_table_picklists: definePref({
+ title: preferencesText.scopeEntireTablePicklists(),
+ description: preferencesText.scopeEntireTablePicklistsDescription(),
requiresReload: false,
- visible: false,
- defaultValue: undefined,
- renderer: f.never,
- container: 'label',
- }),
- showPreparationsTotal: definePref({
- title: localized('Defines if preparation stats include total'),
- requiresReload: false,
- visible: false,
- defaultValue: true,
- renderer: f.never,
- container: 'label',
+ visible: true,
+ defaultValue: false,
type: 'java.lang.Boolean',
}),
- refreshRate: definePref({
- title: localized('_Defines the rate of auto refresh in hours'),
- requiresReload: false,
- visible: false,
- defaultValue: 24,
- renderer: f.never,
- container: 'label',
- type: 'java.lang.Float',
- }),
},
},
- specifyNetwork: {
- title: specifyNetworkText.specifyNetwork(),
+ attachments: {
+ title: attachmentsText.attachments(),
items: {
- publishingOrganization: definePref({
- title: localized('_Stores GBIF\'s "publishingOrgKey"'),
+ 'attachment.is_public_default': definePref({
+ title: attachmentsText.publicDefault(),
+ description: attachmentsText.publicDefaultDescription(),
requiresReload: false,
- visible: false,
- defaultValue: undefined,
- renderer: f.never,
- container: 'label',
- }),
- collectionKey: definePref({
- title: localized('_Stores GBIF\'s "dataSetKey"'),
- requiresReload: false,
- visible: false,
- defaultValue: undefined,
- renderer: f.never,
- container: 'label',
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
}),
},
},
},
},
+
+ treeManagement: {
+ title: treeText.treeManagement(),
+ subCategories: {
+ synonymized: {
+ title: treeText.synonymizedNodes(),
+ description: treeText.synonymizedNodesDescription(),
+ items: {
+ 'sp7.allow_adding_child_to_synonymized_parent.Taxon':
+ definePref({
+ title: () => tableLabel('Taxon'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ 'sp7.allow_adding_child_to_synonymized_parent.Geography':
+ definePref({
+ title: () => tableLabel('Geography'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ 'sp7.allow_adding_child_to_synonymized_parent.Storage':
+ definePref({
+ title: () => tableLabel('Storage'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ 'sp7.allow_adding_child_to_synonymized_parent.GeologicTimePeriod':
+ definePref({
+ title: () => tableLabel('GeologicTimePeriod'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ 'sp7.allow_adding_child_to_synonymized_parent.LithoStrat':
+ definePref({
+ title: () => tableLabel('LithoStrat'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ 'sp7.allow_adding_child_to_synonymized_parent.TectonicUnit':
+ definePref({
+ title: () => tableLabel('TectonicUnit'),
+ requiresReload: false,
+ visible: true,
+ defaultValue: false,
+ type: 'java.lang.Boolean',
+ }),
+ },
+ },
+ },
+ },
queryBuilder: {
title: queryText.queryBuilder(),
subCategories: {
appearance: {
title: preferencesText.appearance(),
items: {
- display: definePref({
+ display: definePref<{
+ readonly basicView: RA;
+ readonly detailedView: RA;
+ }>({
title: preferencesText.displayBasicView(),
requiresReload: false,
visible: false,
@@ -89,19 +160,72 @@ export const collectionPreferenceDefinitions = {
},
},
},
+
+ statistics: {
+ title: statsText.statistics(),
+ subCategories: {
+ appearance: {
+ title: preferencesText.appearance(),
+ items: {
+ layout: definePref | undefined>({
+ title: statsText.layoutPreference(),
+ requiresReload: false,
+ visible: false,
+ defaultValue: undefined,
+ renderer: f.never,
+ container: 'label',
+ }),
+ showPreparationsTotal: definePref({
+ title: statsText.showPreparationsTotal(),
+ description: statsText.showPreparationsTotalDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: true,
+ type: 'java.lang.Boolean',
+ }),
+ refreshRate: definePref({
+ title: statsText.autoRefreshRate(),
+ description: statsText.autoRefreshRateDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: 24,
+ type: 'java.lang.Integer',
+ }),
+ },
+ },
+ specifyNetwork: {
+ title: specifyNetworkText.specifyNetwork(),
+ items: specifyNetworkItems,
+ },
+ },
+ },
+
catalogNumberInheritance: {
title: queryText.catalogNumberInheritance(),
subCategories: {
behavior: {
- title: preferencesText.behavior(),
+ title: () => tableLabel('CollectionObjectGroup'),
items: {
inheritance: definePref({
- title: preferencesText.inheritanceCatNumberPref(),
+ title: () =>
+ preferencesText.inheritanceCatNumberPref({
+ catalogNumber: getField(
+ tables.CollectionObject,
+ 'catalogNumber'
+ ).label,
+ collectionObject: tables.CollectionObject.label,
+ }),
+ description: () =>
+ preferencesText.inheritanceCatNumberPrefDescription({
+ catalogNumber: getField(
+ tables.CollectionObject,
+ 'catalogNumber'
+ ).label,
+ collectionObject: tables.CollectionObject.label,
+ }),
requiresReload: false,
- visible: false,
+ visible: true,
defaultValue: false,
- renderer: f.never,
- container: 'label',
type: 'java.lang.Boolean',
}),
},
@@ -109,24 +233,38 @@ export const collectionPreferenceDefinitions = {
},
},
catalogNumberParentInheritance: {
- title: queryText.catalogNumberParentCOInheritance(),
+ title: queryText.catalogNumberInheritance(),
subCategories: {
behavior: {
- title: preferencesText.behavior(),
+ title: () => camelToHuman('Component'),
items: {
inheritance: definePref({
- title: preferencesText.inheritanceCatNumberParentCOPref(),
+ title: () =>
+ preferencesText.inheritanceCatNumberParentCOPref({
+ catalogNumber: getField(
+ tables.CollectionObject,
+ 'catalogNumber'
+ ).label,
+ collectionObject: tables.CollectionObject.label,
+ }),
+ description: () =>
+ preferencesText.inheritanceCatNumberParentCOPrefDescription({
+ catalogNumber: getField(
+ tables.CollectionObject,
+ 'catalogNumber'
+ ).label,
+ collectionObject: tables.CollectionObject.label,
+ }),
requiresReload: false,
- visible: false,
+ visible: true,
defaultValue: false,
- renderer: f.never,
- container: 'label',
type: 'java.lang.Boolean',
}),
},
},
},
},
+
uniqueCatalogNumberAccrossComponentAndCO: {
title: queryText.uniqueCatalogNumberAcrossComponentAndCo(),
subCategories: {
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
index bac46be2988..b7b53ff6ad1 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
@@ -2,41 +2,125 @@ import React from 'react';
import { useLiveState } from '../../hooks/useLiveState';
import type { AppResourceTabProps } from '../AppResources/TabDefinitions';
+import type { PreferenceType } from '../Preferences';
import { PreferencesContent } from '../Preferences';
+import { PreferencesAside } from '../Preferences/Aside';
import { BasePreferences } from '../Preferences/BasePreferences';
import { userPreferenceDefinitions } from '../Preferences/UserDefinitions';
import { userPreferences } from '../Preferences/userPreferences';
+import { useTopChild } from '../Preferences/useTopChild';
+import { collectionPreferenceDefinitions } from './CollectionDefinitions';
+import { collectionPreferences } from './collectionPreferences';
+import type { GenericPreferences } from './types';
-export function UserPreferencesEditor({
- data,
- onChange: handleChange,
-}: AppResourceTabProps): JSX.Element {
- const [preferencesContext] = useLiveState(
- React.useCallback(() => {
- const userPreferences = new BasePreferences({
- definitions: userPreferenceDefinitions,
- values: {
- resourceName: 'UserPreferences',
- fetchUrl: '/context/user_resource/',
- },
- defaultValues: undefined,
- developmentGlobal: '_editingUserPreferences',
- syncChanges: false,
- });
- userPreferences.setRaw(
- JSON.parse(data === null || data.length === 0 ? '{}' : data)
- );
- userPreferences.events.on('update', () =>
- handleChange(JSON.stringify(userPreferences.getRaw()))
- );
- return userPreferences;
- }, [handleChange])
- );
-
- const Context = userPreferences.Context;
- return (
-
-
-
- );
+type EditorDependencies = Pick;
+
+type PreferencesEditorConfig = {
+ readonly definitions: DEFINITIONS;
+ readonly Context: BasePreferences['Context'];
+ readonly resourceName: string;
+ readonly fetchUrl: string;
+ readonly developmentGlobal: string;
+ readonly prefType?: PreferenceType;
+ readonly dependencyResolver?: (
+ inputs: EditorDependencies
+ ) => React.DependencyList;
+};
+
+const defaultDependencyResolver = ({ onChange }: EditorDependencies) => [
+ onChange,
+];
+
+function createPreferencesEditor(
+ config: PreferencesEditorConfig
+) {
+ const {
+ definitions,
+ Context,
+ resourceName,
+ fetchUrl,
+ developmentGlobal,
+ prefType,
+ dependencyResolver = defaultDependencyResolver,
+ } = config;
+
+ return function PreferencesEditor({
+ data,
+ onChange,
+ }: AppResourceTabProps): JSX.Element {
+ const dependencies = dependencyResolver({ data, onChange });
+
+ const [preferencesInstance] = useLiveState>(
+ React.useCallback(() => {
+ const preferences = new BasePreferences({
+ definitions,
+ values: {
+ resourceName,
+ fetchUrl,
+ },
+ defaultValues: undefined,
+ developmentGlobal,
+ syncChanges: false,
+ });
+
+ preferences.setRaw(
+ JSON.parse(data === null || data.length === 0 ? '{}' : data)
+ );
+
+ preferences.events.on('update', () =>
+ onChange(JSON.stringify(preferences.getRaw()))
+ );
+
+ return preferences;
+ }, dependencies)
+ );
+
+ const Provider = Context.Provider;
+ const contentProps = prefType === undefined ? {} : { prefType };
+ const {
+ visibleChild,
+ setVisibleChild,
+ references,
+ forwardRefs,
+ scrollContainerRef,
+ } = useTopChild();
+ const asidePrefType = prefType ?? 'user';
+
+ return (
+
+
+
+ );
+ };
}
+
+export const UserPreferencesEditor = createPreferencesEditor({
+ definitions: userPreferenceDefinitions,
+ Context: userPreferences.Context,
+ resourceName: 'UserPreferences',
+ fetchUrl: '/context/user_resource/',
+ developmentGlobal: 'editingUserPreferences',
+ dependencyResolver: ({ onChange }) => [onChange],
+});
+
+export const CollectionPreferencesEditor = createPreferencesEditor({
+ definitions: collectionPreferenceDefinitions,
+ Context: collectionPreferences.Context,
+ resourceName: 'CollectionPreferences',
+ fetchUrl: '/context/collection_resource/',
+ developmentGlobal: 'editingCollectionPreferences',
+ prefType: 'collection',
+ dependencyResolver: ({ data, onChange }) => [data, onChange],
+});
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx
index 0af5d46b5a2..a1d25e5ff3e 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx
@@ -24,12 +24,16 @@ import type { RA } from '../../utils/types';
import { Input, Select, Textarea } from '../Atoms/Form';
import { iconClassName } from '../Atoms/Icons';
import { ReadOnlyContext } from '../Core/Contexts';
-import type { AnySchema } from '../DataModel/helperTypes';
+import type { AnySchema, AnyTree } from '../DataModel/helperTypes';
import type { SpecifyTable } from '../DataModel/specifyTable';
import { tables } from '../DataModel/tables';
import type { Collection } from '../DataModel/types';
import { rawMenuItemsPromise } from '../Header/menuItemDefinitions';
import { useMenuItems, useUserTools } from '../Header/menuItemProcessing';
+import {
+ getTreeDefinitions,
+ treeRanksPromise,
+} from '../InitialContext/treeRanks';
import { AttachmentPicker } from '../Molecules/AttachmentPicker';
import { AutoComplete } from '../Molecules/AutoComplete';
import { ListEdit } from '../Toolbar/ListEdit';
@@ -378,3 +382,63 @@ export function DefaultPreferenceItemRender({
/>
);
}
+
+type Rank = { readonly rankId: number; readonly name: string };
+
+/*
+ * This grabs the ranks from the API and displays them in a dropdown
+ * The ranks are sorted in ascending order by `rankId` so they appear in the correct order for the user
+ */
+export function ThresholdRank({
+ value,
+ onChange,
+ tableName,
+}: PreferenceRendererProps & {
+ readonly tableName: AnyTree['tableName'];
+}): JSX.Element {
+ const [items, setItems] = React.useState([]);
+
+ React.useEffect(() => {
+ let isMounted = true;
+ treeRanksPromise
+ .then(() => {
+ const definitions = getTreeDefinitions(tableName);
+ const activeDefinition = definitions[0];
+ /**
+ * Only expose ranks from the treedef tied to the current discipline.
+ * Otherwise ranks from unrelated trees leak into the dropdown.
+ */
+ const ranks = activeDefinition?.ranks ?? [];
+ if (!isMounted) return;
+ setItems(
+ ranks
+ .map(({ rankId, name }) => ({
+ rankId,
+ name,
+ }))
+ .sort((rankA, rankB) => rankA.rankId - rankB.rankId)
+ );
+ })
+ .catch((error: unknown) => {
+ console.error('Error fetching ThresholdRank items:', error);
+ if (isMounted) setItems([]);
+ });
+ return () => {
+ isMounted = false;
+ };
+ }, [tableName]);
+
+ return (
+
+ );
+}
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx
index 722657bfe27..c40a1016154 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx
@@ -50,6 +50,7 @@ import {
defaultFont,
FontFamilyPreferenceItem,
HeaderItemsPreferenceItem,
+ ThresholdRank,
WelcomePageModePreferenceItem,
} from './Renderers';
import type { GenericPreferences, PreferencesVisibilityContext } from './types';
@@ -1395,7 +1396,7 @@ export const userPreferenceDefinitions = {
title: preferencesText.sortByField(),
requiresReload: false,
visible: true,
- defaultValue: 'rankId',
+ defaultValue: 'name',
values: [
{
value: 'name',
@@ -1474,6 +1475,17 @@ export const userPreferenceDefinitions = {
renderer: ColorPickerPreferenceItem,
container: 'label',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => (
+
+ ),
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
@@ -1511,6 +1523,15 @@ export const userPreferenceDefinitions = {
defaultValue: true,
type: 'java.lang.Boolean',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => ,
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
@@ -1541,6 +1562,17 @@ export const userPreferenceDefinitions = {
renderer: ColorPickerPreferenceItem,
container: 'label',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => (
+
+ ),
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
@@ -1571,6 +1603,17 @@ export const userPreferenceDefinitions = {
renderer: ColorPickerPreferenceItem,
container: 'label',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => (
+
+ ),
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
@@ -1601,6 +1644,17 @@ export const userPreferenceDefinitions = {
renderer: ColorPickerPreferenceItem,
container: 'label',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => (
+
+ ),
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
@@ -1631,6 +1685,17 @@ export const userPreferenceDefinitions = {
renderer: ColorPickerPreferenceItem,
container: 'label',
}),
+ rankThreshold: definePref({
+ title: preferencesText.rankThreshold(),
+ description: preferencesText.rankThresholdDescription(),
+ requiresReload: true,
+ visible: true,
+ defaultValue: 0,
+ renderer: (props) => (
+
+ ),
+ container: 'label',
+ }),
statsThreshold: definePref({
title: preferencesText.treeStatsThreshold(),
description: preferencesText.treeStatsThresholdDescription(),
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/ThresholdRank.test.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/ThresholdRank.test.tsx
new file mode 100644
index 00000000000..5bc25699f33
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/ThresholdRank.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+
+import { preferencesText } from '../../../localization/preferences';
+import { overrideAjax } from '../../../tests/ajax';
+import { requireContext } from '../../../tests/helpers';
+import * as treeRanks from '../../InitialContext/treeRanks';
+import { ThresholdRank } from '../Renderers';
+import type { PreferenceItem } from '../types';
+
+overrideAjax('/context/schema_localization.json', {});
+requireContext();
+
+const mockedGetTreeDefinitions = jest.spyOn(treeRanks, 'getTreeDefinitions');
+
+describe('ThresholdRank', () => {
+ beforeEach(() => {
+ mockedGetTreeDefinitions.mockReset();
+ });
+
+ test('only renders ranks from the active tree definition', async () => {
+ mockedGetTreeDefinitions.mockReturnValue([
+ {
+ definition: { id: 1 } as any,
+ ranks: [
+ { rankId: 50, name: 'Active Rank B' },
+ { rankId: 10, name: 'Active Rank A' },
+ ] as any,
+ },
+ {
+ definition: { id: 2 } as any,
+ ranks: [{ rankId: 5, name: 'Inactive Rank' }] as any,
+ },
+ ]);
+
+ const definition: PreferenceItem = {
+ title: preferencesText.rankThreshold(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: 0,
+ values: [],
+ };
+ render(
+
+ );
+
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3));
+
+ const labels = screen
+ .getAllByRole('option')
+ .map((option) => option.textContent);
+ expect(labels).toEqual(['None', 'Active Rank A', 'Active Rank B']);
+ expect(screen.queryByText('Inactive Rank')).not.toBeInTheDocument();
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
index 64faa2c0d8f..3d188776f0b 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
@@ -9,9 +9,11 @@ import type { LocalizedString } from 'typesafe-i18n';
import { usePromise } from '../../hooks/useAsyncState';
import { useBooleanState } from '../../hooks/useBooleanState';
import { commonText } from '../../localization/common';
+import { headerText } from '../../localization/header';
import { preferencesText } from '../../localization/preferences';
import { StringToJsx } from '../../localization/utils';
import { f } from '../../utils/functools';
+import type { IR } from '../../utils/types';
import { Container, H2, Key } from '../Atoms';
import { Button } from '../Atoms/Button';
import { className } from '../Atoms/className';
@@ -21,7 +23,13 @@ import { Submit } from '../Atoms/Submit';
import { LoadingContext, ReadOnlyContext } from '../Core/Contexts';
import { ErrorBoundary } from '../Errors/ErrorBoundary';
import { hasPermission } from '../Permissions/helpers';
+import {
+ ProtectedAction,
+ ProtectedTool,
+} from '../Permissions/PermissionDenied';
import { PreferencesAside } from './Aside';
+import type { BasePreferences } from './BasePreferences';
+import { collectionPreferenceDefinitions } from './CollectionDefinitions';
import { collectionPreferences } from './collectionPreferences';
import { useDarkMode } from './Hooks';
import { DefaultPreferenceItemRender } from './Renderers';
@@ -30,6 +38,58 @@ import { userPreferenceDefinitions } from './UserDefinitions';
import { userPreferences } from './userPreferences';
import { useTopChild } from './useTopChild';
+export type PreferenceType = keyof typeof preferenceInstances;
+
+const preferenceInstances: IR> = {
+ user: userPreferences,
+ collection: collectionPreferences,
+};
+
+const preferenceDefinitions: IR = {
+ user: userPreferenceDefinitions,
+ collection: collectionPreferenceDefinitions,
+};
+
+type SubcategoryDocumentation = {
+ readonly href: string;
+ readonly label: LocalizedString | (() => LocalizedString);
+};
+
+const SUBCATEGORY_DOCS_MAP: Record<
+ string,
+ Record
+> = {
+ treeManagement: {
+ synonymized: {
+ href: 'https://discourse.specifysoftware.org/t/enable-creating-children-for-synonymized-nodes/987',
+ label: headerText.documentation(),
+ },
+ },
+ statistics: {
+ appearance: {
+ href: 'https://discourse.specifysoftware.org/t/statistics-page/1135',
+ label: headerText.documentation(),
+ },
+ },
+};
+
+type DocumentHrefResolver =
+ | ((
+ category: string,
+ subcategory: string,
+ name: string
+ ) => string | undefined)
+ | undefined;
+
+const documentHrefResolvers: IR = {
+ user: undefined,
+ collection: undefined,
+};
+
+const collectionPreferencesPromise = Promise.all([
+ collectionPreferences.fetch(),
+]).then(f.true);
+
/**
* Fetch app resource that stores current user preferences
*
@@ -42,20 +102,29 @@ const preferencesPromise = Promise.all([
collectionPreferences.fetch(),
]).then(f.true);
-function Preferences(): JSX.Element {
+function Preferences({
+ prefType = 'user',
+}: {
+ readonly prefType?: PreferenceType;
+} = {}): JSX.Element {
const [changesMade, handleChangesMade] = useBooleanState();
const [needsRestart, handleRestartNeeded] = useBooleanState();
const loading = React.useContext(LoadingContext);
const navigate = useNavigate();
+ const basePreferences = preferenceInstances[prefType];
+ const heading =
+ prefType === 'collection'
+ ? preferencesText.collectionPreferences()
+ : preferencesText.preferences();
React.useEffect(
() =>
- userPreferences.events.on('update', (payload) => {
+ basePreferences.events.on('update', (payload) => {
if (payload?.definition?.requiresReload === true) handleRestartNeeded();
handleChangesMade();
}),
- [handleChangesMade, handleRestartNeeded]
+ [basePreferences, handleChangesMade, handleRestartNeeded]
);
const {
@@ -68,12 +137,12 @@ function Preferences(): JSX.Element {
return (
- {preferencesText.preferences()}
+ {heading}