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/DataEntryTables/Edit.tsx b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx index 649641d86ee..915edcdbbf8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx @@ -75,7 +75,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..2f3f53217c4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { dataViewsText } from '../../localization/dataViews'; +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'; + +const defaultDataViewTablesConfig: RA = [ + 'Accession', + 'Agent', + 'CollectionObject', + 'CollectingEvent', + 'Gift', + 'Loan', + 'Locality', +]; + +export function DataViewTables(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + const [tables, setTables] = useDataViewTables(); + const [isEditing, handleEditing] = useBooleanState(); + const counts = useTableRecordCounts(tables); + return isEditing ? ( + + ) : ( + + + + {commonText.close()} + + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + headerButtons={} + icon={icons.eye} + onClose={handleClose} + > + `/specify/dataviews/${name.toLowerCase()}`} + tables={tables} + onClick={undefined} + /> + + ); +} + +/** + * 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 allowedTables = React.useMemo(() => { + const visibleTables = + tables.length === 0 + ? defaultDataViewTablesConfig.map(strictGetTable) + : tables.map(getTableById); + + return visibleTables.filter((table) => + tablesFilter(true, false, true, table) + ); + }, [tables]); + + 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..e02a579f7ae --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +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'; +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'; +import { attachmentsText } from '../../localization/attachments'; + +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 defaultOrder = + 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, { + offset, + limit: DEFAULT_FETCH_LIMIT, + domainFilter: true, + orderBy: order, + } as CollectionFetchFilters), + [order, table] + ); + + const [collection] = useAsyncState(handleFetchingCollection, true); + + return collection === undefined ? null : ( + + handleFetchingCollection(index).then(({ records }) => records) + } + headerButtons={ + <> + + {attachmentsText.orderBy()} +
+ + additionalFields={[{ name: 'id', label: commonText.id() }]} + includeHiddenFields + includeVirtualFields={false} + order={order} + table={table} + onChange={setOrder} + /> +
+
+ + } + /> + ); +} + +function DataViewFromTable({ + table, + totalCount: initialTotalCount, + initialRecords, + onFetchRecords: handleFetchRecords, + headerButtons, +}: { + readonly table: SpecifyTable; + readonly totalCount: number; + readonly initialRecords: RA>; + 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 + + const { + results: [collection], + totalCount: [totalCount], + onFetchMore: handleFetchMore, + } = usePaginatedCollection({ + fetchMore: handleFetchRecords, + initialRecords, + totalCount: initialTotalCount, + }); + + return collection === undefined ? null : ( + record?.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 7fec6f6f0b8..f2a253ab8ed 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'; @@ -14,7 +15,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 } from '../../utils/types'; import { ensure } from '../../utils/types'; import { icons } from '../Atoms/Icons'; import type { MenuItem } from '../Core/Main'; @@ -72,6 +73,11 @@ const rawMenuItems = ensure>>()({ hasToolPermission('queryBuilder', 'read') || hasPermission('/querybuilder/query', 'execute'), }, + dataViews: { + url: '/specify/overlay/dataviews/', + title: dataViewsText.dataViewsTitle(), + icon: icons.eye, + }, recordSets: { url: '/specify/overlay/record-sets/', title: commonText.recordSets(), 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/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 5c4eec0596f..375843c28e9 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -60,6 +60,7 @@ import type { PreferencesVisibilityContext, } from './types'; import { definePref } from './types'; +import { dataViewsText } from '../../localization/dataViews'; const isLightMode = ({ isDarkMode, @@ -1921,6 +1922,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 cca9d18d34a..d031eadf6c9 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'; @@ -119,7 +119,12 @@ 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 canMergeTable = canMerge(table); @@ -419,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 : ( diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx deleted file mode 100644 index 5877a45cc6b..00000000000 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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, - 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; - const resultsRef = React.useRef(results); - const handleSetResults: GetOrSet< - RA | undefined - >[1] = React.useCallback( - (results) => { - const resolved = - typeof results === 'function' ? results(resultsRef.current) : results; - setResults(resolved); - resultsRef.current = resolved; - }, - [setResults] - ); - - // Queue for fetching - const fetchersRef = React.useRef | void>>>({}); - - 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; - - // Prevent concurrent fetching in different places - fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex) - .then(async (newResults) => { - if ( - process.env.NODE_ENV === 'development' && - newResults.length > fetchSize - ) - softFail( - new Error( - `Returned ${newResults.length} results, when expected at most ${fetchSize}` - ) - ); - - // Results might have changed while fetching - const newCurrentResults = resultsRef.current ?? currentResults; - - // Not using Array.from() so as not to expand the sparse array - const combinedResults = newCurrentResults.slice(); - /* - * 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); - - handleSetResults(combinedResults); - - fetchersRef.current = removeKey( - fetchersRef.current, - fetchIndex.toString() - ); - - if (typeof index === 'number' && index >= combinedResults.length) - return handleFetchMore(index); - return newResults; - }) - .catch(raise); - - return fetchersRef.current[fetchIndex]; - }, - [fetchResults, fetchSize, setResults, totalCount] - ); - - return { - fetchersRef, - results: [results, handleSetResults], - onFetchMore: handleFetchMore, - totalCount: getSetTotalCount, - canFetchMore, - }; -} diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 61dd841d016..576610f511d 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -14,9 +14,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 */ /** @@ -141,6 +142,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 e5c97cfa656..a2f7a3081bf 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -376,6 +376,18 @@ export const routes: RA = [ }, ], }, + { + path: 'dataviews', + children: [ + { + path: ':tableName', + element: () => + import('../DataViews/index').then( + ({ TableDataView }) => TableDataView + ), + }, + ], + }, { path: 'user-preferences', title: preferencesText.preferences(), @@ -466,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/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index 5dc4454f599..892a9a193e8 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 = ( @@ -165,7 +170,13 @@ function Map({ tableName={tableName} totalCount={props.totalCount} onClose={handleClose} - onFetchMore={canFetchMore ? handleFetchMore : undefined} + onFetchMore={ + canFetchMore + ? async (): Promise => { + await handleFetchMore(); + } + : undefined + } /> ); } 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/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index 07d319df75d..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,15 +109,27 @@ 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 (
    {tables.map(({ name, label }, index) => (
  • - +
  • ))}
@@ -177,21 +190,49 @@ export function QueryTablesWrapper({ function QueryTableItem({ name, label, + count, + isCountLoading, onClick: handleClick, + getHref, }: { readonly name: keyof Tables; readonly label: LocalizedString; + readonly count: number | undefined; + readonly isCountLoading: boolean; readonly onClick: ((tableName: keyof Tables) => 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} ); } 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 +} diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx new file mode 100644 index 00000000000..539931b58c1 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +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 = 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 + >[typeof SET] = React.useCallback( + (results) => { + const resolved = + typeof results === 'function' ? results(resultsRef.current) : results; + setResults(resolved); + resultsRef.current = resolved; + }, + [setResults] + ); + + // Queue for fetching + const fetchersRef = React.useRef | undefined>>>( + {} + ); + + const getSetTotalCount = useTriggerState( + initialTotalCount + ); + const [totalCount] = getSetTotalCount; + const canFetchMore = + !Array.isArray(results) || + totalCount === undefined || + results.length < totalCount || + results.includes(undefined); + + 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[index] ??= rawHandleFetchMore(index) + .then(async (newResults) => { + if ( + process.env.NODE_ENV === 'development' && + newResults.length > fetchSize + ) + softFail( + new Error( + `Returned ${newResults.length} results, when expected at most ${fetchSize}` + ) + ); + + // Results might have changed while fetching + const newCurrentResults = resultsRef.current ?? currentResults; + + // Not using Array.from() so as not to expand the sparse array + const combinedResults = newCurrentResults.slice(); + /* + * This extends the sparse array to fit new results. Without this, + * splice won't place the results in the correct place. + */ + combinedResults[index] ??= undefined; + combinedResults.splice(index, newResults.length, ...newResults); + + handleSetResults(combinedResults); + + fetchersRef.current = removeKey( + fetchersRef.current, + index.toString() + ); + + if (typeof index === 'number' && index >= combinedResults.length) + return handleFetchMore(index); + return newResults; + }) + .catch((error) => { + fetchersRef.current = removeKey( + fetchersRef.current, + index.toString() + ); + 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; + + 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( + 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); + }, + [rawHandleFetchMore, fetchSize, setResults, totalCount] + ); + + return { + 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..378389d49cd --- /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', + }, +} as const);