From 8aecf1fe52852449224f76c68478bdcf3101d06f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:59:49 -0500 Subject: [PATCH 01/58] feat(schema-config): add shared store for unsaved table edits --- .../lib/components/SchemaConfig/Hooks.tsx | 212 ----------- .../lib/components/SchemaConfig/Store.tsx | 347 ++++++++++++++++++ .../lib/components/SchemaConfig/data.ts | 99 +++++ .../lib/components/SchemaConfig/index.tsx | 235 ++++-------- 4 files changed, 515 insertions(+), 378 deletions(-) delete mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/data.ts diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx deleted file mode 100644 index 4d53d05e39e..00000000000 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Hooks.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import React from 'react'; - -import { useAsyncState } from '../../hooks/useAsyncState'; -import { useLiveState } from '../../hooks/useLiveState'; -import { f } from '../../utils/functools'; -import type { RA } from '../../utils/types'; -import { defined } from '../../utils/types'; -import { group, replaceItem } from '../../utils/utils'; -import { fetchCollection } from '../DataModel/collection'; -import { backendFilter, formatRelationshipPath } from '../DataModel/helpers'; -import type { SerializedResource } from '../DataModel/helperTypes'; -import { getTable } from '../DataModel/tables'; -import type { - SpLocaleContainer, - SpLocaleContainerItem, - Tables, -} from '../DataModel/types'; -import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; -import { findString } from './helpers'; -import type { NewSpLocaleItemString, SpLocaleItemString } from './index'; -import type { SchemaData } from './schemaData'; - -export function useSchemaContainer( - tables: SchemaData['tables'], - tableName: keyof Tables -): readonly [ - SerializedResource, - (container: SerializedResource) => void, - boolean, -] { - const initialValue = React.useRef< - SerializedResource | undefined - >(undefined); - const [state, setState] = useLiveState( - React.useCallback(() => { - const container = defined( - Object.values(tables).find( - ({ name }) => name.toLowerCase() === tableName.toLowerCase() - ), - `Unable to find SpLocaleContainer for ${tableName}` - ); - initialValue.current = container; - return container; - }, [tables, tableName]) - ); - return [ - state, - setState, - JSON.stringify(initialValue.current) !== JSON.stringify(state), - ]; -} - -export function useContainerString( - itemType: 'containerDesc' | 'containerName', - container: SerializedResource, - language: string, - country: string | null -): Readonly< - readonly [ - NewSpLocaleItemString | SpLocaleItemString | undefined, - (containerName: NewSpLocaleItemString | SpLocaleItemString) => void, - boolean, - ] -> { - const initialValue = React.useRef< - NewSpLocaleItemString | SpLocaleItemString | undefined - >(undefined); - const [state, setState] = useAsyncState( - React.useCallback( - async () => - fetchCollection('SpLocaleItemStr', { - limit: 0, - [itemType]: container.id, - domainFilter: false, - }).then(({ records }) => { - initialValue.current = findString( - records, - language, - country, - itemType, - container.resource_uri - ); - return initialValue.current; - }), - [itemType, container.resource_uri, language, country] - ), - false - ); - return [ - state, - setState, - JSON.stringify(initialValue.current) !== JSON.stringify(state), - ]; -} - -export function useContainerItems( - container: SerializedResource, - language: string, - country: string | null -): Readonly< - readonly [ - ( - | RA & WithFetchedStrings> - | undefined - ), - ( - index: number, - item: SerializedResource & WithFetchedStrings - ) => void, - RA, - ] -> { - const [changed, setChanged] = React.useState>([]); - const [state, setState] = useAsyncState< - RA & WithFetchedStrings> - >( - React.useCallback( - async () => - f - .all({ - items: fetchCollection('SpLocaleContainerItem', { - limit: 0, - container: container.id, - domainFilter: false, - }), - names: fetchCollection( - 'SpLocaleItemStr', - { - limit: 0, - domainFilter: false, - }, - backendFilter( - formatRelationshipPath('itemName', 'container') - ).equals(container.id) - ).then(({ records }) => - Object.fromEntries( - group(records.map((name) => [name.itemName, name])) - ) - ), - descriptions: fetchCollection( - 'SpLocaleItemStr', - { - limit: 0, - domainFilter: false, - }, - backendFilter( - formatRelationshipPath('itemDesc', 'container') - ).equals(container.id) - ).then(({ records }) => - Object.fromEntries( - group( - records.map((description) => [ - description.itemDesc, - description, - ]) - ) - ) - ), - }) - .then(({ items, names, descriptions }) => - items.records - .filter( - (item) => - /* Ignore removed fields (i.e, Accession->deaccessions) */ - getTable(container.name)!.getField(item.name) !== undefined - ) - .map((item) => ({ - ...item, - strings: { - name: findString( - names[item.resource_uri], - language, - country, - 'itemName', - item.resource_uri - ), - desc: findString( - descriptions[item.resource_uri], - language, - country, - 'itemDesc', - item.resource_uri - ), - }, - })) - ) - .then((items) => { - setChanged([]); - return items; - }), - [container.id, container.resource_uri, container.name, language, country] - ), - false - ); - const setItem = React.useCallback( - ( - index: number, - item: SerializedResource & WithFetchedStrings - ) => { - setChanged([...changed, index]); - setState( - replaceItem( - defined(state, 'Trying to modify SpLocalContainerItem before load'), - index, - item - ) - ); - }, - [state, setState, changed] - ); - return [state, setItem, changed]; -} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx new file mode 100644 index 00000000000..4d80af97899 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -0,0 +1,347 @@ +import React from 'react'; + +import { ping } from '../../utils/ajax/ping'; +import type { IR, RA } from '../../utils/types'; +import { defined } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import { createResource, saveResource } from '../DataModel/resource'; +import type { + SpLocaleContainer, + SpLocaleContainerItem, +} from '../DataModel/types'; +import { formatUrl } from '../Router/queryString'; +import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; +import { fetchContainerItems, fetchContainerString } from './data'; +import type { NewSpLocaleItemString, SpLocaleItemString } from './index'; +import type { SchemaData } from './schemaData'; + +export type SchemaConfigEditorState = { + readonly container: SerializedResource; + readonly name: NewSpLocaleItemString | SpLocaleItemString; + readonly desc: NewSpLocaleItemString | SpLocaleItemString; + readonly items: RA< + SerializedResource & WithFetchedStrings + >; + readonly changedItems: RA; + readonly initialContainer: SerializedResource; + readonly initialName: NewSpLocaleItemString | SpLocaleItemString; + readonly initialDesc: NewSpLocaleItemString | SpLocaleItemString; +}; + +export type SchemaConfigStore = { + readonly schemaData: SchemaData; + readonly isReadOnly: boolean; + readonly modifiedTables: RA; + readonly anyModified: boolean; + readonly saveAll: () => Promise; + readonly loadTable: (tableName: string) => Promise; + readonly editors: IR; + readonly setContainer: ( + tableName: string, + container: SerializedResource + ) => void; + readonly setName: ( + tableName: string, + name: NewSpLocaleItemString | SpLocaleItemString + ) => void; + readonly setDesc: ( + tableName: string, + desc: NewSpLocaleItemString | SpLocaleItemString + ) => void; + readonly setItem: ( + tableName: string, + index: number, + item: SerializedResource & WithFetchedStrings + ) => void; +}; + +const SchemaConfigContext = React.createContext( + undefined +); +SchemaConfigContext.displayName = 'SchemaConfigContext'; + +export const isEditorModified = (editor: SchemaConfigEditorState): boolean => + JSON.stringify(editor.initialContainer) !== + JSON.stringify(editor.container) || + JSON.stringify(editor.initialName) !== JSON.stringify(editor.name) || + JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc) || + editor.changedItems.length > 0; + +export function SchemaConfigStoreProvider({ + schemaData, + rawLanguage, + isReadOnly, + children, +}: { + readonly schemaData: SchemaData; + readonly rawLanguage: string; + readonly isReadOnly: boolean; + readonly children: React.ReactNode; +}): JSX.Element { + const [language, country = null] = rawLanguage.split('-'); + const [editors, setEditors] = React.useState>({}); + + const editorsRef = React.useRef(editors); + editorsRef.current = editors; + const loadingRef = React.useRef>(new Set()); + + const loadTable = React.useCallback( + async (tableName: string): Promise => { + if (tableName in editorsRef.current || loadingRef.current.has(tableName)) + return; + loadingRef.current = new Set(loadingRef.current).add(tableName); + try { + const container = defined( + Object.values(schemaData.tables).find( + ({ name }) => name.toLowerCase() === tableName.toLowerCase() + ), + `Unable to find SpLocaleContainer for ${tableName}` + ); + const [name, desc, items] = await Promise.all([ + fetchContainerString('containerName', container, language, country), + fetchContainerString('containerDesc', container, language, country), + fetchContainerItems(container, language, country), + ]); + setEditors((editors) => ({ + ...editors, + [tableName]: { + container, + name, + desc, + items, + changedItems: [], + initialContainer: container, + initialName: name, + initialDesc: desc, + }, + })); + } finally { + loadingRef.current = new Set( + Array.from(loadingRef.current).filter((item) => item !== tableName) + ); + } + }, + [schemaData.tables, language, country] + ); + + const setContainer = React.useCallback( + (tableName: string, container: SerializedResource) => + setEditors((editors) => { + const editor = editors[tableName]; + return editor === undefined + ? editors + : { ...editors, [tableName]: { ...editor, container } }; + }), + [] + ); + + const setName = React.useCallback( + (tableName: string, name: NewSpLocaleItemString | SpLocaleItemString) => + setEditors((editors) => { + const editor = editors[tableName]; + return editor === undefined + ? editors + : { ...editors, [tableName]: { ...editor, name } }; + }), + [] + ); + + const setDesc = React.useCallback( + (tableName: string, desc: NewSpLocaleItemString | SpLocaleItemString) => + setEditors((editors) => { + const editor = editors[tableName]; + return editor === undefined + ? editors + : { ...editors, [tableName]: { ...editor, desc } }; + }), + [] + ); + + const setItem = React.useCallback( + ( + tableName: string, + index: number, + item: SerializedResource & WithFetchedStrings + ) => + setEditors((editors) => { + const editor = editors[tableName]; + if (editor === undefined) return editors; + return { + ...editors, + [tableName]: { + ...editor, + items: replaceItem(editor.items, index, item), + changedItems: editor.changedItems.includes(index) + ? editor.changedItems + : [...editor.changedItems, index], + }, + }; + }), + [] + ); + + const modifiedTables = React.useMemo( + () => + Object.entries(editors) + .filter(([, editor]) => isEditorModified(editor)) + .map(([tableName]) => tableName), + [editors] + ); + + const saveAll = React.useCallback( + async (): Promise => + Promise.all( + Object.values(editorsRef.current) + .filter(isEditorModified) + .flatMap(buildSaveRequests) + ), + [] + ); + + return ( + 0, + saveAll, + loadTable, + editors, + setContainer, + setName, + setDesc, + setItem, + }} + > + {children} + + ); +} + +export function useSchemaConfig(): SchemaConfigStore { + return React.useContext(SchemaConfigContext)!; +} + +export function useSchemaConfigTable(tableName: string): { + readonly container: SerializedResource; + readonly name: NewSpLocaleItemString | SpLocaleItemString | undefined; + readonly desc: NewSpLocaleItemString | SpLocaleItemString | undefined; + readonly items: + | RA & WithFetchedStrings> + | undefined; + readonly setContainer: ( + container: SerializedResource + ) => void; + readonly setName: (name: NewSpLocaleItemString | SpLocaleItemString) => void; + readonly setDesc: (desc: NewSpLocaleItemString | SpLocaleItemString) => void; + readonly setItem: ( + index: number, + item: SerializedResource & WithFetchedStrings + ) => void; +} { + const { + schemaData, + editors, + loadTable, + setContainer, + setName, + setDesc, + setItem, + } = useSchemaConfig(); + const editor = editors[tableName]; + + React.useEffect(() => { + if (tableName === '') return; + void loadTable(tableName); + }, [loadTable, tableName]); + + const container = React.useMemo( + () => + defined( + Object.values(schemaData.tables).find( + ({ name }) => name.toLowerCase() === tableName.toLowerCase() + ), + `Unable to find SpLocaleContainer for ${tableName}` + ), + [schemaData.tables, tableName] + ); + + return { + container: editor?.container ?? container, + name: editor?.name, + desc: editor?.desc, + items: editor?.items, + setContainer: React.useCallback( + (value) => setContainer(tableName, value), + [setContainer, tableName] + ), + setName: React.useCallback( + (value) => setName(tableName, value), + [setName, tableName] + ), + setDesc: React.useCallback( + (value) => setDesc(tableName, value), + [setDesc, tableName] + ), + setItem: React.useCallback( + (index, value) => setItem(tableName, index, value), + [setItem, tableName] + ), + }; +} + +const buildSaveRequests = ( + editor: SchemaConfigEditorState +): RA> => [ + ...(JSON.stringify(editor.initialName) !== JSON.stringify(editor.name) + ? [saveString(editor.name)] + : []), + ...(JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc) + ? [saveString(editor.desc)] + : []), + ...(JSON.stringify(editor.initialContainer) !== + JSON.stringify(editor.container) + ? [saveResource('SpLocaleContainer', editor.container.id, editor.container)] + : []), + ...editor.items + .filter((_item, index) => editor.changedItems.includes(index)) + .flatMap(({ strings, ...item }) => [ + saveResource('SpLocaleContainerItem', item.id, item), + saveString(strings.name), + saveString(strings.desc), + ]), +]; + +const saveString = async ( + resource: NewSpLocaleItemString | SpLocaleItemString +): Promise => + 'resource_uri' in resource && + typeof resource.id === 'number' && + resource.id >= 0 + ? saveResource('SpLocaleItemStr', resource.id, resource) + : createResource('SpLocaleItemStr', resource); + +export const handleSchemaSaved = async ( + rawLanguage: string, + tableName: string +): Promise => + ping( + // Flush schema cache + formatUrl('/context/schema_localization.json', { + lang: rawLanguage, + }), + { + method: 'HEAD', + cache: 'no-cache', + } + ) + // Reload the page after schema changes + .then((): void => + globalThis.location.assign( + tableName === '' + ? `/specify/schema-config/${rawLanguage}/` + : `/specify/schema-config/${rawLanguage}/${tableName}/` + ) + ); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/data.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/data.ts new file mode 100644 index 00000000000..b208f5eb982 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/data.ts @@ -0,0 +1,99 @@ +import { f } from '../../utils/functools'; +import type { RA } from '../../utils/types'; +import { group } from '../../utils/utils'; +import { fetchCollection } from '../DataModel/collection'; +import { backendFilter, formatRelationshipPath } from '../DataModel/helpers'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import { getTable } from '../DataModel/tables'; +import type { + SpLocaleContainer, + SpLocaleContainerItem, +} from '../DataModel/types'; +import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; +import { findString } from './helpers'; +import type { NewSpLocaleItemString, SpLocaleItemString } from './index'; + +export const fetchContainerString = async ( + itemType: 'containerDesc' | 'containerName', + container: SerializedResource, + language: string, + country: string | null +): Promise => + fetchCollection('SpLocaleItemStr', { + limit: 0, + [itemType]: container.id, + domainFilter: false, + }).then(({ records }) => + findString(records, language, country, itemType, container.resource_uri) + ); + +export const fetchContainerItems = async ( + container: SerializedResource, + language: string, + country: string | null +): Promise< + RA & WithFetchedStrings> +> => + f + .all({ + items: fetchCollection('SpLocaleContainerItem', { + limit: 0, + container: container.id, + domainFilter: false, + }), + names: fetchCollection( + 'SpLocaleItemStr', + { + limit: 0, + domainFilter: false, + }, + backendFilter(formatRelationshipPath('itemName', 'container')).equals( + container.id + ) + ).then(({ records }) => + Object.fromEntries(group(records.map((name) => [name.itemName, name]))) + ), + descriptions: fetchCollection( + 'SpLocaleItemStr', + { + limit: 0, + domainFilter: false, + }, + backendFilter(formatRelationshipPath('itemDesc', 'container')).equals( + container.id + ) + ).then(({ records }) => + Object.fromEntries( + group( + records.map((description) => [description.itemDesc, description]) + ) + ) + ), + }) + .then(({ items, names, descriptions }) => + items.records + .filter( + (item) => + // Ignore removed fields (i.e, Accession->deaccessions) + getTable(container.name)!.getField(item.name) !== undefined + ) + .map((item) => ({ + ...item, + strings: { + name: findString( + names[item.resource_uri], + language, + country, + 'itemName', + item.resource_uri + ), + desc: findString( + descriptions[item.resource_uri], + language, + country, + 'itemDesc', + item.resource_uri + ), + }, + })) + ); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index 2afa2ceba6e..d729fd65419 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -1,30 +1,16 @@ import React from 'react'; -import { useOutletContext } from 'react-router'; import { useParams } from 'react-router-dom'; -import { useUnloadProtect } from '../../hooks/navigation'; import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; -import { ping } from '../../utils/ajax/ping'; import type { PartialBy } from '../../utils/types'; -import { Container } from '../Atoms'; -import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import type { SerializedResource } from '../DataModel/helperTypes'; -import { createResource, saveResource } from '../DataModel/resource'; import { strictGetTable } from '../DataModel/tables'; import type { SpLocaleItemStr } from '../DataModel/types'; import { useTitle } from '../Molecules/AppTitle'; -import { hasToolPermission } from '../Permissions/helpers'; -import { formatUrl } from '../Router/queryString'; -import { SchemaConfigHeader } from './Components'; import { SchemaConfigField } from './Field'; import { SchemaConfigColumn, SchemaConfigFields } from './Fields'; -import { - useContainerItems, - useContainerString, - useSchemaContainer, -} from './Hooks'; -import type { SchemaData } from './schemaData'; +import { useSchemaConfig, useSchemaConfigTable } from './Store'; import { SchemaConfigTable } from './Table'; export type SpLocaleItemString = SerializedResource; @@ -33,166 +19,83 @@ export type NewSpLocaleItemString = PartialBy; export type ItemType = 'formatted' | 'none' | 'pickList' | 'webLink'; export function SchemaConfigMain(): JSX.Element { - const { language: rawLanguage = '', tableName = '' } = useParams(); + const { '*': tableName = '' } = useParams(); const table = strictGetTable(tableName); useTitle(schemaText.schemaViewTitle({ tableName: table.name })); - const schemaData = useOutletContext(); - const isReadOnly = - React.useContext(ReadOnlyContext) || - !hasToolPermission('schemaConfig', 'update') || - !hasToolPermission('schemaConfig', 'create'); - - const [container, setContainer, isChanged] = useSchemaContainer( - schemaData.tables, - table.name - ); - const [language, country = null] = rawLanguage.split('-'); - const [name, setName, nameChanged] = useContainerString( - 'containerName', - container, - language, - country - ); - const [desc, setDesc, descChanged] = useContainerString( - 'containerDesc', + const { schemaData } = useSchemaConfig(); + const { container, - language, - country - ); - const [items, setItem, changedItems] = useContainerItems( - container, - language, - country - ); + name, + desc, + items, + setContainer, + setName, + setDesc, + setItem, + } = useSchemaConfigTable(table.name); + const [index, setIndex] = React.useState(0); const item = items?.[index]; - const isModified = - isChanged || nameChanged || descChanged || changedItems.length > 0; - const unsetUnloadProtect = useUnloadProtect( - isModified, - schemaText.unsavedSchemaUnloadProtect() - ); - - const canSave = - !isReadOnly && - isModified && - typeof items === 'object' && - typeof name === 'object' && - typeof desc === 'object'; - - function handleSave(): void { - if (!canSave) return; - unsetUnloadProtect(); - - const requests = [ - ...(nameChanged ? [saveString(name)] : []), - ...(descChanged ? [saveString(desc)] : []), - ...(isChanged - ? [saveResource('SpLocaleContainer', container.id, container)] - : []), - ...items - .filter((_item, index) => changedItems.includes(index)) - .flatMap(({ strings, ...item }) => [ - saveResource('SpLocaleContainerItem', item.id, item), - saveString(strings.name), - saveString(strings.desc), - ]), - ]; + React.useEffect(() => { + setIndex(0); + }, [table.name]); - loading(Promise.all(requests).then(async () => handleSaved(rawLanguage))); - } - - const loading = React.useContext(LoadingContext); return ( - - - + + + {typeof item === 'object' ? ( + + setItem(index, { + ...item, + ...(field === 'desc' || field === 'name' + ? { + strings: { + ...item.strings, + [field]: { + ...item.strings[field], + text: value, + }, + }, + } + : { + [field]: value as boolean, + }), + }) + } + onFormatted={(format, value): void => + setItem(index, { + ...item, + format: format === 'formatted' ? value : null, + webLinkName: format === 'webLink' ? value : null, + pickListName: format === 'pickList' ? value : null, + }) + } /> -
- - - {typeof item === 'object' ? ( - - setItem(index, { - ...item, - ...(field === 'desc' || field === 'name' - ? { - strings: { - ...item.strings, - [field]: { - ...item.strings[field], - text: value, - }, - }, - } - : { - [field]: value as boolean, - }), - }) - } - onFormatted={(format, value): void => - setItem(index, { - ...item, - format: format === 'formatted' ? value : null, - webLinkName: format === 'webLink' ? value : null, - pickListName: format === 'pickList' ? value : null, - }) - } - /> - ) : ( - - {commonText.loading()} - - )} -
-
-
+ ) : ( + + {commonText.loading()} + + )} + ); } - -const saveString = async ( - resource: NewSpLocaleItemString | SpLocaleItemString -): Promise => - 'resource_uri' in resource && - typeof resource.id === 'number' && - resource.id >= 0 - ? saveResource('SpLocaleItemStr', resource.id, resource) - : createResource('SpLocaleItemStr', resource); - -const handleSaved = async (rawLanguage: string): Promise => - ping( - // Flush schema cache - formatUrl('/context/schema_localization.json', { - lang: rawLanguage, - }), - { - method: 'HEAD', - cache: 'no-cache', - } - ) - // Reload the page after schema changes - .then((): void => - globalThis.location.assign(`/specify/schema-config/${rawLanguage}/`) - ); From 8955a50ae0ea3072aad6c5e89fc72c37c9bf893c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:59:59 -0500 Subject: [PATCH 02/58] feat(schema-config): add table sidebar with bulk save --- .../js_src/lib/components/Router/Routes.tsx | 24 ++---- .../components/SchemaConfig/Components.tsx | 22 +++-- .../lib/components/SchemaConfig/Layout.tsx | 75 +++++++++++++++++ .../lib/components/SchemaConfig/Redirect.tsx | 24 ++++++ .../lib/components/SchemaConfig/Sidebar.tsx | 56 +++++++++++++ .../lib/components/SchemaConfig/Tables.tsx | 82 +++++++++---------- 6 files changed, 211 insertions(+), 72 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/Redirect.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 4b8ca180f5a..cb448feebbc 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -416,24 +416,12 @@ export const routes: RA = [ ), }, { - path: ':language', - children: [ - { - index: true, - title: schemaText.tables(), - element: () => - import('../SchemaConfig/Tables').then( - ({ SchemaConfigTables }) => SchemaConfigTables - ), - }, - { - path: ':tableName', - element: () => - import('../SchemaConfig').then( - ({ SchemaConfigMain }) => SchemaConfigMain - ), - }, - ], + path: ':language/*', + element: () => + import('../SchemaConfig/Layout').then( + ({ SchemaConfigLayout }) => SchemaConfigLayout + ), + isSingleResource: true, }, ], }, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index 64365321dea..5b24b037042 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; import type { LocalizedString } from 'typesafe-i18n'; import { commonText } from '../../localization/common'; @@ -11,18 +10,20 @@ import { H2 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { Select } from '../Atoms/Form'; +import { Link } from '../Atoms/Link'; +import { formatUrl } from '../Router/queryString'; import type { SchemaData } from './schemaData'; export function SchemaConfigHeader({ languages, - language, + rawLanguage, onSave: handleSave, }: { readonly languages: SchemaData['languages']; - readonly language: string; + readonly rawLanguage: string; readonly onSave: (() => void) | undefined; }): JSX.Element { - const navigate = useNavigate(); + const [language] = rawLanguage.split('-'); return (

@@ -32,12 +33,15 @@ export function SchemaConfigHeader({ })` )}

- navigate(`/specify/schema-config/${language}/`)} - > - {schemaText.changeBaseTable()} - + + {commonText.export()} + {commonText.save()} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx new file mode 100644 index 00000000000..35e675a7043 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { useOutletContext } from 'react-router'; +import { useParams } from 'react-router-dom'; + +import { useUnloadProtect } from '../../hooks/navigation'; +import { schemaText } from '../../localization/schema'; +import { Container } from '../Atoms'; +import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; +import { hasToolPermission } from '../Permissions/helpers'; +import { SchemaConfigHeader } from './Components'; +import { SchemaConfigMain } from './index'; +import { SchemaConfigRedirect } from './Redirect'; +import type { SchemaData } from './schemaData'; +import { SchemaConfigSidebar } from './Sidebar'; +import { + handleSchemaSaved, + SchemaConfigStoreProvider, + useSchemaConfig, +} from './Store'; + +export function SchemaConfigLayout(): JSX.Element { + const schemaData = useOutletContext(); + const { language: rawLanguage = '' } = useParams(); + const isReadOnly = + React.useContext(ReadOnlyContext) || + !hasToolPermission('schemaConfig', 'update') || + !hasToolPermission('schemaConfig', 'create'); + + return ( + + + + + + ); +} + +function SchemaConfigLayoutContent(): JSX.Element { + const { schemaData, isReadOnly, anyModified, saveAll } = useSchemaConfig(); + const { language: rawLanguage = '', '*': tableName = '' } = useParams(); + const loading = React.useContext(LoadingContext); + + const unsetUnloadProtect = useUnloadProtect( + anyModified, + schemaText.unsavedSchemaUnloadProtect() + ); + + const canSave = !isReadOnly && anyModified; + const handleSave = (): void => { + if (!canSave) return; + unsetUnloadProtect(); + loading(saveAll().then(() => handleSchemaSaved(rawLanguage, tableName))); + }; + + return ( + + +
+ +
+ {tableName === '' ? : } +
+
+
+ ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Redirect.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Redirect.tsx new file mode 100644 index 00000000000..4137c33db72 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Redirect.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Navigate, useParams } from 'react-router-dom'; + +import { sortFunction } from '../../utils/utils'; +import { genericTables } from '../DataModel/tables'; +import { tablesFilter } from './Tables'; + +export function SchemaConfigRedirect(): JSX.Element | null { + const { language = '' } = useParams(); + const firstTable = React.useMemo( + () => + Object.values(genericTables) + .filter((table) => tablesFilter(false, false, true, table)) + .sort(sortFunction(({ name }) => name))[0], + [] + ); + + return firstTable === undefined ? null : ( + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx new file mode 100644 index 00000000000..01a0db14441 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; +import { localized } from '../../utils/types'; +import { H3 } from '../Atoms'; +import { Input } from '../Atoms/Form'; +import { useSchemaConfig } from './Store'; +import { TableList, tablesFilter } from './Tables'; + +export function SchemaConfigSidebar(): JSX.Element { + const { language = '', tableName = '' } = useParams(); + const { modifiedTables } = useSchemaConfig(); + const [search, setSearch] = React.useState(''); + + return ( + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx index 1edf5436416..2fd6426bf38 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx @@ -1,9 +1,6 @@ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; import { useCachedState } from '../../hooks/useCachedState'; -import { commonText } from '../../localization/common'; -import { schemaText } from '../../localization/schema'; import { wbPlanText } from '../../localization/wbPlan'; import type { CacheDefinitions } from '../../utils/cache/definitions'; import type { RA } from '../../utils/types'; @@ -17,48 +14,8 @@ import type { SpecifyTable } from '../DataModel/specifyTable'; import { genericTables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import { userInformation } from '../InitialContext/userInformation'; -import { Dialog } from '../Molecules/Dialog'; import { TableIcon } from '../Molecules/TableIcon'; import { hasTablePermission } from '../Permissions/helpers'; -import { formatUrl } from '../Router/queryString'; - -export function SchemaConfigTables(): JSX.Element { - const { language = '' } = useParams(); - const navigate = useNavigate(); - - return ( - - - {commonText.export()} - - - navigate('/specify/schema-config/')} - > - {commonText.back()} - - - } - header={schemaText.tables()} - onClose={(): void => navigate('/specify')} - > - - `/specify/schema-config/${language}/${table.name}/` - } - localizeTableNames={false} - /> - - ); -} /** * Get the names of all cache categories in cache definitions that have @@ -110,12 +67,16 @@ export function TableList({ filter = defaultFilter, children, localizeTableNames = true, + currentTableName, + badge, }: { readonly cacheKey: CacheKey; readonly getAction: (table: SpecifyTable) => string | (() => void); readonly filter?: (showHiddenTables: boolean, table: SpecifyTable) => boolean; readonly children?: (table: SpecifyTable) => React.ReactNode; readonly localizeTableNames?: boolean; + readonly currentTableName?: string; + readonly badge?: (table: SpecifyTable) => React.ReactNode; }): JSX.Element { const [showHiddenTables = false, setShowHiddenTables] = useCachedState( cacheKey, @@ -130,12 +91,35 @@ export function TableList({ [filter, showHiddenTables] ); + const listRef = React.useRef(null); + const activeRef = React.useRef(null); + const hasScrolledRef = React.useRef(false); + + React.useEffect(() => { + if (hasScrolledRef.current || currentTableName === '') return; + hasScrolledRef.current = true; + const list = listRef.current; + const active = activeRef.current; + if (list === null || active === null) return; + const listRect = list.getBoundingClientRect(); + const activeRect = active.getBoundingClientRect(); + list.scrollTop += + activeRect.top - listRect.top - (listRect.height - activeRect.height) / 2; + }, [currentTableName]); + return (
-
    +
      {sortedTables.map((table) => { const action = getAction(table); const extraContent = children?.(table); + const badgeContent = badge?.(table); + const isCurrent = + currentTableName !== undefined && + table.name.toLowerCase() === currentTableName.toLowerCase(); const isVisible = showHiddenTables || children === undefined || @@ -145,6 +129,7 @@ export function TableList({ {localizeTableNames ? table.label : localized(table.name)}{' '} {extraContent !== undefined && extraContent} + {badgeContent} ); return isVisible ? ( @@ -152,7 +137,14 @@ export function TableList({ {typeof action === 'function' ? ( {content} ) : ( - {content} + + {content} + )} ) : undefined; From b382a992ff383502a2b82f3b28e18e90b04858c3 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:00:11 -0500 Subject: [PATCH 03/58] test(schema-config): add store and redirect tests --- .../SchemaConfig/__tests__/Redirect.test.tsx | 35 ++++++++++++++ .../SchemaConfig/__tests__/Store.test.ts | 46 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Redirect.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Redirect.test.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Redirect.test.tsx new file mode 100644 index 00000000000..a67c1a7c99c --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Redirect.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { requireContext } from '../../../tests/helpers'; +import { SchemaConfigRedirect } from '../Redirect'; + +requireContext(); + +describe('SchemaConfigRedirect', () => { + test('redirects to the first accessible table', () => { + render( + + + } + path="/specify/schema-config/:language" + /> + table page
} + path="/specify/schema-config/:language/:tableName" + /> + + + ); + + expect(screen.getByText('table page')).toBeInTheDocument(); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts new file mode 100644 index 00000000000..2c05f6f1ab4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts @@ -0,0 +1,46 @@ +import type { SchemaConfigEditorState } from '../Store'; +import { isEditorModified } from '../Store'; + +const base = { + container: { id: 1 }, + name: { id: 1, text: 'Name' }, + desc: { id: 2, text: 'Desc' }, + items: [], + changedItems: [], + initialContainer: { id: 1 }, + initialName: { id: 1, text: 'Name' }, + initialDesc: { id: 2, text: 'Desc' }, +}; + +const makeState = ( + overrides: Record = {} +): SchemaConfigEditorState => + ({ ...base, ...overrides }) as unknown as SchemaConfigEditorState; + +describe('isEditorModified', () => { + test('returns false for an unmodified editor', () => { + expect(isEditorModified(makeState())).toBe(false); + }); + + test('returns true when the container changed', () => { + expect( + isEditorModified(makeState({ container: { id: 1, isHidden: true } })) + ).toBe(true); + }); + + test('returns true when the name changed', () => { + expect( + isEditorModified(makeState({ name: { id: 1, text: 'Changed' } })) + ).toBe(true); + }); + + test('returns true when the description changed', () => { + expect( + isEditorModified(makeState({ desc: { id: 2, text: 'Changed' } })) + ).toBe(true); + }); + + test('returns true when any item changed', () => { + expect(isEditorModified(makeState({ changedItems: [0] }))).toBe(true); + }); +}); From 31e14c9e1d9a01f3443abc57e0a9597ff2a17a2f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:11:35 -0500 Subject: [PATCH 04/58] fix(schema-config): handle trailing slash in table route --- .../frontend/js_src/lib/components/SchemaConfig/Layout.tsx | 4 +++- .../js_src/lib/components/SchemaConfig/Sidebar.tsx | 4 +++- .../frontend/js_src/lib/components/SchemaConfig/helpers.ts | 7 +++++++ .../frontend/js_src/lib/components/SchemaConfig/index.tsx | 4 +++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index 35e675a7043..60af0a88e47 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -8,6 +8,7 @@ import { Container } from '../Atoms'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { hasToolPermission } from '../Permissions/helpers'; import { SchemaConfigHeader } from './Components'; +import { parseSchemaConfigTableName } from './helpers'; import { SchemaConfigMain } from './index'; import { SchemaConfigRedirect } from './Redirect'; import type { SchemaData } from './schemaData'; @@ -42,7 +43,8 @@ export function SchemaConfigLayout(): JSX.Element { function SchemaConfigLayoutContent(): JSX.Element { const { schemaData, isReadOnly, anyModified, saveAll } = useSchemaConfig(); - const { language: rawLanguage = '', '*': tableName = '' } = useParams(); + const { language: rawLanguage = '', '*': rawTableName = '' } = useParams(); + const tableName = parseSchemaConfigTableName(rawTableName); const loading = React.useContext(LoadingContext); const unsetUnloadProtect = useUnloadProtect( diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index 01a0db14441..37d8e990e39 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -6,11 +6,13 @@ import { schemaText } from '../../localization/schema'; import { localized } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input } from '../Atoms/Form'; +import { parseSchemaConfigTableName } from './helpers'; import { useSchemaConfig } from './Store'; import { TableList, tablesFilter } from './Tables'; export function SchemaConfigSidebar(): JSX.Element { - const { language = '', tableName = '' } = useParams(); + const { language = '', '*': rawTableName = '' } = useParams(); + const tableName = parseSchemaConfigTableName(rawTableName); const { modifiedTables } = useSchemaConfig(); const [search, setSearch] = React.useState(''); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 56d2d425e00..09cd9ebf35b 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -101,3 +101,10 @@ export function javaTypeToHuman( else if (type.startsWith('java')) return type.split('.').at(-1)!; else return type; } + +/** + * The Schema Config table route is a splat (`:language/*`), so the table name + * may include a trailing slash. Strip it before looking up the table. + */ +export const parseSchemaConfigTableName = (splat: string): string => + splat.replace(/\/+$/u, ''); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index d729fd65419..ea4d3890025 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -10,6 +10,7 @@ import type { SpLocaleItemStr } from '../DataModel/types'; import { useTitle } from '../Molecules/AppTitle'; import { SchemaConfigField } from './Field'; import { SchemaConfigColumn, SchemaConfigFields } from './Fields'; +import { parseSchemaConfigTableName } from './helpers'; import { useSchemaConfig, useSchemaConfigTable } from './Store'; import { SchemaConfigTable } from './Table'; @@ -19,7 +20,8 @@ export type NewSpLocaleItemString = PartialBy; export type ItemType = 'formatted' | 'none' | 'pickList' | 'webLink'; export function SchemaConfigMain(): JSX.Element { - const { '*': tableName = '' } = useParams(); + const { '*': rawTableName = '' } = useParams(); + const tableName = parseSchemaConfigTableName(rawTableName); const table = strictGetTable(tableName); useTitle(schemaText.schemaViewTitle({ tableName: table.name })); From 2e2869c6f96fe203e340e304a2bb90bc3a4c1625 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:23:17 -0500 Subject: [PATCH 05/58] fix(schema-config): use non-splat table route --- .../js_src/lib/components/Router/Routes.tsx | 20 ++++++++++++++-- .../lib/components/SchemaConfig/Layout.tsx | 23 +++++++++++-------- .../lib/components/SchemaConfig/Sidebar.tsx | 10 ++++---- .../lib/components/SchemaConfig/helpers.ts | 7 ------ .../lib/components/SchemaConfig/index.tsx | 4 +--- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index cb448feebbc..2f2042f4b08 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -416,12 +416,28 @@ export const routes: RA = [ ), }, { - path: ':language/*', + path: ':language', element: () => import('../SchemaConfig/Layout').then( ({ SchemaConfigLayout }) => SchemaConfigLayout ), - isSingleResource: true, + children: [ + { + index: true, + title: schemaText.tables(), + element: () => + import('../SchemaConfig/Redirect').then( + ({ SchemaConfigRedirect }) => SchemaConfigRedirect + ), + }, + { + path: ':tableName', + element: () => + import('../SchemaConfig').then( + ({ SchemaConfigMain }) => SchemaConfigMain + ), + }, + ], }, ], }, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index 60af0a88e47..ea256b205b1 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -1,16 +1,14 @@ import React from 'react'; -import { useOutletContext } from 'react-router'; -import { useParams } from 'react-router-dom'; +import { Outlet, useOutletContext } from 'react-router'; +import { useMatch, useParams } from 'react-router-dom'; import { useUnloadProtect } from '../../hooks/navigation'; import { schemaText } from '../../localization/schema'; import { Container } from '../Atoms'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import { hasToolPermission } from '../Permissions/helpers'; +import { SetSingleResourceContext } from '../Router/Router'; import { SchemaConfigHeader } from './Components'; -import { parseSchemaConfigTableName } from './helpers'; -import { SchemaConfigMain } from './index'; -import { SchemaConfigRedirect } from './Redirect'; import type { SchemaData } from './schemaData'; import { SchemaConfigSidebar } from './Sidebar'; import { @@ -43,10 +41,17 @@ export function SchemaConfigLayout(): JSX.Element { function SchemaConfigLayoutContent(): JSX.Element { const { schemaData, isReadOnly, anyModified, saveAll } = useSchemaConfig(); - const { language: rawLanguage = '', '*': rawTableName = '' } = useParams(); - const tableName = parseSchemaConfigTableName(rawTableName); + const { language: rawLanguage = '' } = useParams(); + const match = useMatch('/specify/schema-config/:language/:tableName'); + const tableName = match?.params.tableName ?? ''; + const setSingleResource = React.useContext(SetSingleResourceContext); const loading = React.useContext(LoadingContext); + React.useEffect(() => { + setSingleResource(`/specify/schema-config/${rawLanguage}/`); + return () => setSingleResource(undefined); + }, [setSingleResource, rawLanguage]); + const unsetUnloadProtect = useUnloadProtect( anyModified, schemaText.unsavedSchemaUnloadProtect() @@ -67,9 +72,9 @@ function SchemaConfigLayoutContent(): JSX.Element { rawLanguage={rawLanguage} />
- +
- {tableName === '' ? : } +
diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index 37d8e990e39..2b57cb4e4d1 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -6,13 +6,15 @@ import { schemaText } from '../../localization/schema'; import { localized } from '../../utils/types'; import { H3 } from '../Atoms'; import { Input } from '../Atoms/Form'; -import { parseSchemaConfigTableName } from './helpers'; import { useSchemaConfig } from './Store'; import { TableList, tablesFilter } from './Tables'; -export function SchemaConfigSidebar(): JSX.Element { - const { language = '', '*': rawTableName = '' } = useParams(); - const tableName = parseSchemaConfigTableName(rawTableName); +export function SchemaConfigSidebar({ + tableName, +}: { + readonly tableName: string; +}): JSX.Element { + const { language = '' } = useParams(); const { modifiedTables } = useSchemaConfig(); const [search, setSearch] = React.useState(''); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 09cd9ebf35b..56d2d425e00 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -101,10 +101,3 @@ export function javaTypeToHuman( else if (type.startsWith('java')) return type.split('.').at(-1)!; else return type; } - -/** - * The Schema Config table route is a splat (`:language/*`), so the table name - * may include a trailing slash. Strip it before looking up the table. - */ -export const parseSchemaConfigTableName = (splat: string): string => - splat.replace(/\/+$/u, ''); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index ea4d3890025..16b03b0454e 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -10,7 +10,6 @@ import type { SpLocaleItemStr } from '../DataModel/types'; import { useTitle } from '../Molecules/AppTitle'; import { SchemaConfigField } from './Field'; import { SchemaConfigColumn, SchemaConfigFields } from './Fields'; -import { parseSchemaConfigTableName } from './helpers'; import { useSchemaConfig, useSchemaConfigTable } from './Store'; import { SchemaConfigTable } from './Table'; @@ -20,8 +19,7 @@ export type NewSpLocaleItemString = PartialBy; export type ItemType = 'formatted' | 'none' | 'pickList' | 'webLink'; export function SchemaConfigMain(): JSX.Element { - const { '*': rawTableName = '' } = useParams(); - const tableName = parseSchemaConfigTableName(rawTableName); + const { tableName = '' } = useParams(); const table = strictGetTable(tableName); useTitle(schemaText.schemaViewTitle({ tableName: table.name })); From b91b7118ea2e32d338fa52ce923861907f282f79 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:49:54 -0500 Subject: [PATCH 06/58] fix(db-viewer): use caption terminology over labels --- .../frontend/js_src/lib/components/SchemaViewer/Fields.tsx | 2 +- .../js_src/lib/components/SchemaViewer/Relationships.tsx | 2 +- .../frontend/js_src/lib/components/SchemaViewer/Table.tsx | 2 +- .../js_src/lib/components/SchemaViewer/schemaToTsv.tsx | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx index fa995a174b9..4c32d1a1eb7 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx @@ -55,7 +55,7 @@ const fieldColumns = f.store( () => ({ name: getField(tables.SpLocaleContainerItem, 'name').label, - label: reportsText.labels(), + label: schemaText.caption(), description: schemaText.description(), isHidden: getField(tables.SpLocaleContainerItem, 'isHidden').label, isReadOnly: schemaText.readOnly(), diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx index 9829502969b..96be8181685 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx @@ -92,7 +92,7 @@ const relationshipColumns = f.store( () => ({ name: getField(tables.SpLocaleContainerItem, 'name').label, - label: reportsText.labels(), + label: schemaText.caption(), description: schemaText.description(), isHidden: getField(tables.SpLocaleContainerItem, 'isHidden').label, isReadOnly: schemaText.readOnly(), diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx index a89e0a385c2..8e3ec8b06f6 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx @@ -55,7 +55,7 @@ export const schemaViewerTableColumns = f.store( () => ({ name: getField(tables.SpLocaleContainer, 'name').label, - label: reportsText.labels(), + label: schemaText.caption(), isSystem: getField(tables.SpLocaleContainer, 'isSystem').label, isHidden: getField(tables.SpLocaleContainer, 'isHidden').label, tableId: schemaText.tableId(), diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx index 0d9086a1cae..8ef99130415 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx @@ -13,12 +13,12 @@ export const schemaToTsv = (): string => [ [ schemaText.table(), - reportsText.labels(), + schemaText.caption(), getField(tables.SpLocaleContainer, 'isSystem').label, getField(tables.SpLocaleContainer, 'isHidden').label, schemaText.tableId(), getField(tables.SpLocaleContainerItem, 'name').label, - reportsText.labels(), + schemaText.caption(), schemaText.description(), getField(tables.SpLocaleContainerItem, 'isHidden').label, schemaText.readOnly(), From 010f695bc3f2887036b681300a8a67b366544ff1 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:58:32 -0500 Subject: [PATCH 07/58] feat(schema-config): replace fields select with sortable tables --- .../lib/components/SchemaConfig/Fields.tsx | 243 ++++++++++++++---- .../js_src/lib/localization/schema.ts | 22 ++ 2 files changed, 210 insertions(+), 55 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index af860fcb76f..ef68db3acab 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -1,17 +1,25 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; -import { useCachedState } from '../../hooks/useCachedState'; import { useId } from '../../hooks/useId'; import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import type { RA } from '../../utils/types'; +import { localized } from '../../utils/types'; import { sortFunction, split } from '../../utils/utils'; import { H3 } from '../Atoms'; -import { Input, Label, Select } from '../Atoms/Form'; +import { Button } from '../Atoms/Button'; +import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; +import { tables } from '../DataModel/tables'; import type { SpLocaleContainerItem } from '../DataModel/types'; +import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; + +type SchemaConfigItem = SerializedResource & + WithFetchedStrings; + +type SortField = 'caption' | 'isHidden' | 'name'; export function SchemaConfigFields({ table, @@ -20,26 +28,30 @@ export function SchemaConfigFields({ onChange: handleChange, }: { readonly table: SpecifyTable; - readonly items: RA> | undefined; + readonly items: RA | undefined; readonly index: number; readonly onChange: (index: number) => void; }): JSX.Element { const id = useId('schema-fields'); - const [isHiddenFirst = true, setIsHiddenFirst] = useCachedState( - 'schemaConfig', - 'sortByHiddenFields' - ); + const [sortField, setSortField] = React.useState('isHidden'); + const [isDescending, setIsDescending] = React.useState(false); - const sortedItems = React.useMemo(() => { - const sorted = Object.values(items ?? []).sort( - sortFunction(({ name }) => name) - ); - return isHiddenFirst - ? sorted.sort(sortFunction(({ isHidden }) => isHidden)) - : sorted; - }, [items, isHiddenFirst]); + const handleSort = (field: SortField): void => { + if (sortField === field) setIsDescending(!isDescending); + else { + setSortField(field); + setIsDescending(false); + } + }; + + const sortedItems = React.useMemo( + () => + Object.values(items ?? []).sort( + sortFunction(getSortValue(sortField), isDescending) + ), + [items, sortField, isDescending] + ); - const currentId = items?.[index].id ?? 0; const [fields, relationships] = split( sortedItems, (item) => table.getField(item.name)!.isRelationship @@ -47,53 +59,174 @@ export function SchemaConfigFields({ return ( - - - setIsHiddenFirst(!isHiddenFirst)} - /> - {schemaText.sortByHiddenFields()} - + + )} ); } -export function SchemaConfigFieldsList({ - fields, +const getSortValue = ( + field: SortField +): ((item: SchemaConfigItem) => string | boolean) => + field === 'name' + ? ({ name }) => name + : field === 'caption' + ? ({ strings }) => strings.name.text + : ({ isHidden }) => isHidden; + +function SchemaConfigFieldsTable({ + title, + rows, + items, + index, + sortField, + isDescending, + onSort: handleSort, + onChange: handleChange, +}: { + readonly title: LocalizedString; + readonly rows: RA; + readonly items: RA | undefined; + readonly index: number; + readonly sortField: SortField; + readonly isDescending: boolean; + readonly onSort: (field: SortField) => void; + readonly onChange: (index: number) => void; +}): JSX.Element { + return ( +
+

{title}

+ + + + + + + + + + {rows.map((item) => { + const itemIndex = + items?.findIndex(({ id }) => id === item.id) ?? -1; + const isCurrent = itemIndex === index; + return ( + handleChange(itemIndex)} + > + + + + + ); + })} + +
+ { + event.stopPropagation(); + handleChange(itemIndex); + }} + > + {localized(item.name)} + + {item.strings.name.text} + {item.isHidden ? ( + <> + + {schemaText.hidden()} + + ) : ( + {schemaText.visible()} + )} +
+
+ ); +} + +function SortableTh({ + field, + label, + sortField, + isDescending, + onSort: handleSort, }: { - readonly fields: RA>; + readonly field: SortField; + readonly label: LocalizedString; + readonly sortField: SortField; + readonly isDescending: boolean; + readonly onSort: (field: SortField) => void; }): JSX.Element { + const isActive = sortField === field; return ( - <> - {fields.map((item) => ( - - ))} - + + + ); } diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index acea8cd9808..3b79ff0354e 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -108,6 +108,17 @@ export const schemaText = createDictionary({ 'hr-hr': 'Polja', nb: 'Felt', }, + literalFields: { + 'en-us': 'Literal Fields', + 'ru-ru': 'Буквенные поля', + 'es-es': 'Campos literales', + 'fr-fr': 'Champs littéraux', + 'uk-ua': 'Буквальні поля', + 'de-ch': 'Literale Felder', + 'pt-br': 'Campos literais', + 'hr-hr': 'Doslovna polja', + nb: 'Bokstavelige felt', + }, relationships: { 'en-us': 'Relationships', 'ru-ru': 'Отношения', @@ -647,6 +658,17 @@ export const schemaText = createDictionary({ 'hr-hr': 'skriven', nb: 'skjult', }, + visible: { + 'en-us': 'Visible', + 'de-ch': 'Sichtbar', + 'es-es': 'Visible', + 'fr-fr': 'Visible', + 'ru-ru': 'Видимый', + 'uk-ua': 'Видимий', + 'pt-br': 'Visível', + 'hr-hr': 'Vidljivo', + nb: 'Synlig', + }, customFieldFormat: { 'en-us': 'Custom Field Format', 'de-ch': 'Format für benutzerdefinierte Felder', From 15d8f1d7fc079f0830e2aff86a5b6ad4304f206b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:58:42 -0500 Subject: [PATCH 08/58] fix(db-viewer): remove unused reportsText imports --- .../frontend/js_src/lib/components/SchemaViewer/Fields.tsx | 1 - .../js_src/lib/components/SchemaViewer/Relationships.tsx | 1 - specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx | 1 - .../frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx | 1 - 4 files changed, 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx index 4c32d1a1eb7..8fce7e08b9c 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { commonText } from '../../localization/common'; -import { reportsText } from '../../localization/report'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { booleanFormatter } from '../../utils/parser/parse'; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx index 96be8181685..2b973245d66 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Relationships.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { reportsText } from '../../localization/report'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { booleanFormatter } from '../../utils/parser/parse'; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx index 8e3ec8b06f6..fc8300a6985 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Table.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { reportsText } from '../../localization/report'; import { schemaText } from '../../localization/schema'; import { f } from '../../utils/functools'; import { booleanFormatter } from '../../utils/parser/parse'; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx index 8ef99130415..c6038f0854f 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx @@ -1,5 +1,4 @@ import { formsText } from '../../localization/forms'; -import { reportsText } from '../../localization/report'; import { schemaText } from '../../localization/schema'; import { booleanFormatter } from '../../utils/parser/parse'; import { getField } from '../DataModel/helpers'; From b73c755adaaa7bc81bc8046e5db9449991af7009 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:03:38 -0500 Subject: [PATCH 09/58] refactor(schema-config): show visibility icons and sort by visibility --- .../lib/components/SchemaConfig/Fields.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index ef68db3acab..643a493cb84 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -6,9 +6,10 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; -import { sortFunction, split } from '../../utils/utils'; +import { multiSortFunction, sortFunction, split } from '../../utils/utils'; import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; +import { icons } from '../Atoms/Icons'; import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyTable } from '../DataModel/specifyTable'; @@ -33,7 +34,9 @@ export function SchemaConfigFields({ readonly onChange: (index: number) => void; }): JSX.Element { const id = useId('schema-fields'); - const [sortField, setSortField] = React.useState('isHidden'); + const [sortField, setSortField] = React.useState( + undefined + ); const [isDescending, setIsDescending] = React.useState(false); const handleSort = (field: SortField): void => { @@ -44,13 +47,17 @@ export function SchemaConfigFields({ } }; - const sortedItems = React.useMemo( - () => - Object.values(items ?? []).sort( - sortFunction(getSortValue(sortField), isDescending) - ), - [items, sortField, isDescending] - ); + const sortedItems = React.useMemo(() => { + const itemList = Object.values(items ?? []); + return typeof sortField === 'undefined' + ? itemList.sort( + multiSortFunction( + ({ isHidden }) => isHidden, + ({ name }) => name + ) + ) + : itemList.sort(sortFunction(getSortValue(sortField), isDescending)); + }, [items, sortField, isDescending]); const [fields, relationships] = split( sortedItems, @@ -114,7 +121,7 @@ function SchemaConfigFieldsTable({ readonly rows: RA; readonly items: RA | undefined; readonly index: number; - readonly sortField: SortField; + readonly sortField: SortField | undefined; readonly isDescending: boolean; readonly onSort: (field: SortField) => void; readonly onChange: (index: number) => void; @@ -142,7 +149,7 @@ function SchemaConfigFieldsTable({ @@ -178,11 +185,14 @@ function SchemaConfigFieldsTable({ {item.isHidden ? ( <> - + {icons.x} {schemaText.hidden()} ) : ( - {schemaText.visible()} + <> + {icons.check} + {schemaText.visible()} + )} @@ -203,7 +213,7 @@ function SortableTh({ }: { readonly field: SortField; readonly label: LocalizedString; - readonly sortField: SortField; + readonly sortField: SortField | undefined; readonly isDescending: boolean; readonly onSort: (field: SortField) => void; }): JSX.Element { From ab5991e09588afa2630d6021faacae13d17ca6a4 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:15:43 -0500 Subject: [PATCH 10/58] fix(schema-config): keep alphabetical secondary sort and italicize hidden fields --- .../lib/components/SchemaConfig/Fields.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 643a493cb84..a4ee14569a9 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -6,7 +6,7 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; -import { multiSortFunction, sortFunction, split } from '../../utils/utils'; +import { multiSortFunction, split } from '../../utils/utils'; import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { icons } from '../Atoms/Icons'; @@ -49,14 +49,27 @@ export function SchemaConfigFields({ const sortedItems = React.useMemo(() => { const itemList = Object.values(items ?? []); - return typeof sortField === 'undefined' + if (typeof sortField === 'undefined') + return itemList.sort( + multiSortFunction( + ({ isHidden }) => isHidden, + ({ name }) => name + ) + ); + return isDescending ? itemList.sort( multiSortFunction( - ({ isHidden }) => isHidden, + getSortValue(sortField), + true, ({ name }) => name ) ) - : itemList.sort(sortFunction(getSortValue(sortField), isDescending)); + : itemList.sort( + multiSortFunction( + getSortValue(sortField), + ({ name }) => name + ) + ); }, [items, sortField, isDescending]); const [fields, relationships] = split( @@ -163,6 +176,8 @@ function SchemaConfigFieldsTable({ return ( Date: Thu, 13 Aug 2026 23:18:02 -0500 Subject: [PATCH 11/58] fix(schema-config): italicize entire hidden field row --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index a4ee14569a9..12b65e19b95 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -188,6 +188,7 @@ function SchemaConfigFieldsTable({ { event.stopPropagation(); handleChange(itemIndex); From 7dacda30a5baccb490fd84519cbd101aaf3d7848 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:30:02 -0500 Subject: [PATCH 12/58] fix(schema-config): align field and relationship column widths --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 12b65e19b95..2c66b4ba913 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -142,7 +142,12 @@ function SchemaConfigFieldsTable({ return (

{title}

- +
+ + + + + Date: Thu, 13 Aug 2026 23:32:37 -0500 Subject: [PATCH 13/58] feat(schema-config): widen fields column --- .../lib/components/SchemaConfig/Fields.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 2c66b4ba913..81f8838970b 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -78,7 +78,11 @@ export function SchemaConfigFields({ ); return ( - + {typeof items === 'undefined' ? ( commonText.loading() ) : ( @@ -190,7 +194,7 @@ function SchemaConfigFieldsTable({ key={item.id} onClick={(): void => handleChange(itemIndex)} > - - + - +
+ - {localized(item.name)} + + {localized(item.name)} + {item.strings.name.text}{item.strings.name.text} {item.isHidden ? ( <> - {icons.x} + {icons.x} {schemaText.hidden()} ) : ( <> - {icons.check} + {icons.check} {schemaText.visible()} )} @@ -265,13 +271,19 @@ export function SchemaConfigColumn({ children, header, id, + className: classNameOverride, }: { readonly children: React.ReactNode; readonly header: LocalizedString; readonly id?: string; + readonly className?: string; }): JSX.Element { return ( -
+

{header}

{children}
From e5a69dde7aa688367228b7c8e660504621ee5fb5 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:41:01 -0500 Subject: [PATCH 14/58] fix(schema-config): force-wrap field name and caption cells --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 81f8838970b..222fc883783 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -194,7 +194,7 @@ function SchemaConfigFieldsTable({ key={item.id} onClick={(): void => handleChange(itemIndex)} > -
+ - + {localized(item.name)} {item.strings.name.text}{item.strings.name.text} {item.isHidden ? ( <> From 5bd752d2ddf104dcc7c3f34e55ed8ce61773ec25 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:52:28 -0500 Subject: [PATCH 15/58] fix(schema-config): prevent scrollbar flicker in columns --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 222fc883783..db7475104b4 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -280,7 +280,7 @@ export function SchemaConfigColumn({ }): JSX.Element { return (
From 49220a9de06f58197ee8257bdc1b5eea28c2fb2c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:57:24 -0500 Subject: [PATCH 16/58] fix(schema-config): reserve width for visible column header --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index db7475104b4..ddb202e1c37 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -148,9 +148,9 @@ function SchemaConfigFieldsTable({

{title}

- - - + + + From 4081af2109bdef9747000a6254c8832b788cbb28 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:21:32 -0500 Subject: [PATCH 17/58] fix(schema-config): use persistent scrollbars in remaining containers --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 2 +- .../frontend/js_src/lib/components/SchemaConfig/Layout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index ddb202e1c37..54d3851eebd 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -86,7 +86,7 @@ export function SchemaConfigFields({ {typeof items === 'undefined' ? ( commonText.loading() ) : ( -
+
-
+
From 9faeee9ec65ec04ac805b482bab9e50f064fa08b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:47:55 -0500 Subject: [PATCH 18/58] fix(schema-config): narrow visible column and wrap sortable headers --- .../js_src/lib/components/SchemaConfig/Fields.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 54d3851eebd..ae3f128810d 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -150,7 +150,7 @@ function SchemaConfigFieldsTable({
- + @@ -254,13 +254,15 @@ function SortableTh({ scope="col" > From 3ee9f853a2b2a6358dfa0e09cb73d62715c5a8d8 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:53:21 -0500 Subject: [PATCH 19/58] fix(schema-config): wrap text gracefully with overflow-wrap anywhere --- .../js_src/lib/components/SchemaConfig/Fields.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index ae3f128810d..fd1c0006918 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -194,7 +194,7 @@ function SchemaConfigFieldsTable({ key={item.id} onClick={(): void => handleChange(itemIndex)} > - - + {rows.map((item) => { - const itemIndex = - items?.findIndex(({ id }) => id === item.id) ?? -1; + const itemIndex = itemIndexes.get(item.id) ?? -1; const isCurrent = itemIndex === index; return ( Date: Fri, 14 Aug 2026 12:42:31 -0500 Subject: [PATCH 26/58] refactor(schema-config): remove stray JSX expression --- .../frontend/js_src/lib/components/SchemaConfig/Components.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index 5b24b037042..d14921825a3 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -104,7 +104,6 @@ export function PickList({ )) )} - {} )} From 7950e8e93d421297355b1cc083e178b003bd4a4a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:32:12 -0500 Subject: [PATCH 27/58] fix(schema-config): keep unload protection until save succeeds --- .../js_src/lib/components/SchemaConfig/Layout.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx index ea256b205b1..48b9d165354 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Layout.tsx @@ -60,8 +60,12 @@ function SchemaConfigLayoutContent(): JSX.Element { const canSave = !isReadOnly && anyModified; const handleSave = (): void => { if (!canSave) return; - unsetUnloadProtect(); - loading(saveAll().then(() => handleSchemaSaved(rawLanguage, tableName))); + loading( + saveAll().then(() => { + unsetUnloadProtect(); + return handleSchemaSaved(rawLanguage, tableName); + }) + ); }; return ( From a4ead227ea04a90e67a97e003521d0de0b515f2f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:32:12 -0500 Subject: [PATCH 28/58] fix(schema-config): handle table load errors --- .../frontend/js_src/lib/components/SchemaConfig/Store.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index 4d80af97899..87c3802fc3c 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -10,6 +10,7 @@ import type { SpLocaleContainer, SpLocaleContainerItem, } from '../DataModel/types'; +import { softFail } from '../Errors/Crash'; import { formatUrl } from '../Router/queryString'; import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; import { fetchContainerItems, fetchContainerString } from './data'; @@ -254,7 +255,7 @@ export function useSchemaConfigTable(tableName: string): { React.useEffect(() => { if (tableName === '') return; - void loadTable(tableName); + void loadTable(tableName).catch(softFail); }, [loadTable, tableName]); const container = React.useMemo( From 7d874d600a7cef2ac699facf4ed89800e534b4df Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:32:13 -0500 Subject: [PATCH 29/58] fix(schema-config): only mark auto-scroll after successful scroll --- .../frontend/js_src/lib/components/SchemaConfig/Tables.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx index 2fd6426bf38..078fd597891 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx @@ -97,10 +97,10 @@ export function TableList({ React.useEffect(() => { if (hasScrolledRef.current || currentTableName === '') return; - hasScrolledRef.current = true; const list = listRef.current; const active = activeRef.current; if (list === null || active === null) return; + hasScrolledRef.current = true; const listRect = list.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); list.scrollTop += From a7f1010c66027ddf2373a8ef61e806bb823367e1 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:32:13 -0500 Subject: [PATCH 30/58] fix(schema-config): reset field index synchronously on table change --- .../js_src/lib/components/SchemaConfig/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index 16b03b0454e..5fe56d7be78 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -36,11 +36,12 @@ export function SchemaConfigMain(): JSX.Element { } = useSchemaConfigTable(table.name); const [index, setIndex] = React.useState(0); - const item = items?.[index]; - - React.useEffect(() => { + const [previousTableName, setPreviousTableName] = React.useState(table.name); + if (previousTableName !== table.name) { + setPreviousTableName(table.name); setIndex(0); - }, [table.name]); + } + const item = items?.[index]; return (
From fd21d0ef5a182b8f5ef950a83bdec3bcd4a7583e Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:18:57 -0500 Subject: [PATCH 31/58] fix(schema-config): fix button sizing --- .../js_src/lib/components/SchemaConfig/Components.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx index d14921825a3..8adbbc64775 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Components.tsx @@ -33,15 +33,15 @@ export function SchemaConfigHeader({ })` )} - - {commonText.export()} - + + {commonText.save()} From 6260c4a9d872d545bad768a81379092645ed18ab Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:22:43 -0500 Subject: [PATCH 32/58] fix(schema-config): keep selected field name in normal color --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 5aa708b9186..f3bf5dd784d 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -9,6 +9,7 @@ import { localized } from '../../utils/types'; import { multiSortFunction, split } from '../../utils/utils'; import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; +import { className } from '../Atoms/className'; import { icons } from '../Atoms/Icons'; import { getField } from '../DataModel/helpers'; import type { SerializedResource } from '../DataModel/helperTypes'; @@ -202,7 +203,9 @@ function SchemaConfigFieldsTable({
+ - + {localized(item.name)} {item.strings.name.text} + {item.strings.name.text} + {item.isHidden ? ( <> @@ -258,7 +260,7 @@ function SortableTh({ type="button" onClick={(): void => handleSort(field)} > - {label} + {label} {isActive ? ( {isDescending ? '↓' : '↑'} From 117e08f6d3efa26d9164c8f50b27a80ef5425593 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:27:45 -0500 Subject: [PATCH 20/58] feat(schema-config): add collapsible tables sidebar --- .../lib/components/SchemaConfig/Sidebar.tsx | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index 2b57cb4e4d1..bda61a32ddb 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -5,6 +5,7 @@ import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; import { localized } from '../../utils/types'; import { H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; import { useSchemaConfig } from './Store'; import { TableList, tablesFilter } from './Tables'; @@ -17,10 +18,26 @@ export function SchemaConfigSidebar({ const { language = '' } = useParams(); const { modifiedTables } = useSchemaConfig(); const [search, setSearch] = React.useState(''); + const [isCollapsed, setIsCollapsed] = React.useState(false); - return ( -
{ event.stopPropagation(); handleChange(itemIndex); From 1f4e5b7a814499aa4f3874e21f2627422b7ac950 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:00:05 -0500 Subject: [PATCH 33/58] test(schema-config): add bulk-save store tests --- .../lib/components/SchemaConfig/Store.tsx | 4 + .../__tests__/Store.http.test.tsx | 195 +++++++++ .../__tests__/Store.saveAll.test.tsx | 379 ++++++++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.http.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index 87c3802fc3c..319ca1b9be3 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -346,3 +346,7 @@ export const handleSchemaSaved = async ( : `/specify/schema-config/${rawLanguage}/${tableName}/` ) ); + +export const exportsForTests = { + buildSaveRequests, +}; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.http.test.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.http.test.tsx new file mode 100644 index 00000000000..fa9e4d3db56 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.http.test.tsx @@ -0,0 +1,195 @@ +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { Http } from '../../../utils/ajax/definitions'; +import type { RA } from '../../../utils/types'; +import { addMissingFields } from '../../DataModel/addMissingFields'; +import type { SerializedResource } from '../../DataModel/helperTypes'; +import { + createResource, + getResourceApiUrl, + saveResource, +} from '../../DataModel/resource'; +import { serializeResource } from '../../DataModel/serializers'; +import type { SpLocaleContainer } from '../../DataModel/types'; +import { fetchContainerItems, fetchContainerString } from '../data'; + +requireContext(); + +describe('fetchContainerItems', () => { + const container = { + id: 1, + name: 'Accession', + resource_uri: getResourceApiUrl('SpLocaleContainer', 1), + } as unknown as SerializedResource; + + // The item name must be a real field of the container table, otherwise + // fetchContainerItems filters it out as a removed field + const itemRecord = { + resource_uri: getResourceApiUrl('SpLocaleContainerItem', 1), + id: 1, + name: 'accessionNumber', + ishidden: false, + isrequired: false, + format: null, + picklistname: null, + weblinkname: null, + container: getResourceApiUrl('SpLocaleContainer', 1), + }; + + const nameStringRecord = { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 1), + id: 1, + language: 'en', + country: null, + text: 'Field 1', + itemname: getResourceApiUrl('SpLocaleContainerItem', 1), + }; + + const descStringRecord = { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 2), + id: 2, + language: 'en', + country: null, + text: 'Desc 1', + itemdesc: getResourceApiUrl('SpLocaleContainerItem', 1), + }; + + const containerNameRecord = { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 3), + id: 3, + language: 'en', + country: null, + text: 'Accession', + containername: getResourceApiUrl('SpLocaleContainer', 1), + }; + + const containerDescRecord = { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 4), + id: 4, + language: 'en', + country: null, + text: 'Accession description', + containerdesc: getResourceApiUrl('SpLocaleContainer', 1), + }; + + const collection = (objects: RA) => ({ + meta: { limit: 0, offset: 0, total_count: objects.length }, + objects, + }); + + overrideAjax( + '/api/specify/splocalecontaineritem/?limit=0&container=1', + collection([itemRecord]) + ); + overrideAjax( + '/api/specify/splocaleitemstr/?limit=0&itemname__container__exact=1', + collection([nameStringRecord]) + ); + overrideAjax( + '/api/specify/splocaleitemstr/?limit=0&itemdesc__container__exact=1', + collection([descStringRecord]) + ); + overrideAjax( + '/api/specify/splocaleitemstr/?limit=0&containername=1', + collection([containerNameRecord]) + ); + overrideAjax( + '/api/specify/splocaleitemstr/?limit=0&containerdesc=1', + collection([containerDescRecord]) + ); + + test('fetches items and resolves their name and description strings', async () => + expect(fetchContainerItems(container, 'en', null)).resolves.toEqual([ + { + ...serializeResource(itemRecord), + strings: { + name: serializeResource(nameStringRecord), + desc: serializeResource(descStringRecord), + }, + }, + ])); + + test('fetches the container name and description strings', async () => { + await expect( + fetchContainerString('containerName', container, 'en', null) + ).resolves.toEqual(serializeResource(containerNameRecord)); + await expect( + fetchContainerString('containerDesc', container, 'en', null) + ).resolves.toEqual(serializeResource(containerDescRecord)); + }); +}); + +describe('saveResource / createResource', () => { + overrideAjax( + '/api/specify/splocaleitemstr/1/', + { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 1), + id: 1, + language: 'en', + country: null, + text: 'Field 1', + }, + { method: 'PUT' } + ); + + test('saveResource on SpLocaleItemStr', async () => + expect( + saveResource('SpLocaleItemStr', 1, { text: 'Field 1' }) + ).resolves.toEqual( + addMissingFields('SpLocaleItemStr', { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 1), + id: 1, + language: 'en', + country: null, + text: 'Field 1', + }) + )); + + overrideAjax( + '/api/specify/splocalecontaineritem/7/', + { + resource_uri: getResourceApiUrl('SpLocaleContainerItem', 7), + id: 7, + name: 'accessionNumber', + ishidden: true, + }, + { method: 'PUT' } + ); + + test('saveResource on SpLocaleContainerItem', async () => + expect( + saveResource('SpLocaleContainerItem', 7, { isHidden: true }) + ).resolves.toEqual( + addMissingFields('SpLocaleContainerItem', { + resource_uri: getResourceApiUrl('SpLocaleContainerItem', 7), + id: 7, + name: 'accessionNumber', + isHidden: true, + }) + )); + + overrideAjax( + '/api/specify/splocaleitemstr/', + { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 5), + id: 5, + language: 'en', + country: null, + text: 'Field 1', + }, + { method: 'POST', responseCode: Http.CREATED } + ); + + test('createResource on SpLocaleItemStr', async () => + expect( + createResource('SpLocaleItemStr', { text: 'Field 1' }) + ).resolves.toEqual( + addMissingFields('SpLocaleItemStr', { + resource_uri: getResourceApiUrl('SpLocaleItemStr', 5), + id: 5, + language: 'en', + country: null, + text: 'Field 1', + }) + )); +}); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx new file mode 100644 index 00000000000..238d3d84c39 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx @@ -0,0 +1,379 @@ +import { act, renderHook } from '@testing-library/react'; +import React from 'react'; + +import { createResource, saveResource } from '../../DataModel/resource'; +import { fetchContainerItems, fetchContainerString } from '../data'; +import type { SchemaData } from '../schemaData'; +import { + exportsForTests, + SchemaConfigStoreProvider, + useSchemaConfig, +} from '../Store'; +import type { SchemaConfigEditorState } from '../Store'; + +jest.mock('../../DataModel/resource', () => ({ + saveResource: jest.fn(async () => ({})), + createResource: jest.fn(async () => ({})), +})); + +jest.mock('../data', () => ({ + fetchContainerItems: jest.fn(), + fetchContainerString: jest.fn(), +})); + +const { buildSaveRequests } = exportsForTests; + +// A SpLocaleContainerItem-shaped fixture. Strings deliberately lack +// resource_uri so saveString routes through createResource and the +// assertions stay predictable +type TestItem = { + readonly id: number; + readonly name: string; + readonly isHidden: boolean; + readonly isRequired: boolean | null; + readonly format: string | null; + readonly pickListName: string | null; + readonly webLinkName: string | null; + readonly strings: { + readonly name: { readonly id: number; readonly text: string }; + readonly desc: { readonly id: number; readonly text: string }; + }; +}; + +const makeItem = (id: number): TestItem => ({ + id, + name: `field${id}`, + isHidden: false, + isRequired: false, + format: null, + pickListName: null, + webLinkName: null, + strings: { + name: { id: 100 + id, text: `Field ${id}` }, + desc: { id: 200 + id, text: `Desc ${id}` }, + }, +}); + +// Cycle through every field-level property the UI can edit +const mutateItem = (item: TestItem, index: number): TestItem => { + switch (index % 7) { + case 0: + return { ...item, isHidden: true }; + case 1: + return { ...item, isRequired: true }; + case 2: + return { ...item, pickListName: 'somePickList' }; + case 3: + return { ...item, webLinkName: 'someWebLink' }; + case 4: + return { ...item, format: 'someFormat' }; + case 5: + return { + ...item, + strings: { + ...item.strings, + name: { ...item.strings.name, text: 'Renamed' }, + }, + }; + default: + return { + ...item, + strings: { + ...item.strings, + desc: { ...item.strings.desc, text: 'New desc' }, + }, + }; + } +}; + +const makeEditor = ( + overrides: Record = {} +): SchemaConfigEditorState => + ({ + container: { id: 1 }, + name: { id: 1, text: 'Name' }, + desc: { id: 2, text: 'Desc' }, + items: [makeItem(1), makeItem(2), makeItem(3)], + changedItems: [], + initialContainer: { id: 1 }, + initialName: { id: 1, text: 'Name' }, + initialDesc: { id: 2, text: 'Desc' }, + ...overrides, + }) as unknown as SchemaConfigEditorState; + +describe('buildSaveRequests', () => { + beforeEach(() => { + jest.mocked(saveResource).mockClear(); + jest.mocked(createResource).mockClear(); + }); + + test('saves each changed container item and its strings', () => { + buildSaveRequests(makeEditor({ changedItems: [0, 2] })); + + expect(saveResource).toHaveBeenCalledTimes(2); + expect(saveResource).toHaveBeenNthCalledWith( + 1, + 'SpLocaleContainerItem', + 1, + expect.anything() + ); + expect(saveResource).toHaveBeenNthCalledWith( + 2, + 'SpLocaleContainerItem', + 3, + expect.anything() + ); + + // Each changed item creates a new name and desc string + expect(createResource).toHaveBeenCalledTimes(4); + expect(createResource).toHaveBeenNthCalledWith( + 1, + 'SpLocaleItemStr', + expect.objectContaining({ text: 'Field 1' }) + ); + }); + + test('skips the untouched item and untouched container/name/desc', () => { + buildSaveRequests(makeEditor({ changedItems: [1] })); + + expect(saveResource).toHaveBeenCalledTimes(1); + expect(saveResource).toHaveBeenCalledWith( + 'SpLocaleContainerItem', + 2, + expect.anything() + ); + expect(saveResource).not.toHaveBeenCalledWith( + 'SpLocaleContainer', + expect.anything(), + expect.anything() + ); + expect(createResource).toHaveBeenCalledTimes(2); + }); + + test('emits container save when the container changed', () => { + buildSaveRequests( + makeEditor({ + container: { id: 1, isHidden: true }, + initialContainer: { id: 1, isHidden: false }, + }) + ); + + expect(saveResource).toHaveBeenCalledWith( + 'SpLocaleContainer', + 1, + expect.objectContaining({ isHidden: true }) + ); + }); +}); + +describe('saveAll', () => { + const schemaData = { + languages: { 'en-us': 'English' }, + tables: { + Accession: { + id: 1, + name: 'Accession', + resource_uri: '/api/specify/sp_locale_container/1/', + }, + CollectionObject: { + id: 2, + name: 'CollectionObject', + resource_uri: '/api/specify/sp_locale_container/2/', + }, + Determination: { + id: 3, + name: 'Determination', + resource_uri: '/api/specify/sp_locale_container/3/', + }, + Taxon: { + id: 4, + name: 'Taxon', + resource_uri: '/api/specify/sp_locale_container/4/', + }, + Locality: { + id: 5, + name: 'Locality', + resource_uri: '/api/specify/sp_locale_container/5/', + }, + Agent: { + id: 6, + name: 'Agent', + resource_uri: '/api/specify/sp_locale_container/6/', + }, + }, + formatters: [], + aggregators: [], + uiFormatters: [], + webLinks: [], + pickLists: {}, + update: () => undefined, + } as unknown as SchemaData; + + const wrapper = ({ + children, + }: { + readonly children: React.ReactNode; + }): JSX.Element => ( + + {children} + + ); + + beforeEach(() => { + jest.mocked(saveResource).mockClear(); + jest.mocked(createResource).mockClear(); + jest.mocked(fetchContainerString).mockResolvedValue({ text: 'x' } as never); + }); + + test('accumulates edits across tables and saves only modified tables', async () => { + jest.mocked(fetchContainerItems).mockImplementation(async (container) => { + const count = container.id === 1 ? 30 : 20; + return Array.from({ length: count }, (_, index) => + makeItem(index + 1) + ) as never; + }); + + const { result } = renderHook(() => useSchemaConfig(), { wrapper }); + + await act(async () => { + await result.current.loadTable('Accession'); + await result.current.loadTable('CollectionObject'); + }); + + expect(Object.keys(result.current.editors)).toEqual([ + 'Accession', + 'CollectionObject', + ]); + + // Edit 10 fields on Accession, none on CollectionObject + act(() => { + const accessionItems = result.current.editors.Accession.items; + for (let index = 0; index < 10; index += 1) + result.current.setItem('Accession', index, { + ...accessionItems[index], + isHidden: true, + }); + }); + + expect(result.current.modifiedTables).toEqual(['Accession']); + expect(result.current.anyModified).toBe(true); + + await act(async () => { + await result.current.saveAll(); + }); + + // Only the 10 changed Accession items get saved, CollectionObject stays + // untouched + expect(saveResource).toHaveBeenCalledTimes(10); + expect(saveResource).toHaveBeenNthCalledWith( + 1, + 'SpLocaleContainerItem', + 1, + expect.anything() + ); + expect(saveResource).toHaveBeenNthCalledWith( + 10, + 'SpLocaleContainerItem', + 10, + expect.anything() + ); + + // 10 names + 10 descriptions = 20 new strings + expect(createResource).toHaveBeenCalledTimes(20); + }); + + test('reports no modifications when nothing changed', async () => { + jest + .mocked(fetchContainerItems) + .mockImplementation( + async () => + Array.from({ length: 5 }, (_, index) => makeItem(index + 1)) as never + ); + + const { result } = renderHook(() => useSchemaConfig(), { wrapper }); + + await act(async () => { + await result.current.loadTable('Accession'); + }); + + expect(result.current.modifiedTables).toEqual([]); + expect(result.current.anyModified).toBe(false); + + await act(async () => { + await result.current.saveAll(); + }); + + expect(saveResource).not.toHaveBeenCalled(); + expect(createResource).not.toHaveBeenCalled(); + }); + + test('handles 300 field and table changes across every editable property', async () => { + let nextId = 0; + jest + .mocked(fetchContainerItems) + .mockImplementation( + async () => + Array.from({ length: 50 }, () => makeItem(++nextId)) as never + ); + + const { result } = renderHook(() => useSchemaConfig(), { wrapper }); + + const tableNames = Object.keys(schemaData.tables); + await act(async () => { + for (const tableName of tableNames) + await result.current.loadTable(tableName); + }); + + act(() => { + tableNames.forEach((tableName, tableIndex) => { + const editor = result.current.editors[tableName]; + editor.items.forEach((item, itemIndex) => + result.current.setItem( + tableName, + itemIndex, + mutateItem(item as unknown as TestItem, itemIndex) as never + ) + ); + result.current.setContainer(tableName, { + ...editor.container, + isHidden: true, + format: 'someFormat', + aggregator: 'someAggregator', + }); + result.current.setName(tableName, { + text: `Table ${tableIndex}`, + } as never); + result.current.setDesc(tableName, { + text: `Table desc ${tableIndex}`, + } as never); + }); + }); + + expect(result.current.modifiedTables).toEqual(tableNames); + + await act(async () => { + await result.current.saveAll(); + }); + + // 6 tables × 50 fields = 300 changed items, plus 6 changed containers + expect(saveResource).toHaveBeenCalledTimes(306); + // 300 items × (name + desc) + 6 tables × (name + desc) = 612 new strings + expect(createResource).toHaveBeenCalledTimes(612); + + // Spot check that field-level and table-level edits reached the payloads + expect(saveResource).toHaveBeenCalledWith( + 'SpLocaleContainerItem', + expect.any(Number), + expect.objectContaining({ pickListName: 'somePickList' }) + ); + expect(saveResource).toHaveBeenCalledWith( + 'SpLocaleContainer', + expect.any(Number), + expect.objectContaining({ isHidden: true, aggregator: 'someAggregator' }) + ); + }); +}); From abf2f4d787b4893eefbe02ef6cce6cd0b36f754b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:02:57 -0500 Subject: [PATCH 34/58] fix(schema-config): refresh formatters, aggregators, and pick lists after editing --- .../lib/components/FieldFormatters/index.ts | 46 ++++++++++++------- .../lib/components/Formatters/formatters.ts | 36 ++++++++++----- .../lib/components/InitialContext/index.ts | 7 ++- .../lib/components/SchemaConfig/schemaData.ts | 20 ++++++-- .../lib/components/Toolbar/SchemaConfig.tsx | 14 +++++- .../js_src/lib/components/WebLinks/index.tsx | 19 ++++++-- 6 files changed, 104 insertions(+), 38 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index 97634d21b1e..a72f6f0b798 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -23,24 +23,36 @@ import type { FieldFormatter, FieldFormatterPart } from './spec'; import { fieldFormattersSpec, trimRegexString } from './spec'; let uiFormatters: IR; -export const fetchContext = Promise.all([ - load(getAppResourceUrl('UIFormatters'), 'text/xml'), - import('../DataModel/tables').then(async ({ fetchContext }) => fetchContext), -]).then(([formatters]) => { - uiFormatters = Object.fromEntries( - filterArray( - xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.map( - (formatter, index) => { - const resolvedFormatter = resolveFieldFormatter(formatter, index); - return resolvedFormatter === undefined - ? undefined - : [formatter.name, resolvedFormatter]; - } + +const loadUiFormatters = (refresh = false): Promise> => + Promise.all([ + load(getAppResourceUrl('UIFormatters'), 'text/xml', refresh), + import('../DataModel/tables').then( + async ({ fetchContext }) => fetchContext + ), + ]).then(([formatters]) => { + uiFormatters = Object.fromEntries( + filterArray( + xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.map( + (formatter, index) => { + const resolvedFormatter = resolveFieldFormatter(formatter, index); + return resolvedFormatter === undefined + ? undefined + : [formatter.name, resolvedFormatter]; + } + ) ) - ) - ); - return uiFormatters; -}); + ); + return uiFormatters; + }); + +export let fetchContext = loadUiFormatters(); + +// Re-fetch UI formatters after the app resource is edited +export const refreshUiFormatters = (): typeof fetchContext => { + fetchContext = loadUiFormatters(true); + return fetchContext; +}; export const getUiFormatters = (): typeof uiFormatters => uiFormatters ?? error('Tried to access UI formatters before fetching them'); diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts index 356b993e3de..ec84d9628e1 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -35,20 +35,32 @@ import { fieldFormat } from './fieldFormat'; import type { Aggregator, Formatter } from './spec'; import { formattersSpec } from './spec'; -export const fetchFormatters: Promise<{ +const loadFormatters = ( + refresh = false +): Promise<{ readonly formatters: RA; readonly aggregators: RA; -}> = contextUnlockedPromise.then(async (entrypoint) => - entrypoint === 'main' - ? Promise.all([ - ajax(cacheableUrl(getAppResourceUrl('DataObjFormatters')), { - headers: { Accept: 'text/xml' }, - }).then(({ data }) => data), - fetchSchema, - fetchDomain, - ]).then(([definitions]) => xmlToSpec(definitions, formattersSpec())) - : foreverFetch() -); +}> => + contextUnlockedPromise.then(async (entrypoint) => + entrypoint === 'main' + ? Promise.all([ + ajax(cacheableUrl(getAppResourceUrl('DataObjFormatters')), { + headers: { Accept: 'text/xml' }, + cache: refresh ? 'no-cache' : undefined, + }).then(({ data }) => data), + fetchSchema, + fetchDomain, + ]).then(([definitions]) => xmlToSpec(definitions, formattersSpec())) + : foreverFetch() + ); + +export let fetchFormatters = loadFormatters(); + +// Re-fetch formatters and aggregators after the app resource is edited +export const refreshFormatters = (): typeof fetchFormatters => { + fetchFormatters = loadFormatters(true); + return fetchFormatters; +}; export const naiveFormatter = ( tableLabel: string, diff --git a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts index a459b73f363..d854d20118a 100644 --- a/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts +++ b/specifyweb/frontend/js_src/lib/components/InitialContext/index.ts @@ -50,7 +50,11 @@ export const foreverFetch = async (): Promise => foreverPromise; export const unlockInitialContext = (entrypoint: typeof entrypointName): void => unlock(entrypoint); -export const load = async (path: string, mimeType: MimeType): Promise => +export const load = async ( + path: string, + mimeType: MimeType, + refresh = false +): Promise => contextUnlockedPromise.then(async (entrypoint) => { if (entrypoint !== 'main') return foreverFetch(); @@ -60,6 +64,7 @@ export const load = async (path: string, mimeType: MimeType): Promise => const { data } = await ajax(cacheableUrl(path), { errorMode: 'visible', headers: { Accept: mimeType }, + cache: refresh ? 'no-cache' : undefined, }); return data; diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index 42120bd4698..6df84858787 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -9,11 +9,14 @@ import type { SerializedResource } from '../DataModel/helperTypes'; import { serializeResource } from '../DataModel/serializers'; import type { LiteralField } from '../DataModel/specifyField'; import type { SpLocaleContainer, Tables } from '../DataModel/types'; -import { fetchContext as fetchUiFormatters } from '../FieldFormatters'; -import { fetchFormatters } from '../Formatters/formatters'; +import { + fetchContext as fetchUiFormatters, + refreshUiFormatters, +} from '../FieldFormatters'; +import { fetchFormatters, refreshFormatters } from '../Formatters/formatters'; import { fetchPickLists } from '../PickLists/definitions'; import { fetchSchemaLanguages } from '../Toolbar/Language'; -import { webLinks } from '../WebLinks'; +import { refreshWebLinks, webLinks } from '../WebLinks'; import type { WebLink } from '../WebLinks/spec'; import { formatAggregators } from './helpers'; @@ -82,6 +85,17 @@ export const fetchSchemaData = async (): Promise => pickLists: fetchSchemaPickLists(), }); +// Re-fetch schema data after editing app resources. Pick lists and tables +// are fetched fresh on every call +export const refreshSchemaData = async (): Promise => { + await Promise.all([ + refreshFormatters(), + refreshUiFormatters(), + refreshWebLinks(), + ]); + return fetchSchemaData(); +}; + export const fetchSchemaPickLists = async (): Promise< SchemaData['pickLists'] > => diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx index 8ee6331fe99..46adc5e16ae 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx @@ -5,13 +5,14 @@ import React from 'react'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { OverlayLocation } from '../Router/Router'; import { SafeOutlet } from '../Router/RouterUtils'; import type { NewSpLocaleItemString, SpLocaleItemString, } from '../SchemaConfig'; import type { SchemaData } from '../SchemaConfig/schemaData'; -import { fetchSchemaData } from '../SchemaConfig/schemaData'; +import { fetchSchemaData, refreshSchemaData } from '../SchemaConfig/schemaData'; export type WithFetchedStrings = { readonly strings: { @@ -23,6 +24,17 @@ export type WithFetchedStrings = { export function SchemaConfig(): JSX.Element | null { const [schemaData, setSchemaData] = useAsyncState(fetchSchemaData, true); + // Refresh schema data when an overlay closes. useLocation won't work here + // since the main route is rendered under the scoped background location + const overlayLocation = React.useContext(OverlayLocation); + const wasInOverlay = React.useRef(false); + React.useEffect(() => { + const isInOverlay = overlayLocation !== undefined; + if (wasInOverlay.current && !isInOverlay) + void refreshSchemaData().then(setSchemaData); + wasInOverlay.current = isInOverlay; + }, [overlayLocation, setSchemaData]); + return schemaData === undefined ? null : ( {...schemaData} update={setSchemaData} /> ); diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx index 89ecaaa0237..93acc6e4d63 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx @@ -21,10 +21,21 @@ import { WebLinksContext } from './Editor'; import type { WebLink } from './spec'; import { webLinksSpec } from './spec'; -export const webLinks = Promise.all([ - load(getAppResourceUrl('WebLinks'), 'text/xml'), - import('../DataModel/tables').then(async ({ fetchContext }) => fetchContext), -]).then(([xml]) => xmlToSpec(xml, webLinksSpec()).webLinks); +const loadWebLinks = (refresh = false): Promise> => + Promise.all([ + load(getAppResourceUrl('WebLinks'), 'text/xml', refresh), + import('../DataModel/tables').then( + async ({ fetchContext }) => fetchContext + ), + ]).then(([xml]) => xmlToSpec(xml, webLinksSpec()).webLinks); + +export let webLinks = loadWebLinks(); + +// Re-fetch web links after the app resource is edited +export const refreshWebLinks = (): typeof webLinks => { + webLinks = loadWebLinks(true); + return webLinks; +}; export function WebLinkField({ resource, From b450fcede7aa6e644fd63c3f0f42aab4968c658d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:05:25 -0500 Subject: [PATCH 35/58] style(schema-config): put fields list in outline --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index f3bf5dd784d..6d7d464746f 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -151,7 +151,7 @@ function SchemaConfigFieldsTable({ readonly onChange: (index: number) => void; }): JSX.Element { return ( -
+

{title}

From d8b71d7e625502922acfb33d4c3196dc4183346d Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:55:58 -0500 Subject: [PATCH 36/58] style(schema-config): make fields list denser --- .../js_src/lib/components/SchemaConfig/Fields.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 6d7d464746f..c6f9c6808dc 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -93,7 +93,7 @@ export function SchemaConfigFields({ {typeof items === 'undefined' ? ( commonText.loading() ) : ( -
+
void; }): JSX.Element { return ( -
+

{title}

@@ -200,7 +200,7 @@ function SchemaConfigFieldsTable({ key={item.id} onClick={(): void => handleChange(itemIndex)} > - - - - + From 6639d82472c5ef159ecd4523f5afd87ebdd4d3d9 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:47 -0500 Subject: [PATCH 44/58] refactor(schema-config): use icon for visible column header --- .../lib/components/SchemaConfig/Fields.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index c3eb9c3f5b9..a7db04995f7 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -205,7 +205,7 @@ function SchemaConfigFieldsTable({ - + @@ -225,6 +225,7 @@ function SchemaConfigFieldsTable({ /> void; + readonly icon?: JSX.Element; }): JSX.Element { const isActive = sortField === field; return ( @@ -312,11 +315,21 @@ function SortableTh({ scope="col" > handleChange(itemIndex)} >
+ + {item.strings.name.text} + {item.isHidden ? ( <> {icons.x} @@ -260,7 +260,7 @@ function SortableTh({ aria-sort={ isActive ? (isDescending ? 'descending' : 'ascending') : undefined } - className="p-1 font-bold" + className="px-1 py-0.5 font-bold" scope="col" > diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index 2c90074bd59..ff43c81e189 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -62,12 +62,42 @@ const SchemaConfigContext = React.createContext( ); SchemaConfigContext.displayName = 'SchemaConfigContext'; -export const isEditorModified = (editor: SchemaConfigEditorState): boolean => - JSON.stringify(editor.initialContainer) !== - JSON.stringify(editor.container) || - JSON.stringify(editor.initialName) !== JSON.stringify(editor.name) || - JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc) || - editor.changedItems.length > 0; +const getEditorChanges = ( + editor: SchemaConfigEditorState +): { + readonly nameChanged: boolean; + readonly descChanged: boolean; + readonly containerChanged: boolean; +} => ({ + nameChanged: + JSON.stringify(editor.initialName) !== JSON.stringify(editor.name), + descChanged: + JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc), + containerChanged: + JSON.stringify(editor.initialContainer) !== + JSON.stringify(editor.container), +}); + +export const isEditorModified = (editor: SchemaConfigEditorState): boolean => { + const changes = getEditorChanges(editor); + return ( + changes.nameChanged || + changes.descChanged || + changes.containerChanged || + editor.changedItems.length > 0 + ); +}; + +const updateEditor = ( + editors: IR, + tableName: string, + update: (editor: SchemaConfigEditorState) => SchemaConfigEditorState +): IR => { + const editor = editors[tableName]; + return editor === undefined + ? editors + : { ...editors, [tableName]: update(editor) }; +}; export function SchemaConfigStoreProvider({ schemaData, @@ -128,34 +158,28 @@ export function SchemaConfigStoreProvider({ const setContainer = React.useCallback( (tableName: string, container: SerializedResource) => - setEditors((editors) => { - const editor = editors[tableName]; - return editor === undefined - ? editors - : { ...editors, [tableName]: { ...editor, container } }; - }), + setEditors((editors) => + updateEditor(editors, tableName, (editor) => ({ + ...editor, + container, + })) + ), [] ); const setName = React.useCallback( (tableName: string, name: NewSpLocaleItemString | SpLocaleItemString) => - setEditors((editors) => { - const editor = editors[tableName]; - return editor === undefined - ? editors - : { ...editors, [tableName]: { ...editor, name } }; - }), + setEditors((editors) => + updateEditor(editors, tableName, (editor) => ({ ...editor, name })) + ), [] ); const setDesc = React.useCallback( (tableName: string, desc: NewSpLocaleItemString | SpLocaleItemString) => - setEditors((editors) => { - const editor = editors[tableName]; - return editor === undefined - ? editors - : { ...editors, [tableName]: { ...editor, desc } }; - }), + setEditors((editors) => + updateEditor(editors, tableName, (editor) => ({ ...editor, desc })) + ), [] ); @@ -165,20 +189,15 @@ export function SchemaConfigStoreProvider({ index: number, item: SerializedResource & WithFetchedStrings ) => - setEditors((editors) => { - const editor = editors[tableName]; - if (editor === undefined) return editors; - return { - ...editors, - [tableName]: { - ...editor, - items: replaceItem(editor.items, index, item), - changedItems: editor.changedItems.includes(index) - ? editor.changedItems - : [...editor.changedItems, index], - }, - }; - }), + setEditors((editors) => + updateEditor(editors, tableName, (editor) => ({ + ...editor, + items: replaceItem(editor.items, index, item), + changedItems: editor.changedItems.includes(index) + ? editor.changedItems + : [...editor.changedItems, index], + })) + ), [] ); @@ -380,85 +399,88 @@ const applyItemStringId = ( const buildSaveRequests = ( editor: SchemaConfigEditorState -): RA => [ - ...(JSON.stringify(editor.initialName) !== JSON.stringify(editor.name) - ? [ - { - promise: saveString(editor.name), - reconcile: ( - editor: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => { - const saved = applySavedId(editor.name, result); - return { ...editor, name: saved, initialName: saved }; - }, - }, - ] - : []), - ...(JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc) - ? [ - { - promise: saveString(editor.desc), - reconcile: ( - editor: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => { - const saved = applySavedId(editor.desc, result); - return { ...editor, desc: saved, initialDesc: saved }; - }, - }, - ] - : []), - ...(JSON.stringify(editor.initialContainer) !== - JSON.stringify(editor.container) - ? [ - { - promise: saveResource( - 'SpLocaleContainer', - editor.container.id, - editor.container - ), - reconcile: ( - editor: SchemaConfigEditorState - ): SchemaConfigEditorState => ({ - ...editor, - initialContainer: editor.container, - }), - }, - ] - : []), - ...editor.items.flatMap(({ strings, ...item }, index) => - editor.changedItems.includes(index) +): RA => { + const { nameChanged, descChanged, containerChanged } = + getEditorChanges(editor); + return [ + ...(nameChanged ? [ { - itemIndex: index, - promise: saveResource('SpLocaleContainerItem', item.id, item), + promise: saveString(editor.name), reconcile: ( - editor: SchemaConfigEditorState - ): SchemaConfigEditorState => editor, + editor: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => { + const saved = applySavedId(editor.name, result); + return { ...editor, name: saved, initialName: saved }; + }, }, + ] + : []), + ...(descChanged + ? [ { - itemIndex: index, - promise: saveString(strings.name), + promise: saveString(editor.desc), reconcile: ( editor: SchemaConfigEditorState, result: unknown - ): SchemaConfigEditorState => - applyItemStringId(editor, index, 'name', result), + ): SchemaConfigEditorState => { + const saved = applySavedId(editor.desc, result); + return { ...editor, desc: saved, initialDesc: saved }; + }, }, + ] + : []), + ...(containerChanged + ? [ { - itemIndex: index, - promise: saveString(strings.desc), + promise: saveResource( + 'SpLocaleContainer', + editor.container.id, + editor.container + ), reconcile: ( - editor: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => - applyItemStringId(editor, index, 'desc', result), + editor: SchemaConfigEditorState + ): SchemaConfigEditorState => ({ + ...editor, + initialContainer: editor.container, + }), }, ] - : [] - ), -]; + : []), + ...editor.items.flatMap(({ strings, ...item }, index) => + editor.changedItems.includes(index) + ? [ + { + itemIndex: index, + promise: saveResource('SpLocaleContainerItem', item.id, item), + reconcile: ( + editor: SchemaConfigEditorState + ): SchemaConfigEditorState => editor, + }, + { + itemIndex: index, + promise: saveString(strings.name), + reconcile: ( + editor: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => + applyItemStringId(editor, index, 'name', result), + }, + { + itemIndex: index, + promise: saveString(strings.desc), + reconcile: ( + editor: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => + applyItemStringId(editor, index, 'desc', result), + }, + ] + : [] + ), + ]; +}; const saveString = async ( resource: NewSpLocaleItemString | SpLocaleItemString diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts index 6df84858787..5642ee4014e 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/schemaData.ts @@ -103,14 +103,8 @@ export const fetchSchemaPickLists = async (): Promise< Object.fromEntries( filterArray(Object.values(pickLists)) .map(serializeResource) - .map(({ id, name, isSystem }) => [ - id, - { - name, - isSystem, - }, - // Filter out front-end only pick lists - ]) - .filter(([id]) => typeof id === 'number') + // Filter out front-end only pick lists + .filter(({ id }) => typeof id === 'number') + .map(({ id, name, isSystem }) => [id, { name, isSystem }]) ) ); From 845658790fcd5854da5151420afb36728b6f261c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:34:57 -0500 Subject: [PATCH 41/58] feat(schema-config): navigate fields list with arrow keys --- .../lib/components/SchemaConfig/Fields.tsx | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index c27c7789c8d..b907364cbbe 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -84,6 +84,48 @@ export function SchemaConfigFields({ [items] ); + // Navigate the fields list with the arrow keys!!! + // Nice QoL and accessibility feature that matches the previous selectable list behavior pre-table. + const listRef = React.useRef(null); + // Follow the visual order instead of jumping between sections + const sortedItemIndexes = React.useMemo( + () => + [...fields, ...relationships].map( + (item) => itemIndexes.get(item.id) ?? -1 + ), + [fields, relationships, itemIndexes] + ); + const currentPosition = sortedItemIndexes.indexOf(index); + + React.useEffect(() => { + listRef.current + ?.querySelector('[aria-current="true"]') + ?.scrollIntoView({ block: 'nearest' }); + }, [index]); + + const handleSelect = (newIndex: number): void => { + handleChange(newIndex); + listRef.current?.focus(); + }; + + const handleKeyDown = (event: React.KeyboardEvent): void => { + const { key } = event; + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key)) return; + event.preventDefault(); + if (sortedItemIndexes.length === 0) return; + const last = sortedItemIndexes.length - 1; + const current = currentPosition === -1 ? 0 : currentPosition; + const next = + key === 'ArrowDown' + ? Math.min(current + 1, last) + : key === 'ArrowUp' + ? Math.max(current - 1, 0) + : key === 'Home' + ? 0 + : last; + if (next !== currentPosition) handleChange(sortedItemIndexes[next]); + }; + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
Date: Sun, 16 Aug 2026 20:50:22 -0500 Subject: [PATCH 42/58] fix(schema-config): shrink sort indicator so header labels don't wrap --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index b907364cbbe..416c5a416aa 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -84,7 +84,7 @@ export function SchemaConfigFields({ [items] ); - // Navigate the fields list with the arrow keys!!! + // Navigate the fields list with the arrow keys!!! // Nice QoL and accessibility feature that matches the previous selectable list behavior pre-table. const listRef = React.useRef(null); // Follow the visual order instead of jumping between sections @@ -318,7 +318,7 @@ function SortableTh({ > {label} {isActive ? ( - + {isDescending ? icons.chevronDown : icons.chevronUp} ) : undefined} From 677233e219aceec5fa8b743b59c4c890e5317338 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:59:02 -0500 Subject: [PATCH 43/58] fix(schema-config): widen visible column so header fits on one line --- .../frontend/js_src/lib/components/SchemaConfig/Fields.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 416c5a416aa..c3eb9c3f5b9 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -205,7 +205,7 @@ function SchemaConfigFieldsTable({
- { - event.stopPropagation(); - handleChange(itemIndex); - }} - > - - {localized(item.name)} - - + + {relatedTable !== undefined && ( + + )} + { + event.stopPropagation(); + handleChange(itemIndex); + }} + > + + {localized(item.name)} + + + {item.strings.name.text} From cb12ac70374c35e81cdb60fd08234c235d04583b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:42:45 -0500 Subject: [PATCH 47/58] feat(schema-config): jump to field by typing its name --- .../lib/components/SchemaConfig/Fields.tsx | 80 ++++++++++++++----- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 767782404a5..6d0862bdd00 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -88,15 +88,16 @@ export function SchemaConfigFields({ // Navigate the fields list with the arrow keys!!! // Nice QoL and accessibility feature that matches the previous selectable list behavior pre-table. const listRef = React.useRef(null); + const searchBuffer = React.useRef(''); + const searchTimeout = React.useRef(undefined); // Follow the visual order instead of jumping between sections - const sortedItemIndexes = React.useMemo( - () => - [...fields, ...relationships].map( - (item) => itemIndexes.get(item.id) ?? -1 - ), - [fields, relationships, itemIndexes] + const displayItems = React.useMemo( + () => [...fields, ...relationships], + [fields, relationships] + ); + const currentPosition = displayItems.findIndex( + (item) => itemIndexes.get(item.id) === index ); - const currentPosition = sortedItemIndexes.indexOf(index); React.useEffect(() => { listRef.current @@ -109,22 +110,59 @@ export function SchemaConfigFields({ listRef.current?.focus(); }; + const selectItem = (item: SchemaConfigItem | undefined): void => { + if (item === undefined) return; + const itemIndex = itemIndexes.get(item.id); + if (itemIndex !== undefined) handleChange(itemIndex); + }; + + const findMatch = ( + prefix: string, + fromTop: boolean + ): SchemaConfigItem | undefined => { + const start = fromTop || currentPosition === -1 ? 0 : currentPosition + 1; + let prefixMatch: SchemaConfigItem | undefined; + for (let offset = 0; offset < displayItems.length; offset += 1) { + const item = displayItems[(start + offset) % displayItems.length]; + const name = item.name.toLowerCase(); + if (name === prefix) return item; + if (prefixMatch === undefined && name.startsWith(prefix)) + prefixMatch = item; + } + return prefixMatch; + }; + const handleKeyDown = (event: React.KeyboardEvent): void => { const { key } = event; - if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key)) return; - event.preventDefault(); - if (sortedItemIndexes.length === 0) return; - const last = sortedItemIndexes.length - 1; - const current = currentPosition === -1 ? 0 : currentPosition; - const next = - key === 'ArrowDown' - ? Math.min(current + 1, last) - : key === 'ArrowUp' - ? Math.max(current - 1, 0) - : key === 'Home' - ? 0 - : last; - if (next !== currentPosition) handleChange(sortedItemIndexes[next]); + + if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key)) { + event.preventDefault(); + const last = displayItems.length - 1; + const current = currentPosition === -1 ? 0 : currentPosition; + const next = + key === 'ArrowDown' + ? Math.min(current + 1, last) + : key === 'ArrowUp' + ? Math.max(current - 1, 0) + : key === 'Home' + ? 0 + : last; + if (next !== currentPosition) selectItem(displayItems[next]); + return; + } + + // Type to jump to a field, matching the select list behavior pre-table + if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + event.preventDefault(); + const candidate = `${searchBuffer.current}${key}`.toLowerCase(); + const match = findMatch(candidate, searchBuffer.current !== ''); + searchBuffer.current = match === undefined ? '' : candidate; + window.clearTimeout(searchTimeout.current); + searchTimeout.current = window.setTimeout(() => { + searchBuffer.current = ''; + }, 1000); // Just resets after a second + selectItem(match); + } }; return ( From bcdba22fa75465a05efc8841f45f214d8cb95da5 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:50:34 -0500 Subject: [PATCH 48/58] fix(schema-config): reconcile saves against latest editor state --- .../lib/components/SchemaConfig/Store.tsx | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index ff43c81e189..29afd7bf7cd 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -210,8 +210,9 @@ export function SchemaConfigStoreProvider({ ); const saveAll = React.useCallback(async (): Promise => { + const snapshot = editorsRef.current; const outcomes = await Promise.all( - Object.entries(editorsRef.current) + Object.entries(snapshot) .filter(([, editor]) => isEditorModified(editor)) .flatMap(([tableName, editor]) => buildSaveRequests(editor).map((request) => ({ @@ -232,40 +233,52 @@ export function SchemaConfigStoreProvider({ ) ); - const newEditors: Record = { - ...editorsRef.current, - }; const failedItems = new Map>(); let hasFailure = false; for (const outcome of outcomes) { - if (outcome.status === 'fulfilled') - newEditors[outcome.tableName] = outcome.request.reconcile( - newEditors[outcome.tableName], - outcome.result - ); - else { - hasFailure = true; - if (typeof outcome.request.itemIndex === 'number') { - const failed = - failedItems.get(outcome.tableName) ?? new Set(); - failed.add(outcome.request.itemIndex); - failedItems.set(outcome.tableName, failed); - } + if (outcome.status !== 'rejected') continue; + hasFailure = true; + if (typeof outcome.request.itemIndex === 'number') { + const failed = failedItems.get(outcome.tableName) ?? new Set(); + failed.add(outcome.request.itemIndex); + failedItems.set(outcome.tableName, failed); } } - // Keep only items whose writes all failed, so a retry re-saves them - for (const [tableName, editor] of Object.entries(newEditors)) { - const failed = failedItems.get(tableName); - if (failed === undefined) continue; - newEditors[tableName] = { - ...editor, - changedItems: editor.changedItems.filter((index) => failed.has(index)), + // Reconcile against the latest state so edits made while requests were + // in flight aren't overwritten + setEditors((current) => { + const newEditors: Record = { + ...current, }; - } + for (const outcome of outcomes) { + if (outcome.status !== 'fulfilled') continue; + const editor = newEditors[outcome.tableName]; + if (editor !== undefined) + newEditors[outcome.tableName] = outcome.request.reconcile( + editor, + outcome.result + ); + } - setEditors(newEditors); + // Drop only the changed items that were saved successfully this round + for (const [tableName, editor] of Object.entries(newEditors)) { + const failed = failedItems.get(tableName); + const saved = new Set( + (snapshot[tableName]?.changedItems ?? []).filter( + (index) => failed?.has(index) !== true + ) + ); + newEditors[tableName] = { + ...editor, + changedItems: editor.changedItems.filter( + (index) => !saved.has(index) + ), + }; + } + return newEditors; + }); if (hasFailure) throw new Error('Some schema changes could not be saved'); }, []); @@ -375,10 +388,10 @@ type SaveRequest = { const applySavedId = ( resource: T, result: unknown -): T => { - const id = (result as { readonly id?: number } | undefined)?.id; - return typeof id === 'number' ? { ...resource, id } : resource; -}; +): T => + typeof (result as { readonly id?: number } | undefined)?.id === 'number' + ? { ...resource, ...(result as Partial) } + : resource; const applyItemStringId = ( editor: SchemaConfigEditorState, From 68090e548b27727aeb5a2ab7a0412ebc6cc12aa1 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:50:41 -0500 Subject: [PATCH 49/58] fix(schema-config): clarify captions and unsaved-change badge --- .../lib/components/SchemaConfig/Sidebar.tsx | 5 ++--- .../components/SchemaViewer/schemaToTsv.tsx | 2 +- .../js_src/lib/localization/schema.ts | 22 +++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx index 9b83838779b..32771d78b08 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Sidebar.tsx @@ -3,7 +3,6 @@ import { useParams } from 'react-router-dom'; import { commonText } from '../../localization/common'; import { schemaText } from '../../localization/schema'; -import { localized } from '../../utils/types'; import { H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input } from '../Atoms/Form'; @@ -48,9 +47,9 @@ export function SchemaConfigSidebar({ badge={(table): React.ReactNode => modifiedTables.includes(table.name) ? ( * diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx index c6038f0854f..0771ac3f346 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx @@ -12,7 +12,7 @@ export const schemaToTsv = (): string => [ [ schemaText.table(), - schemaText.caption(), + schemaText.tableCaption(), getField(tables.SpLocaleContainer, 'isSystem').label, getField(tables.SpLocaleContainer, 'isHidden').label, schemaText.tableId(), diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 3b79ff0354e..e1a379b6646 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -75,6 +75,17 @@ export const schemaText = createDictionary({ 'hr-hr': 'Promjene sheme nisu spremljene', nb: 'Skjemaendringer er ikke lagret', }, + unsavedChanges: { + 'en-us': 'Unsaved changes', + 'ru-ru': 'Несохранённые изменения', + 'es-es': 'Cambios sin guardar', + 'fr-fr': 'Modifications non enregistrées', + 'uk-ua': 'Незбережені зміни', + 'de-ch': 'Ungespeicherte Änderungen', + 'pt-br': 'Alterações não salvas', + 'hr-hr': 'Nespremljene promjene', + nb: 'Ulagrede endringer', + }, changeBaseTable: { 'en-us': 'Change Base Table', 'ru-ru': 'Изменить базовую таблицу', @@ -163,6 +174,17 @@ export const schemaText = createDictionary({ 'hr-hr': 'Naslov', nb: 'Tekst', }, + tableCaption: { + 'en-us': 'Table Caption', + 'ru-ru': 'Подпись таблицы', + 'es-es': 'Subtítulo de la tabla', + 'fr-fr': 'Légende de la table', + 'uk-ua': 'Підпис таблиці', + 'de-ch': 'Tabellenbeschriftung', + 'pt-br': 'Rubrica da tabela', + 'hr-hr': 'Naslov tablice', + nb: 'Tabelltekst', + }, description: { 'en-us': 'Description', 'ru-ru': 'Описание', From 5da1b2159e6ac8e8bb0d482a379d7e61365072ec Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:50:48 -0500 Subject: [PATCH 50/58] fix(schema-config): harden field list and formatter refresh --- .../lib/components/Formatters/formatters.ts | 17 ++++++++++++++--- .../lib/components/SchemaConfig/Fields.tsx | 10 ++++++++-- .../lib/components/SchemaConfig/index.tsx | 6 ++++-- .../lib/components/Toolbar/SchemaConfig.tsx | 9 ++++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts index ec84d9628e1..6ab299b8406 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts +++ b/specifyweb/frontend/js_src/lib/components/Formatters/formatters.ts @@ -57,9 +57,20 @@ const loadFormatters = ( export let fetchFormatters = loadFormatters(); // Re-fetch formatters and aggregators after the app resource is edited -export const refreshFormatters = (): typeof fetchFormatters => { - fetchFormatters = loadFormatters(true); - return fetchFormatters; +export const refreshFormatters = async (): Promise< + Awaited +> => { + const previous = fetchFormatters; + const refreshed = loadFormatters(true); + try { + const result = await refreshed; + fetchFormatters = refreshed; + return result; + } catch (error) { + // Keep the previous formatters on failure rather than a rejected promise + fetchFormatters = previous; + throw error; + } }; export const naiveFormatter = ( diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx index 6d0862bdd00..cc36cf32d13 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Fields.tsx @@ -76,7 +76,7 @@ export function SchemaConfigFields({ const [fields, relationships] = split( sortedItems, - (item) => table.getField(item.name)!.isRelationship + (item) => table.getField(item.name)?.isRelationship ?? false ); const itemIndexes = React.useMemo( @@ -152,7 +152,13 @@ export function SchemaConfigFields({ } // Type to jump to a field, matching the select list behavior pre-table - if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + if ( + key !== ' ' && + key.length === 1 && + !event.ctrlKey && + !event.metaKey && + !event.altKey + ) { event.preventDefault(); const candidate = `${searchBuffer.current}${key}`.toLowerCase(); const match = findMatch(candidate, searchBuffer.current !== ''); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index 5fe56d7be78..261cd5e50af 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -42,6 +42,8 @@ export function SchemaConfigMain(): JSX.Element { setIndex(0); } const item = items?.[index]; + const field = + typeof item === 'object' ? table.getField(item.name) : undefined; return (
@@ -60,9 +62,9 @@ export function SchemaConfigMain(): JSX.Element { table={table} onChange={setIndex} /> - {typeof item === 'object' ? ( + {typeof item === 'object' && field !== undefined ? ( diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx index cecc8c9bdea..464bb61e8ea 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/SchemaConfig.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { softFail } from '../Errors/Crash'; import { OverlayLocation } from '../Router/Router'; import { SafeOutlet } from '../Router/RouterUtils'; import type { @@ -33,9 +34,11 @@ export function SchemaConfig(): JSX.Element | null { const isInOverlay = overlayLocation !== undefined; if (wasInOverlay.current && !isInOverlay) { const sequence = ++refreshSequence.current; - void refreshSchemaData().then((data) => { - if (refreshSequence.current === sequence) setSchemaData(data); - }); + void refreshSchemaData() + .then((data) => { + if (refreshSequence.current === sequence) setSchemaData(data); + }) + .catch(softFail); } wasInOverlay.current = isInOverlay; }, [overlayLocation, setSchemaData]); From 47a49540c0bc0722e29a1822a6cfaed8b36e0c99 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:19:15 -0500 Subject: [PATCH 51/58] fix(schema-config): respond to code review comments --- .../lib/components/FieldFormatters/index.ts | 17 ++++- .../lib/components/SchemaConfig/Store.tsx | 66 ++++++++++++------- .../lib/components/SchemaConfig/index.tsx | 6 +- .../js_src/lib/components/WebLinks/index.tsx | 14 +++- 4 files changed, 73 insertions(+), 30 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index a72f6f0b798..fad4d494757 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -49,9 +49,20 @@ const loadUiFormatters = (refresh = false): Promise> => export let fetchContext = loadUiFormatters(); // Re-fetch UI formatters after the app resource is edited -export const refreshUiFormatters = (): typeof fetchContext => { - fetchContext = loadUiFormatters(true); - return fetchContext; +export const refreshUiFormatters = async (): Promise< + Awaited +> => { + const previous = fetchContext; + const refreshed = loadUiFormatters(true); + try { + const result = await refreshed; + fetchContext = refreshed; + return result; + } catch (error) { + // Keep the previous UI formatters on failure rather than a rejected promise + fetchContext = previous; + throw error; + } }; export const getUiFormatters = (): typeof uiFormatters => uiFormatters ?? error('Tried to access UI formatters before fetching them'); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index 29afd7bf7cd..8b2650c72ae 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -265,9 +265,12 @@ export function SchemaConfigStoreProvider({ // Drop only the changed items that were saved successfully this round for (const [tableName, editor] of Object.entries(newEditors)) { const failed = failedItems.get(tableName); + const snapshotEditor = snapshot[tableName]; const saved = new Set( - (snapshot[tableName]?.changedItems ?? []).filter( - (index) => failed?.has(index) !== true + (snapshotEditor?.changedItems ?? []).filter( + (index) => + failed?.has(index) !== true && + current[tableName]?.items[index] === snapshotEditor?.items[index] ) ); newEditors[tableName] = { @@ -397,13 +400,17 @@ const applyItemStringId = ( editor: SchemaConfigEditorState, index: number, key: 'name' | 'desc', + sent: NewSpLocaleItemString | SpLocaleItemString, result: unknown ): SchemaConfigEditorState => { const item = editor.items[index]; + const current = key === 'name' ? item.strings.name : item.strings.desc; + const saved = applySavedId(sent, result); + const string = current === sent ? saved : current; const strings = key === 'name' - ? { ...item.strings, name: applySavedId(item.strings.name, result) } - : { ...item.strings, desc: applySavedId(item.strings.desc, result) }; + ? { ...item.strings, name: string } + : { ...item.strings, desc: string }; return { ...editor, items: replaceItem(editor.items, index, { ...item, strings }), @@ -415,17 +422,26 @@ const buildSaveRequests = ( ): RA => { const { nameChanged, descChanged, containerChanged } = getEditorChanges(editor); + // Capture the values sent with each request so reconciliation can compare + // the current state against what was actually saved + const sentName = editor.name; + const sentDesc = editor.desc; + const sentContainer = editor.container; return [ ...(nameChanged ? [ { - promise: saveString(editor.name), + promise: saveString(sentName), reconcile: ( - editor: SchemaConfigEditorState, + current: SchemaConfigEditorState, result: unknown ): SchemaConfigEditorState => { - const saved = applySavedId(editor.name, result); - return { ...editor, name: saved, initialName: saved }; + const saved = applySavedId(sentName, result); + return { + ...current, + name: current.name === sentName ? saved : current.name, + initialName: saved, + }; }, }, ] @@ -433,13 +449,17 @@ const buildSaveRequests = ( ...(descChanged ? [ { - promise: saveString(editor.desc), + promise: saveString(sentDesc), reconcile: ( - editor: SchemaConfigEditorState, + current: SchemaConfigEditorState, result: unknown ): SchemaConfigEditorState => { - const saved = applySavedId(editor.desc, result); - return { ...editor, desc: saved, initialDesc: saved }; + const saved = applySavedId(sentDesc, result); + return { + ...current, + desc: current.desc === sentDesc ? saved : current.desc, + initialDesc: saved, + }; }, }, ] @@ -449,14 +469,14 @@ const buildSaveRequests = ( { promise: saveResource( 'SpLocaleContainer', - editor.container.id, - editor.container + sentContainer.id, + sentContainer ), reconcile: ( - editor: SchemaConfigEditorState + current: SchemaConfigEditorState ): SchemaConfigEditorState => ({ - ...editor, - initialContainer: editor.container, + ...current, + initialContainer: sentContainer, }), }, ] @@ -468,26 +488,26 @@ const buildSaveRequests = ( itemIndex: index, promise: saveResource('SpLocaleContainerItem', item.id, item), reconcile: ( - editor: SchemaConfigEditorState - ): SchemaConfigEditorState => editor, + current: SchemaConfigEditorState + ): SchemaConfigEditorState => current, }, { itemIndex: index, promise: saveString(strings.name), reconcile: ( - editor: SchemaConfigEditorState, + current: SchemaConfigEditorState, result: unknown ): SchemaConfigEditorState => - applyItemStringId(editor, index, 'name', result), + applyItemStringId(current, index, 'name', strings.name, result), }, { itemIndex: index, promise: saveString(strings.desc), reconcile: ( - editor: SchemaConfigEditorState, + current: SchemaConfigEditorState, result: unknown ): SchemaConfigEditorState => - applyItemStringId(editor, index, 'desc', result), + applyItemStringId(current, index, 'desc', strings.desc, result), }, ] : [] diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx index 261cd5e50af..a698a04a5cc 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/index.tsx @@ -94,10 +94,14 @@ export function SchemaConfigMain(): JSX.Element { }) } /> - ) : ( + ) : items === undefined ? ( {commonText.loading()} + ) : ( + + {commonText.noneAvailable()} + )}
); diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx index 93acc6e4d63..e220f61811d 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx @@ -32,9 +32,17 @@ const loadWebLinks = (refresh = false): Promise> => export let webLinks = loadWebLinks(); // Re-fetch web links after the app resource is edited -export const refreshWebLinks = (): typeof webLinks => { - webLinks = loadWebLinks(true); - return webLinks; +export const refreshWebLinks = async (): Promise> => { + const previous = webLinks; + const refreshed = loadWebLinks(true); + try { + const result = await refreshed; + webLinks = refreshed; + return result; + } catch (error) { + webLinks = previous; + throw error; + } }; export function WebLinkField({ From b4e1116e4f217613161e5090c8232760451c5447 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:46:16 -0500 Subject: [PATCH 52/58] fix(schema-config): prevent stale updates --- .../lib/components/FieldFormatters/index.ts | 17 +++++++++++------ .../js_src/lib/components/WebLinks/index.tsx | 7 +++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts index fad4d494757..a4d9bdc0583 100644 --- a/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts +++ b/specifyweb/frontend/js_src/lib/components/FieldFormatters/index.ts @@ -23,15 +23,17 @@ import type { FieldFormatter, FieldFormatterPart } from './spec'; import { fieldFormattersSpec, trimRegexString } from './spec'; let uiFormatters: IR; +let uiFormattersGeneration = 0; -const loadUiFormatters = (refresh = false): Promise> => - Promise.all([ +const loadUiFormatters = (refresh = false): Promise> => { + const generation = ++uiFormattersGeneration; + return Promise.all([ load(getAppResourceUrl('UIFormatters'), 'text/xml', refresh), import('../DataModel/tables').then( async ({ fetchContext }) => fetchContext ), ]).then(([formatters]) => { - uiFormatters = Object.fromEntries( + const result = Object.fromEntries( filterArray( xmlToSpec(formatters, fieldFormattersSpec()).fieldFormatters.map( (formatter, index) => { @@ -43,8 +45,10 @@ const loadUiFormatters = (refresh = false): Promise> => ) ) ); - return uiFormatters; + if (generation === uiFormattersGeneration) uiFormatters = result; + return result; }); +}; export let fetchContext = loadUiFormatters(); @@ -54,13 +58,14 @@ export const refreshUiFormatters = async (): Promise< > => { const previous = fetchContext; const refreshed = loadUiFormatters(true); + const generation = uiFormattersGeneration; try { const result = await refreshed; - fetchContext = refreshed; + if (generation === uiFormattersGeneration) fetchContext = refreshed; return result; } catch (error) { // Keep the previous UI formatters on failure rather than a rejected promise - fetchContext = previous; + if (generation === uiFormattersGeneration) fetchContext = previous; throw error; } }; diff --git a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx index e220f61811d..1ace9fbe128 100644 --- a/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/WebLinks/index.tsx @@ -31,16 +31,19 @@ const loadWebLinks = (refresh = false): Promise> => export let webLinks = loadWebLinks(); +let webLinksGeneration = 0; + // Re-fetch web links after the app resource is edited export const refreshWebLinks = async (): Promise> => { const previous = webLinks; + const generation = ++webLinksGeneration; const refreshed = loadWebLinks(true); try { const result = await refreshed; - webLinks = refreshed; + if (generation === webLinksGeneration) webLinks = refreshed; return result; } catch (error) { - webLinks = previous; + if (generation === webLinksGeneration) webLinks = previous; throw error; } }; From bb4e1717dd2cea34102cbd7601e8f58fdbe274d0 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:06:37 -0500 Subject: [PATCH 53/58] fix(schema-config): remove unused sortByHiddenFields string --- specifyweb/frontend/js_src/lib/localization/schema.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index e1a379b6646..b452833309f 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -658,17 +658,6 @@ export const schemaText = createDictionary({ 'hr-hr': 'Navedite 7 podatkovnih modela', nb: 'Spesifiser 7 datamodell', }, - sortByHiddenFields: { - 'en-us': 'Sort by hidden fields', - 'de-ch': 'Nach ausgeblendeten Feldern sortieren', - 'es-es': 'Ordenar por campos ocultos', - 'fr-fr': 'Trier par champs cachés', - 'ru-ru': 'Сортировать по скрытым полям', - 'uk-ua': 'Сортувати за прихованими полями', - 'pt-br': 'Ordenar por campos ocultos', - 'hr-hr': 'Sortiraj po skrivenim poljima', - nb: 'Sorter etter skjulte felt', - }, hidden: { 'en-us': 'hidden', 'de-ch': 'versteckt', From 307b3dcb944dbec7094885f04bd292482b44bc2c Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:07:30 -0500 Subject: [PATCH 54/58] refactor(schema-config): extract store types and save helpers --- .../lib/components/SchemaConfig/Store.tsx | 225 +----------------- .../__tests__/Store.saveAll.test.tsx | 2 +- .../SchemaConfig/__tests__/Store.test.ts | 4 +- .../lib/components/SchemaConfig/helpers.ts | 176 ++++++++++++++ .../lib/components/SchemaConfig/types.ts | 58 +++++ 5 files changed, 239 insertions(+), 226 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/SchemaConfig/types.ts diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx index 8b2650c72ae..4200034d0a1 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Store.tsx @@ -5,7 +5,6 @@ import type { IR, RA } from '../../utils/types'; import { defined } from '../../utils/types'; import { replaceItem } from '../../utils/utils'; import type { SerializedResource } from '../DataModel/helperTypes'; -import { createResource, saveResource } from '../DataModel/resource'; import type { SpLocaleContainer, SpLocaleContainerItem, @@ -14,91 +13,16 @@ import { softFail } from '../Errors/Crash'; import { formatUrl } from '../Router/queryString'; import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; import { fetchContainerItems, fetchContainerString } from './data'; +import { buildSaveRequests, isEditorModified, updateEditor } from './helpers'; import type { NewSpLocaleItemString, SpLocaleItemString } from './index'; import type { SchemaData } from './schemaData'; - -export type SchemaConfigEditorState = { - readonly container: SerializedResource; - readonly name: NewSpLocaleItemString | SpLocaleItemString; - readonly desc: NewSpLocaleItemString | SpLocaleItemString; - readonly items: RA< - SerializedResource & WithFetchedStrings - >; - readonly changedItems: RA; - readonly initialContainer: SerializedResource; - readonly initialName: NewSpLocaleItemString | SpLocaleItemString; - readonly initialDesc: NewSpLocaleItemString | SpLocaleItemString; -}; - -export type SchemaConfigStore = { - readonly schemaData: SchemaData; - readonly isReadOnly: boolean; - readonly modifiedTables: RA; - readonly anyModified: boolean; - readonly saveAll: () => Promise; - readonly loadTable: (tableName: string) => Promise; - readonly editors: IR; - readonly setContainer: ( - tableName: string, - container: SerializedResource - ) => void; - readonly setName: ( - tableName: string, - name: NewSpLocaleItemString | SpLocaleItemString - ) => void; - readonly setDesc: ( - tableName: string, - desc: NewSpLocaleItemString | SpLocaleItemString - ) => void; - readonly setItem: ( - tableName: string, - index: number, - item: SerializedResource & WithFetchedStrings - ) => void; -}; +import type { SchemaConfigEditorState, SchemaConfigStore } from './types'; const SchemaConfigContext = React.createContext( undefined ); SchemaConfigContext.displayName = 'SchemaConfigContext'; -const getEditorChanges = ( - editor: SchemaConfigEditorState -): { - readonly nameChanged: boolean; - readonly descChanged: boolean; - readonly containerChanged: boolean; -} => ({ - nameChanged: - JSON.stringify(editor.initialName) !== JSON.stringify(editor.name), - descChanged: - JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc), - containerChanged: - JSON.stringify(editor.initialContainer) !== - JSON.stringify(editor.container), -}); - -export const isEditorModified = (editor: SchemaConfigEditorState): boolean => { - const changes = getEditorChanges(editor); - return ( - changes.nameChanged || - changes.descChanged || - changes.containerChanged || - editor.changedItems.length > 0 - ); -}; - -const updateEditor = ( - editors: IR, - tableName: string, - update: (editor: SchemaConfigEditorState) => SchemaConfigEditorState -): IR => { - const editor = editors[tableName]; - return editor === undefined - ? editors - : { ...editors, [tableName]: update(editor) }; -}; - export function SchemaConfigStoreProvider({ schemaData, rawLanguage, @@ -379,151 +303,6 @@ export function useSchemaConfigTable(tableName: string): { }; } -type SaveRequest = { - readonly promise: Promise; - readonly itemIndex?: number; - readonly reconcile: ( - editor: SchemaConfigEditorState, - result: unknown - ) => SchemaConfigEditorState; -}; - -const applySavedId = ( - resource: T, - result: unknown -): T => - typeof (result as { readonly id?: number } | undefined)?.id === 'number' - ? { ...resource, ...(result as Partial) } - : resource; - -const applyItemStringId = ( - editor: SchemaConfigEditorState, - index: number, - key: 'name' | 'desc', - sent: NewSpLocaleItemString | SpLocaleItemString, - result: unknown -): SchemaConfigEditorState => { - const item = editor.items[index]; - const current = key === 'name' ? item.strings.name : item.strings.desc; - const saved = applySavedId(sent, result); - const string = current === sent ? saved : current; - const strings = - key === 'name' - ? { ...item.strings, name: string } - : { ...item.strings, desc: string }; - return { - ...editor, - items: replaceItem(editor.items, index, { ...item, strings }), - }; -}; - -const buildSaveRequests = ( - editor: SchemaConfigEditorState -): RA => { - const { nameChanged, descChanged, containerChanged } = - getEditorChanges(editor); - // Capture the values sent with each request so reconciliation can compare - // the current state against what was actually saved - const sentName = editor.name; - const sentDesc = editor.desc; - const sentContainer = editor.container; - return [ - ...(nameChanged - ? [ - { - promise: saveString(sentName), - reconcile: ( - current: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => { - const saved = applySavedId(sentName, result); - return { - ...current, - name: current.name === sentName ? saved : current.name, - initialName: saved, - }; - }, - }, - ] - : []), - ...(descChanged - ? [ - { - promise: saveString(sentDesc), - reconcile: ( - current: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => { - const saved = applySavedId(sentDesc, result); - return { - ...current, - desc: current.desc === sentDesc ? saved : current.desc, - initialDesc: saved, - }; - }, - }, - ] - : []), - ...(containerChanged - ? [ - { - promise: saveResource( - 'SpLocaleContainer', - sentContainer.id, - sentContainer - ), - reconcile: ( - current: SchemaConfigEditorState - ): SchemaConfigEditorState => ({ - ...current, - initialContainer: sentContainer, - }), - }, - ] - : []), - ...editor.items.flatMap(({ strings, ...item }, index) => - editor.changedItems.includes(index) - ? [ - { - itemIndex: index, - promise: saveResource('SpLocaleContainerItem', item.id, item), - reconcile: ( - current: SchemaConfigEditorState - ): SchemaConfigEditorState => current, - }, - { - itemIndex: index, - promise: saveString(strings.name), - reconcile: ( - current: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => - applyItemStringId(current, index, 'name', strings.name, result), - }, - { - itemIndex: index, - promise: saveString(strings.desc), - reconcile: ( - current: SchemaConfigEditorState, - result: unknown - ): SchemaConfigEditorState => - applyItemStringId(current, index, 'desc', strings.desc, result), - }, - ] - : [] - ), - ]; -}; - -const saveString = async ( - resource: NewSpLocaleItemString | SpLocaleItemString -): Promise => - 'resource_uri' in resource && - typeof resource.id === 'number' && - resource.id >= 0 - ? saveResource('SpLocaleItemStr', resource.id, resource) - : createResource('SpLocaleItemStr', resource); - export const handleSchemaSaved = async ( rawLanguage: string, tableName: string diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx index 238d3d84c39..ea44be598c5 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.saveAll.test.tsx @@ -9,7 +9,7 @@ import { SchemaConfigStoreProvider, useSchemaConfig, } from '../Store'; -import type { SchemaConfigEditorState } from '../Store'; +import type { SchemaConfigEditorState } from '../types'; jest.mock('../../DataModel/resource', () => ({ saveResource: jest.fn(async () => ({})), diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts index 2c05f6f1ab4..daee2ce4fe9 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/__tests__/Store.test.ts @@ -1,5 +1,5 @@ -import type { SchemaConfigEditorState } from '../Store'; -import { isEditorModified } from '../Store'; +import type { SchemaConfigEditorState } from '../types'; +import { isEditorModified } from '../helpers'; const base = { container: { id: 1 }, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 56d2d425e00..8604fcaf488 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -1,8 +1,10 @@ import { schemaText } from '../../localization/schema'; import type { IR, RA } from '../../utils/types'; import { localized } from '../../utils/types'; +import { replaceItem } from '../../utils/utils'; import { addMissingFields } from '../DataModel/addMissingFields'; import type { SerializedResource } from '../DataModel/helperTypes'; +import { createResource, saveResource } from '../DataModel/resource'; import type { SpLocaleContainerItem } from '../DataModel/types'; import type { Aggregator, Formatter } from '../Formatters/spec'; import type { @@ -11,6 +13,7 @@ import type { SpLocaleItemString, } from './index'; import type { SchemaFormatter } from './schemaData'; +import type { SaveRequest, SchemaConfigEditorState } from './types'; let newStringId = 1; const defaultLanguage = 'en'; @@ -101,3 +104,176 @@ export function javaTypeToHuman( else if (type.startsWith('java')) return type.split('.').at(-1)!; else return type; } + +const getEditorChanges = ( + editor: SchemaConfigEditorState +): { + readonly nameChanged: boolean; + readonly descChanged: boolean; + readonly containerChanged: boolean; +} => ({ + nameChanged: + JSON.stringify(editor.initialName) !== JSON.stringify(editor.name), + descChanged: + JSON.stringify(editor.initialDesc) !== JSON.stringify(editor.desc), + containerChanged: + JSON.stringify(editor.initialContainer) !== + JSON.stringify(editor.container), +}); + +export const isEditorModified = (editor: SchemaConfigEditorState): boolean => { + const changes = getEditorChanges(editor); + return ( + changes.nameChanged || + changes.descChanged || + changes.containerChanged || + editor.changedItems.length > 0 + ); +}; + +export const updateEditor = ( + editors: IR, + tableName: string, + update: (editor: SchemaConfigEditorState) => SchemaConfigEditorState +): IR => { + const editor = editors[tableName]; + return editor === undefined + ? editors + : { ...editors, [tableName]: update(editor) }; +}; + +const applySavedId = ( + resource: T, + result: unknown +): T => + typeof (result as { readonly id?: number } | undefined)?.id === 'number' + ? { ...resource, ...(result as Partial) } + : resource; + +const applyItemStringId = ( + editor: SchemaConfigEditorState, + index: number, + key: 'name' | 'desc', + sent: NewSpLocaleItemString | SpLocaleItemString, + result: unknown +): SchemaConfigEditorState => { + const item = editor.items[index]; + const current = key === 'name' ? item.strings.name : item.strings.desc; + const saved = applySavedId(sent, result); + const string = current === sent ? saved : current; + const strings = + key === 'name' + ? { ...item.strings, name: string } + : { ...item.strings, desc: string }; + return { + ...editor, + items: replaceItem(editor.items, index, { ...item, strings }), + }; +}; + +export const buildSaveRequests = ( + editor: SchemaConfigEditorState +): RA => { + const { nameChanged, descChanged, containerChanged } = + getEditorChanges(editor); + // Capture the values sent with each request so reconciliation can compare + // the current state against what was actually saved + const sentName = editor.name; + const sentDesc = editor.desc; + const sentContainer = editor.container; + return [ + ...(nameChanged + ? [ + { + promise: saveString(sentName), + reconcile: ( + current: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => { + const saved = applySavedId(sentName, result); + return { + ...current, + name: current.name === sentName ? saved : current.name, + initialName: saved, + }; + }, + }, + ] + : []), + ...(descChanged + ? [ + { + promise: saveString(sentDesc), + reconcile: ( + current: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => { + const saved = applySavedId(sentDesc, result); + return { + ...current, + desc: current.desc === sentDesc ? saved : current.desc, + initialDesc: saved, + }; + }, + }, + ] + : []), + ...(containerChanged + ? [ + { + promise: saveResource( + 'SpLocaleContainer', + sentContainer.id, + sentContainer + ), + reconcile: ( + current: SchemaConfigEditorState + ): SchemaConfigEditorState => ({ + ...current, + initialContainer: sentContainer, + }), + }, + ] + : []), + ...editor.items.flatMap(({ strings, ...item }, index) => + editor.changedItems.includes(index) + ? [ + { + itemIndex: index, + promise: saveResource('SpLocaleContainerItem', item.id, item), + reconcile: ( + current: SchemaConfigEditorState + ): SchemaConfigEditorState => current, + }, + { + itemIndex: index, + promise: saveString(strings.name), + reconcile: ( + current: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => + applyItemStringId(current, index, 'name', strings.name, result), + }, + { + itemIndex: index, + promise: saveString(strings.desc), + reconcile: ( + current: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => + applyItemStringId(current, index, 'desc', strings.desc, result), + }, + ] + : [] + ), + ]; +}; + +const saveString = async ( + resource: NewSpLocaleItemString | SpLocaleItemString +): Promise => + 'resource_uri' in resource && + typeof resource.id === 'number' && + resource.id >= 0 + ? saveResource('SpLocaleItemStr', resource.id, resource) + : createResource('SpLocaleItemStr', resource); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/types.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/types.ts new file mode 100644 index 00000000000..784758c5fcb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/types.ts @@ -0,0 +1,58 @@ +import type { IR, RA } from '../../utils/types'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { + SpLocaleContainer, + SpLocaleContainerItem, +} from '../DataModel/types'; +import type { WithFetchedStrings } from '../Toolbar/SchemaConfig'; +import type { NewSpLocaleItemString, SpLocaleItemString } from './index'; +import type { SchemaData } from './schemaData'; + +export type SchemaConfigEditorState = { + readonly container: SerializedResource; + readonly name: NewSpLocaleItemString | SpLocaleItemString; + readonly desc: NewSpLocaleItemString | SpLocaleItemString; + readonly items: RA< + SerializedResource & WithFetchedStrings + >; + readonly changedItems: RA; + readonly initialContainer: SerializedResource; + readonly initialName: NewSpLocaleItemString | SpLocaleItemString; + readonly initialDesc: NewSpLocaleItemString | SpLocaleItemString; +}; + +export type SchemaConfigStore = { + readonly schemaData: SchemaData; + readonly isReadOnly: boolean; + readonly modifiedTables: RA; + readonly anyModified: boolean; + readonly saveAll: () => Promise; + readonly loadTable: (tableName: string) => Promise; + readonly editors: IR; + readonly setContainer: ( + tableName: string, + container: SerializedResource + ) => void; + readonly setName: ( + tableName: string, + name: NewSpLocaleItemString | SpLocaleItemString + ) => void; + readonly setDesc: ( + tableName: string, + desc: NewSpLocaleItemString | SpLocaleItemString + ) => void; + readonly setItem: ( + tableName: string, + index: number, + item: SerializedResource & WithFetchedStrings + ) => void; +}; + +export type SaveRequest = { + readonly promise: Promise; + readonly itemIndex?: number; + readonly reconcile: ( + editor: SchemaConfigEditorState, + result: unknown + ) => SchemaConfigEditorState; +}; From 2a36472c4391cf04ba1e996b5a8d5cc90006658f Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:31:34 -0500 Subject: [PATCH 55/58] refactor(schema-config): rename fieldLength to characterLimit --- .../lib/components/SchemaConfig/Field.tsx | 2 +- .../lib/components/SchemaViewer/Fields.tsx | 2 +- .../components/SchemaViewer/schemaToTsv.tsx | 2 +- .../js_src/lib/localization/schema.ts | 20 +++++++++---------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx index dde71819d9f..41433d57fde 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Field.tsx @@ -65,7 +65,7 @@ export function SchemaConfigField({ /> - {schemaText.fieldLength()} + {schemaText.characterLimit()} diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx index 8fce7e08b9c..6525d9236d2 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/Fields.tsx @@ -60,7 +60,7 @@ const fieldColumns = f.store( isReadOnly: schemaText.readOnly(), isRequired: getField(tables.SpLocaleContainerItem, 'isRequired').label, type: getField(tables.SpLocaleContainerItem, 'type').label, - length: schemaText.fieldLength(), + length: schemaText.characterLimit(), databaseColumn: schemaText.databaseColumn(), }) as const ); diff --git a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx index 0771ac3f346..a171614f14f 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaViewer/schemaToTsv.tsx @@ -24,7 +24,7 @@ export const schemaToTsv = (): string => getField(tables.SpLocaleContainerItem, 'isRequired').label, formsText.relationship(), getField(tables.SpLocaleContainerItem, 'type').label, - schemaText.fieldLength(), + schemaText.characterLimit(), schemaText.databaseColumn(), schemaText.relatedTable(), schemaText.otherSideName(), diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index b452833309f..feed6f53b41 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -284,16 +284,16 @@ export const schemaText = createDictionary({ 'hr-hr': 'mnogo-na-mnogo', nb: 'mange-til-mange', }, - fieldLength: { - 'en-us': 'Length', - 'es-es': 'Longitud', - 'fr-fr': 'Longueur', - 'uk-ua': 'Довжина', - 'de-ch': 'Länge', - 'ru-ru': 'Длина', - 'pt-br': 'Comprimento', - 'hr-hr': 'Duljina', - nb: 'Lengde', + characterLimit: { + 'en-us': 'Character Limit', + 'ru-ru': 'Ограничение символов', + 'es-es': 'Límite de caracteres', + 'fr-fr': 'Limite de caractères', + 'uk-ua': 'Обмеження символів', + 'de-ch': 'Zeichenlimit', + 'pt-br': 'Limite de caracteres', + 'hr-hr': 'Ograničenje znakova', + nb: 'Tegngrense', }, readOnly: { 'en-us': 'Read-only', From 67e7170759a1f725dd1c3edf921ee9b66442e495 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:47:23 -0500 Subject: [PATCH 56/58] feat(schema-config): add human friendly labels for field types --- .../lib/components/SchemaConfig/helpers.ts | 18 ++++++ .../js_src/lib/localization/schema.ts | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 8604fcaf488..770fb9edbdd 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -101,6 +101,24 @@ export function javaTypeToHuman( if (type === null) return ''; else if (type in localizedRelationshipTypes) return `${localizedRelationshipTypes[type]} (${relatedTableName})`; + else if (type === 'java.lang.String' || type === 'text') + return schemaText.text(); + else if ( + type === 'java.lang.Byte' || + type === 'java.lang.Short' || + type === 'java.lang.Integer' || + type === 'java.lang.Long' + ) + return schemaText.integer(); + else if (type === 'java.lang.Float' || type === 'java.lang.Double') + return `${schemaText.number()} (${type.split('.').at(-1)!})`; + else if (type === 'java.math.BigDecimal') return schemaText.decimal(); + else if ( + type === 'java.sql.Timestamp' || + type === 'java.util.Calendar' || + type === 'java.util.Date' + ) + return schemaText.date(); else if (type.startsWith('java')) return type.split('.').at(-1)!; else return type; } diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index feed6f53b41..348155a4c13 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -295,6 +295,61 @@ export const schemaText = createDictionary({ 'hr-hr': 'Ograničenje znakova', nb: 'Tegngrense', }, + text: { + 'en-us': 'Text', + 'ru-ru': 'Текст', + 'es-es': 'Texto', + 'fr-fr': 'Texte', + 'uk-ua': 'Текст', + 'de-ch': 'Text', + 'pt-br': 'Texto', + 'hr-hr': 'Tekst', + nb: 'Tekst', + }, + integer: { + 'en-us': 'Integer', + 'ru-ru': 'Целое число', + 'es-es': 'Entero', + 'fr-fr': 'Entier', + 'uk-ua': 'Ціле число', + 'de-ch': 'Ganzzahl', + 'pt-br': 'Inteiro', + 'hr-hr': 'Cijeli broj', + nb: 'Heltall', + }, + number: { + 'en-us': 'Number', + 'ru-ru': 'Число', + 'es-es': 'Número', + 'fr-fr': 'Nombre', + 'uk-ua': 'Число', + 'de-ch': 'Zahl', + 'pt-br': 'Número', + 'hr-hr': 'Broj', + nb: 'Tall', + }, + date: { + 'en-us': 'Date', + 'ru-ru': 'Дата', + 'es-es': 'Fecha', + 'fr-fr': 'Date', + 'uk-ua': 'Дата', + 'de-ch': 'Datum', + 'pt-br': 'Data', + 'hr-hr': 'Datum', + nb: 'Dato', + }, + decimal: { + 'en-us': 'Decimal', + 'ru-ru': 'Десятичное число', + 'es-es': 'Decimal', + 'fr-fr': 'Décimal', + 'uk-ua': 'Десяткове число', + 'de-ch': 'Dezimalzahl', + 'pt-br': 'Decimal', + 'hr-hr': 'Decimalni broj', + nb: 'Desimaltall', + }, readOnly: { 'en-us': 'Read-only', 'ru-ru': 'Только для чтения', From 3afd103c53fc267996a6decbdcbda62ab7bef458 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:16:24 -0500 Subject: [PATCH 57/58] fix(schema-config): show timestamp --- .../frontend/js_src/lib/components/SchemaConfig/helpers.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 770fb9edbdd..2c5c24b6155 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -113,11 +113,7 @@ export function javaTypeToHuman( else if (type === 'java.lang.Float' || type === 'java.lang.Double') return `${schemaText.number()} (${type.split('.').at(-1)!})`; else if (type === 'java.math.BigDecimal') return schemaText.decimal(); - else if ( - type === 'java.sql.Timestamp' || - type === 'java.util.Calendar' || - type === 'java.util.Date' - ) + else if (type === 'java.util.Calendar' || type === 'java.util.Date') return schemaText.date(); else if (type.startsWith('java')) return type.split('.').at(-1)!; else return type; From 212e235ca78f2b5d52ac670eb452ea20a34b75d7 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:51:56 -0500 Subject: [PATCH 58/58] fix(schema-config): make edits during save reconcile --- .../lib/components/DataModel/resource.ts | 2 +- .../lib/components/SchemaConfig/helpers.ts | 44 +++++++++++-- .../js_src/lib/localization/schema.ts | 64 ------------------- 3 files changed, 41 insertions(+), 69 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts index b3d26f608c5..3bf3ea958c9 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/resource.ts @@ -99,7 +99,7 @@ export const saveResource = async ( tableName: TABLE_NAME, id: number, data: DeepPartial>, - handleConflict: (() => void) | void + handleConflict?: () => void ): Promise> => ajax>( `/api/specify/${tableName.toLowerCase()}/${id}/`, diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts index 2c5c24b6155..ab5aa7b5ce5 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/helpers.ts @@ -164,6 +164,20 @@ const applySavedId = ( ? { ...resource, ...(result as Partial) } : resource; +const mergeSavedResource = ( + sent: T, + current: T, + result: unknown +): T => { + const saved = { ...sent, ...(result as Partial) }; + const currentChanges = Object.fromEntries( + Object.entries(current).filter( + ([key, value]) => value !== sent[key as keyof T] + ) + ) as Partial; + return { ...saved, ...currentChanges }; +}; + const applyItemStringId = ( editor: SchemaConfigEditorState, index: number, @@ -241,10 +255,20 @@ export const buildSaveRequests = ( sentContainer ), reconcile: ( - current: SchemaConfigEditorState + current: SchemaConfigEditorState, + result: unknown ): SchemaConfigEditorState => ({ ...current, - initialContainer: sentContainer, + container: mergeSavedResource( + sentContainer, + current.container, + result + ), + initialContainer: mergeSavedResource( + sentContainer, + current.container, + result + ), }), }, ] @@ -256,8 +280,20 @@ export const buildSaveRequests = ( itemIndex: index, promise: saveResource('SpLocaleContainerItem', item.id, item), reconcile: ( - current: SchemaConfigEditorState - ): SchemaConfigEditorState => current, + current: SchemaConfigEditorState, + result: unknown + ): SchemaConfigEditorState => ({ + ...current, + items: replaceItem( + current.items, + index, + mergeSavedResource( + { ...item, strings }, + current.items[index], + result + ) + ), + }), }, { itemIndex: index, diff --git a/specifyweb/frontend/js_src/lib/localization/schema.ts b/specifyweb/frontend/js_src/lib/localization/schema.ts index 348155a4c13..f7c820740ed 100644 --- a/specifyweb/frontend/js_src/lib/localization/schema.ts +++ b/specifyweb/frontend/js_src/lib/localization/schema.ts @@ -77,14 +77,6 @@ export const schemaText = createDictionary({ }, unsavedChanges: { 'en-us': 'Unsaved changes', - 'ru-ru': 'Несохранённые изменения', - 'es-es': 'Cambios sin guardar', - 'fr-fr': 'Modifications non enregistrées', - 'uk-ua': 'Незбережені зміни', - 'de-ch': 'Ungespeicherte Änderungen', - 'pt-br': 'Alterações não salvas', - 'hr-hr': 'Nespremljene promjene', - nb: 'Ulagrede endringer', }, changeBaseTable: { 'en-us': 'Change Base Table', @@ -176,14 +168,6 @@ export const schemaText = createDictionary({ }, tableCaption: { 'en-us': 'Table Caption', - 'ru-ru': 'Подпись таблицы', - 'es-es': 'Subtítulo de la tabla', - 'fr-fr': 'Légende de la table', - 'uk-ua': 'Підпис таблиці', - 'de-ch': 'Tabellenbeschriftung', - 'pt-br': 'Rubrica da tabela', - 'hr-hr': 'Naslov tablice', - nb: 'Tabelltekst', }, description: { 'en-us': 'Description', @@ -286,69 +270,21 @@ export const schemaText = createDictionary({ }, characterLimit: { 'en-us': 'Character Limit', - 'ru-ru': 'Ограничение символов', - 'es-es': 'Límite de caracteres', - 'fr-fr': 'Limite de caractères', - 'uk-ua': 'Обмеження символів', - 'de-ch': 'Zeichenlimit', - 'pt-br': 'Limite de caracteres', - 'hr-hr': 'Ograničenje znakova', - nb: 'Tegngrense', }, text: { 'en-us': 'Text', - 'ru-ru': 'Текст', - 'es-es': 'Texto', - 'fr-fr': 'Texte', - 'uk-ua': 'Текст', - 'de-ch': 'Text', - 'pt-br': 'Texto', - 'hr-hr': 'Tekst', - nb: 'Tekst', }, integer: { 'en-us': 'Integer', - 'ru-ru': 'Целое число', - 'es-es': 'Entero', - 'fr-fr': 'Entier', - 'uk-ua': 'Ціле число', - 'de-ch': 'Ganzzahl', - 'pt-br': 'Inteiro', - 'hr-hr': 'Cijeli broj', - nb: 'Heltall', }, number: { 'en-us': 'Number', - 'ru-ru': 'Число', - 'es-es': 'Número', - 'fr-fr': 'Nombre', - 'uk-ua': 'Число', - 'de-ch': 'Zahl', - 'pt-br': 'Número', - 'hr-hr': 'Broj', - nb: 'Tall', }, date: { 'en-us': 'Date', - 'ru-ru': 'Дата', - 'es-es': 'Fecha', - 'fr-fr': 'Date', - 'uk-ua': 'Дата', - 'de-ch': 'Datum', - 'pt-br': 'Data', - 'hr-hr': 'Datum', - nb: 'Dato', }, decimal: { 'en-us': 'Decimal', - 'ru-ru': 'Десятичное число', - 'es-es': 'Decimal', - 'fr-fr': 'Décimal', - 'uk-ua': 'Десяткове число', - 'de-ch': 'Dezimalzahl', - 'pt-br': 'Decimal', - 'hr-hr': 'Decimalni broj', - nb: 'Desimaltall', }, readOnly: { 'en-us': 'Read-only',