From 1720e88330a8ae5c06b1cf3a765d48fbbb81e3a2 Mon Sep 17 00:00:00 2001 From: melton-jason Date: Tue, 21 Apr 2026 09:28:44 -0500 Subject: [PATCH 01/23] feat: implement basic Data Views See #6565 --- .../lib/components/DataEntryTables/Edit.tsx | 2 +- .../components/DataViews/DataViewTables.tsx | 136 ++++++++++++++++ .../js_src/lib/components/DataViews/index.tsx | 123 ++++++++++++++ .../components/Header/menuItemDefinitions.ts | 7 +- .../Preferences/UserDefinitions.tsx | 19 +++ .../lib/components/QueryBuilder/Results.tsx | 16 +- .../lib/components/Router/OverlayRoutes.tsx | 11 +- .../js_src/lib/components/Router/Routes.tsx | 12 ++ .../lib/components/SpecifyNetwork/Map.tsx | 9 +- .../components/Toolbar/QueryTablesEdit.tsx | 8 +- .../usePaginatedCollection.tsx} | 154 +++++++++--------- .../lib/hooks/useSerializedCollection.tsx | 5 +- .../js_src/lib/localization/dataViews.ts | 23 +++ 13 files changed, 436 insertions(+), 89 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/index.tsx rename specifyweb/frontend/js_src/lib/{components/QueryBuilder/hooks.tsx => hooks/usePaginatedCollection.tsx} (58%) create mode 100644 specifyweb/frontend/js_src/lib/localization/dataViews.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx index b12154b8fa7..c9265b1ce02 100644 --- a/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx @@ -129,7 +129,7 @@ function CustomEditTables({ ? formsText.configureDataEntryTables() : formsText.configureInteractionTables() } - isNoRestrictionMode={false} + showHiddenTables={false} tables={tables} onChange={handleChange} onClose={handleClose} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx new file mode 100644 index 00000000000..72c230909b9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; +import { GetSet, RA } from '../../utils/types'; +import { Ul } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { DataEntry } from '../Atoms/DataEntry'; +import { icons } from '../Atoms/Icons'; +import { Link } from '../Atoms/Link'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { getTableById, strictGetTable } from '../DataModel/tables'; +import { Tables } from '../DataModel/types'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { TableIcon } from '../Molecules/TableIcon'; +import { userPreferences } from '../Preferences/userPreferences'; +import { OverlayContext } from '../Router/Router'; +import { tablesFilter } from '../SchemaConfig/Tables'; +import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; +import { dataViewsText } from '../../localization/dataViews'; + +const defaultDataViewTablesConfig: RA = [ + 'Accession', + 'AddressOfRecord', + 'Agent', + 'Appraisal', + 'Author', + 'Borrow', + 'CollectingEvent', + 'CollectingTrip', + 'Collection', + 'CollectionObject', + 'CollectionObjectGroup', + 'ConservDescription', + 'Container', + 'DNASequence', + 'Deaccession', + 'Determination', + 'Discipline', + 'Disposal', + 'Division', + 'ExchangeIn', + 'ExchangeOut', + 'Exsiccata', + 'FieldNotebook', + 'Geography', + 'GeologicTimePeriod', + 'Gift', + 'InfoRequest', + 'Institution', + 'Journal', + 'LithoStrat', + 'Loan', + 'Locality', + 'MaterialSample', + 'PaleoContext', + 'Permit', + 'Preparation', + 'PrepType', + 'ReferenceWork', + 'RepositoryAgreement', + 'Shipment', + 'Storage', + 'Taxon', + 'TectonicUnit', + 'TreatmentEvent', +]; + +export function DataViewTables(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + const [tables, setTables] = useDataViewTables(); + const [isEditing, handleEditing] = useBooleanState(); + return isEditing ? ( + + ) : ( + + + + {commonText.close()} + + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + headerButtons={} + icon={icons.eye} + onClose={handleClose} + > + {/* REFACTOR: Generalize QueryTables component */} +
    + {tables.map(({ name, label }, index) => ( +
  • + + + {label} + +
  • + ))} +
