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 ? ( <> 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`] = ` - -`; - -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 ?? ''); }