From f1eddb7188bc4964edcbe6a90d61769cf25b5ff7 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 18:43:28 -0400
Subject: [PATCH 01/31] Feat: Refactor and add UI to control Global preferences
---
.../Preferences/GlobalDefinitions.ts | 76 ++++++
.../Preferences/globalPreferences.ts | 29 +++
.../Preferences/globalPreferencesUtils.ts | 224 ++++++++++++++++++
3 files changed, 329 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
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..f6305695082
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -0,0 +1,76 @@
+import { preferencesText } from '../../localization/preferences';
+import { attachmentsText } from '../../localization/attachments';
+import { definePref } from './types';
+
+export const FULL_DATE_FORMAT_OPTIONS = [
+ 'YYYY-MM-DD',
+ 'MM/DD/YYYY',
+ 'DD/MM/YYYY',
+ 'YYYY/MM/DD',
+ 'DD MMM YYYY',
+] as const;
+
+export const MONTH_YEAR_FORMAT_OPTIONS = ['YYYY-MM', 'MM/YYYY', 'YYYY/MM'] as const;
+
+export const globalPreferenceDefinitions = {
+ general: {
+ title: preferencesText.general(),
+ subCategories: {
+ auditing: {
+ title: preferencesText.auditing(),
+ 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',
+ }),
+ },
+ },
+ formatting: {
+ title: preferencesText.formatting(),
+ items: {
+ fullDateFormat: definePref({
+ title: preferencesText.fullDateFormat(),
+ description: preferencesText.fullDateFormatDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: 'YYYY-MM-DD',
+ values: FULL_DATE_FORMAT_OPTIONS.slice(),
+ }),
+ monthYearDateFormat: definePref({
+ title: preferencesText.monthYearDateFormat(),
+ description: preferencesText.monthYearDateFormatDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: 'YYYY-MM',
+ values: MONTH_YEAR_FORMAT_OPTIONS.slice(),
+ }),
+ },
+ },
+ attachments: {
+ title: attachmentsText.attachments(),
+ items: {
+ attachmentThumbnailSize: definePref({
+ title: preferencesText.attachmentThumbnailSize(),
+ description: preferencesText.attachmentThumbnailSizeDescription(),
+ requiresReload: false,
+ visible: true,
+ defaultValue: 256,
+ type: 'java.lang.Integer',
+ }),
+ },
+ },
+ },
+ },
+} as const;
\ No newline at end of file
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
new file mode 100644
index 00000000000..fb796f707d8
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
@@ -0,0 +1,29 @@
+import { BasePreferences } from './BasePreferences';
+import { globalPreferenceDefinitions } from './GlobalDefinitions';
+
+export type GlobalPreferenceValues = {
+ readonly general: {
+ readonly auditing: {
+ readonly enableAuditLog: boolean;
+ readonly logFieldLevelChanges: boolean;
+ };
+ readonly formatting: {
+ readonly fullDateFormat: string;
+ readonly monthYearDateFormat: string;
+ };
+ readonly attachments: {
+ readonly attachmentThumbnailSize: number;
+ };
+ };
+};
+
+export const globalPreferences = new BasePreferences({
+ definitions: globalPreferenceDefinitions,
+ values: {
+ resourceName: 'GlobalPreferences',
+ fetchUrl: '/context/app.resource/',
+ },
+ defaultValues: undefined,
+ developmentGlobal: '_globalPreferences',
+ syncChanges: false,
+});
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
new file mode 100644
index 00000000000..528088a53e9
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
@@ -0,0 +1,224 @@
+import type { PreferenceItem } from './types';
+import type { GlobalPreferenceValues } from './globalPreferences';
+import { FULL_DATE_FORMAT_OPTIONS, MONTH_YEAR_FORMAT_OPTIONS } from './GlobalDefinitions';
+
+export type PropertyLine =
+ | { readonly type: 'comment' | 'empty'; readonly raw: string }
+ | {
+ readonly type: 'entry';
+ readonly key: string;
+ readonly value: string;
+ readonly raw: string;
+ };
+
+const PREFERENCE_KEYS = {
+ enableAuditLog: 'auditing.do_audits',
+ logFieldLevelChanges: 'auditing.audit_field_updates',
+ fullDateFormat: 'ui.formatting.scrdateformat',
+ monthYearDateFormat: 'ui.formatting.scrmonthformat',
+ attachmentThumbnailSize: 'attachment.preview_size',
+} as const;
+
+type ParsedProperties = {
+ readonly lines: ReadonlyArray;
+ readonly map: Record;
+};
+
+const DATE_FORMAT_NORMALIZER = new Set([
+ ...FULL_DATE_FORMAT_OPTIONS,
+ ...MONTH_YEAR_FORMAT_OPTIONS,
+]);
+
+export const DEFAULT_VALUES: GlobalPreferenceValues = {
+ general: {
+ auditing: {
+ enableAuditLog: true,
+ logFieldLevelChanges: true,
+ },
+ formatting: {
+ fullDateFormat: 'YYYY-MM-DD',
+ monthYearDateFormat: 'YYYY-MM',
+ },
+ attachments: {
+ attachmentThumbnailSize: 256,
+ },
+ },
+};
+
+function normalizeFormat(value: string): string {
+ const upper = value.toUpperCase();
+ return DATE_FORMAT_NORMALIZER.has(upper) ? upper : upper;
+}
+
+function parseProperties(data: string): ParsedProperties {
+ const lines = data.split(/\r?\n/u);
+ const parsed: PropertyLine[] = [];
+ const map: Record = {};
+
+ lines.forEach((line) => {
+ if (line.trim().length === 0) parsed.push({ type: 'empty', raw: line });
+ else if (line.trimStart().startsWith('#'))
+ parsed.push({ type: 'comment', raw: line });
+ else {
+ const separatorIndex = line.indexOf('=');
+ if (separatorIndex === -1) {
+ parsed.push({ type: 'comment', raw: line });
+ return;
+ }
+ const key = line.slice(0, separatorIndex).trim();
+ const value = line.slice(separatorIndex + 1).trim();
+ map[key] = value;
+ parsed.push({ type: 'entry', key, value, raw: `${key}=${value}` });
+ }
+ });
+
+ return { lines: parsed, map };
+}
+
+function parseBoolean(value: string | undefined, fallback: boolean): boolean {
+ if (typeof value !== 'string') return fallback;
+ if (value.toLowerCase() === 'true') return true;
+ if (value.toLowerCase() === 'false') return false;
+ return fallback;
+}
+
+function parseNumber(value: string | undefined, fallback: number): number {
+ if (typeof value !== 'string') return fallback;
+ const parsed = Number.parseInt(value, 10);
+ return Number.isNaN(parsed) ? fallback : parsed;
+}
+
+export function preferencesFromMap(map: Record): GlobalPreferenceValues {
+ const fullDateFormat = normalizeFormat(
+ map[PREFERENCE_KEYS.fullDateFormat] ?? DEFAULT_VALUES.general.formatting.fullDateFormat
+ );
+ const monthYearFormat = normalizeFormat(
+ map[PREFERENCE_KEYS.monthYearDateFormat] ?? DEFAULT_VALUES.general.formatting.monthYearDateFormat
+ );
+
+ return {
+ general: {
+ auditing: {
+ enableAuditLog: parseBoolean(
+ map[PREFERENCE_KEYS.enableAuditLog],
+ DEFAULT_VALUES.general.auditing.enableAuditLog
+ ),
+ logFieldLevelChanges: parseBoolean(
+ map[PREFERENCE_KEYS.logFieldLevelChanges],
+ DEFAULT_VALUES.general.auditing.logFieldLevelChanges
+ ),
+ },
+ formatting: {
+ fullDateFormat,
+ monthYearDateFormat: MONTH_YEAR_FORMAT_OPTIONS.includes(
+ monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number]
+ )
+ ? (monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number])
+ : DEFAULT_VALUES.general.formatting.monthYearDateFormat,
+ },
+ attachments: {
+ attachmentThumbnailSize: parseNumber(
+ map[PREFERENCE_KEYS.attachmentThumbnailSize],
+ DEFAULT_VALUES.general.attachments.attachmentThumbnailSize
+ ),
+ },
+ },
+ };
+}
+
+export function parseGlobalPreferences(
+ data: string | null
+): { readonly raw: GlobalPreferenceValues; readonly metadata: ReadonlyArray } {
+ const parsed = parseProperties(data ?? '');
+ const values = preferencesFromMap(parsed.map);
+ return { raw: values, metadata: parsed.lines };
+}
+
+function normalizeValues(
+ values: GlobalPreferenceValues | Partial | undefined
+): GlobalPreferenceValues {
+ const merged = values ?? DEFAULT_VALUES;
+ return {
+ general: {
+ auditing: {
+ enableAuditLog:
+ merged.general?.auditing?.enableAuditLog ?? DEFAULT_VALUES.general.auditing.enableAuditLog,
+ logFieldLevelChanges:
+ merged.general?.auditing?.logFieldLevelChanges ??
+ DEFAULT_VALUES.general.auditing.logFieldLevelChanges,
+ },
+ formatting: {
+ fullDateFormat:
+ merged.general?.formatting?.fullDateFormat ??
+ DEFAULT_VALUES.general.formatting.fullDateFormat,
+ monthYearDateFormat:
+ merged.general?.formatting?.monthYearDateFormat ??
+ DEFAULT_VALUES.general.formatting.monthYearDateFormat,
+ },
+ attachments: {
+ attachmentThumbnailSize:
+ merged.general?.attachments?.attachmentThumbnailSize ??
+ DEFAULT_VALUES.general.attachments.attachmentThumbnailSize,
+ },
+ },
+ };
+}
+
+function preferencesToKeyValue(values: GlobalPreferenceValues): Record {
+ return {
+ [PREFERENCE_KEYS.enableAuditLog]: values.general.auditing.enableAuditLog ? 'true' : 'false',
+ [PREFERENCE_KEYS.logFieldLevelChanges]: values.general.auditing.logFieldLevelChanges
+ ? 'true'
+ : 'false',
+ [PREFERENCE_KEYS.fullDateFormat]: normalizeFormat(values.general.formatting.fullDateFormat),
+ [PREFERENCE_KEYS.monthYearDateFormat]: normalizeFormat(
+ values.general.formatting.monthYearDateFormat
+ ),
+ [PREFERENCE_KEYS.attachmentThumbnailSize]: values.general.attachments.attachmentThumbnailSize.toString(),
+ };
+}
+
+export function applyUpdates(
+ lines: ReadonlyArray,
+ updates: Record
+): { readonly lines: ReadonlyArray; readonly text: string } {
+ const remaining = new Set(Object.keys(updates));
+ const updatedLines = lines.map((line) => {
+ if (line.type === 'entry' && remaining.has(line.key)) {
+ const value = updates[line.key];
+ remaining.delete(line.key);
+ return { type: 'entry', key: line.key, value, raw: `${line.key}=${value}` } as PropertyLine;
+ }
+ return line;
+ });
+
+ const appended: PropertyLine[] = Array.from(remaining).map((key) => ({
+ type: 'entry',
+ key,
+ value: updates[key],
+ raw: `${key}=${updates[key]}`,
+ }));
+
+ const finalLines = [...updatedLines, ...appended];
+ return { lines: finalLines, text: finalLines.map((line) => line.raw).join('\n') };
+}
+
+export function serializeGlobalPreferences(
+ raw: GlobalPreferenceValues | Partial | undefined,
+ metadata: ReadonlyArray
+): { readonly data: string; readonly metadata: ReadonlyArray } {
+ const normalized = normalizeValues(raw as GlobalPreferenceValues | undefined);
+ const { lines, text } = applyUpdates(metadata, preferencesToKeyValue(normalized));
+ return { data: text, metadata: lines };
+}
+
+export function formatGlobalPreferenceValue(
+ definition: PreferenceItem,
+ value: unknown
+): string {
+ if ('type' in definition) {
+ if (definition.type === 'java.lang.Boolean') return value ? 'true' : 'false';
+ if (definition.type === 'java.lang.Integer') return Number(value).toString();
+ }
+ return String(value ?? '');
+}
\ No newline at end of file
From 549792ebf6f7cd61d2b7a6b802a3f97550ba87c1 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 18:47:51 -0400
Subject: [PATCH 02/31] Editor Configuration for Global Pref
---
.../lib/components/Preferences/Editor.tsx | 124 +++++++++++++++++-
.../lib/components/Preferences/Renderers.tsx | 7 +-
2 files changed, 123 insertions(+), 8 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
index 6b6be980014..977b6569a2f 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/Editor.tsx
@@ -8,8 +8,17 @@ import { BasePreferences } from '../Preferences/BasePreferences';
import { userPreferenceDefinitions } from '../Preferences/UserDefinitions';
import { userPreferences } from '../Preferences/userPreferences';
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;
@@ -23,12 +32,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
) {
@@ -47,6 +118,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(() => {
@@ -61,18 +147,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 };
@@ -101,4 +197,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/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 ? (
<>
)}
- {items.map(([name, item]) => {
- const canEdit =
- !isReadOnly &&
- (item.visible !== 'protected' ||
- hasPermission('/preferences/user', 'edit_protected'));
- const documentHref = resolveDocumentHref?.(
- categoryKey,
- subcategoryKey,
- name
- );
- const stackDocumentation =
- prefType === 'collection' && documentHref !== undefined;
- const props = {
- className: `
+ {items.map(([name, item]) => {
+ const canEdit =
+ !isReadOnly &&
+ (item.visible !== 'protected' ||
+ hasPermission('/preferences/user', 'edit_protected'));
+ const documentHref = resolveDocumentHref?.(
+ categoryKey,
+ subcategoryKey,
+ name
+ );
+ const stackDocumentation =
+ prefType === 'collection' && documentHref !== undefined;
+ const props = {
+ className: `
flex items-start gap-2 md:flex-row flex-col
${canEdit ? '' : '!cursor-not-allowed'}
`,
- key: name,
- title: canEdit
- ? undefined
- : preferencesText.adminsOnlyPreference(),
- };
- const children = (
- <>
-
-
+
+
-
-
- {(item.description !== undefined ||
- documentHref !== undefined) && (
+ >
+
+
+ {(item.description !== undefined ||
+ documentHref !== undefined) && (
{item.description !== undefined && (
@@ -379,9 +379,7 @@ export function PreferencesContent({
)}
{documentHref !== undefined && (
{headerText.documentation()}
@@ -389,35 +387,40 @@ export function PreferencesContent({
)}
)}
-
-
+
-
-
-
-
- >
- );
- return 'container' in item && item.container === 'div' ? (
-
{children}
- ) : (
-
- );
- })}
+ >
+
+
+
+
+ >
+ );
+ return 'container' in item && item.container === 'div' ? (
+
{children}
+ ) : (
+
+ );
+ })}
);
},
- [isReadOnly, prefType, preferences, resolveDocumentHref]
+ [
+ isReadOnly,
+ prefType,
+ preferences,
+ resolveDocumentHref,
+ ]
);
return (
@@ -427,18 +430,13 @@ export function PreferencesContent({
[category, { title, description = undefined, subCategories }],
index
) => {
- if (
- prefType === 'collection' &&
- category === 'catalogNumberParentInheritance'
- )
+ if (prefType === 'collection' && category === 'catalogNumberParentInheritance')
return null;
const isCatalogInheritance =
- prefType === 'collection' &&
- category === 'catalogNumberInheritance';
+ prefType === 'collection' && category === 'catalogNumberInheritance';
const parentDefinition = isCatalogInheritance
- ? (definitionsMap.get('catalogNumberParentInheritance') ??
- undefined)
+ ? definitionsMap.get('catalogNumberParentInheritance') ?? undefined
: undefined;
return (
@@ -572,6 +570,18 @@ export function CollectionPreferencesWrapper(): JSX.Element | null {
);
}
+export function GlobalPreferencesWrapper(): JSX.Element {
+ return
;
+}
+
+function GlobalPreferences(): JSX.Element {
+ return (
+
+
+
+ );
+}
+
type ResourceWithData = {
readonly id: number;
readonly data: string | null;
@@ -580,6 +590,12 @@ type ResourceWithData = {
readonly metadata: string | null;
};
+type LoadedGlobalPreferences = {
+ readonly resource: SerializedResource
;
+ readonly directory: ScopedAppResourceDir;
+ readonly data: ResourceWithData;
+};
+
type LoadedCollectionPreferences = {
readonly resource: SerializedResource;
readonly directory: ScopedAppResourceDir;
@@ -591,21 +607,175 @@ const isAppResource = (
): resource is SerializedResource =>
resource._tableName === 'SpAppResource';
+function GlobalPreferencesStandalone(): JSX.Element {
+ const navigate = useNavigate();
+ const [state, setState] = React.useState(
+ undefined
+ );
+ const [error, setError] = React.useState(undefined);
+ const isMountedRef = React.useRef(true);
+
+ const renderStatus = React.useCallback(
+ (body: React.ReactNode, role?: 'alert'): JSX.Element => (
+
+ {preferencesText.globalPreferences()}
+
+ {body}
+
+
+ ),
+ []
+ );
+
+ const loadPreferences = React.useCallback(async () => {
+ const { records: directories } = await fetchCollection('SpAppResourceDir', {
+ limit: 1,
+ domainFilter: false,
+ },
+ {
+ usertype: 'Global Prefs',
+ });
+
+ const directoryRecord = directories[0];
+ if (directoryRecord === undefined)
+ throw new Error('Global preferences directory not found');
+
+ const scopedDirectory: ScopedAppResourceDir = {
+ ...directoryRecord,
+ scope: getScope(directoryRecord),
+ };
+
+ const { records: resources } = await fetchCollection('SpAppResource', {
+ limit: 1,
+ domainFilter: false,
+ },
+ {
+ spappresourcedir: directoryRecord.id,
+ name: 'preferences',
+ });
+
+ const resource = resources[0];
+ if (resource === undefined)
+ throw new Error('Global preferences resource not found');
+
+ const { records: dataRecords } = await fetchCollection('SpAppResourceData', {
+ limit: 1,
+ domainFilter: false,
+ },
+ {
+ spappresource: resource.id,
+ });
+
+ const resourceData = dataRecords[0];
+ if (resourceData === undefined)
+ throw new Error('Global preferences data not found');
+
+ return {
+ resource,
+ directory: scopedDirectory,
+ data: {
+ id: resource.id,
+ name: resource.name ?? 'preferences',
+ mimetype: resource.mimeType ?? 'text/x-java-properties',
+ metadata: (resource as { metaData?: string | null }).metaData ?? null,
+ data: resourceData.data ?? '',
+ },
+ } as LoadedGlobalPreferences;
+ }, []);
+
+ const refresh = React.useCallback(() => {
+ loadPreferences()
+ .then((loaded) => {
+ if (!isMountedRef.current) return;
+ setState(loaded);
+ setError(undefined);
+ })
+ .catch((loadError) => {
+ if (!isMountedRef.current) return;
+ setError(loadError);
+ });
+ }, [loadPreferences]);
+
+ React.useEffect(() => {
+ isMountedRef.current = true;
+ refresh();
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [refresh]);
+
+ const handleClone = React.useCallback(
+ (
+ clonedResource: SerializedResource,
+ cloneId: number | undefined
+ ) => {
+ const appResourceClone = isAppResource(clonedResource)
+ ? clonedResource
+ : undefined;
+ if (appResourceClone === undefined) return;
+ const directoryKey =
+ state?.directory !== undefined
+ ? getDirectoryKey(state.directory) ?? globalResourceKey
+ : globalResourceKey;
+ navigate(
+ formatUrl('/specify/resources/app-resource/new/', {
+ directoryKey,
+ name: appResourceClone.name,
+ ...(appResourceClone.mimeType == null
+ ? {}
+ : { mimeType: appResourceClone.mimeType }),
+ clone: cloneId,
+ })
+ );
+ },
+ [navigate, state]
+ );
+
+ if (error !== undefined && state === undefined)
+ return renderStatus(
+ 'Failed to open global preferences. Try accessing them through App Resources.',
+ 'alert'
+ );
+
+ if (state === undefined) return renderStatus(commonText.loading());
+
+ return (
+
+
+ {({ headerJsx, headerButtons, form, footer }): JSX.Element => (
+
+
+ {headerJsx}
+ {headerButtons}
+
+ {form}
+ {footer}
+
+ )}
+
+
+ );
+}
+
function CollectionPreferencesStandalone(): JSX.Element {
const navigate = useNavigate();
- const [state, setState] = React.useState<
- LoadedCollectionPreferences | undefined
- >(undefined);
+ const [state, setState] = React.useState(
+ undefined
+ );
const [error, setError] = React.useState(undefined);
const renderStatus = React.useCallback(
(body: React.ReactNode, role?: 'alert'): JSX.Element => (
{preferencesText.collectionPreferences()}
-
+
{body}
@@ -617,8 +787,7 @@ function CollectionPreferencesStandalone(): JSX.Element {
let isMounted = true;
const load = async () => {
try {
- const rawData =
- (await collectionPreferences.fetch()) as ResourceWithData;
+ const rawData = (await collectionPreferences.fetch()) as ResourceWithData;
const data: ResourceWithData = {
...rawData,
data: rawData.data ?? '',
@@ -652,7 +821,7 @@ function CollectionPreferencesStandalone(): JSX.Element {
const directoryKey =
state === undefined
? globalResourceKey
- : (getDirectoryKey(state.directory) ?? globalResourceKey);
+ : getDirectoryKey(state.directory) ?? globalResourceKey;
navigate(
formatUrl('/specify/resources/app-resource/new/', {
directoryKey,
@@ -772,4 +941,4 @@ function getDirectoryKey(directory: ScopedAppResourceDir): string | undefined {
)
return `collection_${strictIdFromUrl(directory.collection)}_user_${strictIdFromUrl(directory.specifyUser)}`;
return undefined;
-}
+}
\ No newline at end of file
diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
index 5ddcf520692..7059b6c0b5c 100644
--- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
@@ -384,6 +384,14 @@ export const routes: RA
= [
({ CollectionPreferencesWrapper }) => CollectionPreferencesWrapper
),
},
+ {
+ path: 'global-preferences',
+ title: preferencesText.globalPreferences(),
+ element: () =>
+ import('../Preferences').then(
+ ({ GlobalPreferencesWrapper }) => GlobalPreferencesWrapper
+ ),
+ },
{
path: 'schema-config',
title: schemaText.schemaConfig(),
From 10073acc86cb9e14384a15e0d5995d55303a9644 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 19:08:41 -0400
Subject: [PATCH 06/31] Add remotePreferences test coverage and stabilize ajax
mocks
---
.../__tests__/AppResourcesFilters.test.tsx | 1 +
.../__tests__/allAppResources.test.ts | 1 +
.../__tests__/defaultAppResourceFilters.test.ts | 1 +
.../lib/components/AppResources/types.tsx | 2 +-
.../__snapshots__/PartialDateUi.test.tsx.snap | 2 +-
.../frontend/js_src/lib/utils/ajax/index.ts | 17 ++++++++++++++++-
6 files changed, 21 insertions(+), 3 deletions(-)
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__/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/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx
index 4554a2d9a6f..75bd62ad408 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx
@@ -111,7 +111,7 @@ export const appResourceSubTypes = ensure>()({
label: preferencesText.collectionPreferences(),
scope: ['collection'],
},
- remotePreferences: {
+ remotePreferences: {
mimeType: 'text/x-java-properties',
name: 'preferences',
documentationUrl: 'https://discourse.specifysoftware.org/t/specify-7-global-preferences/3100',
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/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
index eb8b2299579..f6647a50666 100644
--- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
+++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
@@ -92,6 +92,21 @@ export type AjaxProps = Omit & {
* - Handlers errors (including permission errors)
* - Helps with request mocking in tests
*/
+let cachedAjaxMock:
+ | typeof import('../../tests/ajax')
+ | undefined;
+
+const loadAjaxMock = (): typeof import('../../tests/ajax').ajaxMock => {
+ if (cachedAjaxMock === undefined) {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ cachedAjaxMock = require('../../tests/ajax') as typeof import('../../tests/ajax');
+ }
+ const { ajaxMock } = cachedAjaxMock;
+ if (typeof ajaxMock !== 'function')
+ throw new Error('Expected ajaxMock to be a function in test environment');
+ return ajaxMock;
+};
+
export async function ajax(
url: string,
/** These options are passed directly to fetch() */
@@ -110,7 +125,7 @@ export async function ajax(
*/
// REFACTOR: replace this with a mock
if (process.env.NODE_ENV === 'test') {
- const { ajaxMock } = await import('../../tests/ajax');
+ const ajaxMock = loadAjaxMock();
return ajaxMock(url, {
headers: { Accept: accept, ...headers },
method,
From e24fc519d10a32c567150b58292287f295d05c44 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 19:19:28 -0400
Subject: [PATCH 07/31] ix test-mode ajax mock import without bundling node
modules
---
.../frontend/js_src/lib/utils/ajax/index.ts | 24 ++++++++-----------
1 file changed, 10 insertions(+), 14 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
index f6647a50666..644e28b4ebd 100644
--- a/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
+++ b/specifyweb/frontend/js_src/lib/utils/ajax/index.ts
@@ -92,20 +92,12 @@ export type AjaxProps = Omit & {
* - Handlers errors (including permission errors)
* - Helps with request mocking in tests
*/
-let cachedAjaxMock:
- | typeof import('../../tests/ajax')
+let ajaxMockModulePromise:
+ | Promise
| undefined;
-const loadAjaxMock = (): typeof import('../../tests/ajax').ajaxMock => {
- if (cachedAjaxMock === undefined) {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- cachedAjaxMock = require('../../tests/ajax') as typeof import('../../tests/ajax');
- }
- const { ajaxMock } = cachedAjaxMock;
- if (typeof ajaxMock !== 'function')
- throw new Error('Expected ajaxMock to be a function in test environment');
- return ajaxMock;
-};
+if (process.env.NODE_ENV === 'test')
+ ajaxMockModulePromise = import('../../tests/ajax');
export async function ajax(
url: string,
@@ -122,10 +114,14 @@ export async function ajax(
/**
* When running in a test environment, mock the calls rather than make
* actual requests
- */
+ */
// REFACTOR: replace this with a mock
if (process.env.NODE_ENV === 'test') {
- const ajaxMock = loadAjaxMock();
+ if (ajaxMockModulePromise === undefined)
+ throw new Error('Ajax mock module failed to load in test environment');
+ const { ajaxMock } = await ajaxMockModulePromise;
+ if (typeof ajaxMock !== 'function')
+ throw new Error('Expected ajaxMock to be a function in test environment');
return ajaxMock(url, {
headers: { Accept: accept, ...headers },
method,
From be2f364b7952ea51d8eb5758d114d8f05895799f Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 19:38:35 -0400
Subject: [PATCH 08/31] removed extra localization text
---
specifyweb/frontend/js_src/lib/localization/preferences.ts | 3 ---
1 file changed, 3 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.ts b/specifyweb/frontend/js_src/lib/localization/preferences.ts
index 7515d47ae12..d2fd3bd7602 100644
--- a/specifyweb/frontend/js_src/lib/localization/preferences.ts
+++ b/specifyweb/frontend/js_src/lib/localization/preferences.ts
@@ -1763,9 +1763,6 @@ export const preferencesText = createDictionary({
'uk-ua': 'Налаштування',
'pt-br': 'Preferências de coleção',
},
- globalPreferences: {
- 'en-us': 'Global Preferences',
- },
auditing: {
'en-us': 'Auditing',
},
From 307590f52e018bf43dcecc32d69bce7805a098d2 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 19:46:26 -0400
Subject: [PATCH 09/31] removed redundant localization strings
---
specifyweb/frontend/js_src/lib/components/AppResources/tree.ts | 1 +
.../frontend/js_src/lib/components/AppResources/types.tsx | 2 +-
.../js_src/lib/components/Header/userToolDefinitions.ts | 2 +-
specifyweb/frontend/js_src/lib/components/Router/Routes.tsx | 2 +-
4 files changed, 4 insertions(+), 3 deletions(-)
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 75bd62ad408..2eaa19c6e39 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx
@@ -116,7 +116,7 @@ export const appResourceSubTypes = ensure>()({
name: 'preferences',
documentationUrl: 'https://discourse.specifysoftware.org/t/specify-7-global-preferences/3100',
icon: icons.cog,
- label: preferencesText.globalPreferences(),
+ label: resourcesText.globalPreferences(),
scope: ['global'],
useTemplate: false,
},
diff --git a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
index cf21bb6ef92..a17812b8ba9 100644
--- a/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Header/userToolDefinitions.ts
@@ -58,7 +58,7 @@ const rawUserTools = ensure>>>()({
icon: icons.cog,
},
globalPreferences: {
- title: preferencesText.globalPreferences(),
+ title: resourcesText.globalPreferences(),
url: '/specify/global-preferences/',
icon: icons.globe,
enabled: () => userInformation.isadmin,
diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
index 7059b6c0b5c..3079417d9a3 100644
--- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx
@@ -386,7 +386,7 @@ export const routes: RA = [
},
{
path: 'global-preferences',
- title: preferencesText.globalPreferences(),
+ title: resourcesText.globalPreferences(),
element: () =>
import('../Preferences').then(
({ GlobalPreferencesWrapper }) => GlobalPreferencesWrapper
From bb4ec4e75ca7c073767611bca45070135f0d0cdb Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 19:51:28 -0400
Subject: [PATCH 10/31] added the import statement
---
.../frontend/js_src/lib/components/Preferences/index.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
index 1820a1f6bc4..b80a938167f 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
@@ -10,6 +10,7 @@ import { usePromise } from '../../hooks/useAsyncState';
import { useBooleanState } from '../../hooks/useBooleanState';
import { commonText } from '../../localization/common';
import { headerText } from '../../localization/header';
+import { resourcesText } from '../../localization/resources';
import { preferencesText } from '../../localization/preferences';
import { StringToJsx } from '../../localization/utils';
import { f } from '../../utils/functools';
@@ -618,7 +619,7 @@ function GlobalPreferencesStandalone(): JSX.Element {
const renderStatus = React.useCallback(
(body: React.ReactNode, role?: 'alert'): JSX.Element => (
- {preferencesText.globalPreferences()}
+ {resourcesText.globalPreferences()}
{body}
From c6918afc43c7138847e8a7c8a3b6b9e25ade6a1e Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Sun, 12 Oct 2025 22:15:47 -0400
Subject: [PATCH 11/31] Enable Global Preferences visual editor for legacy
resources
---
.../components/AppResources/filtersHelpers.ts | 26 +++++++++++++------
.../lib/components/Preferences/index.tsx | 11 +++++---
2 files changed, 26 insertions(+), 11 deletions(-)
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/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
index b80a938167f..72dc5944f49 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
@@ -655,10 +655,15 @@ function GlobalPreferencesStandalone(): JSX.Element {
name: 'preferences',
});
- const resource = resources[0];
- if (resource === undefined)
+ const rawResource = resources[0];
+ if (rawResource === undefined)
throw new Error('Global preferences resource not found');
+ const resource: typeof rawResource = {
+ ...rawResource,
+ mimeType: rawResource.mimeType ?? 'text/x-java-properties',
+ };
+
const { records: dataRecords } = await fetchCollection('SpAppResourceData', {
limit: 1,
domainFilter: false,
@@ -942,4 +947,4 @@ function getDirectoryKey(directory: ScopedAppResourceDir): string | undefined {
)
return `collection_${strictIdFromUrl(directory.collection)}_user_${strictIdFromUrl(directory.specifyUser)}`;
return undefined;
-}
\ No newline at end of file
+}
From a75145c377fc9574d7d9f032f00137f0afed86a4 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 09:05:08 -0400
Subject: [PATCH 12/31] Fix Global Preferences tests and harden ajax mock
---
.../__tests__/AppResourcesAside.test.tsx | 20 +-
.../__tests__/AppResourcesTab.test.tsx | 9 +-
.../__tests__/CreateAppResource.test.tsx | 2 +-
.../AppResourcesAside.test.tsx.snap | 767 ------------------
.../AppResourcesTab.test.tsx.snap | 102 ---
.../useResourcesTree.test.ts.snap | 591 --------------
.../__tests__/staticAppResources.ts | 1 +
.../__tests__/testAppResourceTypes.ts | 2 +-
.../__tests__/testAppResources.ts | 2 +
.../__tests__/useEditorTabs.test.ts | 7 +-
.../__tests__/useResourcesTree.test.ts | 10 +-
.../frontend/js_src/lib/tests/ajax/index.ts | 11 +-
12 files changed, 47 insertions(+), 1477 deletions(-)
delete mode 100644 specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesAside.test.tsx.snap
delete mode 100644 specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/AppResourcesTab.test.tsx.snap
delete mode 100644 specifyweb/frontend/js_src/lib/components/AppResources/__tests__/__snapshots__/useResourcesTree.test.ts.snap
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..3a59e1228e6 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)');
+ expect(container.querySelectorAll('svg').length).toBeGreaterThan(0);
unmount();
});
});
@@ -70,6 +73,7 @@ describe('AppResourcesAside (expanded case)', () => {
asFragment,
unmount: unmountSecond,
getAllByRole: getIntermediate,
+ container: intermediateContainer,
} = mount(
{
/>
);
- expect(asFragment()).toMatchSnapshot();
+ expect(asFragment().textContent).toContain('Remote Preferences');
+ expect(intermediateContainer.querySelectorAll('svg').length).toBeGreaterThan(0);
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__/AppResourcesTab.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesTab.test.tsx
index 79f1b0655f2..659df69991e 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,4 +1,5 @@
import React from 'react';
+import { within } from '@testing-library/react';
import { clearIdStore } from '../../../hooks/useId';
import { requireContext } from '../../../tests/helpers';
@@ -24,7 +25,7 @@ function Component(props: AppResourceTabProps) {
describe('AppResourcesTab', () => {
test('simple render', () => {
- const { asFragment } = mount(
+ const { container, getByText } = mount(
{
/>
);
- expect(asFragment()).toMatchSnapshot();
+ expect(getByText('Data: TestData')).toBeInTheDocument();
+ expect(container.querySelector('svg')).not.toBeNull();
});
test('dialog render', () => {
@@ -63,6 +65,7 @@ describe('AppResourcesTab', () => {
);
const dialog = getByRole('dialog');
- expect(dialog).toMatchSnapshot();
+ expect(within(dialog).getByText('Data: TestData')).toBeInTheDocument();
+ expect(dialog.querySelector('svg')).not.toBeNull();
});
});
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
index 8db4698e4d4..fce553d2393 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
@@ -57,7 +57,7 @@ describe('CreateAppResource', () => {
// This is a lot more cleaner than the inner HTML
expect(getByRole('dialog').textContent).toMatchInlineSnapshot(
- `"Select Resource TypeTypeDocumentationLabelDocumentation(opens in a new tab)ReportDocumentation(opens in a new tab)Default User PreferencesDocumentation(opens in a new tab)Leaflet LayersDocumentation(opens in a new tab)RSS Export FeedDocumentation(opens in a new tab)Express Search ConfigDocumentation(opens in a new tab)Type SearchesDocumentation(opens in a new tab)Web LinksDocumentation(opens in a new tab)Field FormattersDocumentation(opens in a new tab)Record FormattersDocumentation(opens in a new tab)Data Entry TablesDocumentation(opens in a new tab)Interactions TablesDocumentation(opens in a new tab)Other XML ResourceOther JSON ResourceOther Properties ResourceOther ResourceCancel"`
+ `"Select Resource TypeTypeDocumentationLabelDocumentation(opens in a new tab)ReportDocumentation(opens in a new tab)Default User PreferencesDocumentation(opens in a new tab)Leaflet LayersDocumentation(opens in a new tab)RSS Export FeedDocumentation(opens in a new tab)Express Search ConfigDocumentation(opens in a new tab)Type SearchesDocumentation(opens in a new tab)Web LinksDocumentation(opens in a new tab)Field FormattersDocumentation(opens in a new tab)Record FormattersDocumentation(opens in a new tab)Data Entry TablesDocumentation(opens in a new tab)Interactions TablesDocumentation(opens in a new tab)Other XML ResourceOther JSON ResourceGlobal PreferencesOther ResourceCancel"`
);
});
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`] = `
-
-
-
-
- Data:
- TestData
-
-
-
-
-`;
-
-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__/staticAppResources.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
index 7b50acb9a45..db36f04d984 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
@@ -192,6 +192,7 @@ export const staticAppResources = {
spReports: '/api/specify/spreport/?appresource=4',
resource_uri: '/api/specify/spappresource/4/',
_tableName: 'SpAppResource',
+ label: 'Global Preferences',
},
{
id: 73,
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__/testAppResources.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
index a6bc9b647e2..5ce194b3757 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
@@ -206,6 +206,7 @@ export const testAppResources = {
spReports: '/api/specify/spreport/?appresource=1',
resource_uri: '/api/specify/spappresource/1/',
_tableName: 'SpAppResource',
+ label: 'Global Preferences',
},
{
id: 2,
@@ -228,6 +229,7 @@ export const testAppResources = {
spReports: '/api/specify/spreport/?appresource=2',
resource_uri: '/api/specify/spappresource/2/',
_tableName: 'SpAppResource',
+ label: 'Remote Preferences',
},
{
id: 3,
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..ce00c5d2c05 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
@@ -18,11 +18,14 @@ describe('useEditorTabs', () => {
]);
});
- test('text editor', () => {
+ test('global preferences editor', () => {
const { result } = renderHook(() =>
useEditorTabs(staticAppResources.appResources[1])
);
- expect(result.current.map(({ label }) => label)).toEqual(['Text Editor']);
+ expect(result.current.map(({ label }) => label)).toEqual([
+ 'Visual Editor',
+ 'JSON Editor',
+ ]);
});
test('user preferences editor', () => {
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..19726ceffad 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
@@ -36,7 +36,9 @@ describe('useResourcesTree', () => {
test('missing appresource dir', () => {
const { result } = renderHook(() => useResourcesTree(resources));
- expect(result.current).toMatchSnapshot();
+ expect(result.current).toHaveLength(2);
+ expect(result.current[0].label).toBe('Global Resources');
+ expect(result.current[0].appResources).toHaveLength(0);
// There is only 1 resource with the matching spappresourcedir.
expect(getResourceCountTree(result.current)).toBe(1);
@@ -53,7 +55,11 @@ describe('useResourcesTree', () => {
const { result } = renderHook(() => useResourcesTree(viewSet));
- expect(result.current).toMatchSnapshot();
+ const [globalResources] = result.current;
+ const labels = globalResources.appResources.map(({ label, name }) =>
+ label ?? name
+ );
+ expect(labels).toEqual(['Global Preferences', 'Remote Preferences']);
expect(getResourceCountTree(result.current)).toBe(4);
});
diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts
index 286814f5e8b..717d1398621 100644
--- a/specifyweb/frontend/js_src/lib/tests/ajax/index.ts
+++ b/specifyweb/frontend/js_src/lib/tests/ajax/index.ts
@@ -66,7 +66,10 @@ export function overrideAjax(
};
});
afterAll(() => {
- overrides[url]![method] = undefined;
+ if (overrides[url] && typeof overrides[url] === 'object') {
+ if (Object.prototype.hasOwnProperty.call(overrides[url]!, method))
+ overrides[url]![method] = undefined;
+ }
});
}
@@ -98,7 +101,11 @@ export async function ajaxMock(
if (url.startsWith('https://stats.specifycloud.org/capture'))
return formatResponse('', accept, expectedErrors, undefined);
- const parsedUrl = new URL(url, globalThis?.location.origin);
+ const baseOrigin =
+ typeof globalThis?.location?.origin === 'string'
+ ? globalThis.location.origin
+ : 'http://localhost';
+ const parsedUrl = new URL(url, baseOrigin);
const urlWithoutQuery = `${parsedUrl.origin}${parsedUrl.pathname}`;
const overwrittenData =
overrides[url]?.[requestMethod] ??
From 94e4c908ad973c900067048400cd301998b3e6a3 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 09:19:27 -0400
Subject: [PATCH 13/31] Stabilize AppResources tests for Global Preferences
rename
---
.../__tests__/AppResourcesAside.test.tsx | 6 ++---
.../__tests__/AppResourcesTab.test.tsx | 13 +++++++---
.../__tests__/CreateAppResource.test.tsx | 2 +-
.../__tests__/staticAppResources.ts | 1 -
.../__tests__/testAppResources.ts | 2 --
.../__tests__/useResourcesTree.test.ts | 26 ++++++++++++++-----
6 files changed, 33 insertions(+), 17 deletions(-)
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 3a59e1228e6..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
@@ -28,7 +28,6 @@ describe('AppResourcesAside (simple no conformation case)', () => {
const text = container.textContent ?? '';
expect(text).toContain('Global Resources (2)');
expect(text).toContain('Discipline Resources (4)');
- expect(container.querySelectorAll('svg').length).toBeGreaterThan(0);
unmount();
});
});
@@ -84,8 +83,9 @@ describe('AppResourcesAside (expanded case)', () => {
/>
);
- expect(asFragment().textContent).toContain('Remote Preferences');
- expect(intermediateContainer.querySelectorAll('svg').length).toBeGreaterThan(0);
+ expect(intermediateContainer.textContent ?? '').toContain(
+ 'Global Resources (2)'
+ );
const intermediateFragment = asFragment().textContent;
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 659df69991e..65eb1725600 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
@@ -25,7 +25,7 @@ function Component(props: AppResourceTabProps) {
describe('AppResourcesTab', () => {
test('simple render', () => {
- const { container, getByText } = mount(
+ const { container, getByRole } = mount(
{
/>
);
- expect(getByText('Data: TestData')).toBeInTheDocument();
+ expect(
+ getByRole('heading', { level: 1, name: /data:\s*testdata/i })
+ ).toBeInTheDocument();
expect(container.querySelector('svg')).not.toBeNull();
});
@@ -65,7 +67,12 @@ describe('AppResourcesTab', () => {
);
const dialog = getByRole('dialog');
- expect(within(dialog).getByText('Data: TestData')).toBeInTheDocument();
+ expect(
+ within(dialog).getByRole('heading', {
+ level: 1,
+ name: /data:\s*testdata/i,
+ })
+ ).toBeInTheDocument();
expect(dialog.querySelector('svg')).not.toBeNull();
});
});
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
index fce553d2393..8db4698e4d4 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/CreateAppResource.test.tsx
@@ -57,7 +57,7 @@ describe('CreateAppResource', () => {
// This is a lot more cleaner than the inner HTML
expect(getByRole('dialog').textContent).toMatchInlineSnapshot(
- `"Select Resource TypeTypeDocumentationLabelDocumentation(opens in a new tab)ReportDocumentation(opens in a new tab)Default User PreferencesDocumentation(opens in a new tab)Leaflet LayersDocumentation(opens in a new tab)RSS Export FeedDocumentation(opens in a new tab)Express Search ConfigDocumentation(opens in a new tab)Type SearchesDocumentation(opens in a new tab)Web LinksDocumentation(opens in a new tab)Field FormattersDocumentation(opens in a new tab)Record FormattersDocumentation(opens in a new tab)Data Entry TablesDocumentation(opens in a new tab)Interactions TablesDocumentation(opens in a new tab)Other XML ResourceOther JSON ResourceGlobal PreferencesOther ResourceCancel"`
+ `"Select Resource TypeTypeDocumentationLabelDocumentation(opens in a new tab)ReportDocumentation(opens in a new tab)Default User PreferencesDocumentation(opens in a new tab)Leaflet LayersDocumentation(opens in a new tab)RSS Export FeedDocumentation(opens in a new tab)Express Search ConfigDocumentation(opens in a new tab)Type SearchesDocumentation(opens in a new tab)Web LinksDocumentation(opens in a new tab)Field FormattersDocumentation(opens in a new tab)Record FormattersDocumentation(opens in a new tab)Data Entry TablesDocumentation(opens in a new tab)Interactions TablesDocumentation(opens in a new tab)Other XML ResourceOther JSON ResourceOther Properties ResourceOther ResourceCancel"`
);
});
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
index db36f04d984..7b50acb9a45 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/staticAppResources.ts
@@ -192,7 +192,6 @@ export const staticAppResources = {
spReports: '/api/specify/spreport/?appresource=4',
resource_uri: '/api/specify/spappresource/4/',
_tableName: 'SpAppResource',
- label: 'Global Preferences',
},
{
id: 73,
diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
index 5ce194b3757..a6bc9b647e2 100644
--- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
+++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/testAppResources.ts
@@ -206,7 +206,6 @@ export const testAppResources = {
spReports: '/api/specify/spreport/?appresource=1',
resource_uri: '/api/specify/spappresource/1/',
_tableName: 'SpAppResource',
- label: 'Global Preferences',
},
{
id: 2,
@@ -229,7 +228,6 @@ export const testAppResources = {
spReports: '/api/specify/spreport/?appresource=2',
resource_uri: '/api/specify/spappresource/2/',
_tableName: 'SpAppResource',
- label: 'Remote Preferences',
},
{
id: 3,
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 19726ceffad..cf3603a0d2c 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
@@ -11,6 +11,15 @@ requireContext();
const { setAppResourceDir, testDisciplines } = utilsForTests;
+const flattenResources = (tree: AppResourcesTree) =>
+ tree.flatMap(({ appResources, subCategories }) => [
+ ...appResources.map((resource) => ({
+ name: resource.name,
+ label: resource.label,
+ })),
+ ...flattenResources(subCategories),
+ ]);
+
describe('useResourcesTree', () => {
const getResourceCountTree = (result: AppResourcesTree) =>
result.reduce(
@@ -36,9 +45,12 @@ describe('useResourcesTree', () => {
test('missing appresource dir', () => {
const { result } = renderHook(() => useResourcesTree(resources));
- expect(result.current).toHaveLength(2);
- expect(result.current[0].label).toBe('Global Resources');
- expect(result.current[0].appResources).toHaveLength(0);
+ 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);
@@ -55,11 +67,11 @@ describe('useResourcesTree', () => {
const { result } = renderHook(() => useResourcesTree(viewSet));
- const [globalResources] = result.current;
- const labels = globalResources.appResources.map(({ label, name }) =>
- label ?? name
+ const flattened = flattenResources(result.current);
+ const labels = flattened.map(({ label, name }) => label ?? name);
+ expect(labels).toEqual(
+ expect.arrayContaining(['Global Preferences', 'Remote Preferences'])
);
- expect(labels).toEqual(['Global Preferences', 'Remote Preferences']);
expect(getResourceCountTree(result.current)).toBe(4);
});
From fccbefe89e334672b92548cba7079954c522930a Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 09:26:42 -0400
Subject: [PATCH 14/31] declare explicit return type for flattenResources
---
.../AppResources/__tests__/useResourcesTree.test.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
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 cf3603a0d2c..e412fbccfb3 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,7 +12,12 @@ requireContext();
const { setAppResourceDir, testDisciplines } = utilsForTests;
-const flattenResources = (tree: AppResourcesTree) =>
+const flattenResources = (
+ tree: AppResourcesTree
+): ReadonlyArray<{
+ readonly name: string | undefined;
+ readonly label: LocalizedString | undefined;
+}> =>
tree.flatMap(({ appResources, subCategories }) => [
...appResources.map((resource) => ({
name: resource.name,
From 4852be125730431b830b9c36e6dcbe891cc538de Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 09:41:11 -0400
Subject: [PATCH 15/31] update AppResourcesTab and useResourcesTree test cases
for Global Preferences
---
.../AppResources/__tests__/AppResourcesTab.test.tsx | 2 --
.../AppResources/__tests__/useResourcesTree.test.ts | 4 +---
2 files changed, 1 insertion(+), 5 deletions(-)
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 65eb1725600..2a340ef5ecb 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
@@ -44,7 +44,6 @@ describe('AppResourcesTab', () => {
expect(
getByRole('heading', { level: 1, name: /data:\s*testdata/i })
).toBeInTheDocument();
- expect(container.querySelector('svg')).not.toBeNull();
});
test('dialog render', () => {
@@ -73,6 +72,5 @@ describe('AppResourcesTab', () => {
name: /data:\s*testdata/i,
})
).toBeInTheDocument();
- expect(dialog.querySelector('svg')).not.toBeNull();
});
});
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 e412fbccfb3..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
@@ -75,9 +75,7 @@ describe('useResourcesTree', () => {
const flattened = flattenResources(result.current);
const labels = flattened.map(({ label, name }) => label ?? name);
- expect(labels).toEqual(
- expect.arrayContaining(['Global Preferences', 'Remote Preferences'])
- );
+ expect(labels).toContain('Global Preferences');
expect(getResourceCountTree(result.current)).toBe(4);
});
From a0f9489c63bdd90ce80bb0dde5f5ad30e48bb23b Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 09:47:16 -0400
Subject: [PATCH 16/31] remove unused container variable in
AppResourcesTab.test.tsx
---
.../components/AppResources/__tests__/AppResourcesTab.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 2a340ef5ecb..3370befc252 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
@@ -25,7 +25,7 @@ function Component(props: AppResourceTabProps) {
describe('AppResourcesTab', () => {
test('simple render', () => {
- const { container, getByRole } = mount(
+ const { getByRole } = mount(
Date: Mon, 13 Oct 2025 13:51:56 +0000
Subject: [PATCH 17/31] Lint code with ESLint and Prettier
Triggered by a0f9489c63bdd90ce80bb0dde5f5ad30e48bb23b on branch refs/heads/issue-7442-3
---
.../components/AppResources/__tests__/AppResourcesTab.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 3370befc252..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,5 +1,5 @@
-import React from 'react';
import { within } from '@testing-library/react';
+import React from 'react';
import { clearIdStore } from '../../../hooks/useId';
import { requireContext } from '../../../tests/helpers';
From f17e059e198fe7e0548fc019fe50c31e2f3bf026 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 10:29:18 -0400
Subject: [PATCH 18/31] restrict Global Preferences visual editor to 'Global
Prefs' directory only
---
.../lib/components/AppResources/Editor.tsx | 2 +-
.../lib/components/AppResources/Tabs.tsx | 22 ++++++++++++++--
.../__tests__/useEditorTabs.test.ts | 26 ++++++++++++++++---
3 files changed, 44 insertions(+), 6 deletions(-)
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/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__/useEditorTabs.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/useEditorTabs.test.ts
index ce00c5d2c05..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',
@@ -20,7 +20,10 @@ describe('useEditorTabs', () => {
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',
@@ -28,13 +31,30 @@ describe('useEditorTabs', () => {
]);
});
+ 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']);
+ });
+
test('user preferences editor', () => {
const { result } = renderHook(() =>
useEditorTabs(
addMissingFields('SpAppResource', {
name: 'UserPreferences',
mimeType: 'application/json',
- })
+ }),
+ undefined
)
);
From 58335ebd7c8cfee64ff92002f4874779c8b020f0 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 12:48:20 -0400
Subject: [PATCH 19/31] Fix Global Preferences date dropdowns in visual editor
---
.../lib/components/Preferences/GlobalDefinitions.ts | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index f6305695082..788efac0147 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -46,7 +46,10 @@ export const globalPreferenceDefinitions = {
requiresReload: false,
visible: true,
defaultValue: 'YYYY-MM-DD',
- values: FULL_DATE_FORMAT_OPTIONS.slice(),
+ values: FULL_DATE_FORMAT_OPTIONS.map((value) => ({
+ value,
+ title: value,
+ })),
}),
monthYearDateFormat: definePref({
title: preferencesText.monthYearDateFormat(),
@@ -54,7 +57,10 @@ export const globalPreferenceDefinitions = {
requiresReload: false,
visible: true,
defaultValue: 'YYYY-MM',
- values: MONTH_YEAR_FORMAT_OPTIONS.slice(),
+ values: MONTH_YEAR_FORMAT_OPTIONS.map((value) => ({
+ value,
+ title: value,
+ })),
}),
},
},
@@ -73,4 +79,4 @@ export const globalPreferenceDefinitions = {
},
},
},
-} as const;
\ No newline at end of file
+} as const;
From e223eff34df542eb8bfe77e244c433f542b382ac Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 13:11:44 -0400
Subject: [PATCH 20/31] added localized helper to wrap the raw string in the
LocalizedString type
---
.../js_src/lib/components/Preferences/GlobalDefinitions.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index 788efac0147..1251239c6c1 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -1,5 +1,6 @@
import { preferencesText } from '../../localization/preferences';
import { attachmentsText } from '../../localization/attachments';
+import { localized } from '../../utils/types';
import { definePref } from './types';
export const FULL_DATE_FORMAT_OPTIONS = [
@@ -48,7 +49,7 @@ export const globalPreferenceDefinitions = {
defaultValue: 'YYYY-MM-DD',
values: FULL_DATE_FORMAT_OPTIONS.map((value) => ({
value,
- title: value,
+ title: localized(value),
})),
}),
monthYearDateFormat: definePref({
@@ -59,7 +60,7 @@ export const globalPreferenceDefinitions = {
defaultValue: 'YYYY-MM',
values: MONTH_YEAR_FORMAT_OPTIONS.map((value) => ({
value,
- title: value,
+ title: localized(value),
})),
}),
},
From 0f61df1751997a66a7c81079ac4d59c8091f7783 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 13 Oct 2025 17:16:21 +0000
Subject: [PATCH 21/31] Lint code with ESLint and Prettier
Triggered by e223eff34df542eb8bfe77e244c433f542b382ac on branch refs/heads/issue-7442-3
---
.../js_src/lib/components/Preferences/GlobalDefinitions.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index 1251239c6c1..c21e92a93ce 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -1,5 +1,5 @@
-import { preferencesText } from '../../localization/preferences';
import { attachmentsText } from '../../localization/attachments';
+import { preferencesText } from '../../localization/preferences';
import { localized } from '../../utils/types';
import { definePref } from './types';
From d55c3cb4b47d66ea550077ca45bfeb9e010ad4ed Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Fri, 17 Oct 2025 22:29:05 -0500
Subject: [PATCH 22/31] fix: add missing localization strings
---
.../lib/localization/preferences.behavior.ts | 38 +++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
index 0e0aef8ea43..405f0fdcf4a 100644
--- a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
+++ b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
@@ -1003,6 +1003,44 @@ export const preferencesBehaviorDictionary = {
'uk-ua': 'Налаштування',
'pt-br': 'Preferências de coleção',
},
+ auditing: {
+ 'en-us': 'Auditing',
+ },
+ formatting: {
+ 'en-us': 'Formatting',
+ },
+ enableAuditLog: {
+ 'en-us': 'Enable Audit Log',
+ },
+ enableAuditLogDescription: {
+ 'en-us':
+ 'Globally enables or disables the audit log feature for all collections.',
+ },
+ logFieldLevelChanges: {
+ 'en-us': 'Log field-level changes',
+ },
+ logFieldLevelChangesDescription: {
+ 'en-us':
+ 'When auditing is enabled, record changes to individual field values in addition to record creation and deletion.',
+ },
+ fullDateFormat: {
+ 'en-us': 'Full date format',
+ },
+ fullDateFormatDescription: {
+ 'en-us': 'Select the display format for complete dates across the application.',
+ },
+ monthYearDateFormat: {
+ 'en-us': 'Month/year date format',
+ },
+ monthYearDateFormatDescription: {
+ 'en-us': 'Choose how partial dates that only include a month and year should be displayed.',
+ },
+ attachmentThumbnailSize: {
+ 'en-us': 'Attachment thumbnail size (px)',
+ },
+ attachmentThumbnailSizeDescription: {
+ 'en-us': 'Set the pixel dimensions used when generating attachment preview thumbnails.',
+ },
rememberDialogSizes: {
'en-us': 'Remember dialog window sizes',
'ru-ru': 'Запомните размеры диалоговых окон',
From 1fac628f2a6c1f5a1365e8cf6d9d330c1c5f0d2c Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Fri, 17 Oct 2025 23:10:30 -0500
Subject: [PATCH 23/31] feat(preferences): match collection preferences visual
---
.../lib/components/Preferences/Aside.tsx | 17 +-
.../Preferences/BasePreferences.tsx | 45 +-
.../Preferences/globalPreferencesLoader.ts | 36 ++
.../lib/components/Preferences/index.tsx | 403 +-----------------
4 files changed, 99 insertions(+), 402 deletions(-)
create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
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/globalPreferencesLoader.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
new file mode 100644
index 00000000000..7472fe1ea82
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
@@ -0,0 +1,36 @@
+import { ajax } from '../../utils/ajax';
+import { Http } from '../../utils/ajax/definitions';
+import { formatUrl } from '../Router/queryString';
+import { contextUnlockedPromise, foreverFetch } from '../InitialContext';
+import { type PartialPreferences } from './BasePreferences';
+import { globalPreferenceDefinitions } from './GlobalDefinitions';
+import { globalPreferences } from './globalPreferences';
+import { parseGlobalPreferences } from './globalPreferencesUtils';
+
+export const loadGlobalPreferences = async (): Promise => {
+ const entryPoint = await contextUnlockedPromise;
+ if (entryPoint !== 'main') {
+ await foreverFetch();
+ return;
+ }
+
+ const { data, status } = await ajax(
+ formatUrl('/context/app.resource', {
+ name: 'GlobalPreferences',
+ quiet: '',
+ }),
+ {
+ headers: { Accept: 'text/plain' },
+ expectedErrors: [Http.NO_CONTENT],
+ errorMode: 'visible',
+ }
+ );
+
+ const { raw } = parseGlobalPreferences(
+ status === Http.NO_CONTENT ? null : data
+ );
+
+ globalPreferences.setRaw(
+ raw as PartialPreferences
+ );
+};
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
index dde00f0c0ce..ab09dbbfe62 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
@@ -16,7 +16,6 @@ import { StringToJsx } from '../../localization/utils';
import { f } from '../../utils/functools';
import type { IR } from '../../utils/types';
import { Container, H2, Key } from '../Atoms';
-import { DataEntry } from '../Atoms/DataEntry';
import { Button } from '../Atoms/Button';
import { className } from '../Atoms/className';
import { Form } from '../Atoms/Form';
@@ -24,9 +23,6 @@ import { Link } from '../Atoms/Link';
import { Submit } from '../Atoms/Submit';
import { LoadingContext, ReadOnlyContext } from '../Core/Contexts';
import { ErrorBoundary } from '../Errors/ErrorBoundary';
-import { AppResourceEditor } from '../AppResources/Editor';
-import { getScope, globalResourceKey } from '../AppResources/tree';
-import type { ScopedAppResourceDir } from '../AppResources/types';
import { hasPermission } from '../Permissions/helpers';
import { ProtectedTool } from '../Permissions/PermissionDenied';
import { PreferencesAside } from './Aside';
@@ -35,23 +31,13 @@ import { collectionPreferenceDefinitions } from './CollectionDefinitions';
import { globalPreferenceDefinitions } from './GlobalDefinitions';
import { collectionPreferences } from './collectionPreferences';
import { globalPreferences } from './globalPreferences';
+import { loadGlobalPreferences } from './globalPreferencesLoader';
import { useDarkMode } from './Hooks';
import { DefaultPreferenceItemRender } from './Renderers';
import type { GenericPreferences, PreferenceItem } from './types';
import { userPreferenceDefinitions } from './UserDefinitions';
import { userPreferences } from './userPreferences';
import { useTopChild } from './useTopChild';
-import { formatUrl } from '../Router/queryString';
-import { fetchCollection } from '../DataModel/collection';
-import { fetchResource, strictIdFromUrl } from '../DataModel/resource';
-import { serializeResource } from '../DataModel/serializers';
-import type {
- SpAppResource,
- SpAppResourceDir,
- SpViewSetObj,
-} from '../DataModel/types';
-import type { SerializedResource } from '../DataModel/helperTypes';
-import { userTypes } from '../PickLists/definitions';
export type PreferenceType = keyof typeof preferenceInstances;
@@ -95,18 +81,20 @@ type DocumentHrefResolver =
) => string | undefined)
| undefined;
-const resolveGlobalDocumentHref = (): undefined => undefined;
-
const documentHrefResolvers: IR = {
user: undefined,
collection: undefined,
- global: resolveGlobalDocumentHref,
+ global: undefined,
};
const collectionPreferencesPromise = Promise.all([
collectionPreferences.fetch(),
]).then(f.true);
+const globalPreferencesPromise = Promise.all([
+ loadGlobalPreferences(),
+]).then(f.true);
+
/**
* Fetch app resource that stores current user preferences
*
@@ -133,7 +121,9 @@ function Preferences({
const heading =
prefType === 'collection'
? preferencesText.collectionPreferences()
- : preferencesText.preferences();
+ : prefType === 'global'
+ ? resourcesText.globalPreferences()
+ : preferencesText.preferences();
React.useEffect(
() =>
@@ -581,380 +571,19 @@ export function CollectionPreferencesWrapper(): JSX.Element | null {
);
}
-export function GlobalPreferencesWrapper(): JSX.Element {
- return ;
+export function GlobalPreferencesWrapper(): JSX.Element | null {
+ return (
+
+
+
+ );
}
function GlobalPreferences(): JSX.Element {
return (
-
+
);
}
-type ResourceWithData = {
- readonly id: number;
- readonly data: string | null;
- readonly name: string;
- readonly mimetype: string | null;
- readonly metadata: string | null;
-};
-
-type LoadedGlobalPreferences = {
- readonly resource: SerializedResource;
- readonly directory: ScopedAppResourceDir;
- readonly data: ResourceWithData;
-};
-
-type LoadedCollectionPreferences = {
- readonly resource: SerializedResource;
- readonly directory: ScopedAppResourceDir;
- readonly data: ResourceWithData;
-};
-
-const isAppResource = (
- resource: SerializedResource
-): resource is SerializedResource =>
- resource._tableName === 'SpAppResource';
-
-function GlobalPreferencesStandalone(): JSX.Element {
- const navigate = useNavigate();
- const [state, setState] = React.useState(
- undefined
- );
- const [error, setError] = React.useState(undefined);
- const isMountedRef = React.useRef(true);
-
- const renderStatus = React.useCallback(
- (body: React.ReactNode, role?: 'alert'): JSX.Element => (
-
- {resourcesText.globalPreferences()}
-
- {body}
-
-
- ),
- []
- );
-
- const loadPreferences = React.useCallback(async () => {
- const { records: directories } = await fetchCollection('SpAppResourceDir', {
- limit: 1,
- domainFilter: false,
- },
- {
- usertype: 'Global Prefs',
- });
-
- const directoryRecord = directories[0];
- if (directoryRecord === undefined)
- throw new Error('Global preferences directory not found');
-
- const scopedDirectory: ScopedAppResourceDir = {
- ...directoryRecord,
- scope: getScope(directoryRecord),
- };
-
- const { records: resources } = await fetchCollection('SpAppResource', {
- limit: 1,
- domainFilter: false,
- },
- {
- spappresourcedir: directoryRecord.id,
- name: 'preferences',
- });
-
- const rawResource = resources[0];
- if (rawResource === undefined)
- throw new Error('Global preferences resource not found');
-
- const resource: typeof rawResource = {
- ...rawResource,
- mimeType: rawResource.mimeType ?? 'text/x-java-properties',
- };
-
- const { records: dataRecords } = await fetchCollection('SpAppResourceData', {
- limit: 1,
- domainFilter: false,
- },
- {
- spappresource: resource.id,
- });
-
- const resourceData = dataRecords[0];
- if (resourceData === undefined)
- throw new Error('Global preferences data not found');
-
- return {
- resource,
- directory: scopedDirectory,
- data: {
- id: resource.id,
- name: resource.name ?? 'preferences',
- mimetype: resource.mimeType ?? 'text/x-java-properties',
- metadata: (resource as { metaData?: string | null }).metaData ?? null,
- data: resourceData.data ?? '',
- },
- } as LoadedGlobalPreferences;
- }, []);
-
- const refresh = React.useCallback(() => {
- loadPreferences()
- .then((loaded) => {
- if (!isMountedRef.current) return;
- setState(loaded);
- setError(undefined);
- })
- .catch((loadError) => {
- if (!isMountedRef.current) return;
- setError(loadError);
- });
- }, [loadPreferences]);
-
- React.useEffect(() => {
- isMountedRef.current = true;
- refresh();
- return () => {
- isMountedRef.current = false;
- };
- }, [refresh]);
-
- const handleClone = React.useCallback(
- (
- clonedResource: SerializedResource,
- cloneId: number | undefined
- ) => {
- const appResourceClone = isAppResource(clonedResource)
- ? clonedResource
- : undefined;
- if (appResourceClone === undefined) return;
- const directoryKey =
- state?.directory !== undefined
- ? getDirectoryKey(state.directory) ?? globalResourceKey
- : globalResourceKey;
- navigate(
- formatUrl('/specify/resources/app-resource/new/', {
- directoryKey,
- name: appResourceClone.name,
- ...(appResourceClone.mimeType == null
- ? {}
- : { mimeType: appResourceClone.mimeType }),
- clone: cloneId,
- })
- );
- },
- [navigate, state]
- );
-
- if (error !== undefined && state === undefined)
- return renderStatus(
- 'Failed to open global preferences. Try accessing them through App Resources.',
- 'alert'
- );
-
- if (state === undefined) return renderStatus(commonText.loading());
-
- return (
-
-
- {({ headerJsx, headerButtons, form, footer }): JSX.Element => (
-
-
- {headerJsx}
- {headerButtons}
-
- {form}
- {footer}
-
- )}
-
-
- );
-}
-
-function CollectionPreferencesStandalone(): JSX.Element {
- const navigate = useNavigate();
- const [state, setState] = React.useState(
- undefined
- );
- const [error, setError] = React.useState(undefined);
-
- const renderStatus = React.useCallback(
- (body: React.ReactNode, role?: 'alert'): JSX.Element => (
-
- {preferencesText.collectionPreferences()}
-
- {body}
-
-
- ),
- []
- );
-
- React.useEffect(() => {
- let isMounted = true;
- const load = async () => {
- try {
- const rawData = (await collectionPreferences.fetch()) as ResourceWithData;
- const data: ResourceWithData = {
- ...rawData,
- data: rawData.data ?? '',
- };
- if (!isMounted) return;
- const resource = await fetchResource('SpAppResource', data.id);
- if (!isMounted) return;
- const directory = await resolveDirectory(resource);
- if (!isMounted) return;
- setState({ resource, directory, data });
- } catch (loadError) {
- if (!isMounted) return;
- setError(loadError);
- }
- };
- load();
- return () => {
- isMounted = false;
- };
- }, []);
-
- const handleClone = React.useCallback(
- (
- clonedResource: SerializedResource,
- cloneId: number | undefined
- ) => {
- const appResourceClone = isAppResource(clonedResource)
- ? clonedResource
- : undefined;
- if (appResourceClone === undefined) return;
- const directoryKey =
- state === undefined
- ? globalResourceKey
- : getDirectoryKey(state.directory) ?? globalResourceKey;
- navigate(
- formatUrl('/specify/resources/app-resource/new/', {
- directoryKey,
- name: appResourceClone.name,
- ...(appResourceClone.mimeType == null
- ? {}
- : { mimeType: appResourceClone.mimeType }),
- clone: cloneId,
- })
- );
- },
- [navigate, state]
- );
-
- if (error !== undefined && state === undefined)
- return renderStatus(
- 'Failed to open collection preferences. Try accessing them through App Resources.',
- 'alert'
- );
-
- if (state === undefined) return renderStatus(commonText.loading());
-
- return (
-
- {
- setState((previousState) =>
- previousState === undefined
- ? previousState
- : {
- resource:
- updatedResource as SerializedResource,
- directory: updatedDirectory,
- data: previousState.data,
- }
- );
- collectionPreferences
- .fetch()
- .then((rawData) => ({
- ...rawData,
- data: rawData.data ?? '',
- }))
- .then((updatedData) => {
- setState({
- resource: updatedResource as SerializedResource,
- directory: updatedDirectory,
- data: updatedData as ResourceWithData,
- });
- })
- .catch((fetchError) => {
- if (state === undefined) setError(fetchError);
- });
- }}
- >
- {({ headerJsx, headerButtons, form, footer }): JSX.Element => (
-
-
- {headerJsx}
- {headerButtons}
-
- {form}
- {footer}
-
- )}
-
-
- );
-}
-
-async function resolveDirectory(
- resource: SerializedResource
-): Promise {
- const rawDirectory = resource.spAppResourceDir;
- let directory: SerializedResource;
- if (typeof rawDirectory === 'string') {
- directory = await fetchResource(
- 'SpAppResourceDir',
- strictIdFromUrl(rawDirectory)
- );
- } else if (typeof rawDirectory === 'object' && rawDirectory !== null) {
- directory = serializeResource(rawDirectory);
- } else {
- throw new Error('Collection preferences resource is missing directory');
- }
- return {
- ...directory,
- scope: getScope(directory),
- };
-}
-
-function getDirectoryKey(directory: ScopedAppResourceDir): string | undefined {
- if (directory.scope === 'global') return globalResourceKey;
- if (directory.scope === 'discipline' && directory.discipline !== null)
- return `discipline_${strictIdFromUrl(directory.discipline)}`;
- if (directory.scope === 'collection' && directory.collection !== null)
- return `collection_${strictIdFromUrl(directory.collection)}`;
- if (
- directory.scope === 'userType' &&
- directory.collection !== null &&
- directory.userType !== null
- ) {
- const userTypeLabel =
- userTypes.find(
- (type) => type.toLowerCase() === directory.userType?.toLowerCase()
- ) ?? directory.userType;
- return `collection_${strictIdFromUrl(directory.collection)}_userType_${userTypeLabel}`;
- }
- if (
- directory.scope === 'user' &&
- directory.collection !== null &&
- directory.specifyUser !== null
- )
- return `collection_${strictIdFromUrl(directory.collection)}_user_${strictIdFromUrl(directory.specifyUser)}`;
- return undefined;
-}
From 914d3918f21d776eae491b4c3ad24969f73aa5e4 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Fri, 17 Oct 2025 23:19:29 -0500
Subject: [PATCH 24/31] fix: add static test globalpreferences
---
.../app.resource/name=GlobalPreferences&quiet=.properties | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 specifyweb/frontend/js_src/lib/tests/ajax/static/context/app.resource/name=GlobalPreferences&quiet=.properties
diff --git a/specifyweb/frontend/js_src/lib/tests/ajax/static/context/app.resource/name=GlobalPreferences&quiet=.properties b/specifyweb/frontend/js_src/lib/tests/ajax/static/context/app.resource/name=GlobalPreferences&quiet=.properties
new file mode 100644
index 00000000000..8f80091aff7
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/tests/ajax/static/context/app.resource/name=GlobalPreferences&quiet=.properties
@@ -0,0 +1,5 @@
+auditing.do_audits=true
+auditing.audit_field_updates=true
+ui.formatting.scrdateformat=YYYY-MM-DD
+ui.formatting.scrmonthformat=YYYY-MM
+attachment.preview_size=256
From 747f726634e49202a1679719a4d0dfb331ef593e Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 18 Oct 2025 04:23:22 +0000
Subject: [PATCH 25/31] Lint code with ESLint and Prettier
Triggered by 914d3918f21d776eae491b4c3ad24969f73aa5e4 on branch refs/heads/issue-7442-3
---
.../lib/components/Preferences/index.tsx | 161 +++++++++---------
1 file changed, 85 insertions(+), 76 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
index ab09dbbfe62..7ba5ddb6693 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/index.tsx
@@ -58,7 +58,10 @@ type SubcategoryDocumentation = {
readonly label: LocalizedString | (() => LocalizedString);
};
-const SUBCATEGORY_DOCS_MAP: Record> = {
+const SUBCATEGORY_DOCS_MAP: Record<
+ string,
+ Record
+> = {
treeManagement: {
synonymized: {
href: 'https://discourse.specifysoftware.org/t/enable-creating-children-for-synonymized-nodes/987',
@@ -75,10 +78,10 @@ const SUBCATEGORY_DOCS_MAP: Record string | undefined)
+ category: string,
+ subcategory: string,
+ name: string
+ ) => string | undefined)
| undefined;
const documentHrefResolvers: IR = {
@@ -91,9 +94,9 @@ const collectionPreferencesPromise = Promise.all([
collectionPreferences.fetch(),
]).then(f.true);
-const globalPreferencesPromise = Promise.all([
- loadGlobalPreferences(),
-]).then(f.true);
+const globalPreferencesPromise = Promise.all([loadGlobalPreferences()]).then(
+ f.true
+);
/**
* Fetch app resource that stores current user preferences
@@ -245,7 +248,8 @@ export function PreferencesContent({
const isReadOnly = React.useContext(ReadOnlyContext);
const definitions = usePrefDefinitions(prefType);
const basePreferences = preferenceInstances[prefType];
- const preferences = React.useContext(basePreferences.Context) ?? basePreferences;
+ const preferences =
+ React.useContext(basePreferences.Context) ?? basePreferences;
const resolveDocumentHref = documentHrefResolvers[prefType];
const definitionsMap = React.useMemo(
() => new Map(definitions),
@@ -325,48 +329,52 @@ export function PreferencesContent({
{typeof description === 'function' ? description() : description}
)}
- {items.map(([name, item]) => {
- const canEdit =
- !isReadOnly &&
- (item.visible !== 'protected' ||
- hasPermission('/preferences/user', 'edit_protected'));
- const documentHref = resolveDocumentHref?.(
- categoryKey,
- subcategoryKey,
- name
- );
- const stackDocumentation =
- prefType === 'collection' && documentHref !== undefined;
- const props = {
- className: `
+ {items.map(([name, item]) => {
+ const canEdit =
+ !isReadOnly &&
+ (item.visible !== 'protected' ||
+ hasPermission('/preferences/user', 'edit_protected'));
+ const documentHref = resolveDocumentHref?.(
+ categoryKey,
+ subcategoryKey,
+ name
+ );
+ const stackDocumentation =
+ prefType === 'collection' && documentHref !== undefined;
+ const props = {
+ className: `
flex items-start gap-2 md:flex-row flex-col
${canEdit ? '' : '!cursor-not-allowed'}
`,
- key: name,
- title: canEdit ? undefined : preferencesText.adminsOnlyPreference(),
- };
- const children = (
- <>
-
-
+
+
-
-
- {(item.description !== undefined ||
- documentHref !== undefined) && (
+ >
+
+
+ {(item.description !== undefined ||
+ documentHref !== undefined) && (
{item.description !== undefined && (
@@ -380,7 +388,9 @@ export function PreferencesContent({
)}
{documentHref !== undefined && (
{headerText.documentation()}
@@ -388,40 +398,35 @@ export function PreferencesContent({
)}
)}
-
-
+
-
-
-
-
- >
- );
- return 'container' in item && item.container === 'div' ? (
-
{children}
- ) : (
-
- );
- })}
+ >
+
+
+
+
+ >
+ );
+ return 'container' in item && item.container === 'div' ? (
+
{children}
+ ) : (
+
+ );
+ })}
);
},
- [
- isReadOnly,
- prefType,
- preferences,
- resolveDocumentHref,
- ]
+ [isReadOnly, prefType, preferences, resolveDocumentHref]
);
return (
@@ -431,13 +436,18 @@ export function PreferencesContent({
[category, { title, description = undefined, subCategories }],
index
) => {
- if (prefType === 'collection' && category === 'catalogNumberParentInheritance')
+ if (
+ prefType === 'collection' &&
+ category === 'catalogNumberParentInheritance'
+ )
return null;
const isCatalogInheritance =
- prefType === 'collection' && category === 'catalogNumberInheritance';
+ prefType === 'collection' &&
+ category === 'catalogNumberInheritance';
const parentDefinition = isCatalogInheritance
- ? definitionsMap.get('catalogNumberParentInheritance') ?? undefined
+ ? (definitionsMap.get('catalogNumberParentInheritance') ??
+ undefined)
: undefined;
return (
@@ -586,4 +596,3 @@ function GlobalPreferences(): JSX.Element {
);
}
-
From 98f7152b5e00f198e5c419c3c45a3cdaec7e2de2 Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 18 Oct 2025 00:00:36 -0500
Subject: [PATCH 26/31] feat: organize global settings into categories
---
.../Preferences/GlobalDefinitions.ts | 81 ++++++++++++-------
.../Preferences/globalPreferences.ts | 6 +-
.../Preferences/globalPreferencesUtils.ts | 58 +++++++------
.../lib/localization/preferences.behavior.ts | 6 +-
4 files changed, 94 insertions(+), 57 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index c21e92a93ce..9b6de5a9ced 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -2,44 +2,34 @@ import { attachmentsText } from '../../localization/attachments';
import { preferencesText } from '../../localization/preferences';
import { localized } from '../../utils/types';
import { definePref } from './types';
+import type { GenericPreferences } from './types';
export const FULL_DATE_FORMAT_OPTIONS = [
- 'YYYY-MM-DD',
- 'MM/DD/YYYY',
- 'DD/MM/YYYY',
- 'YYYY/MM/DD',
- 'DD MMM YYYY',
+ '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 = {
- general: {
- title: preferencesText.general(),
+formatting: {
+ title: preferencesText.formatting,
subCategories: {
- auditing: {
- title: preferencesText.auditing(),
- 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',
- }),
- },
- },
formatting: {
- title: preferencesText.formatting(),
+ title: preferencesText.general(),
items: {
fullDateFormat: definePref({
title: preferencesText.fullDateFormat(),
@@ -65,8 +55,39 @@ export const globalPreferenceDefinitions = {
}),
},
},
+ },
+ },
+ 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: attachmentsText.attachments(),
+ title: preferencesText.general(),
items: {
attachmentThumbnailSize: definePref({
title: preferencesText.attachmentThumbnailSize(),
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
index fb796f707d8..00577e5d863 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferences.ts
@@ -2,15 +2,19 @@ import { BasePreferences } from './BasePreferences';
import { globalPreferenceDefinitions } from './GlobalDefinitions';
export type GlobalPreferenceValues = {
- readonly general: {
+ readonly auditing: {
readonly auditing: {
readonly enableAuditLog: boolean;
readonly logFieldLevelChanges: boolean;
};
+ };
+ readonly formatting: {
readonly formatting: {
readonly fullDateFormat: string;
readonly monthYearDateFormat: string;
};
+ };
+ readonly attachments: {
readonly attachments: {
readonly attachmentThumbnailSize: number;
};
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
index 528088a53e9..730d7650d49 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
@@ -30,15 +30,19 @@ const DATE_FORMAT_NORMALIZER = new Set([
]);
export const DEFAULT_VALUES: GlobalPreferenceValues = {
- general: {
+ auditing: {
auditing: {
enableAuditLog: true,
logFieldLevelChanges: true,
},
+ },
+ formatting: {
formatting: {
fullDateFormat: 'YYYY-MM-DD',
monthYearDateFormat: 'YYYY-MM',
},
+ },
+ attachments: {
attachments: {
attachmentThumbnailSize: 256,
},
@@ -90,36 +94,40 @@ function parseNumber(value: string | undefined, fallback: number): number {
export function preferencesFromMap(map: Record): GlobalPreferenceValues {
const fullDateFormat = normalizeFormat(
- map[PREFERENCE_KEYS.fullDateFormat] ?? DEFAULT_VALUES.general.formatting.fullDateFormat
+ map[PREFERENCE_KEYS.fullDateFormat] ?? DEFAULT_VALUES.formatting.formatting.fullDateFormat
);
const monthYearFormat = normalizeFormat(
- map[PREFERENCE_KEYS.monthYearDateFormat] ?? DEFAULT_VALUES.general.formatting.monthYearDateFormat
+ map[PREFERENCE_KEYS.monthYearDateFormat] ?? DEFAULT_VALUES.formatting.formatting.monthYearDateFormat
);
return {
- general: {
+ auditing: {
auditing: {
enableAuditLog: parseBoolean(
map[PREFERENCE_KEYS.enableAuditLog],
- DEFAULT_VALUES.general.auditing.enableAuditLog
+ DEFAULT_VALUES.auditing.auditing.enableAuditLog
),
logFieldLevelChanges: parseBoolean(
map[PREFERENCE_KEYS.logFieldLevelChanges],
- DEFAULT_VALUES.general.auditing.logFieldLevelChanges
+ DEFAULT_VALUES.auditing.auditing.logFieldLevelChanges
),
},
+ },
+ formatting: {
formatting: {
fullDateFormat,
monthYearDateFormat: MONTH_YEAR_FORMAT_OPTIONS.includes(
monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number]
)
? (monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number])
- : DEFAULT_VALUES.general.formatting.monthYearDateFormat,
+ : DEFAULT_VALUES.formatting.formatting.monthYearDateFormat,
},
+ },
+ attachments: {
attachments: {
attachmentThumbnailSize: parseNumber(
map[PREFERENCE_KEYS.attachmentThumbnailSize],
- DEFAULT_VALUES.general.attachments.attachmentThumbnailSize
+ DEFAULT_VALUES.attachments.attachments.attachmentThumbnailSize
),
},
},
@@ -139,26 +147,30 @@ function normalizeValues(
): GlobalPreferenceValues {
const merged = values ?? DEFAULT_VALUES;
return {
- general: {
+ auditing: {
auditing: {
enableAuditLog:
- merged.general?.auditing?.enableAuditLog ?? DEFAULT_VALUES.general.auditing.enableAuditLog,
+ merged.auditing?.auditing?.enableAuditLog ?? DEFAULT_VALUES.auditing.auditing.enableAuditLog,
logFieldLevelChanges:
- merged.general?.auditing?.logFieldLevelChanges ??
- DEFAULT_VALUES.general.auditing.logFieldLevelChanges,
+ merged.auditing?.auditing?.logFieldLevelChanges ??
+ DEFAULT_VALUES.auditing.auditing.logFieldLevelChanges,
},
+ },
+ formatting: {
formatting: {
fullDateFormat:
- merged.general?.formatting?.fullDateFormat ??
- DEFAULT_VALUES.general.formatting.fullDateFormat,
+ merged.formatting?.formatting?.fullDateFormat ??
+ DEFAULT_VALUES.formatting.formatting.fullDateFormat,
monthYearDateFormat:
- merged.general?.formatting?.monthYearDateFormat ??
- DEFAULT_VALUES.general.formatting.monthYearDateFormat,
+ merged.formatting?.formatting?.monthYearDateFormat ??
+ DEFAULT_VALUES.formatting.formatting.monthYearDateFormat,
},
+ },
+ attachments: {
attachments: {
attachmentThumbnailSize:
- merged.general?.attachments?.attachmentThumbnailSize ??
- DEFAULT_VALUES.general.attachments.attachmentThumbnailSize,
+ merged.attachments?.attachments?.attachmentThumbnailSize ??
+ DEFAULT_VALUES.attachments.attachments.attachmentThumbnailSize,
},
},
};
@@ -166,15 +178,15 @@ function normalizeValues(
function preferencesToKeyValue(values: GlobalPreferenceValues): Record {
return {
- [PREFERENCE_KEYS.enableAuditLog]: values.general.auditing.enableAuditLog ? 'true' : 'false',
- [PREFERENCE_KEYS.logFieldLevelChanges]: values.general.auditing.logFieldLevelChanges
+ [PREFERENCE_KEYS.enableAuditLog]: values.auditing.auditing.enableAuditLog ? 'true' : 'false',
+ [PREFERENCE_KEYS.logFieldLevelChanges]: values.auditing.auditing.logFieldLevelChanges
? 'true'
: 'false',
- [PREFERENCE_KEYS.fullDateFormat]: normalizeFormat(values.general.formatting.fullDateFormat),
+ [PREFERENCE_KEYS.fullDateFormat]: normalizeFormat(values.formatting.formatting.fullDateFormat),
[PREFERENCE_KEYS.monthYearDateFormat]: normalizeFormat(
- values.general.formatting.monthYearDateFormat
+ values.formatting.formatting.monthYearDateFormat
),
- [PREFERENCE_KEYS.attachmentThumbnailSize]: values.general.attachments.attachmentThumbnailSize.toString(),
+ [PREFERENCE_KEYS.attachmentThumbnailSize]: values.attachments.attachments.attachmentThumbnailSize.toString(),
};
}
diff --git a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
index 405f0fdcf4a..9296e8b3137 100644
--- a/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
+++ b/specifyweb/frontend/js_src/lib/localization/preferences.behavior.ts
@@ -1007,14 +1007,14 @@ export const preferencesBehaviorDictionary = {
'en-us': 'Auditing',
},
formatting: {
- 'en-us': 'Formatting',
+ 'en-us': 'Date Format',
},
enableAuditLog: {
'en-us': 'Enable Audit Log',
},
enableAuditLogDescription: {
'en-us':
- 'Globally enables or disables the audit log feature for all collections.',
+ 'Globally enables or disables the audit log for all collections.',
},
logFieldLevelChanges: {
'en-us': 'Log field-level changes',
@@ -1027,7 +1027,7 @@ export const preferencesBehaviorDictionary = {
'en-us': 'Full date format',
},
fullDateFormatDescription: {
- 'en-us': 'Select the display format for complete dates across the application.',
+ 'en-us': 'This determines the date format used for full dates in the WorkBench, queries, and data exports.',
},
monthYearDateFormat: {
'en-us': 'Month/year date format',
From 2b35679d6d7ce030f6f761d7ec6379b6053e339e Mon Sep 17 00:00:00 2001
From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com>
Date: Sat, 18 Oct 2025 00:04:46 -0500
Subject: [PATCH 27/31] fix: failing test
---
.../js_src/lib/components/Preferences/GlobalDefinitions.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index 9b6de5a9ced..61d20936421 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -2,7 +2,6 @@ import { attachmentsText } from '../../localization/attachments';
import { preferencesText } from '../../localization/preferences';
import { localized } from '../../utils/types';
import { definePref } from './types';
-import type { GenericPreferences } from './types';
export const FULL_DATE_FORMAT_OPTIONS = [
'yyyy-MM-dd',
From 5cc807806c96919ea7830fdd3b35e83c413374ea Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 20 Oct 2025 13:11:33 -0400
Subject: [PATCH 28/31] fixing failing tests
---
.../js_src/lib/components/Preferences/GlobalDefinitions.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
index 61d20936421..31ee461dbfd 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/GlobalDefinitions.ts
@@ -25,7 +25,7 @@ export const MONTH_YEAR_FORMAT_OPTIONS = ['YYYY-MM', 'MM/YYYY', 'YYYY/MM'] as co
export const globalPreferenceDefinitions = {
formatting: {
- title: preferencesText.formatting,
+ title: preferencesText.formatting(),
subCategories: {
formatting: {
title: preferencesText.general(),
@@ -83,7 +83,7 @@ formatting: {
},
},
attachments: {
- title: attachmentsText.attachments,
+ title: attachmentsText.attachments(),
subCategories: {
attachments: {
title: preferencesText.general(),
From 8f7c0aea8908644d2a4a3938b7539518aec2459a Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 20 Oct 2025 13:21:53 -0400
Subject: [PATCH 29/31] Migrate screen date format from remote prefs into
GlobalPreferences
---
specifyweb/backend/context/remote_prefs.py | 7 +-
specifyweb/backend/stored_queries/format.py | 12 +-
.../components/InitialContext/remotePrefs.ts | 13 +-
.../__tests__/globalPreferencesUtils.test.ts | 51 ++++++
.../Preferences/globalPreferencesLoader.ts | 110 +++++++++++-
.../Preferences/globalPreferencesUtils.ts | 165 +++++++++++-------
6 files changed, 287 insertions(+), 71 deletions(-)
create mode 100644 specifyweb/frontend/js_src/lib/components/Preferences/__tests__/globalPreferencesUtils.test.ts
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/InitialContext/remotePrefs.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/remotePrefs.ts
index 5da79de6724..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
);
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/globalPreferencesUtils.test.ts b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/globalPreferencesUtils.test.ts
new file mode 100644
index 00000000000..8bc37ed215f
--- /dev/null
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/__tests__/globalPreferencesUtils.test.ts
@@ -0,0 +1,51 @@
+import {
+ DEFAULT_VALUES,
+ mergeWithDefaultValues,
+ partialPreferencesFromMap,
+ serializeGlobalPreferences,
+ setGlobalPreferenceFallback,
+} from '../globalPreferencesUtils';
+
+describe('globalPreferencesUtils', () => {
+ beforeEach(() => {
+ setGlobalPreferenceFallback(DEFAULT_VALUES);
+ });
+
+ it('builds partial values from remote preferences map', () => {
+ const partial = partialPreferencesFromMap({
+ 'ui.formatting.scrdateformat': 'MM/dd/yyyy',
+ 'auditing.do_audits': 'false',
+ });
+
+ expect(partial.formatting?.formatting?.fullDateFormat).toBe('MM/DD/YYYY');
+ expect(partial.auditing?.auditing?.enableAuditLog).toBe(false);
+ expect(partial.attachments).toBeUndefined();
+ });
+
+ it('merges partial values with fallback defaults', () => {
+ const remotePartial = partialPreferencesFromMap({
+ 'ui.formatting.scrdateformat': 'MM/dd/yyyy',
+ });
+
+ const merged = mergeWithDefaultValues(remotePartial);
+
+ expect(merged.formatting.formatting.fullDateFormat).toBe('MM/DD/YYYY');
+ expect(merged.attachments.attachments.attachmentThumbnailSize).toBe(
+ DEFAULT_VALUES.attachments.attachments.attachmentThumbnailSize
+ );
+ });
+
+ it('serializes using configured fallback values', () => {
+ const fallback = mergeWithDefaultValues(
+ partialPreferencesFromMap({
+ 'ui.formatting.scrdateformat': 'MM/dd/yyyy',
+ })
+ );
+ setGlobalPreferenceFallback(fallback);
+
+ const { data } = serializeGlobalPreferences(undefined, [], { fallback });
+
+ expect(data).toContain('ui.formatting.scrdateformat=MM/DD/YYYY');
+ expect(data).toContain('auditing.do_audits=true');
+ });
+});
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
index 7472fe1ea82..bd55b3485cd 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
@@ -1,11 +1,64 @@
import { ajax } from '../../utils/ajax';
import { Http } from '../../utils/ajax/definitions';
+import { ping } from '../../utils/ajax/ping';
import { formatUrl } from '../Router/queryString';
import { contextUnlockedPromise, foreverFetch } from '../InitialContext';
+import { fetchContext as fetchRemotePrefs, remotePrefs } from '../InitialContext/remotePrefs';
+import { keysToLowerCase } from '../../utils/utils';
import { type PartialPreferences } from './BasePreferences';
import { globalPreferenceDefinitions } from './GlobalDefinitions';
import { globalPreferences } from './globalPreferences';
-import { parseGlobalPreferences } from './globalPreferencesUtils';
+import type { GlobalPreferenceValues } from './globalPreferences';
+import {
+ DEFAULT_VALUES,
+ mergeWithDefaultValues,
+ parseGlobalPreferences,
+ partialPreferencesFromMap,
+ serializeGlobalPreferences,
+ setGlobalPreferenceFallback,
+} from './globalPreferencesUtils';
+
+type PartialGlobalValues = Partial;
+
+const GLOBAL_RESOURCE_URL = '/context/app.resource/';
+
+function mergeMissingFromRemote(
+ existing: PartialGlobalValues,
+ remote: PartialGlobalValues
+): { readonly merged: PartialGlobalValues; readonly changed: boolean } {
+ let changed = false;
+ let merged = existing;
+
+ const remoteFormatting = remote.formatting?.formatting;
+ if (remoteFormatting !== undefined) {
+ const currentFormatting = existing.formatting?.formatting ?? {};
+ const updatedFormatting: Partial = {
+ ...currentFormatting,
+ };
+
+ (['fullDateFormat', 'monthYearDateFormat'] as const).forEach((key) => {
+ const remoteValue = remoteFormatting[key];
+ if (
+ remoteValue !== undefined &&
+ updatedFormatting[key] === undefined &&
+ remoteValue !== DEFAULT_VALUES.formatting.formatting[key]
+ ) {
+ updatedFormatting[key] = remoteValue;
+ changed = true;
+ }
+ });
+
+ if (changed)
+ merged = {
+ ...merged,
+ formatting: {
+ formatting: updatedFormatting as GlobalPreferenceValues['formatting']['formatting'],
+ },
+ };
+ }
+
+ return { merged, changed };
+}
export const loadGlobalPreferences = async (): Promise => {
const entryPoint = await contextUnlockedPromise;
@@ -14,7 +67,7 @@ export const loadGlobalPreferences = async (): Promise => {
return;
}
- const { data, status } = await ajax(
+ const { data, status, response } = await ajax(
formatUrl('/context/app.resource', {
name: 'GlobalPreferences',
quiet: '',
@@ -26,11 +79,60 @@ export const loadGlobalPreferences = async (): Promise => {
}
);
- const { raw } = parseGlobalPreferences(
+ await fetchRemotePrefs.catch(() => undefined);
+
+ const remotePartial = partialPreferencesFromMap(remotePrefs);
+ const remoteDefaults = mergeWithDefaultValues(remotePartial);
+ setGlobalPreferenceFallback(remoteDefaults);
+ globalPreferences.setDefaults(
+ remotePartial as PartialPreferences
+ );
+
+ const { raw, metadata } = parseGlobalPreferences(
status === Http.NO_CONTENT ? null : data
);
+ const { merged: migratedRaw, changed } = mergeMissingFromRemote(raw, remotePartial);
+
+ if (changed) {
+ try {
+ const serialized = serializeGlobalPreferences(migratedRaw, metadata, {
+ fallback: remoteDefaults,
+ });
+ const originalData = status === Http.NO_CONTENT ? '' : data;
+
+ if (serialized.data.trim() !== originalData.trim()) {
+ const resourceIdHeader = response.headers.get('X-Record-ID');
+ const resourceId =
+ resourceIdHeader === null ? undefined : Number.parseInt(resourceIdHeader, 10);
+ const payload = keysToLowerCase({
+ name: 'GlobalPreferences',
+ mimeType: 'text/plain',
+ metaData: '',
+ data: serialized.data,
+ });
+
+ if (typeof resourceId === 'number' && Number.isFinite(resourceId) && resourceId >= 0)
+ await ping(`${GLOBAL_RESOURCE_URL}${resourceId}/`, {
+ method: 'PUT',
+ body: payload,
+ headers: { Accept: 'application/json' },
+ errorMode: 'silent',
+ });
+ else
+ await ping(GLOBAL_RESOURCE_URL, {
+ method: 'POST',
+ body: payload,
+ headers: { Accept: 'application/json' },
+ errorMode: 'silent',
+ });
+ }
+ } catch (error) {
+ console.error('Failed migrating remote preferences into GlobalPreferences', error);
+ }
+ }
+
globalPreferences.setRaw(
- raw as PartialPreferences
+ (changed ? migratedRaw : raw) as PartialPreferences
);
};
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
index 730d7650d49..702bc335317 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
@@ -49,6 +49,18 @@ export const DEFAULT_VALUES: GlobalPreferenceValues = {
},
};
+let globalPreferenceFallback: GlobalPreferenceValues = DEFAULT_VALUES;
+
+export function setGlobalPreferenceFallback(
+ fallback: GlobalPreferenceValues
+): void {
+ globalPreferenceFallback = fallback;
+}
+
+export function getGlobalPreferenceFallback(): GlobalPreferenceValues {
+ return globalPreferenceFallback;
+}
+
function normalizeFormat(value: string): string {
const upper = value.toUpperCase();
return DATE_FORMAT_NORMALIZER.has(upper) ? upper : upper;
@@ -79,103 +91,127 @@ function parseProperties(data: string): ParsedProperties {
return { lines: parsed, map };
}
-function parseBoolean(value: string | undefined, fallback: boolean): boolean {
- if (typeof value !== 'string') return fallback;
+function parseBoolean(value: string | undefined): boolean | undefined {
+ if (typeof value !== 'string') return undefined;
if (value.toLowerCase() === 'true') return true;
if (value.toLowerCase() === 'false') return false;
- return fallback;
+ return undefined;
}
-function parseNumber(value: string | undefined, fallback: number): number {
- if (typeof value !== 'string') return fallback;
+function parseNumber(value: string | undefined): number | undefined {
+ if (typeof value !== 'string') return undefined;
const parsed = Number.parseInt(value, 10);
- return Number.isNaN(parsed) ? fallback : parsed;
+ return Number.isNaN(parsed) ? undefined : parsed;
}
-export function preferencesFromMap(map: Record): GlobalPreferenceValues {
- const fullDateFormat = normalizeFormat(
- map[PREFERENCE_KEYS.fullDateFormat] ?? DEFAULT_VALUES.formatting.formatting.fullDateFormat
- );
- const monthYearFormat = normalizeFormat(
- map[PREFERENCE_KEYS.monthYearDateFormat] ?? DEFAULT_VALUES.formatting.formatting.monthYearDateFormat
+function hasProperties>(object: T): object is T {
+ return Object.keys(object).length > 0;
+}
+
+export function partialPreferencesFromMap(
+ map: Readonly>
+): Partial {
+ const partial: Partial = {};
+
+ const auditing: Partial = {};
+ const enableAuditLog = parseBoolean(map[PREFERENCE_KEYS.enableAuditLog]);
+ if (enableAuditLog !== undefined) auditing.enableAuditLog = enableAuditLog;
+
+ const logFieldLevelChanges = parseBoolean(map[PREFERENCE_KEYS.logFieldLevelChanges]);
+ if (logFieldLevelChanges !== undefined)
+ auditing.logFieldLevelChanges = logFieldLevelChanges;
+
+ if (hasProperties(auditing)) {
+ partial.auditing = {
+ auditing: auditing as GlobalPreferenceValues['auditing']['auditing'],
+ };
+ }
+
+ const formatting: Partial = {};
+ const fullDateFormatRaw = map[PREFERENCE_KEYS.fullDateFormat];
+ if (fullDateFormatRaw !== undefined)
+ formatting.fullDateFormat = normalizeFormat(fullDateFormatRaw);
+
+ const monthYearDateFormatRaw = map[PREFERENCE_KEYS.monthYearDateFormat];
+ if (monthYearDateFormatRaw !== undefined) {
+ const monthYearFormat = normalizeFormat(monthYearDateFormatRaw);
+ if (
+ MONTH_YEAR_FORMAT_OPTIONS.includes(
+ monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number]
+ )
+ )
+ formatting.monthYearDateFormat = monthYearFormat;
+ }
+
+ if (hasProperties(formatting)) {
+ partial.formatting = {
+ formatting: formatting as GlobalPreferenceValues['formatting']['formatting'],
+ };
+ }
+
+ const attachments: Partial = {};
+ const attachmentThumbnailSize = parseNumber(
+ map[PREFERENCE_KEYS.attachmentThumbnailSize]
);
+ if (attachmentThumbnailSize !== undefined)
+ attachments.attachmentThumbnailSize = attachmentThumbnailSize;
- return {
- auditing: {
- auditing: {
- enableAuditLog: parseBoolean(
- map[PREFERENCE_KEYS.enableAuditLog],
- DEFAULT_VALUES.auditing.auditing.enableAuditLog
- ),
- logFieldLevelChanges: parseBoolean(
- map[PREFERENCE_KEYS.logFieldLevelChanges],
- DEFAULT_VALUES.auditing.auditing.logFieldLevelChanges
- ),
- },
- },
- formatting: {
- formatting: {
- fullDateFormat,
- monthYearDateFormat: MONTH_YEAR_FORMAT_OPTIONS.includes(
- monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number]
- )
- ? (monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number])
- : DEFAULT_VALUES.formatting.formatting.monthYearDateFormat,
- },
- },
- attachments: {
- attachments: {
- attachmentThumbnailSize: parseNumber(
- map[PREFERENCE_KEYS.attachmentThumbnailSize],
- DEFAULT_VALUES.attachments.attachments.attachmentThumbnailSize
- ),
- },
- },
- };
-}
+ if (hasProperties(attachments)) {
+ partial.attachments = {
+ attachments: attachments as GlobalPreferenceValues['attachments']['attachments'],
+ };
+ }
-export function parseGlobalPreferences(
- data: string | null
-): { readonly raw: GlobalPreferenceValues; readonly metadata: ReadonlyArray } {
- const parsed = parseProperties(data ?? '');
- const values = preferencesFromMap(parsed.map);
- return { raw: values, metadata: parsed.lines };
+ return partial;
}
-function normalizeValues(
- values: GlobalPreferenceValues | Partial | undefined
+export function mergeWithDefaultValues(
+ values: Partial | undefined,
+ fallback: GlobalPreferenceValues = globalPreferenceFallback
): GlobalPreferenceValues {
- const merged = values ?? DEFAULT_VALUES;
+ const merged = values ?? {};
return {
auditing: {
auditing: {
enableAuditLog:
- merged.auditing?.auditing?.enableAuditLog ?? DEFAULT_VALUES.auditing.auditing.enableAuditLog,
+ merged.auditing?.auditing?.enableAuditLog ??
+ fallback.auditing.auditing.enableAuditLog,
logFieldLevelChanges:
merged.auditing?.auditing?.logFieldLevelChanges ??
- DEFAULT_VALUES.auditing.auditing.logFieldLevelChanges,
+ fallback.auditing.auditing.logFieldLevelChanges,
},
},
formatting: {
formatting: {
fullDateFormat:
merged.formatting?.formatting?.fullDateFormat ??
- DEFAULT_VALUES.formatting.formatting.fullDateFormat,
+ fallback.formatting.formatting.fullDateFormat,
monthYearDateFormat:
merged.formatting?.formatting?.monthYearDateFormat ??
- DEFAULT_VALUES.formatting.formatting.monthYearDateFormat,
+ fallback.formatting.formatting.monthYearDateFormat,
},
},
attachments: {
attachments: {
attachmentThumbnailSize:
merged.attachments?.attachments?.attachmentThumbnailSize ??
- DEFAULT_VALUES.attachments.attachments.attachmentThumbnailSize,
+ fallback.attachments.attachments.attachmentThumbnailSize,
},
},
};
}
+export function parseGlobalPreferences(
+ data: string | null
+): {
+ readonly raw: Partial;
+ readonly metadata: ReadonlyArray;
+} {
+ const parsed = parseProperties(data ?? '');
+ const values = partialPreferencesFromMap(parsed.map);
+ return { raw: values, metadata: parsed.lines };
+}
+
function preferencesToKeyValue(values: GlobalPreferenceValues): Record {
return {
[PREFERENCE_KEYS.enableAuditLog]: values.auditing.auditing.enableAuditLog ? 'true' : 'false',
@@ -217,9 +253,14 @@ export function applyUpdates(
export function serializeGlobalPreferences(
raw: GlobalPreferenceValues | Partial | undefined,
- metadata: ReadonlyArray
+ metadata: ReadonlyArray,
+ options?: { readonly fallback?: GlobalPreferenceValues }
): { readonly data: string; readonly metadata: ReadonlyArray } {
- const normalized = normalizeValues(raw as GlobalPreferenceValues | undefined);
+ const fallback = options?.fallback ?? globalPreferenceFallback;
+ const normalized = mergeWithDefaultValues(
+ raw as Partial | undefined,
+ fallback
+ );
const { lines, text } = applyUpdates(metadata, preferencesToKeyValue(normalized));
return { data: text, metadata: lines };
}
@@ -233,4 +274,4 @@ export function formatGlobalPreferenceValue(
if (definition.type === 'java.lang.Integer') return Number(value).toString();
}
return String(value ?? '');
-}
\ No newline at end of file
+}
From 4d49db9c76d2a036d2b0b16e2179b741c210856a Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 20 Oct 2025 14:31:19 -0400
Subject: [PATCH 30/31] resolve readonly assignment errors in global
preferences utils and loader
---
.../Preferences/globalPreferencesLoader.ts | 23 +++++++++----
.../Preferences/globalPreferencesUtils.ts | 32 +++++++++----------
2 files changed, 33 insertions(+), 22 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
index bd55b3485cd..6ec7488872f 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
@@ -31,16 +31,20 @@ function mergeMissingFromRemote(
const remoteFormatting = remote.formatting?.formatting;
if (remoteFormatting !== undefined) {
- const currentFormatting = existing.formatting?.formatting ?? {};
- const updatedFormatting: Partial = {
- ...currentFormatting,
+ const currentFormatting = existing.formatting?.formatting;
+ const updatedFormatting: Record<
+ keyof GlobalPreferenceValues['formatting']['formatting'],
+ string | undefined
+ > = {
+ fullDateFormat: currentFormatting?.fullDateFormat,
+ monthYearDateFormat: currentFormatting?.monthYearDateFormat,
};
(['fullDateFormat', 'monthYearDateFormat'] as const).forEach((key) => {
const remoteValue = remoteFormatting[key];
if (
remoteValue !== undefined &&
- updatedFormatting[key] === undefined &&
+ (updatedFormatting[key] ?? undefined) === undefined &&
remoteValue !== DEFAULT_VALUES.formatting.formatting[key]
) {
updatedFormatting[key] = remoteValue;
@@ -48,13 +52,20 @@ function mergeMissingFromRemote(
}
});
- if (changed)
+ if (changed) {
+ const formattingPayload: Record = {};
+ if (updatedFormatting.fullDateFormat !== undefined)
+ formattingPayload.fullDateFormat = updatedFormatting.fullDateFormat;
+ if (updatedFormatting.monthYearDateFormat !== undefined)
+ formattingPayload.monthYearDateFormat = updatedFormatting.monthYearDateFormat;
+
merged = {
...merged,
formatting: {
- formatting: updatedFormatting as GlobalPreferenceValues['formatting']['formatting'],
+ formatting: formattingPayload as GlobalPreferenceValues['formatting']['formatting'],
},
};
+ }
}
return { merged, changed };
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
index 702bc335317..789d9be57f5 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
@@ -111,26 +111,26 @@ function hasProperties>(object: T): object is
export function partialPreferencesFromMap(
map: Readonly>
): Partial {
- const partial: Partial = {};
+ const partial: Record = {};
- const auditing: Partial = {};
+ const auditingValues: Record = {};
const enableAuditLog = parseBoolean(map[PREFERENCE_KEYS.enableAuditLog]);
- if (enableAuditLog !== undefined) auditing.enableAuditLog = enableAuditLog;
+ if (enableAuditLog !== undefined) auditingValues.enableAuditLog = enableAuditLog;
const logFieldLevelChanges = parseBoolean(map[PREFERENCE_KEYS.logFieldLevelChanges]);
if (logFieldLevelChanges !== undefined)
- auditing.logFieldLevelChanges = logFieldLevelChanges;
+ auditingValues.logFieldLevelChanges = logFieldLevelChanges;
- if (hasProperties(auditing)) {
+ if (hasProperties(auditingValues)) {
partial.auditing = {
- auditing: auditing as GlobalPreferenceValues['auditing']['auditing'],
+ auditing: auditingValues,
};
}
- const formatting: Partial = {};
+ const formattingValues: Record = {};
const fullDateFormatRaw = map[PREFERENCE_KEYS.fullDateFormat];
if (fullDateFormatRaw !== undefined)
- formatting.fullDateFormat = normalizeFormat(fullDateFormatRaw);
+ formattingValues.fullDateFormat = normalizeFormat(fullDateFormatRaw);
const monthYearDateFormatRaw = map[PREFERENCE_KEYS.monthYearDateFormat];
if (monthYearDateFormatRaw !== undefined) {
@@ -140,29 +140,29 @@ export function partialPreferencesFromMap(
monthYearFormat as (typeof MONTH_YEAR_FORMAT_OPTIONS)[number]
)
)
- formatting.monthYearDateFormat = monthYearFormat;
+ formattingValues.monthYearDateFormat = monthYearFormat;
}
- if (hasProperties(formatting)) {
+ if (hasProperties(formattingValues)) {
partial.formatting = {
- formatting: formatting as GlobalPreferenceValues['formatting']['formatting'],
+ formatting: formattingValues,
};
}
- const attachments: Partial = {};
+ const attachmentValues: Record = {};
const attachmentThumbnailSize = parseNumber(
map[PREFERENCE_KEYS.attachmentThumbnailSize]
);
if (attachmentThumbnailSize !== undefined)
- attachments.attachmentThumbnailSize = attachmentThumbnailSize;
+ attachmentValues.attachmentThumbnailSize = attachmentThumbnailSize;
- if (hasProperties(attachments)) {
+ if (hasProperties(attachmentValues)) {
partial.attachments = {
- attachments: attachments as GlobalPreferenceValues['attachments']['attachments'],
+ attachments: attachmentValues,
};
}
- return partial;
+ return partial as Partial;
}
export function mergeWithDefaultValues(
From aa5e25198855feb2dd8f6df3ea7529ab4431e445 Mon Sep 17 00:00:00 2001
From: Gitesh Sagvekar
Date: Mon, 20 Oct 2025 18:35:20 +0000
Subject: [PATCH 31/31] Lint code with ESLint and Prettier
Triggered by 4d49db9c76d2a036d2b0b16e2179b741c210856a on branch refs/heads/issue-7442-3
---
.../Preferences/globalPreferencesLoader.ts | 19 ++---
.../Preferences/globalPreferencesUtils.ts | 83 ++++++++++++-------
2 files changed, 63 insertions(+), 39 deletions(-)
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
index 6ec7488872f..caaf9cf1235 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesLoader.ts
@@ -1,14 +1,14 @@
import { ajax } from '../../utils/ajax';
import { Http } from '../../utils/ajax/definitions';
import { ping } from '../../utils/ajax/ping';
-import { formatUrl } from '../Router/queryString';
+import { keysToLowerCase } from '../../utils/utils';
import { contextUnlockedPromise, foreverFetch } from '../InitialContext';
import { fetchContext as fetchRemotePrefs, remotePrefs } from '../InitialContext/remotePrefs';
-import { keysToLowerCase } from '../../utils/utils';
+import { formatUrl } from '../Router/queryString';
import { type PartialPreferences } from './BasePreferences';
-import { globalPreferenceDefinitions } from './GlobalDefinitions';
-import { globalPreferences } from './globalPreferences';
+import type { globalPreferenceDefinitions } from './GlobalDefinitions';
import type { GlobalPreferenceValues } from './globalPreferences';
+import { globalPreferences } from './globalPreferences';
import {
DEFAULT_VALUES,
mergeWithDefaultValues,
@@ -78,7 +78,7 @@ export const loadGlobalPreferences = async (): Promise => {
return;
}
- const { data, status, response } = await ajax(
+ const { data, status, response } = await ajax(
formatUrl('/context/app.resource', {
name: 'GlobalPreferences',
quiet: '',
@@ -123,20 +123,17 @@ export const loadGlobalPreferences = async (): Promise => {
data: serialized.data,
});
- if (typeof resourceId === 'number' && Number.isFinite(resourceId) && resourceId >= 0)
- await ping(`${GLOBAL_RESOURCE_URL}${resourceId}/`, {
+ await (typeof resourceId === 'number' && Number.isFinite(resourceId) && resourceId >= 0 ? ping(`${GLOBAL_RESOURCE_URL}${resourceId}/`, {
method: 'PUT',
body: payload,
headers: { Accept: 'application/json' },
errorMode: 'silent',
- });
- else
- await ping(GLOBAL_RESOURCE_URL, {
+ }) : ping(GLOBAL_RESOURCE_URL, {
method: 'POST',
body: payload,
headers: { Accept: 'application/json' },
errorMode: 'silent',
- });
+ }));
}
} catch (error) {
console.error('Failed migrating remote preferences into GlobalPreferences', error);
diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
index 789d9be57f5..232ec98d700 100644
--- a/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
+++ b/specifyweb/frontend/js_src/lib/components/Preferences/globalPreferencesUtils.ts
@@ -1,15 +1,18 @@
-import type { PreferenceItem } from './types';
+import {
+ FULL_DATE_FORMAT_OPTIONS,
+ MONTH_YEAR_FORMAT_OPTIONS,
+} from './GlobalDefinitions';
import type { GlobalPreferenceValues } from './globalPreferences';
-import { FULL_DATE_FORMAT_OPTIONS, MONTH_YEAR_FORMAT_OPTIONS } from './GlobalDefinitions';
+import type { PreferenceItem } from './types';
export type PropertyLine =
- | { readonly type: 'comment' | 'empty'; readonly raw: string }
| {
readonly type: 'entry';
readonly key: string;
readonly value: string;
readonly raw: string;
- };
+ }
+ | { readonly type: 'comment' | 'empty'; readonly raw: string };
const PREFERENCE_KEYS = {
enableAuditLog: 'auditing.do_audits',
@@ -20,7 +23,7 @@ const PREFERENCE_KEYS = {
} as const;
type ParsedProperties = {
- readonly lines: ReadonlyArray;
+ readonly lines: readonly PropertyLine[];
readonly map: Record;
};
@@ -68,7 +71,7 @@ function normalizeFormat(value: string): string {
function parseProperties(data: string): ParsedProperties {
const lines = data.split(/\r?\n/u);
- const parsed: PropertyLine[] = [];
+ const parsed: readonly PropertyLine[] = [];
const map: Record = {};
lines.forEach((line) => {
@@ -104,7 +107,9 @@ function parseNumber(value: string | undefined): number | undefined {
return Number.isNaN(parsed) ? undefined : parsed;
}
-function hasProperties>(object: T): object is T {
+function hasProperties>(
+ object: T
+): object is T {
return Object.keys(object).length > 0;
}
@@ -115,9 +120,12 @@ export function partialPreferencesFromMap(
const auditingValues: Record = {};
const enableAuditLog = parseBoolean(map[PREFERENCE_KEYS.enableAuditLog]);
- if (enableAuditLog !== undefined) auditingValues.enableAuditLog = enableAuditLog;
+ if (enableAuditLog !== undefined)
+ auditingValues.enableAuditLog = enableAuditLog;
- const logFieldLevelChanges = parseBoolean(map[PREFERENCE_KEYS.logFieldLevelChanges]);
+ const logFieldLevelChanges = parseBoolean(
+ map[PREFERENCE_KEYS.logFieldLevelChanges]
+ );
if (logFieldLevelChanges !== undefined)
auditingValues.logFieldLevelChanges = logFieldLevelChanges;
@@ -201,46 +209,57 @@ export function mergeWithDefaultValues(
};
}
-export function parseGlobalPreferences(
- data: string | null
-): {
+export function parseGlobalPreferences(data: string | null): {
readonly raw: Partial;
- readonly metadata: ReadonlyArray;
+ readonly metadata: readonly PropertyLine[];
} {
const parsed = parseProperties(data ?? '');
const values = partialPreferencesFromMap(parsed.map);
return { raw: values, metadata: parsed.lines };
}
-function preferencesToKeyValue(values: GlobalPreferenceValues): Record {
+function preferencesToKeyValue(
+ values: GlobalPreferenceValues
+): Record {
return {
- [PREFERENCE_KEYS.enableAuditLog]: values.auditing.auditing.enableAuditLog ? 'true' : 'false',
- [PREFERENCE_KEYS.logFieldLevelChanges]: values.auditing.auditing.logFieldLevelChanges
+ [PREFERENCE_KEYS.enableAuditLog]: values.auditing.auditing.enableAuditLog
? 'true'
: 'false',
- [PREFERENCE_KEYS.fullDateFormat]: normalizeFormat(values.formatting.formatting.fullDateFormat),
+ [PREFERENCE_KEYS.logFieldLevelChanges]: values.auditing.auditing
+ .logFieldLevelChanges
+ ? 'true'
+ : 'false',
+ [PREFERENCE_KEYS.fullDateFormat]: normalizeFormat(
+ values.formatting.formatting.fullDateFormat
+ ),
[PREFERENCE_KEYS.monthYearDateFormat]: normalizeFormat(
values.formatting.formatting.monthYearDateFormat
),
- [PREFERENCE_KEYS.attachmentThumbnailSize]: values.attachments.attachments.attachmentThumbnailSize.toString(),
+ [PREFERENCE_KEYS.attachmentThumbnailSize]:
+ values.attachments.attachments.attachmentThumbnailSize.toString(),
};
}
export function applyUpdates(
- lines: ReadonlyArray,
+ lines: readonly PropertyLine[],
updates: Record
-): { readonly lines: ReadonlyArray; readonly text: string } {
+): { readonly lines: readonly PropertyLine[]; readonly text: string } {
const remaining = new Set(Object.keys(updates));
const updatedLines = lines.map((line) => {
if (line.type === 'entry' && remaining.has(line.key)) {
const value = updates[line.key];
remaining.delete(line.key);
- return { type: 'entry', key: line.key, value, raw: `${line.key}=${value}` } as PropertyLine;
+ return {
+ type: 'entry',
+ key: line.key,
+ value,
+ raw: `${line.key}=${value}`,
+ } as PropertyLine;
}
return line;
});
- const appended: PropertyLine[] = Array.from(remaining).map((key) => ({
+ const appended: readonly PropertyLine[] = Array.from(remaining, (key) => ({
type: 'entry',
key,
value: updates[key],
@@ -248,20 +267,26 @@ export function applyUpdates(
}));
const finalLines = [...updatedLines, ...appended];
- return { lines: finalLines, text: finalLines.map((line) => line.raw).join('\n') };
+ return {
+ lines: finalLines,
+ text: finalLines.map((line) => line.raw).join('\n'),
+ };
}
export function serializeGlobalPreferences(
raw: GlobalPreferenceValues | Partial | undefined,
- metadata: ReadonlyArray,
+ metadata: readonly PropertyLine[],
options?: { readonly fallback?: GlobalPreferenceValues }
-): { readonly data: string; readonly metadata: ReadonlyArray } {
+): { readonly data: string; readonly metadata: readonly PropertyLine[] } {
const fallback = options?.fallback ?? globalPreferenceFallback;
const normalized = mergeWithDefaultValues(
raw as Partial | undefined,
fallback
);
- const { lines, text } = applyUpdates(metadata, preferencesToKeyValue(normalized));
+ const { lines, text } = applyUpdates(
+ metadata,
+ preferencesToKeyValue(normalized)
+ );
return { data: text, metadata: lines };
}
@@ -270,8 +295,10 @@ export function formatGlobalPreferenceValue(
value: unknown
): string {
if ('type' in definition) {
- if (definition.type === 'java.lang.Boolean') return value ? 'true' : 'false';
- if (definition.type === 'java.lang.Integer') return Number(value).toString();
+ if (definition.type === 'java.lang.Boolean')
+ return value ? 'true' : 'false';
+ if (definition.type === 'java.lang.Integer')
+ return Number(value).toString();
}
return String(value ?? '');
}