+
+ ); +} + +function useDataViewTables(): GetSet> { + const [tables, setTables] = userPreferences.use( + 'dataViews', + 'general', + 'shownTables' + ); + const visibleTables = + tables.length === 0 + ? defaultDataViewTablesConfig.map(strictGetTable) + : tables.map(getTableById); + + const allowedTables = visibleTables.filter((table) => + tablesFilter(true, false, true, table) + ); + + const handleChange = React.useCallback( + (models: RA) => + setTables(models.map((model) => model.tableId)), + [setTables] + ); + + return [allowedTables, handleChange]; +} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx new file mode 100644 index 00000000000..4b1b7d67391 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; +import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { getTable } from '../DataModel/tables'; +import { ProtectedTable } from '../Permissions/PermissionDenied'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { NotFoundView } from '../Router/NotFoundView'; +import { f } from '../../utils/functools'; +import { dataViewsText } from '../../localization/dataViews'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; +import { Tables } from '../DataModel/types'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { RA } from '../../utils/types'; + +export function TableDataView(): JSX.Element { + const { tableName = '' } = useParams(); + + const table = getTable(tableName); + + return table === undefined ? ( + + ) : ( + + + + ); +} + +function DataViewFromTableWrapped({ + table, +}: { + readonly table: SpecifyTable; +}): JSX.Element | null { + const handleFetchingCollection = React.useCallback( + (offset: number = 0) => + fetchCollection(table.name, { + offset, + limit: DEFAULT_FETCH_LIMIT, + domainFilter: true, + orderBy: + table.getLiteralField('timestampCreated') === undefined + ? '-id' + : '-timestampcreated', + }), + [table] + ); + const [collection] = useAsyncState(handleFetchingCollection, true); + + return collection === undefined ? null : ( + + handleFetchingCollection(index).then(({ records }) => records) + } + /> + ); +} + +function DataViewFromTable({ + table, + totalCount: initialTotalCount, + initialRecords, + onFetchRecords: handleFetchRecords, +}: { + readonly table: SpecifyTable; + readonly totalCount: number; + readonly initialRecords: RA>; + readonly onFetchRecords: ( + index: number + ) => Promise>>; +}): JSX.Element | null { + // FEATURE: Use useNavigator and keep current record/index in query + // parameter of page + + const { + results: [collection], + totalCount: [totalCount], + onFetchMore: handleFetchMore, + } = usePaginatedCollection({ + fetchMore: handleFetchRecords, + initialRecords, + totalCount: initialTotalCount, + }); + + return collection === undefined ? null : ( + + // + // order} + // /> + // + // } + dialog={false} + ids={collection.map(({ id }) => id)} + isDependent={false} + isInRecordSet={false} + newResource={undefined} + table={table} + title={dataViewsText.tableRecords({ tableLabel: table.label })} + totalCount={totalCount} + onAdd={undefined} + onClone={undefined} + onClose={() => undefined} + onDelete={undefined} + onSaved={f.void} + onSlide={(new_index) => { + handleFetchMore(new_index); + }} + /> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index d9efbe6b6df..c8c6675dced 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -14,7 +14,7 @@ import { treeText } from '../../localization/tree'; import { wbText } from '../../localization/workbench'; import { getCache } from '../../utils/cache'; import { f } from '../../utils/functools'; -import type { IR } from '../../utils/types'; +import { IR, localized } from '../../utils/types'; import { ensure } from '../../utils/types'; import { icons } from '../Atoms/Icons'; import { @@ -76,6 +76,11 @@ const rawMenuItems = ensure>>()({ hasToolPermission('queryBuilder', 'read') || hasPermission('/querybuilder/query', 'execute'), }, + dataViews: { + url: '/specify/overlay/dataviews/', + title: localized('Data Views'), + icon: icons.eye, + }, recordSets: { url: '/specify/overlay/record-sets/', title: commonText.recordSets(), diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 4e0039645f5..b100ad1d0ba 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -54,6 +54,7 @@ import { } from './Renderers'; import type { GenericPreferences, PreferencesVisibilityContext } from './types'; import { definePref } from './types'; +import { dataViewsText } from '../../localization/dataViews'; const isLightMode = ({ isDarkMode, @@ -1670,6 +1671,24 @@ export const userPreferenceDefinitions = { }, }, }, + dataViews: { + title: dataViewsText.dataViewsTitle(), + subCategories: { + general: { + title: preferencesText.general(), + items: { + shownTables: definePref>({ + title: localized('_shownTables'), + requiresReload: false, + visible: false, + defaultValue: [], + renderer: f.never, + container: 'div', + }), + }, + }, + } + }, recordMerging: { title: mergingText.recordMerging(), subCategories: { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 5e46eb601d8..9a54abf7d16 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -3,6 +3,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { useInfiniteScroll } from '../../hooks/useInfiniteScroll'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import { f } from '../../utils/functools'; @@ -31,7 +32,6 @@ import { CreateRecordSet } from './CreateRecordSet'; import type { QueryFieldSpec } from './fieldSpec'; import type { QueryField } from './helpers'; import { sortTypes } from './helpers'; -import { useFetchQueryResults } from './hooks'; import { QueryResultsTable } from './ResultsTable'; import { QueryToForms } from './ToForms'; import { QueryToMap } from './ToMap'; @@ -99,7 +99,19 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { onFetchMore: handleFetchMore, totalCount: [totalCount, setTotalCount], canFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedCollection({ + initialRecords: initialData, + fetchMore: fetchResults, + fetchSize: props.fetchSize, + totalCount: props.totalCount, + }); + + // const { + // results: [results, setResults], + // onFetchMore: handleFetchMore, + // totalCount: [totalCount, setTotalCount], + // canFetchMore, + // } = useFetchQueryResults(props); const canMergeTable = canMerge(table); diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 0dd82a1e8f0..4a539ee922e 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -13,9 +13,10 @@ import { treeText } from '../../localization/tree'; import { userText } from '../../localization/user'; import { welcomeText } from '../../localization/welcome'; import { wbText } from '../../localization/workbench'; -import type { RA } from '../../utils/types'; +import { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; +import { dataViewsText } from '../../localization/dataViews'; /* eslint-disable @typescript-eslint/promise-function-async */ /** @@ -132,6 +133,14 @@ export const overlayRoutes: RA = [ }, ], }, + { + path: 'dataviews', + title: dataViewsText.dataViewsTitle(), + element: () => + import('../DataViews/DataViewTables').then( + ({ DataViewTables }) => DataViewTables + ), + }, { path: 'record-sets', title: commonText.recordSets(), diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index e7fef4d8f33..725be3d8793 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -360,6 +360,18 @@ export const routes: RA = [ }, ], }, + { + path: 'dataviews', + children: [ + { + path: ':tableName', + element: () => + import('../DataViews/index').then( + ({ TableDataView }) => TableDataView + ), + }, + ], + }, { path: 'user-preferences', title: preferencesText.preferences(), diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index e46d5401e45..1d3fe080074 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useResource } from '../../hooks/resource'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; import { developmentText } from '../../localization/development'; import { specifyNetworkText } from '../../localization/specifyNetwork'; import { f } from '../../utils/functools'; @@ -18,7 +19,6 @@ import { LoadingScreen } from '../Molecules/Dialog'; import { queryFromTree } from '../QueryBuilder/fromTree'; import type { QueryField } from '../QueryBuilder/helpers'; import { parseQueryFields } from '../QueryBuilder/helpers'; -import { useFetchQueryResults } from '../QueryBuilder/hooks'; import type { QueryResultRow } from '../QueryBuilder/Results'; import { useQueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; import { @@ -144,7 +144,12 @@ function Map({ results: [results], canFetchMore, onFetchMore: handleFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedCollection({ + fetchMore: props.fetchResults, + fetchSize: props.fetchSize, + totalCount: props.totalCount, + initialRecords: props.initialData, + }); const undefinedResult = results?.indexOf(undefined); const loadedResults = ( diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx index 5e07f49b1cc..ac2df9562e9 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx @@ -30,7 +30,7 @@ export function QueryTablesEdit({ ; readonly header: LocalizedString; readonly tables: RA; @@ -56,7 +56,7 @@ export function TablesListEdit({ const selectedValues = selectedTables.map(({ name }) => name); const allTables = Object.values(genericTables) .filter((table) => - tablesFilter(isNoRestrictionMode, false, true, table, selectedValues) + tablesFilter(showHiddenTables, false, true, table, selectedValues) ) .map(({ name, label }) => ({ name, label })); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx similarity index 58% rename from specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx rename to specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 5877a45cc6b..3ae9fd74e4b 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -1,42 +1,30 @@ import React from 'react'; - -import { useTriggerState } from '../../hooks/useTriggerState'; -import type { GetOrSet, IR, R, RA } from '../../utils/types'; -import { removeKey } from '../../utils/utils'; -import { raise, softFail } from '../Errors/Crash'; -import type { QueryResultRow, QueryResultsProps } from './Results'; - -export function useFetchQueryResults({ - initialData, - fetchResults, +import { GetOrSet, R, RA } from '../utils/types'; +import { useTriggerState } from './useTriggerState'; +import { removeKey, SET } from '../utils/utils'; +import { DEFAULT_FETCH_LIMIT } from '../components/DataModel/collection'; +import { raise, softFail } from '../components/Errors/Crash'; + +export function usePaginatedCollection({ + initialRecords, totalCount: initialTotalCount, - fetchSize, -}: Pick< - QueryResultsProps, - 'fetchResults' | 'fetchSize' | 'initialData' | 'totalCount' ->): { - readonly results: GetOrSet | undefined>; - readonly fetchersRef: { - readonly current: IR | void>>; - }; - readonly onFetchMore: (index?: number) => Promise | void>; - readonly totalCount: GetOrSet; - readonly canFetchMore: boolean; -} { - /* - * Warning: - * "results" can be a sparse array. Using sparse array to allow - * efficiently retrieving the last query result in a query that returns - * hundreds of thousands of results. - */ - const getSetResults = useTriggerState< - RA | undefined - >(initialData); - const [results, setResults] = getSetResults; + fetchSize = DEFAULT_FETCH_LIMIT, + fetchMore: rawHandleFetchMore, +}: { + readonly initialRecords?: RA; + readonly totalCount?: number; + readonly fetchSize?: number; + readonly fetchMore: + | ((offset: number) => Promise>) + | undefined; +}) { + const [results, setResults] = useTriggerState< + RA | undefined + >(initialRecords); const resultsRef = React.useRef(results); const handleSetResults: GetOrSet< - RA | undefined - >[1] = React.useCallback( + RA | undefined + >[typeof SET] = React.useCallback( (results) => { const resolved = typeof results === 'function' ? results(resultsRef.current) : results; @@ -47,47 +35,25 @@ export function useFetchQueryResults({ ); // Queue for fetching - const fetchersRef = React.useRef | void>>>({}); + const fetchersRef = React.useRef | undefined>>>( + {} + ); - const getSetTotalCount = useTriggerState(initialTotalCount); + const getSetTotalCount = useTriggerState( + initialTotalCount + ); const [totalCount] = getSetTotalCount; const canFetchMore = !Array.isArray(results) || totalCount === undefined || results.length < totalCount; - const handleFetchMore = React.useCallback( - async (index?: number): Promise | void> => { - const currentResults = resultsRef.current; - const canFetch = Array.isArray(currentResults); - - if (!canFetch || fetchResults === undefined) return undefined; - - const alreadyFetched = - currentResults.length === totalCount && - !currentResults.includes(undefined); - if (alreadyFetched) return undefined; - - /* - * REFACTOR: make this smarter - * when going to the last record, fetch 40 before the last - * when somewhere in the middle, adjust the fetch region to get the - * most unhatched records fetched - */ - const naiveFetchIndex = index ?? currentResults.length; - if (currentResults[naiveFetchIndex] !== undefined) return undefined; - - const fetchIndex = - /* If navigating backwards, fetch the previous 40 records */ - typeof index === 'number' && - typeof currentResults[index + 1] === 'object' && - currentResults[index - 1] === undefined && - index > fetchSize - ? naiveFetchIndex - fetchSize + 1 - : naiveFetchIndex; - + const internalFetchMore = React.useCallback( + async (index: number = 0): Promise | undefined> => { + const currentResults = resultsRef.current ?? []; + if (rawHandleFetchMore == undefined) return undefined; // Prevent concurrent fetching in different places - fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex) + fetchersRef.current[index] ??= rawHandleFetchMore(index) .then(async (newResults) => { if ( process.env.NODE_ENV === 'development' && @@ -108,30 +74,66 @@ export function useFetchQueryResults({ * This extends the sparse array to fit new results. Without this, * splice won't place the results in the correct place. */ - combinedResults[fetchIndex] ??= undefined; - combinedResults.splice(fetchIndex, newResults.length, ...newResults); + combinedResults[index] ??= undefined; + combinedResults.splice(index, newResults.length, ...newResults); handleSetResults(combinedResults); fetchersRef.current = removeKey( fetchersRef.current, - fetchIndex.toString() + index.toString() ); if (typeof index === 'number' && index >= combinedResults.length) return handleFetchMore(index); return newResults; }) - .catch(raise); + .catch((error) => { + raise(error); + return undefined; + }); + return fetchersRef.current[index]; + }, + [totalCount, setResults, rawHandleFetchMore] + ); + + const handleFetchMore = React.useCallback( + async (index?: number): Promise | undefined> => { + const currentResults = resultsRef.current; + const canFetch = Array.isArray(currentResults); + + if (!canFetch || rawHandleFetchMore === undefined) return undefined; + + const alreadyFetched = + currentResults.length === totalCount && + !currentResults.includes(undefined); + if (alreadyFetched) return undefined; + + /* + * REFACTOR: make this smarter + * when going to the last record, fetch 40 before the last + * when somewhere in the middle, adjust the fetch region to get the + * most unhatched records fetched + */ + const naiveFetchIndex = index ?? currentResults.length; + if (currentResults[naiveFetchIndex] !== undefined) return undefined; + + const fetchIndex = + /* If navigating backwards, fetch the previous 40 records */ + typeof index === 'number' && + typeof currentResults[index + 1] === 'object' && + currentResults[index - 1] === undefined && + index > fetchSize + ? naiveFetchIndex - fetchSize + 1 + : naiveFetchIndex; - return fetchersRef.current[fetchIndex]; + return internalFetchMore(fetchIndex); }, - [fetchResults, fetchSize, setResults, totalCount] + [rawHandleFetchMore, fetchSize, setResults, totalCount] ); return { - fetchersRef, - results: [results, handleSetResults], + results: [results, handleSetResults] as const, onFetchMore: handleFetchMore, totalCount: getSetTotalCount, canFetchMore, diff --git a/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx index 594f5b698f6..e4e623889c5 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx @@ -11,7 +11,8 @@ import { useAsyncState } from './useAsyncState'; * A hook for fetching a collection of resources in a paginated way */ export function useSerializedCollection( - fetch: (offset: number) => Promise> + fetch: (offset: number) => Promise>, + showLoadingScreen: boolean = false ): readonly [ SerializedCollection | undefined, GetOrSet | undefined>[1], @@ -48,7 +49,7 @@ export function useSerializedCollection( collectionRef.current = undefined; return callback(); }, [callback]), - false + showLoadingScreen ); const collectionRef = React.useRef< SerializedCollection | undefined diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts new file mode 100644 index 00000000000..e314b08086e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -0,0 +1,23 @@ +/** + * Localization strings for the Data Views component + * + * @module + */ + +import { createDictionary } from './utils'; + +// Refer to "Guidelines for Programmers" in ./README.md before editing this file + +export const dataViewsText = createDictionary({ + dataViewsTitle: { + 'comment': "The name of the component", + "en-us": "Data Views" + }, + tableRecords: { + 'comment': "Used as a dialog header within the Data Views component", + "en-us": "{tableLabel:string} Records" + }, + configureDataViews: { + "en-us": "Configure Data Views tables" + } +}); From f76ce7f1589ad7ad0f610de68716fe4ff0a173cb Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 10:09:26 +0200 Subject: [PATCH 02/23] Refactor: Improve fetch index --- .../lib/hooks/usePaginatedCollection.tsx | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 3ae9fd74e4b..288eb945c53 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -109,23 +109,29 @@ export function usePaginatedCollection({ !currentResults.includes(undefined); if (alreadyFetched) return undefined; - /* - * REFACTOR: make this smarter - * when going to the last record, fetch 40 before the last - * when somewhere in the middle, adjust the fetch region to get the - * most unhatched records fetched - */ const naiveFetchIndex = index ?? currentResults.length; if (currentResults[naiveFetchIndex] !== undefined) return undefined; - const fetchIndex = - /* If navigating backwards, fetch the previous 40 records */ - typeof index === 'number' && - typeof currentResults[index + 1] === 'object' && - currentResults[index - 1] === undefined && - index > fetchSize - ? naiveFetchIndex - fetchSize + 1 - : naiveFetchIndex; + const firstFetchIndex = Math.max(0, naiveFetchIndex - fetchSize + 1); + const lastFetchIndex = Math.min( + naiveFetchIndex, + Math.max(0, (totalCount ?? currentResults.length) - fetchSize) + ); + const fetchIndex = Array.from( + { length: lastFetchIndex - firstFetchIndex + 1 }, + (_, offset) => firstFetchIndex + offset + ).reduce((bestIndex, candidateIndex) => + Array.from( + { length: fetchSize }, + (_, offset) => currentResults[candidateIndex + offset] + ).filter((result) => result === undefined).length > + Array.from( + { length: fetchSize }, + (_, offset) => currentResults[bestIndex + offset] + ).filter((result) => result === undefined).length + ? candidateIndex + : bestIndex + ); return internalFetchMore(fetchIndex); }, From 9a003091d2553661caa666ac676680e034169d9e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 10:25:52 +0200 Subject: [PATCH 03/23] Chore: cleanup --- .../js_src/lib/components/QueryBuilder/Results.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 69bc4819504..5a7e639f6bf 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -126,13 +126,6 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { totalCount: props.totalCount, }); - // const { - // results: [results, setResults], - // onFetchMore: handleFetchMore, - // totalCount: [totalCount, setTotalCount], - // canFetchMore, - // } = useFetchQueryResults(props); - const canMergeTable = canMerge(table); const visibleColumns = React.useMemo( From 1c07b953590eb6a81c1212c8e4687375a08d942f Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 10:37:18 +0200 Subject: [PATCH 04/23] Feat: Add a new sort picker in dataView --- .../js_src/lib/components/DataViews/index.tsx | 66 ++++++++++---- .../lib/components/Preferences/Renderers.tsx | 85 ++++++++++++------- .../js_src/lib/localization/dataViews.ts | 18 ++-- 3 files changed, 114 insertions(+), 55 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 4b1b7d67391..649fa96b759 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -8,11 +8,14 @@ import { ProtectedTable } from '../Permissions/PermissionDenied'; import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; import { NotFoundView } from '../Router/NotFoundView'; import { f } from '../../utils/functools'; +import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; import { Tables } from '../DataModel/types'; import { useAsyncState } from '../../hooks/useAsyncState'; import { RA } from '../../utils/types'; +import { Label } from '../Atoms/Form'; +import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -33,29 +36,65 @@ function DataViewFromTableWrapped({ }: { readonly table: SpecifyTable; }): JSX.Element | null { + const defaultOrder = + table.getLiteralField('timestampCreated') === undefined + ? '-id' + : '-timestampcreated'; + const [order, setOrder] = React.useState>( + defaultOrder as OrderPickerOrder + ); + const [isScoped, setIsScoped] = React.useState(true); + const canBeScoped = + table.name === 'Attachment' || typeof table.getScope() === 'object'; const handleFetchingCollection = React.useCallback( (offset: number = 0) => fetchCollection(table.name, { offset, limit: DEFAULT_FETCH_LIMIT, - domainFilter: true, - orderBy: - table.getLiteralField('timestampCreated') === undefined - ? '-id' - : '-timestampcreated', + domainFilter: isScoped, + orderBy: order, }), - [table] + [isScoped, order, table] ); const [collection] = useAsyncState(handleFetchingCollection, true); return collection === undefined ? null : ( handleFetchingCollection(index).then(({ records }) => records) } + headerButtons={ + <> + + {dataViewsText.orderBy()} +
+ + additionalFields={[{ name: 'id', label: commonText.id() }]} + includeHiddenFields + includeVirtualFields={false} + order={order} + table={table} + onChange={setOrder} + /> +
+
+ {canBeScoped ? ( + + setIsScoped(target.checked)} + /> + {dataViewsText.useCurrentScope()} + + ) : undefined} + + } /> ); } @@ -65,6 +104,7 @@ function DataViewFromTable({ totalCount: initialTotalCount, initialRecords, onFetchRecords: handleFetchRecords, + headerButtons, }: { readonly table: SpecifyTable; readonly totalCount: number; @@ -72,6 +112,7 @@ function DataViewFromTable({ readonly onFetchRecords: ( index: number ) => Promise>>; + readonly headerButtons: JSX.Element; }): JSX.Element | null { // FEATURE: Use useNavigator and keep current record/index in query // parameter of page @@ -90,18 +131,7 @@ function DataViewFromTable({ - // - // order} - // /> - // - // } + headerButtons={headerButtons} dialog={false} ids={collection.map(({ id }) => id)} isDependent={false} diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx index 3ea129529c5..8f540e5b8d3 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/Renderers.tsx @@ -4,6 +4,7 @@ */ import React from 'react'; +import type { LocalizedString } from 'typesafe-i18n'; import { usePromise } from '../../hooks/useAsyncState'; import { useTriggerState } from '../../hooks/useTriggerState'; @@ -136,53 +137,75 @@ export function HeaderItemsPreferenceItem({ ); } -export function OrderPicker({ +export type OrderPickerOrder< + SCHEMA extends AnySchema, + EXTRA_FIELD extends string = never, +> = + | EXTRA_FIELD + | `-${EXTRA_FIELD}` + | `-${string & keyof SCHEMA['fields']}` + | (string & keyof SCHEMA['fields']); + +export function OrderPicker< + SCHEMA extends AnySchema, + EXTRA_FIELD extends string = never, +>({ table, order, onChange: handleChange, + additionalFields = [], + includeHiddenFields = false, + includeVirtualFields = true, }: { readonly table: SpecifyTable; - readonly order: - | `-${string & keyof SCHEMA['fields']}` - | (string & keyof SCHEMA['fields']) - | undefined; - readonly onChange: ( - order: - | `-${string & keyof SCHEMA['fields']}` - | (string & keyof SCHEMA['fields']) - ) => void; + readonly order: OrderPickerOrder | undefined; + readonly onChange: (order: OrderPickerOrder) => void; + readonly additionalFields?: RA<{ + readonly name: EXTRA_FIELD; + readonly label: LocalizedString; + }>; + readonly includeHiddenFields?: boolean; + readonly includeVirtualFields?: boolean; }): JSX.Element { + const getFields = (isDescending: boolean) => + table.literalFields.filter( + ({ isHidden, isVirtual, name }) => + (includeHiddenFields || + !isHidden || + (isDescending ? order?.slice(1) : order) === name) && + (includeVirtualFields || !isVirtual) + ); return ( ); diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index e314b08086e..0ca1bc8e1f7 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -10,14 +10,20 @@ import { createDictionary } from './utils'; export const dataViewsText = createDictionary({ dataViewsTitle: { - 'comment': "The name of the component", - "en-us": "Data Views" + comment: 'The name of the component', + 'en-us': 'Data Views', }, tableRecords: { - 'comment': "Used as a dialog header within the Data Views component", - "en-us": "{tableLabel:string} Records" + comment: 'Used as a dialog header within the Data Views component', + 'en-us': '{tableLabel:string} Records', + }, + orderBy: { + 'en-us': 'Order by', + }, + useCurrentScope: { + 'en-us': 'Use current scope', }, configureDataViews: { - "en-us": "Configure Data Views tables" - } + 'en-us': 'Configure Data Views tables', + }, }); From 79328b185c832214fe6193ba3ba048895e807586 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 10:55:20 +0200 Subject: [PATCH 05/23] Fix: Typescript types --- .../js_src/lib/components/DataViews/index.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 649fa96b759..20677cdc08c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; +import type { LocalizedString } from 'typesafe-i18n'; +import { + type CollectionFetchFilters, + DEFAULT_FETCH_LIMIT, + fetchCollection, +} from '../DataModel/collection'; import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; import { SpecifyTable } from '../DataModel/specifyTable'; import { getTable } from '../DataModel/tables'; @@ -17,6 +22,10 @@ import { RA } from '../../utils/types'; import { Label } from '../Atoms/Form'; import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; +const tableRecordsText = dataViewsText.tableRecords as (values: { + readonly tableLabel: LocalizedString; +}) => LocalizedString; + export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -53,7 +62,7 @@ function DataViewFromTableWrapped({ limit: DEFAULT_FETCH_LIMIT, domainFilter: isScoped, orderBy: order, - }), + } as CollectionFetchFilters), [isScoped, order, table] ); const [collection] = useAsyncState(handleFetchingCollection, true); @@ -138,7 +147,7 @@ function DataViewFromTable({ isInRecordSet={false} newResource={undefined} table={table} - title={dataViewsText.tableRecords({ tableLabel: table.label })} + title={tableRecordsText({ tableLabel: table.label })} totalCount={totalCount} onAdd={undefined} onClone={undefined} From 43fe3a0a6b83c5ee61f0c75c5694bab15521fc97 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 10:58:10 +0200 Subject: [PATCH 06/23] Fix: Typescript types --- specifyweb/frontend/js_src/lib/components/DataViews/index.tsx | 2 +- .../frontend/js_src/lib/hooks/usePaginatedCollection.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 20677cdc08c..38d1e686ae0 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -142,7 +142,7 @@ function DataViewFromTable({ defaultIndex={0} headerButtons={headerButtons} dialog={false} - ids={collection.map(({ id }) => id)} + ids={collection.map((record) => record?.id)} isDependent={false} isInRecordSet={false} newResource={undefined} diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 288eb945c53..1df50090861 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -19,11 +19,11 @@ export function usePaginatedCollection({ | undefined; }) { const [results, setResults] = useTriggerState< - RA | undefined + RA | undefined >(initialRecords); const resultsRef = React.useRef(results); const handleSetResults: GetOrSet< - RA | undefined + RA | undefined >[typeof SET] = React.useCallback( (results) => { const resolved = From 9462044563e7dcb2eca203b06be00a0ca76b39cb Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 11:00:49 +0200 Subject: [PATCH 07/23] Fix: Typescript types --- .../frontend/js_src/lib/components/QueryBuilder/Results.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 5a7e639f6bf..d031eadf6c9 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -424,7 +424,11 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { table={table} totalCount={totalCount} onFetchMore={ - canFetchMore && !isFetching ? handleFetchMore : undefined + canFetchMore && !isFetching + ? async (): Promise => { + await handleFetchMore(); + } + : undefined } /> {isDistinct ? null : ( From f12b86c3981048a63fb8b0ec05f453ea192d0724 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 13:57:42 +0200 Subject: [PATCH 08/23] Fix: Typescript --- .../frontend/js_src/lib/components/SpecifyNetwork/Map.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index b1131825c98..892a9a193e8 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -170,7 +170,13 @@ function Map({ tableName={tableName} totalCount={props.totalCount} onClose={handleClose} - onFetchMore={canFetchMore ? handleFetchMore : undefined} + onFetchMore={ + canFetchMore + ? async (): Promise => { + await handleFetchMore(); + } + : undefined + } /> ); } From ac1031d8050bc8b9eb13a0c2b9e90e8bc986877c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 13:59:32 +0200 Subject: [PATCH 09/23] Fix: Use localization --- .../js_src/lib/components/Header/menuItemDefinitions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index c8c6675dced..28385ae251d 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -5,6 +5,7 @@ import { attachmentsText } from '../../localization/attachments'; import { batchEditText } from '../../localization/batchEdit'; import { commonText } from '../../localization/common'; +import { dataViewsText } from '../../localization/dataViews'; import { headerText } from '../../localization/header'; import { interactionsText } from '../../localization/interactions'; import { queryText } from '../../localization/query'; @@ -78,7 +79,7 @@ const rawMenuItems = ensure>>()({ }, dataViews: { url: '/specify/overlay/dataviews/', - title: localized('Data Views'), + title: dataViewsText.dataViewsTitle(), icon: icons.eye, }, recordSets: { From f1694546a76acb1834a04b68c7774560ce249813 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:00:34 +0200 Subject: [PATCH 10/23] Update specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../frontend/js_src/lib/hooks/usePaginatedCollection.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 1df50090861..f7c5ca94d49 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -89,6 +89,10 @@ export function usePaginatedCollection({ return newResults; }) .catch((error) => { + fetchersRef.current = removeKey( + fetchersRef.current, + index.toString() + ); raise(error); return undefined; }); From d1603fb2d46a009f99c38c25313f862490b27a04 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:03:38 +0200 Subject: [PATCH 11/23] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../js_src/lib/components/Header/menuItemDefinitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index 28385ae251d..633b7d4bac7 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -15,7 +15,7 @@ import { treeText } from '../../localization/tree'; import { wbText } from '../../localization/workbench'; import { getCache } from '../../utils/cache'; import { f } from '../../utils/functools'; -import { IR, localized } from '../../utils/types'; +import { IR } from '../../utils/types'; import { ensure } from '../../utils/types'; import { icons } from '../Atoms/Icons'; import { From f14ceff50678c9daa9adba55952a88c1b439a60e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 14:13:16 +0200 Subject: [PATCH 12/23] Fix: Localization error --- .../frontend/js_src/lib/components/DataViews/index.tsx | 9 ++------- specifyweb/frontend/js_src/lib/localization/dataViews.ts | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 38d1e686ae0..9e44fc1f34d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import type { LocalizedString } from 'typesafe-i18n'; import { type CollectionFetchFilters, DEFAULT_FETCH_LIMIT, @@ -22,10 +21,6 @@ import { RA } from '../../utils/types'; import { Label } from '../Atoms/Form'; import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; -const tableRecordsText = dataViewsText.tableRecords as (values: { - readonly tableLabel: LocalizedString; -}) => LocalizedString; - export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -79,7 +74,7 @@ function DataViewFromTableWrapped({ headerButtons={ <> - {dataViewsText.orderBy()} + {dataViewsText.dataViewOrderBy()}
additionalFields={[{ name: 'id', label: commonText.id() }]} @@ -147,7 +142,7 @@ function DataViewFromTable({ isInRecordSet={false} newResource={undefined} table={table} - title={tableRecordsText({ tableLabel: table.label })} + title={dataViewsText.tableRecords({ tableLabel: table.label })} totalCount={totalCount} onAdd={undefined} onClone={undefined} diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 0ca1bc8e1f7..9db814f3cfa 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -17,7 +17,7 @@ export const dataViewsText = createDictionary({ comment: 'Used as a dialog header within the Data Views component', 'en-us': '{tableLabel:string} Records', }, - orderBy: { + dataViewOrderBy: { 'en-us': 'Order by', }, useCurrentScope: { @@ -26,4 +26,4 @@ export const dataViewsText = createDictionary({ configureDataViews: { 'en-us': 'Configure Data Views tables', }, -}); +} as const); From 6c33abbccfefa7f5b6818cc63618c1f1844dbb3e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 14:44:17 +0200 Subject: [PATCH 13/23] Refactor: reuse QueryTables comp in DataViews --- .../components/DataViews/DataViewTables.tsx | 22 ++++++------------- .../components/Toolbar/QueryTablesWrapper.tsx | 14 ++++++++++-- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 72c230909b9..04e1fc73c0b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -1,23 +1,21 @@ import React from 'react'; import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; +import { dataViewsText } from '../../localization/dataViews'; import { schemaText } from '../../localization/schema'; import { GetSet, RA } from '../../utils/types'; -import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; -import { Link } from '../Atoms/Link'; import { SpecifyTable } from '../DataModel/specifyTable'; import { getTableById, strictGetTable } from '../DataModel/tables'; import { Tables } from '../DataModel/types'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { TableIcon } from '../Molecules/TableIcon'; import { userPreferences } from '../Preferences/userPreferences'; import { OverlayContext } from '../Router/Router'; import { tablesFilter } from '../SchemaConfig/Tables'; import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; -import { dataViewsText } from '../../localization/dataViews'; +import { QueryTables } from '../Toolbar/QueryTablesWrapper'; const defaultDataViewTablesConfig: RA = [ 'Accession', @@ -96,17 +94,11 @@ export function DataViewTables(): JSX.Element { icon={icons.eye} onClose={handleClose} > - {/* REFACTOR: Generalize QueryTables component */} -
    - {tables.map(({ name, label }, index) => ( -
  • - - - {label} - -
  • - ))} -
+ `/specify/dataviews/${name.toLowerCase()}`} + tables={tables} + onClick={undefined} + /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index 07d319df75d..cb7158b0eb6 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx @@ -108,15 +108,23 @@ export function useQueryTables(): GetSet> { export function QueryTables({ tables, onClick: handleClick, + getHref = (tableName): string => + `/specify/query/new/${tableName.toLowerCase()}/`, }: { readonly tables: RA; readonly onClick: ((tableName: keyof Tables) => void) | undefined; + readonly getHref?: (tableName: keyof Tables) => string; }): JSX.Element { return (
    {tables.map(({ name, label }, index) => (
  • - +
  • ))}
@@ -178,13 +186,15 @@ function QueryTableItem({ name, label, onClick: handleClick, + getHref, }: { readonly name: keyof Tables; readonly label: LocalizedString; readonly onClick: ((tableName: keyof Tables) => void) | undefined; + readonly getHref: (tableName: keyof Tables) => string; }): JSX.Element { return handleClick === undefined ? ( - + {label} From b91e06bf1b8818756204990a6efb92ccd094c16e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 15:13:38 +0200 Subject: [PATCH 14/23] Fix: Keep canFetchMore true for sparse entries --- .../lib/hooks/usePaginatedCollection.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index f7c5ca94d49..539931b58c1 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -46,7 +46,8 @@ export function usePaginatedCollection({ const canFetchMore = !Array.isArray(results) || totalCount === undefined || - results.length < totalCount; + results.length < totalCount || + results.includes(undefined); const internalFetchMore = React.useCallback( async (index: number = 0): Promise | undefined> => { @@ -113,8 +114,19 @@ export function usePaginatedCollection({ !currentResults.includes(undefined); if (alreadyFetched) return undefined; - const naiveFetchIndex = index ?? currentResults.length; - if (currentResults[naiveFetchIndex] !== undefined) return undefined; + const missingResultIndex = currentResults.findIndex( + (result) => result === undefined + ); + const naiveFetchIndex = + index ?? + (missingResultIndex === -1 + ? currentResults.length + : missingResultIndex); + if ( + (totalCount !== undefined && naiveFetchIndex >= totalCount) || + currentResults[naiveFetchIndex] !== undefined + ) + return undefined; const firstFetchIndex = Math.max(0, naiveFetchIndex - fetchSize + 1); const lastFetchIndex = Math.min( From 99d8b42784bd4566a881a0ec29677206222fbd73 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 15:52:38 +0200 Subject: [PATCH 15/23] Fix: Change data view dialog header --- .../frontend/js_src/lib/components/DataViews/DataViewTables.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 04e1fc73c0b..4ce934c8fcb 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -78,7 +78,7 @@ export function DataViewTables(): JSX.Element { /> ) : ( From f59bd597dcab784863641c35082e3f65d57400bc Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 15:55:07 +0200 Subject: [PATCH 16/23] Fix: Restrict default lis of tables --- .../components/DataViews/DataViewTables.tsx | 40 +------------------ 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 4ce934c8fcb..99abf725da1 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; -import { schemaText } from '../../localization/schema'; import { GetSet, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; @@ -19,49 +18,12 @@ import { QueryTables } from '../Toolbar/QueryTablesWrapper'; const defaultDataViewTablesConfig: RA = [ 'Accession', - 'AddressOfRecord', 'Agent', - 'Appraisal', - 'Author', - 'Borrow', - 'CollectingEvent', - 'CollectingTrip', - 'Collection', 'CollectionObject', - 'CollectionObjectGroup', - 'ConservDescription', - 'Container', - 'DNASequence', - 'Deaccession', - 'Determination', - 'Discipline', - 'Disposal', - 'Division', - 'ExchangeIn', - 'ExchangeOut', - 'Exsiccata', - 'FieldNotebook', - 'Geography', - 'GeologicTimePeriod', + 'CollectingEvent', 'Gift', - 'InfoRequest', - 'Institution', - 'Journal', - 'LithoStrat', 'Loan', 'Locality', - 'MaterialSample', - 'PaleoContext', - 'Permit', - 'Preparation', - 'PrepType', - 'ReferenceWork', - 'RepositoryAgreement', - 'Shipment', - 'Storage', - 'Taxon', - 'TectonicUnit', - 'TreatmentEvent', ]; export function DataViewTables(): JSX.Element { From e1d4313e1a7243fc9a470e2597c15941975ddada Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 15:56:41 +0200 Subject: [PATCH 17/23] Fix: Reuse existing localization --- specifyweb/frontend/js_src/lib/components/DataViews/index.tsx | 3 ++- specifyweb/frontend/js_src/lib/localization/dataViews.ts | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 9e44fc1f34d..8dab55b9651 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -20,6 +20,7 @@ import { useAsyncState } from '../../hooks/useAsyncState'; import { RA } from '../../utils/types'; import { Label } from '../Atoms/Form'; import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; +import { attachmentsText } from '../../localization/attachments'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -74,7 +75,7 @@ function DataViewFromTableWrapped({ headerButtons={ <> - {dataViewsText.dataViewOrderBy()} + {attachmentsText.orderBy()}
additionalFields={[{ name: 'id', label: commonText.id() }]} diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 9db814f3cfa..14e5195fcc9 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -17,9 +17,6 @@ export const dataViewsText = createDictionary({ comment: 'Used as a dialog header within the Data Views component', 'en-us': '{tableLabel:string} Records', }, - dataViewOrderBy: { - 'en-us': 'Order by', - }, useCurrentScope: { 'en-us': 'Use current scope', }, From e3564035b545803b658161d72d530357a37c1754 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 15:58:41 +0200 Subject: [PATCH 18/23] Clean: Remove use current scope --- .../js_src/lib/components/DataViews/index.tsx | 21 ++++--------------- .../js_src/lib/localization/dataViews.ts | 3 --- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 8dab55b9651..9b72f014ccd 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -48,24 +48,22 @@ function DataViewFromTableWrapped({ const [order, setOrder] = React.useState>( defaultOrder as OrderPickerOrder ); - const [isScoped, setIsScoped] = React.useState(true); - const canBeScoped = - table.name === 'Attachment' || typeof table.getScope() === 'object'; + const handleFetchingCollection = React.useCallback( (offset: number = 0) => fetchCollection(table.name, { offset, limit: DEFAULT_FETCH_LIMIT, - domainFilter: isScoped, + domainFilter: true, orderBy: order, } as CollectionFetchFilters), - [isScoped, order, table] + [order, table] ); const [collection] = useAsyncState(handleFetchingCollection, true); return collection === undefined ? null : ( ({ />
- {canBeScoped ? ( - - setIsScoped(target.checked)} - /> - {dataViewsText.useCurrentScope()} - - ) : undefined} } /> diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 14e5195fcc9..378389d49cd 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -17,9 +17,6 @@ export const dataViewsText = createDictionary({ comment: 'Used as a dialog header within the Data Views component', 'en-us': '{tableLabel:string} Records', }, - useCurrentScope: { - 'en-us': 'Use current scope', - }, configureDataViews: { 'en-us': 'Configure Data Views tables', }, From c7588498415c06fadac96b473f50e780783e76fa Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 16:01:58 +0200 Subject: [PATCH 19/23] Fix: typo in timestamp field --- specifyweb/frontend/js_src/lib/components/DataViews/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 9b72f014ccd..f9c1ffe82b2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -44,7 +44,7 @@ function DataViewFromTableWrapped({ const defaultOrder = table.getLiteralField('timestampCreated') === undefined ? '-id' - : '-timestampcreated'; + : '-timestampCreated'; const [order, setOrder] = React.useState>( defaultOrder as OrderPickerOrder ); From 4a38754b4581af9bde791f211339b56b659f15e7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 25 Aug 2026 19:48:26 +0200 Subject: [PATCH 20/23] Fix: Reset table state when table.name changes --- .../frontend/js_src/lib/components/DataViews/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index f9c1ffe82b2..e02a579f7ae 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -45,10 +45,15 @@ function DataViewFromTableWrapped({ table.getLiteralField('timestampCreated') === undefined ? '-id' : '-timestampCreated'; + const [order, setOrder] = React.useState>( defaultOrder as OrderPickerOrder ); + React.useEffect(() => { + setOrder(defaultOrder as OrderPickerOrder); + }, [table.name, defaultOrder]); + const handleFetchingCollection = React.useCallback( (offset: number = 0) => fetchCollection(table.name, { @@ -59,11 +64,12 @@ function DataViewFromTableWrapped({ } as CollectionFetchFilters), [order, table] ); + const [collection] = useAsyncState(handleFetchingCollection, true); return collection === undefined ? null : ( Date: Wed, 26 Aug 2026 13:44:10 +0200 Subject: [PATCH 21/23] Feat: Add a total count amount next to each table --- .../components/DataViews/DataViewTables.tsx | 71 ++++++++++++++++--- .../components/Toolbar/QueryTablesWrapper.tsx | 45 ++++++++++-- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 99abf725da1..2f3f53217c4 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -2,17 +2,25 @@ import React from 'react'; import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; -import { GetSet, RA } from '../../utils/types'; +import { Http } from '../../utils/ajax/definitions'; +import { throttledPromise } from '../../utils/ajax/throttledPromise'; +import type { GetSet, IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; +import { serializeResource } from '../DataModel/serializers'; import { SpecifyTable } from '../DataModel/specifyTable'; import { getTableById, strictGetTable } from '../DataModel/tables'; import { Tables } from '../DataModel/types'; +import { raise } from '../Errors/Crash'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; import { userPreferences } from '../Preferences/userPreferences'; import { OverlayContext } from '../Router/Router'; import { tablesFilter } from '../SchemaConfig/Tables'; +import { + queryCountPromiseGenerator, + querySpecToResource, +} from '../Statistics/hooks'; import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; import { QueryTables } from '../Toolbar/QueryTablesWrapper'; @@ -30,6 +38,7 @@ export function DataViewTables(): JSX.Element { const handleClose = React.useContext(OverlayContext); const [tables, setTables] = useDataViewTables(); const [isEditing, handleEditing] = useBooleanState(); + const counts = useTableRecordCounts(tables); return isEditing ? ( `/specify/dataviews/${name.toLowerCase()}`} tables={tables} onClick={undefined} @@ -65,20 +75,65 @@ export function DataViewTables(): JSX.Element { ); } +/** + * Fetches the number of records in each table in the background, using the + * same count-only query builder logic that is used in the Statistics page. + */ +function useTableRecordCounts( + tables: RA +): IR { + const [counts, setCounts] = React.useState>({}); + + React.useEffect(() => { + let destructorCalled = false; + tables.forEach((table) => { + const query = serializeResource( + querySpecToResource(table.name, { + tableName: table.name, + fields: [], + }) + ); + throttledPromise( + 'queryStats', + async () => + queryCountPromiseGenerator(query)().then((response) => + response.status === Http.OK ? response.data.count : undefined + ), + JSON.stringify(query) + ) + .then((count) => { + if (destructorCalled) return; + setCounts((previousCounts) => ({ + ...previousCounts, + [table.name]: count, + })); + }) + .catch(raise); + }); + return (): void => { + destructorCalled = true; + }; + }, [tables]); + + return counts; +} + function useDataViewTables(): GetSet> { const [tables, setTables] = userPreferences.use( 'dataViews', 'general', 'shownTables' ); - const visibleTables = - tables.length === 0 - ? defaultDataViewTablesConfig.map(strictGetTable) - : tables.map(getTableById); + const allowedTables = React.useMemo(() => { + const visibleTables = + tables.length === 0 + ? defaultDataViewTablesConfig.map(strictGetTable) + : tables.map(getTableById); - const allowedTables = visibleTables.filter((table) => - tablesFilter(true, false, true, table) - ); + return visibleTables.filter((table) => + tablesFilter(true, false, true, table) + ); + }, [tables]); const handleChange = React.useCallback( (models: RA) => diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index cb7158b0eb6..af3c80810f7 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx @@ -4,7 +4,8 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; import { commonText } from '../../localization/common'; import { queryText } from '../../localization/query'; -import type { GetSet, RA } from '../../utils/types'; +import { StringToJsx } from '../../localization/utils'; +import type { GetSet, IR, RA } from '../../utils/types'; import { Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; @@ -108,11 +109,13 @@ export function useQueryTables(): GetSet> { export function QueryTables({ tables, onClick: handleClick, + counts, getHref = (tableName): string => `/specify/query/new/${tableName.toLowerCase()}/`, }: { readonly tables: RA; readonly onClick: ((tableName: keyof Tables) => void) | undefined; + readonly counts?: IR; readonly getHref?: (tableName: keyof Tables) => string; }): JSX.Element { return ( @@ -120,7 +123,9 @@ export function QueryTables({ {tables.map(({ name, label }, index) => (
  • void) | undefined; readonly getHref: (tableName: keyof Tables) => string; }): JSX.Element { - return handleClick === undefined ? ( - + const content = ( + <> - {label} - + {typeof count === 'number' ? ( + ( + {formattedCount} + ), + }} + string={commonText.jsxCountLine({ resource: label, count })} + /> + ) : isCountLoading ? ( + <> + {label} + + + ) : ( + label + )} + + ); + return handleClick === undefined ? ( + {content} ) : ( handleClick(name)}> - - {label} + {content} ); } From 4cdffc4286ceb496976c08aaa1ad7356f05c6416 Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:51:26 +0000 Subject: [PATCH 22/23] Lint code with ESLint and Prettier Triggered by cf38805445dcdea5bee12c1b2397f1b946fc49d9 on branch refs/heads/issue-6565 --- .../lib/components/ChooseCollection/index.tsx | 2 +- .../frontend/js_src/lib/components/Core/Main.tsx | 4 +++- .../lib/components/Preferences/UserDefinitions.tsx | 2 +- .../js_src/lib/components/Router/Routes.tsx | 13 +++++-------- .../js_src/lib/components/WbToolkit/GeoLocate.tsx | 6 ++---- 5 files changed, 12 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx index 53266302ad1..0545e2fb681 100644 --- a/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/ChooseCollection/index.tsx @@ -172,7 +172,7 @@ function Wrapped({ loading( - ping('/accounts/logout/', {method: 'POST'}).then(() => + ping('/accounts/logout/', { method: 'POST' }).then(() => globalThis.location.assign( formatUrl('/specify/command/logout/', { next: nextUrl }) ) diff --git a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx index 92a8971cc46..21c7bbb9fe4 100644 --- a/specifyweb/frontend/js_src/lib/components/Core/Main.tsx +++ b/specifyweb/frontend/js_src/lib/components/Core/Main.tsx @@ -102,7 +102,9 @@ function MissingAgent(): JSX.Element { }} forceToTop header={userText.noAgent()} - onClose={(): void => globalThis.location.assign('/specify/command/logout/')} + onClose={(): void => + globalThis.location.assign('/specify/command/logout/') + } > {userText.noAgentDescription()}
  • diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 56a2350a4ad..375843c28e9 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -1938,7 +1938,7 @@ export const userPreferenceDefinitions = { }), }, }, - } + }, }, recordMerging: { title: mergingText.recordMerging(), diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index d5d49a9a7e2..a2f7a3081bf 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -478,14 +478,11 @@ export const routes: RA = [ ({ CacheBuster }) => CacheBuster ), }, - { - path: 'logout', - title: userText.logOut(), - element: () => - import('../Logout').then( - ({ Logout }) => Logout - ) - }, + { + path: 'logout', + title: userText.logOut(), + element: () => import('../Logout').then(({ Logout }) => Logout), + }, ], }, { diff --git a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx index b4dfc1c845b..3feeb2a3ec9 100644 --- a/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbToolkit/GeoLocate.tsx @@ -12,9 +12,7 @@ import { filterArray } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { getLocalityField } from '../Leaflet/helpers'; -import { - getSelectedLocalityColumns, -} from '../Leaflet/wbLocalityDataExtractor'; +import { getSelectedLocalityColumns } from '../Leaflet/wbLocalityDataExtractor'; import type { GeoLocatePayload } from '../Molecules/GeoLocate'; import { GenericGeoLocate } from '../Molecules/GeoLocate'; import type { Dataset } from '../WbPlanView/Wrapped'; @@ -305,4 +303,4 @@ export function buildGeoLocateData( ) ) ); -} \ No newline at end of file +} From 0e54224afea8d5371fda38793c2a895f7559dd6e Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:36:23 +0000 Subject: [PATCH 23/23] Lint code with ESLint and Prettier Triggered by 29d0b24048dcb8916b2bfb26c49716cf49d38fd9 on branch refs/heads/issue-6565 --- .../lib/components/WorkBench/WbValidation.tsx | 56 +++++++++---------- .../WorkBench/__tests__/resultsParser.test.ts | 6 +- .../WorkBench/resultMessageResolvers.ts | 22 ++++++-- .../lib/components/WorkBench/resultsParser.ts | 5 +- 4 files changed, 50 insertions(+), 39 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx index 281abdcba31..c6102836a57 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/WbValidation.tsx @@ -83,8 +83,7 @@ const hasUploadInfo = ( readonly name: string; } | null; }; -} => - typeof value === 'object' && value !== null && 'info' in value; +} => typeof value === 'object' && value !== null && 'info' in value; /* eslint-disable functional/no-this-expression */ export class WbValidation { @@ -403,20 +402,20 @@ export class WbValidation { 'MatchedAndChanged' in recordResult || 'Deleted' in recordResult ) { - const [statusKey, statusData, metaKey] = 'Uploaded' in recordResult - ? (['Uploaded', recordResult.Uploaded, 'isNew'] as const) - : 'Updated' in recordResult - ? (['Updated', recordResult.Updated, 'isUpdated'] as const) - : 'MatchedAndChanged' in recordResult - ? (['MatchedAndChanged', recordResult.MatchedAndChanged, 'isMatchedAndChanged'] as const) - : (['Deleted', recordResult.Deleted, 'isDeleted'] as const); - - setMetaCallback( - metaKey, - true, - statusData.info.columns, - undefined - ); + const [statusKey, statusData, metaKey] = + 'Uploaded' in recordResult + ? (['Uploaded', recordResult.Uploaded, 'isNew'] as const) + : 'Updated' in recordResult + ? (['Updated', recordResult.Updated, 'isUpdated'] as const) + : 'MatchedAndChanged' in recordResult + ? ([ + 'MatchedAndChanged', + recordResult.MatchedAndChanged, + 'isMatchedAndChanged', + ] as const) + : (['Deleted', recordResult.Deleted, 'isDeleted'] as const); + + setMetaCallback(metaKey, true, statusData.info.columns, undefined); const tableName = statusData.info.tableName.toLowerCase() as Lowercase< keyof Tables @@ -429,19 +428,18 @@ export class WbValidation { const writable = this.uploadResults.interestingRecords; writable[physicalRow] ??= []; - this.resolveValidationColumns( - statusData.info.columns, - undefined - ).forEach((physicalCol) => { - writable[physicalRow]![physicalCol] ??= []; - writable[physicalRow]![physicalCol].push([ - tableName, - statusData.id, - statusData.info?.treeInfo - ? `${statusData.info.treeInfo!.name} (${statusData.info.treeInfo!.rank})` - : '', - ]); - }); + this.resolveValidationColumns(statusData.info.columns, undefined).forEach( + (physicalCol) => { + writable[physicalRow]![physicalCol] ??= []; + writable[physicalRow]![physicalCol].push([ + tableName, + statusData.id, + statusData.info?.treeInfo + ? `${statusData.info.treeInfo!.name} (${statusData.info.treeInfo!.rank})` + : '', + ]); + } + ); } else raise( new Error( diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts index 4d3e792d40c..79833ac223c 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/resultsParser.test.ts @@ -51,9 +51,9 @@ describe('resolveValidationMessage business-rule handling', () => { conflicting: [4], }; - expect(resolveValidationMessage('backend raw business-rule text', payload)).toBe( - 'backend raw business-rule text' - ); + expect( + resolveValidationMessage('backend raw business-rule text', payload) + ).toBe('backend raw business-rule text'); }); test('resolves datasetAlreadyUploaded via localizationKey payload', () => { diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts index c88dd3c9c34..c057d78d478 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultMessageResolvers.ts @@ -16,7 +16,10 @@ type BusinessRuleMessageResolver = ( payload: IR ) => LocalizedString | undefined; -export const backendParsingMessageResolvers: RR = { +export const backendParsingMessageResolvers: RR< + string, + PayloadMessageResolver +> = { failedParsingBoolean: (payload): LocalizedString => backEndText.failedParsingBoolean({ value: payload.value as string }), failedParsingDecimal: (payload): LocalizedString => @@ -142,7 +145,10 @@ function getSchemaFieldLabels( : formatConjunction(labels); } -export const businessRuleMessageResolvers: RR = { +export const businessRuleMessageResolvers: RR< + string, + BusinessRuleMessageResolver +> = { fieldNotUnique: (payload): LocalizedString | undefined => { const tableName = getStringPayload(payload, 'table'); const fieldName = getStringPayload(payload, 'fieldName'); @@ -179,7 +185,8 @@ export const businessRuleMessageResolvers: RR backEndText.deletingTreeRoot(), - nodeParentInvalidRank: (): LocalizedString => backEndText.nodeParentInvalidRank(), + nodeParentInvalidRank: (): LocalizedString => + backEndText.nodeParentInvalidRank(), nodeChildrenInvalidRank: (): LocalizedString => backEndText.nodeChildrenInvalidRank(), nodeOperationToSynonymizedParent: (payload): LocalizedString => @@ -286,7 +293,8 @@ export const validationMessageResolvers: RR = { }), fieldRequiredByUploadPlan: (): LocalizedString => backEndText.fieldRequiredByUploadPlan(), - invalidTreeStructure: (): LocalizedString => backEndText.invalidTreeStructure(), + invalidTreeStructure: (): LocalizedString => + backEndText.invalidTreeStructure(), scopeChangeError: (): LocalizedString => backEndText.scopeChangeDetected(), multipleTreeDefsInRow: (): LocalizedString => backEndText.multipleTreeDefsInRow(), @@ -323,6 +331,8 @@ export const attachmentValidationMessageResolvers: RR< export function resolveAttachmentValidationMessageByKey( key: string ): LocalizedString { - return attachmentValidationMessageResolvers[key]?.() ?? - backEndText.attachmentNotFound(); + return ( + attachmentValidationMessageResolvers[key]?.() ?? + backEndText.attachmentNotFound() + ); } diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts index fd222fa67e7..c3935480631 100644 --- a/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/resultsParser.ts @@ -30,7 +30,10 @@ export function resolveValidationMessage( return businessRuleMessage; } - const specificValidationMessage = resolveSpecificValidationMessage(key, payload); + const specificValidationMessage = resolveSpecificValidationMessage( + key, + payload + ); if (specificValidationMessage !== undefined) { return specificValidationMessage; }