From 3e333655b7b2b34cb42e2b2f1820c1cfb9d6ee74 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 15:39:59 +0200 Subject: [PATCH 01/70] Feat: Add a new data view boolean field to query table --- specifyweb/frontend/js_src/lib/components/DataModel/types.ts | 1 + specifyweb/specify/datamodel.py | 1 + specifyweb/specify/models.py | 1 + 3 files changed, 3 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts index 55992cb2255..d5d39d0615a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts @@ -5363,6 +5363,7 @@ export type SpQuery = { readonly contextTableId: number; readonly countOnly: boolean | null; readonly formatAuditRecIds: boolean | null; + readonly isDataView: boolean | null; readonly isFavorite: boolean | null; readonly name: string; readonly ordinal: number | null; diff --git a/specifyweb/specify/datamodel.py b/specifyweb/specify/datamodel.py index 7c6d3f86326..50b7451e9af 100644 --- a/specifyweb/specify/datamodel.py +++ b/specifyweb/specify/datamodel.py @@ -6681,6 +6681,7 @@ def is_tree_table(table: Table): Field(name='contextTableId', column='ContextTableId', indexed=False, unique=False, required=True, type='java.lang.Short'), Field(name='countOnly', column='CountOnly', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='formatAuditRecIds', column='FormatAuditRecIds', indexed=False, unique=False, required=False, type='java.lang.Boolean'), + Field(name='isDataView', column='IsDataView', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='isFavorite', column='IsFavorite', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='name', column='Name', indexed=True, unique=False, required=True, type='java.lang.String', length=256), Field(name='ordinal', column='Ordinal', indexed=False, unique=False, required=False, type='java.lang.Short'), diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 6ab316a15f8..12e713377e4 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -6520,6 +6520,7 @@ class Spquery(models.Model): contexttableid = models.SmallIntegerField(blank=False, null=False, unique=False, db_column='ContextTableId', db_index=False) countonly = models.BooleanField(blank=True, null=True, unique=False, db_column='CountOnly', db_index=False, default=False) formatauditrecids = models.BooleanField(blank=True, null=True, unique=False, db_column='FormatAuditRecIds', db_index=False) + isdataview = models.BooleanField(blank=True, null=True, unique=False, db_column='IsDataView', db_index=False, default=False) isfavorite = models.BooleanField(blank=True, null=True, unique=False, db_column='IsFavorite', db_index=False) name = models.CharField(blank=False, max_length=256, null=False, unique=False, db_column='Name', db_index=False) ordinal = models.SmallIntegerField(blank=True, null=True, unique=False, db_column='Ordinal', db_index=False) From 76754e59f19a0522311b94fe2e34cb2a4634a53d Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 15:40:28 +0200 Subject: [PATCH 02/70] Chore: Create migration for new query field --- .../migrations/0049_spquery_isdataview.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 specifyweb/specify/migrations/0049_spquery_isdataview.py diff --git a/specifyweb/specify/migrations/0049_spquery_isdataview.py b/specifyweb/specify/migrations/0049_spquery_isdataview.py new file mode 100644 index 00000000000..3698b0bfe33 --- /dev/null +++ b/specifyweb/specify/migrations/0049_spquery_isdataview.py @@ -0,0 +1,18 @@ +# Generated manually: adds Spquery.isdataview used to distinguish Data View queries + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('specify', '0048_taxontreedefitem_parent_context_delete'), + ] + + operations = [ + migrations.AddField( + model_name='spquery', + name='isdataview', + field=models.BooleanField(blank=True, db_column='IsDataView', default=False, null=True), + ), + ] From 7e227f7ea0928f470be6134264833220557f8367 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 15:47:33 +0200 Subject: [PATCH 03/70] Feat: Add data view table list to collection pref --- .../Preferences/CollectionDefinitions.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index dc524fa589a..f90f437cc87 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -1,15 +1,17 @@ import type { LocalizedString } from 'typesafe-i18n'; import { attachmentsText } from '../../localization/attachments'; +import { dataViewsText } from '../../localization/dataViews'; import { preferencesText } from '../../localization/preferences'; import { queryText } from '../../localization/query'; import { specifyNetworkText } from '../../localization/specifyNetwork'; import { statsText } from '../../localization/stats'; import { treeText } from '../../localization/tree'; import { f } from '../../utils/functools'; -import type { RA } from '../../utils/types'; -import { ensure } from '../../utils/types'; +import type { IR, RA } from '../../utils/types'; +import { ensure, localized } from '../../utils/types'; import { camelToHuman } from '../../utils/utils'; +import type { DataViewTableConfig } from '../DataViews/fields'; import { genericTables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import type { QueryView } from '../QueryBuilder/Header'; @@ -308,6 +310,24 @@ export const collectionPreferenceDefinitions = { }, }, }, + dataViews: { + title: dataViewsText.dataViewsTitle(), + subCategories: { + general: { + title: preferencesText.general(), + items: { + tableConfigs: definePref>({ + title: localized('_dataViewTableConfigs'), + requiresReload: false, + visible: false, + defaultValue: {}, + renderer: f.never, + container: 'div', + }), + }, + }, + }, + }, } as const; ensure()(collectionPreferenceDefinitions); From 1107e5a9691e2ff63f8dac89c9c09bd56787e00e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 15:53:56 +0200 Subject: [PATCH 04/70] Feat: Create default data view queries --- .../components/DataViews/defaultQueries.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts new file mode 100644 index 00000000000..6b718b05516 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts @@ -0,0 +1,93 @@ +/** + * Hardcoded default field/relationship lists used to seed a starter Data View + * query for the built-in default tables (see DataViewTables.tsx). + */ + +import { dataViewsText } from '../../localization/dataViews'; +import type { RA, RR } from '../../utils/types'; +import { strictGetTable } from '../DataModel/tables'; +import type { Tables } from '../DataModel/types'; +import { createQuery } from '../QueryBuilder'; +import { makeSerializedFieldsFromPaths } from '../Statistics/hooks'; + +export const defaultDataViewQueryFields: Partial>> = + { + Accession: [ + 'accessionNumber', + 'status', + 'type', + 'remarks', + 'division.name', + 'repositoryAgreement.repositoryAgreementNumber', + ], + Agent: [ + 'firstName', + 'lastName', + 'email', + 'jobTitle', + 'division.name', + 'organization.lastName', + ], + CollectionObject: [ + 'catalogNumber', + 'fieldNumber', + 'description', + 'collectionObjectType.name', + 'collection.collectionName', + ], + CollectingEvent: [ + 'stationFieldNumber', + 'startDate', + 'method', + 'remarks', + 'locality.localityName', + 'discipline.name', + ], + Gift: [ + 'giftNumber', + 'giftDate', + 'status', + 'purposeOfGift', + 'discipline.name', + 'division.name', + ], + Loan: [ + 'loanNumber', + 'loanDate', + 'currentDueDate', + 'status', + 'discipline.name', + 'division.name', + ], + Locality: [ + 'localityName', + 'namedPlace', + 'remarks', + 'geography.name', + 'discipline.name', + ], + }; + +/** Creates and saves a default Data View query for a table. Returns its new id */ +export async function createDefaultDataViewQuery( + tableName: keyof Tables +): Promise { + const paths = defaultDataViewQueryFields[tableName]; + if (paths === undefined) return undefined; + + const table = strictGetTable(tableName); + const query = createQuery( + dataViewsText.dataViewQueryName({ tableLabel: table.label }), + table + ); + query.set('isDataView', true); + query.set( + 'fields', + makeSerializedFieldsFromPaths( + tableName, + paths.map((path) => ({ path })) + ) + ); + await query.save(); + return query.id; +} From 1c4d62b7382d973e9d4daf5d62d26a10b702e81e Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 17:54:26 +0200 Subject: [PATCH 05/70] Feat: Add data view query builder and split panel --- .../components/DataViews/DataViewTables.tsx | 135 +++++- .../lib/components/DataViews/QueryEditor.tsx | 69 +++ .../js_src/lib/components/DataViews/index.tsx | 452 ++++++++++++++---- .../Preferences/CollectionDefinitions.tsx | 24 +- .../lib/components/QueryBuilder/Results.tsx | 2 +- .../lib/components/QueryBuilder/Toolbar.tsx | 11 +- .../lib/components/QueryBuilder/Wrapped.tsx | 4 + .../js_src/lib/components/Toolbar/Query.tsx | 2 + .../components/Toolbar/QueryTablesWrapper.tsx | 57 ++- .../js_src/lib/localization/dataViews.ts | 18 + 10 files changed, 612 insertions(+), 162 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 2f3f53217c4..66cdb08aa50 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -8,12 +8,14 @@ import type { GetSet, IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; +import { fetchCollection } from '../DataModel/collection'; 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 { hasToolPermission } from '../Permissions/helpers'; import { userPreferences } from '../Preferences/userPreferences'; import { OverlayContext } from '../Router/Router'; import { tablesFilter } from '../SchemaConfig/Tables'; @@ -23,6 +25,8 @@ import { } from '../Statistics/hooks'; import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; import { QueryTables } from '../Toolbar/QueryTablesWrapper'; +import { createDefaultDataViewQuery } from './defaultQueries'; +import { DataViewQueryEditor } from './QueryEditor'; const defaultDataViewTablesConfig: RA = [ 'Accession', @@ -39,6 +43,10 @@ export function DataViewTables(): JSX.Element { const [tables, setTables] = useDataViewTables(); const [isEditing, handleEditing] = useBooleanState(); const counts = useTableRecordCounts(tables); + const [queryIds, refreshQueryId] = useDataViewQueryIds(tables); + const [editingQueryTable, setEditingQueryTable] = React.useState< + SpecifyTable | undefined + >(undefined); return isEditing ? ( ) : ( - - - - {commonText.close()} - - - } - className={{ - container: dialogClassNames.narrowContainer, - }} - headerButtons={} - icon={icons.eye} - onClose={handleClose} - > - `/specify/dataviews/${name.toLowerCase()}`} - tables={tables} - onClick={undefined} - /> - + <> + + + + {commonText.close()} + + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + headerButtons={} + icon={icons.eye} + onClose={handleClose} + > + `/specify/dataviews/${name.toLowerCase()}`} + isDisabled={(table): boolean => + typeof queryIds[table.name] !== 'number' + } + tables={tables} + onClick={undefined} + renderAction={(table): JSX.Element => { + const hasQuery = typeof queryIds[table.name] === 'number'; + return ( + setEditingQueryTable(table)} + /> + ); + }} + /> + + {editingQueryTable !== undefined && ( + setEditingQueryTable(undefined)} + onSaved={(): void => refreshQueryId(editingQueryTable)} + /> + )} + ); } @@ -118,6 +154,57 @@ function useTableRecordCounts( return counts; } +/** + * Looks up each table's saved "Data View" query (Spquery.isDataView=true), + * if one exists. Absent key = still loading; undefined value = none found. + */ +function useDataViewQueryIds( + tables: RA +): readonly [IR, (table: SpecifyTable) => void] { + const [queryIds, setQueryIds] = React.useState>({}); + const seededTables = React.useRef>(new Set()); + + const fetchQueryId = React.useCallback(async (table: SpecifyTable) => { + const { records } = await fetchCollection('SpQuery', { + contextTableId: table.tableId, + isDataView: true, + domainFilter: false, + limit: 1, + }); + let queryId: number | undefined = records[0]?.id; + if ( + queryId === undefined && + !seededTables.current.has(table.name) && + hasToolPermission('queryBuilder', 'create') + ) { + seededTables.current.add(table.name); + queryId = await createDefaultDataViewQuery(table.name).catch( + (): undefined => undefined + ); + } + setQueryIds((previousIds) => ({ + ...previousIds, + [table.name]: queryId, + })); + }, []); + + React.useEffect(() => { + let destructorCalled = false; + tables.forEach((table) => { + fetchQueryId(table) + .then(() => undefined) + .catch((error) => { + if (!destructorCalled) raise(error); + }); + }); + return (): void => { + destructorCalled = true; + }; + }, [tables, fetchQueryId]); + + return [queryIds, fetchQueryId] as const; +} + function useDataViewTables(): GetSet> { const [tables, setTables] = userPreferences.use( 'dataViews', diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx new file mode 100644 index 00000000000..eb4b089e3db --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { dataViewsText } from '../../localization/dataViews'; +import { commonText } from '../../localization/common'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { fetchResource, resourceOn } from '../DataModel/resource'; +import { deserializeResource } from '../DataModel/serializers'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import type { SpQuery } from '../DataModel/types'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { createQuery } from '../QueryBuilder'; +import { QueryBuilder } from '../QueryBuilder/Wrapped'; + +/** + * Let a user create or edit the single "Data View" query for a table + */ +export function DataViewQueryEditor({ + table, + queryId, + onClose: handleClose, + onSaved: handleSaved, +}: { + readonly table: SpecifyTable; + readonly queryId: number | undefined; + readonly onClose: () => void; + readonly onSaved: () => void; +}): JSX.Element | null { + const [query] = useAsyncState>( + React.useCallback(async () => { + if (typeof queryId === 'number') + return fetchResource('SpQuery', queryId).then(deserializeResource); + const newQuery = createQuery( + dataViewsText.dataViewQueryName({ tableLabel: table.label }), + table + ); + newQuery.set('isDataView', true); + return newQuery; + }, [queryId, table]), + true + ); + + React.useEffect( + () => + query === undefined + ? undefined + : resourceOn(query, 'saved', handleSaved, false), + [query, handleSaved] + ); + + return query === undefined ? null : ( + + + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index e02a579f7ae..69477e6b090 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,26 +1,39 @@ import React from 'react'; +import Splitter from 'm-react-splitters'; import { useParams } from 'react-router-dom'; -import { - type CollectionFetchFilters, - DEFAULT_FETCH_LIMIT, - fetchCollection, -} from '../DataModel/collection'; +import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { fetchResource } from '../DataModel/resource'; 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 { ResourceView } from '../Forms/ResourceView'; import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; -import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; -import { Tables } from '../DataModel/types'; +import type { SpQuery } from '../DataModel/types'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { useInfiniteScroll } from '../../hooks/useInfiniteScroll'; import { RA } from '../../utils/types'; -import { Label } from '../Atoms/Form'; -import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers'; -import { attachmentsText } from '../../localization/attachments'; +import { replaceItem } from '../../utils/utils'; +import { Container, H3 } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { loadingGif } from '../Molecules'; +import { Http } from '../../utils/ajax/definitions'; +import { userPreferences } from '../Preferences/userPreferences'; +import { interactionsText } from '../../localization/interactions'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { + flippedSortTypes, + type SortTypes, + sortTypes, +} from '../QueryBuilder/helpers'; +import { QueryResultsTable } from '../QueryBuilder/ResultsTable'; +import { type QueryResultRow, TableHeaderCell } from '../QueryBuilder/Results'; +import { runQuery } from '../QueryBuilder/ResultsWrapper'; +import { QueryToForms } from '../QueryBuilder/ToForms'; +import { queryCountPromiseGenerator } from '../Statistics/hooks'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -36,62 +49,137 @@ export function TableDataView(): JSX.Element { ); } +/** The table's saved Data View query (Spquery.isDataView=true), or false if none exists */ +function useDataViewQuery( + table: SpecifyTable +): SerializedResource | false | undefined { + const [query] = useAsyncState | false>( + React.useCallback(async () => { + const { records } = await fetchCollection('SpQuery', { + contextTableId: table.tableId, + isDataView: true, + domainFilter: false, + limit: 1, + }); + return records.length === 0 + ? false + : fetchResource('SpQuery', records[0].id); + }, [table]), + true + ); + return query; +} + function DataViewFromTableWrapped({ table, }: { readonly table: SpecifyTable; }): JSX.Element | null { - const defaultOrder = - table.getLiteralField('timestampCreated') === undefined - ? '-id' - : '-timestampCreated'; + const dataViewQuery = useDataViewQuery(table); - const [order, setOrder] = React.useState>( - defaultOrder as OrderPickerOrder + const displayFields = React.useMemo( + () => + dataViewQuery === undefined || dataViewQuery === false + ? undefined + : dataViewQuery.fields + .filter((field) => field.isDisplay) + .sort((left, right) => left.position - right.position), + [dataViewQuery] ); - 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 fieldSpecs = React.useMemo( + () => + displayFields?.map((field) => + QueryFieldSpec.fromStringId(field.stringId, field.isRelFld ?? false) + ), + [displayFields] ); - const [collection] = useAsyncState(handleFetchingCollection, true); + const [columnSort, setColumnSort] = React.useState>([]); + React.useEffect( + () => + setColumnSort( + displayFields?.map((field) => sortTypes[field.sortType]) ?? [] + ), + [displayFields] + ); + + // The executed query, with each display field's sortType overridden by columnSort + const sortedQuery = React.useMemo((): + | SerializedResource + | undefined => { + if ( + displayFields === undefined || + dataViewQuery === undefined || + dataViewQuery === false + ) + return undefined; + const sortByStringId = new Map( + displayFields.map((field, index) => [field.stringId, columnSort[index]]) + ); + return { + ...dataViewQuery, + fields: dataViewQuery.fields.map((field) => + sortByStringId.has(field.stringId) + ? { + ...field, + sortType: + flippedSortTypes[sortByStringId.get(field.stringId) ?? 'none'], + } + : field + ), + }; + }, [dataViewQuery, displayFields, columnSort]); + + const totalCountRef = React.useRef(undefined); + + const fetchRows = React.useCallback( + async ( + offset: number, + limit = DEFAULT_FETCH_LIMIT + ): Promise> => { + if ( + dataViewQuery === undefined || + dataViewQuery === false || + sortedQuery === undefined + ) + return []; + if (totalCountRef.current === undefined) { + const countResponse = await queryCountPromiseGenerator(dataViewQuery)(); + totalCountRef.current = + countResponse.status === Http.OK ? countResponse.data.count : 0; + } + return runQuery(sortedQuery, { limit, offset }); + }, + [dataViewQuery, sortedQuery] + ); - return collection === undefined ? null : ( + const [initialRows] = useAsyncState( + React.useCallback( + async () => (dataViewQuery === undefined ? undefined : fetchRows(0)), + [dataViewQuery, fetchRows] + ), + true + ); + + if (dataViewQuery === false) + return ( + +

{dataViewsText.noDataViewQuery()}

+
+ ); + + return fieldSpecs === undefined || initialRows === 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} - /> -
-
- + columnSort={columnSort} + fieldSpecs={fieldSpecs} + initialRows={initialRows} + totalCount={totalCountRef.current ?? initialRows.length} + onFetchRows={fetchRows} + onSortChange={(index, sortType): void => + setColumnSort((previous) => replaceItem(previous, index, sortType)) } /> ); @@ -99,53 +187,219 @@ function DataViewFromTableWrapped({ function DataViewFromTable({ table, - totalCount: initialTotalCount, - initialRecords, - onFetchRecords: handleFetchRecords, - headerButtons, + fieldSpecs, + totalCount, + initialRows, + columnSort, + onFetchRows: handleFetchRows, + onSortChange: handleSortChange, }: { readonly table: SpecifyTable; + readonly fieldSpecs: RA; readonly totalCount: number; - readonly initialRecords: RA>; - readonly onFetchRecords: ( - index: number - ) => Promise>>; - readonly headerButtons: JSX.Element; + readonly initialRows: RA; + readonly columnSort: RA; + readonly onFetchRows: ( + offset: number, + limit?: number + ) => Promise>; + readonly onSortChange: (index: number, sortType: SortTypes) => void; }): 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); - }} - /> + const [rows, setRows] = React.useState>(initialRows); + const [selectedRows, setSelectedRows] = React.useState>( + new Set() + ); + const [activeId, setActiveId] = React.useState(undefined); + const canFetchMore = rows.length < totalCount; + + const [showLineNumber] = userPreferences.use( + 'queryBuilder', + 'appearance', + 'showLineNumber' + ); + + const handleLoadMore = React.useCallback(async (): Promise => { + const newRows = await handleFetchRows(rows.length); + setRows((previousRows) => [...previousRows, ...newRows]); + }, [handleFetchRows, rows.length]); + + const scrollerRef = React.useRef(null); + const { isFetching, handleScroll } = useInfiniteScroll( + canFetchMore ? handleLoadMore : undefined, + scrollerRef + ); + + const handleDelete = (id: number): void => { + setRows((previousRows) => previousRows.filter((row) => row[0] !== id)); + setSelectedRows( + (previousSelected) => + new Set(Array.from(previousSelected).filter((rowId) => rowId !== id)) + ); + setActiveId((previousActiveId) => + previousActiveId === id ? undefined : previousActiveId + ); + }; + + const handleSaved = async (): Promise => { + const refreshedRows = await handleFetchRows(0, rows.length); + setRows(refreshedRows); + }; + + return ( + +
+

+ {commonText.colonLine({ + label: dataViewsText.tableRecords({ tableLabel: table.label }), + value: `(${ + selectedRows.size === 0 + ? totalCount + : `${selectedRows.size}/${totalCount}` + })`, + })} +

+ {selectedRows.size > 0 && ( + setSelectedRows(new Set())}> + {interactionsText.deselectAll()} + + )} +
+ +
+
+ +
'minmax(120px,1fr)'), + ].join(' '), + }} + > +
+
+ {showLineNumber && ( + + )} + + + {fieldSpecs.map((fieldSpec, index) => ( + + handleSortChange(index, sortType) + } + /> + ))} +
+
+
+ { + const id = rows[index][0] as number; + setSelectedRows((previousSelected) => { + const newSelected = new Set(previousSelected); + if (isSelected) newSelected.add(id); + else newSelected.delete(id); + return newSelected; + }); + if (isShiftClick) return; + setActiveId((previousActiveId) => + isSelected + ? id + : previousActiveId === id + ? undefined + : previousActiveId + ); + }} + /> + {isFetching && ( +
+ {loadingGif} +
+ )} +
+
+ setActiveId(undefined)} + onDeleted={(id): void => handleDelete(id)} + onSaved={(): void => void handleSaved()} + /> +
+
+ + ); +} + +function DataViewRecordPane({ + table, + recordId, + onClose: handleClose, + onSaved: handleSaved, + onDeleted: handleDeleted, +}: { + readonly table: SpecifyTable; + readonly recordId: number | undefined; + readonly onClose: () => void; + readonly onSaved: (id: number) => void; + readonly onDeleted: (id: number) => void; +}): JSX.Element { + const resource = React.useMemo | undefined>( + () => + recordId === undefined ? undefined : new table.Resource({ id: recordId }), + [table, recordId] + ); + + return ( +
+ {resource === undefined ? ( +
+ {dataViewsText.selectRecordToView()} +
+ ) : ( + handleDeleted(recordId!)} + onSaved={(): void => handleSaved(recordId!)} + /> + )} +
); } diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx index f90f437cc87..dc524fa589a 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/CollectionDefinitions.tsx @@ -1,17 +1,15 @@ import type { LocalizedString } from 'typesafe-i18n'; import { attachmentsText } from '../../localization/attachments'; -import { dataViewsText } from '../../localization/dataViews'; import { preferencesText } from '../../localization/preferences'; import { queryText } from '../../localization/query'; import { specifyNetworkText } from '../../localization/specifyNetwork'; import { statsText } from '../../localization/stats'; import { treeText } from '../../localization/tree'; import { f } from '../../utils/functools'; -import type { IR, RA } from '../../utils/types'; -import { ensure, localized } from '../../utils/types'; +import type { RA } from '../../utils/types'; +import { ensure } from '../../utils/types'; import { camelToHuman } from '../../utils/utils'; -import type { DataViewTableConfig } from '../DataViews/fields'; import { genericTables } from '../DataModel/tables'; import type { Tables } from '../DataModel/types'; import type { QueryView } from '../QueryBuilder/Header'; @@ -310,24 +308,6 @@ export const collectionPreferenceDefinitions = { }, }, }, - dataViews: { - title: dataViewsText.dataViewsTitle(), - subCategories: { - general: { - title: preferencesText.general(), - items: { - tableConfigs: definePref>({ - title: localized('_dataViewTableConfigs'), - requiresReload: false, - visible: false, - defaultValue: {}, - renderer: f.never, - container: 'div', - }), - }, - }, - }, - }, } as const; ensure()(collectionPreferenceDefinitions); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index d031eadf6c9..efcc0ca536d 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -564,7 +564,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ); } -function TableHeaderCell({ +export function TableHeaderCell({ columnIndex, fieldSpec, sortConfig, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index 7af71e77d03..e708f7d76cc 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -16,6 +16,7 @@ export function QueryToolbar({ isSeries, showSeries, searchSynonymy, + hideRunButton = false, onToggleHidden: handleToggleHidden, onToggleDistinct: handleToggleDistinct, onToggleSeries: handleToggleSeries, @@ -29,6 +30,8 @@ export function QueryToolbar({ readonly isSeries: boolean; readonly showSeries: boolean; readonly searchSynonymy: boolean; + /** When true, hides the "Query" run button (used by the Data Views query editor) */ + readonly hideRunButton?: boolean; readonly onToggleHidden: (value: boolean) => void; readonly onToggleDistinct: () => void; readonly onToggleSeries: () => void; @@ -84,9 +87,11 @@ export function QueryToolbar({ {queryText.countOnly()} - - {queryText.query()} - + {!hideRunButton && ( + + {queryText.query()} + + )} )}
diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 80c360c86de..4feffd5c331 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -83,6 +83,7 @@ function Wrapped({ forceCollection, isEmbedded = false, autoRun = false, + hideRunButton = false, // If present, this callback is called when query results are selected onSelected: handleSelected, onChange: handleChange, @@ -92,6 +93,8 @@ function Wrapped({ readonly forceCollection: number | undefined; readonly isEmbedded?: boolean; readonly autoRun?: boolean; + /** When true, hides the toolbar's "Query" run button (used by the Data Views query editor) */ + readonly hideRunButton?: boolean; readonly onSelected?: (selected: RA) => void; readonly onChange?: (props: { readonly fields: RA>; @@ -585,6 +588,7 @@ function Wrapped({ showHiddenFields={showHiddenFields} showSeries={showSeries} tableName={table.name} + hideRunButton={hideRunButton} onRunCountOnly={(): void => runQuery('count')} onSubmitClick={(): void => form?.checkValidity() === false diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx index 63c65c11c1c..9eda50a5467 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx @@ -111,6 +111,8 @@ export function QueryListDialog({ limit, domainFilter: false, ...(filters ?? { specifyUser: userInformation.id }), + // Data View queries are managed from the Data Views dialog, not here + isDataView: false, offset, orderBy, }), diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index af3c80810f7..ddc6bdfe444 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx @@ -112,26 +112,46 @@ export function QueryTables({ counts, getHref = (tableName): string => `/specify/query/new/${tableName.toLowerCase()}/`, + renderAction, + isDisabled, + disabledTitle, }: { readonly tables: RA; readonly onClick: ((tableName: keyof Tables) => void) | undefined; readonly counts?: IR; readonly getHref?: (tableName: keyof Tables) => string; + readonly renderAction?: (table: SpecifyTable) => JSX.Element | undefined; + readonly isDisabled?: (table: SpecifyTable) => boolean; + readonly disabledTitle?: LocalizedString; }): JSX.Element { return (
    - {tables.map(({ name, label }, index) => ( -
  • - -
  • - ))} + {tables.map((table, index) => { + const { name, label } = table; + return ( +
  • + + + {renderAction?.(table)} +
  • + ); + })}
); } @@ -194,6 +214,8 @@ function QueryTableItem({ isCountLoading, onClick: handleClick, getHref, + disabled, + disabledTitle, }: { readonly name: keyof Tables; readonly label: LocalizedString; @@ -201,6 +223,8 @@ function QueryTableItem({ readonly isCountLoading: boolean; readonly onClick: ((tableName: keyof Tables) => void) | undefined; readonly getHref: (tableName: keyof Tables) => string; + readonly disabled: boolean; + readonly disabledTitle?: LocalizedString; }): JSX.Element { const content = ( <> @@ -228,7 +252,14 @@ function QueryTableItem({ )} ); - return handleClick === undefined ? ( + return disabled ? ( + + {content} + + ) : handleClick === undefined ? ( {content} ) : ( handleClick(name)}> diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 378389d49cd..d76496cdc2e 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -20,4 +20,22 @@ export const dataViewsText = createDictionary({ configureDataViews: { 'en-us': 'Configure Data Views tables', }, + selectRecordToView: { + 'en-us': 'Select a record to view it here', + }, + dataViewQueryEditorTitle: { + 'en-us': '{tableLabel:string} Data View Query', + }, + dataViewQueryName: { + 'en-us': '{tableLabel:string} Data View', + }, + createDataViewQuery: { + 'en-us': 'Create Data View query', + }, + editDataViewQuery: { + 'en-us': 'Edit Data View query', + }, + noDataViewQuery: { + 'en-us': 'This table does not have a Data View query configured yet', + }, } as const); From 9e872c075759d8a698a1f7bdf4eaeb61a5554157 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 18:36:39 +0200 Subject: [PATCH 06/70] Clean: Remove new spquery field migration --- .../js_src/lib/components/DataModel/types.ts | 1 - specifyweb/specify/datamodel.py | 1 - .../migrations/0049_spquery_isdataview.py | 18 ------------------ specifyweb/specify/models.py | 1 - 4 files changed, 21 deletions(-) delete mode 100644 specifyweb/specify/migrations/0049_spquery_isdataview.py diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts index d5d39d0615a..55992cb2255 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/types.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/types.ts @@ -5363,7 +5363,6 @@ export type SpQuery = { readonly contextTableId: number; readonly countOnly: boolean | null; readonly formatAuditRecIds: boolean | null; - readonly isDataView: boolean | null; readonly isFavorite: boolean | null; readonly name: string; readonly ordinal: number | null; diff --git a/specifyweb/specify/datamodel.py b/specifyweb/specify/datamodel.py index 50b7451e9af..7c6d3f86326 100644 --- a/specifyweb/specify/datamodel.py +++ b/specifyweb/specify/datamodel.py @@ -6681,7 +6681,6 @@ def is_tree_table(table: Table): Field(name='contextTableId', column='ContextTableId', indexed=False, unique=False, required=True, type='java.lang.Short'), Field(name='countOnly', column='CountOnly', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='formatAuditRecIds', column='FormatAuditRecIds', indexed=False, unique=False, required=False, type='java.lang.Boolean'), - Field(name='isDataView', column='IsDataView', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='isFavorite', column='IsFavorite', indexed=False, unique=False, required=False, type='java.lang.Boolean'), Field(name='name', column='Name', indexed=True, unique=False, required=True, type='java.lang.String', length=256), Field(name='ordinal', column='Ordinal', indexed=False, unique=False, required=False, type='java.lang.Short'), diff --git a/specifyweb/specify/migrations/0049_spquery_isdataview.py b/specifyweb/specify/migrations/0049_spquery_isdataview.py deleted file mode 100644 index 3698b0bfe33..00000000000 --- a/specifyweb/specify/migrations/0049_spquery_isdataview.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated manually: adds Spquery.isdataview used to distinguish Data View queries - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('specify', '0048_taxontreedefitem_parent_context_delete'), - ] - - operations = [ - migrations.AddField( - model_name='spquery', - name='isdataview', - field=models.BooleanField(blank=True, db_column='IsDataView', default=False, null=True), - ), - ] diff --git a/specifyweb/specify/models.py b/specifyweb/specify/models.py index 12e713377e4..6ab316a15f8 100644 --- a/specifyweb/specify/models.py +++ b/specifyweb/specify/models.py @@ -6520,7 +6520,6 @@ class Spquery(models.Model): contexttableid = models.SmallIntegerField(blank=False, null=False, unique=False, db_column='ContextTableId', db_index=False) countonly = models.BooleanField(blank=True, null=True, unique=False, db_column='CountOnly', db_index=False, default=False) formatauditrecids = models.BooleanField(blank=True, null=True, unique=False, db_column='FormatAuditRecIds', db_index=False) - isdataview = models.BooleanField(blank=True, null=True, unique=False, db_column='IsDataView', db_index=False, default=False) isfavorite = models.BooleanField(blank=True, null=True, unique=False, db_column='IsFavorite', db_index=False) name = models.CharField(blank=False, max_length=256, null=False, unique=False, db_column='Name', db_index=False) ordinal = models.SmallIntegerField(blank=True, null=True, unique=False, db_column='Ordinal', db_index=False) From e3d7f7f7e292ed2d3eaa53164cf0e1883bc81855 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 18:37:27 +0200 Subject: [PATCH 07/70] Clean: Leave run query button for data views --- .../js_src/lib/components/QueryBuilder/Toolbar.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx index e708f7d76cc..7af71e77d03 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Toolbar.tsx @@ -16,7 +16,6 @@ export function QueryToolbar({ isSeries, showSeries, searchSynonymy, - hideRunButton = false, onToggleHidden: handleToggleHidden, onToggleDistinct: handleToggleDistinct, onToggleSeries: handleToggleSeries, @@ -30,8 +29,6 @@ export function QueryToolbar({ readonly isSeries: boolean; readonly showSeries: boolean; readonly searchSynonymy: boolean; - /** When true, hides the "Query" run button (used by the Data Views query editor) */ - readonly hideRunButton?: boolean; readonly onToggleHidden: (value: boolean) => void; readonly onToggleDistinct: () => void; readonly onToggleSeries: () => void; @@ -87,11 +84,9 @@ export function QueryToolbar({ {queryText.countOnly()} - {!hideRunButton && ( - - {queryText.query()} - - )} + + {queryText.query()} + )} From 54a471d9170925841bbe0b379d962ab9d948c7fb Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 18:38:04 +0200 Subject: [PATCH 08/70] Clean: remove default data view queries --- .../components/DataViews/defaultQueries.ts | 93 ------------------- 1 file changed, 93 deletions(-) delete mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts deleted file mode 100644 index 6b718b05516..00000000000 --- a/specifyweb/frontend/js_src/lib/components/DataViews/defaultQueries.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Hardcoded default field/relationship lists used to seed a starter Data View - * query for the built-in default tables (see DataViewTables.tsx). - */ - -import { dataViewsText } from '../../localization/dataViews'; -import type { RA, RR } from '../../utils/types'; -import { strictGetTable } from '../DataModel/tables'; -import type { Tables } from '../DataModel/types'; -import { createQuery } from '../QueryBuilder'; -import { makeSerializedFieldsFromPaths } from '../Statistics/hooks'; - -export const defaultDataViewQueryFields: Partial>> = - { - Accession: [ - 'accessionNumber', - 'status', - 'type', - 'remarks', - 'division.name', - 'repositoryAgreement.repositoryAgreementNumber', - ], - Agent: [ - 'firstName', - 'lastName', - 'email', - 'jobTitle', - 'division.name', - 'organization.lastName', - ], - CollectionObject: [ - 'catalogNumber', - 'fieldNumber', - 'description', - 'collectionObjectType.name', - 'collection.collectionName', - ], - CollectingEvent: [ - 'stationFieldNumber', - 'startDate', - 'method', - 'remarks', - 'locality.localityName', - 'discipline.name', - ], - Gift: [ - 'giftNumber', - 'giftDate', - 'status', - 'purposeOfGift', - 'discipline.name', - 'division.name', - ], - Loan: [ - 'loanNumber', - 'loanDate', - 'currentDueDate', - 'status', - 'discipline.name', - 'division.name', - ], - Locality: [ - 'localityName', - 'namedPlace', - 'remarks', - 'geography.name', - 'discipline.name', - ], - }; - -/** Creates and saves a default Data View query for a table. Returns its new id */ -export async function createDefaultDataViewQuery( - tableName: keyof Tables -): Promise { - const paths = defaultDataViewQueryFields[tableName]; - if (paths === undefined) return undefined; - - const table = strictGetTable(tableName); - const query = createQuery( - dataViewsText.dataViewQueryName({ tableLabel: table.label }), - table - ); - query.set('isDataView', true); - query.set( - 'fields', - makeSerializedFieldsFromPaths( - tableName, - paths.map((path) => ({ path })) - ) - ); - await query.save(); - return query.id; -} From 9f511113052c0ac25206c8361866a3285811d060 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 18:39:16 +0200 Subject: [PATCH 09/70] Clean: remove is data view from query component --- specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx index 9eda50a5467..63c65c11c1c 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/Query.tsx @@ -111,8 +111,6 @@ export function QueryListDialog({ limit, domainFilter: false, ...(filters ?? { specifyUser: userInformation.id }), - // Data View queries are managed from the Data Views dialog, not here - isDataView: false, offset, orderBy, }), From caa92c4e2cb19890847dde751c955c90effd18d5 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Mon, 31 Aug 2026 18:40:21 +0200 Subject: [PATCH 10/70] Clean: remove is data view actions --- .../components/Toolbar/QueryTablesWrapper.tsx | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index ddc6bdfe444..a3c8c4c0d33 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx @@ -109,20 +109,16 @@ export function useQueryTables(): GetSet> { export function QueryTables({ tables, onClick: handleClick, + onEdit: handleEdit, counts, getHref = (tableName): string => `/specify/query/new/${tableName.toLowerCase()}/`, - renderAction, - isDisabled, - disabledTitle, }: { readonly tables: RA; readonly onClick: ((tableName: keyof Tables) => void) | undefined; + readonly onEdit?: (table: SpecifyTable) => void; readonly counts?: IR; readonly getHref?: (tableName: keyof Tables) => string; - readonly renderAction?: (table: SpecifyTable) => JSX.Element | undefined; - readonly isDisabled?: (table: SpecifyTable) => boolean; - readonly disabledTitle?: LocalizedString; }): JSX.Element { return (
    @@ -131,16 +127,12 @@ export function QueryTables({ return (
  • - {renderAction?.(table)} + {handleEdit === undefined ? undefined : ( + handleEdit(table)} + /> + )}
  • ); })} @@ -214,8 +212,6 @@ function QueryTableItem({ isCountLoading, onClick: handleClick, getHref, - disabled, - disabledTitle, }: { readonly name: keyof Tables; readonly label: LocalizedString; @@ -223,8 +219,6 @@ function QueryTableItem({ readonly isCountLoading: boolean; readonly onClick: ((tableName: keyof Tables) => void) | undefined; readonly getHref: (tableName: keyof Tables) => string; - readonly disabled: boolean; - readonly disabledTitle?: LocalizedString; }): JSX.Element { const content = ( <> @@ -252,14 +246,7 @@ function QueryTableItem({ )} ); - return disabled ? ( - - {content} - - ) : handleClick === undefined ? ( + return handleClick === undefined ? ( {content} ) : ( handleClick(name)}> From f0ae140502fdd618e8bad205f150a7996ed2121c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 10:30:46 +0200 Subject: [PATCH 11/70] Test paginated collection behavior --- .../__tests__/usePaginatedCollection.test.tsx | 59 +++++++++++++++++++ .../lib/hooks/usePaginatedCollection.tsx | 24 ++++---- 2 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/hooks/__tests__/usePaginatedCollection.test.tsx diff --git a/specifyweb/frontend/js_src/lib/hooks/__tests__/usePaginatedCollection.test.tsx b/specifyweb/frontend/js_src/lib/hooks/__tests__/usePaginatedCollection.test.tsx new file mode 100644 index 00000000000..59547e3b0be --- /dev/null +++ b/specifyweb/frontend/js_src/lib/hooks/__tests__/usePaginatedCollection.test.tsx @@ -0,0 +1,59 @@ +import { act, renderHook } from '@testing-library/react'; + +import { usePaginatedCollection } from '../usePaginatedCollection'; + +test('recognizes a complete initial result set', () => { + const fetchMore = jest.fn(); + const initialRecords = [1, 2]; + const { result } = renderHook(() => + usePaginatedCollection({ + initialRecords, + totalCount: 2, + fetchMore, + }) + ); + + expect(result.current.results[0]).toEqual([1, 2]); + expect(result.current.canFetchMore).toBe(false); +}); + +test('appends the next page of results', async () => { + const fetchMore = jest.fn(async (offset: number) => + offset === 2 ? [3, 4] : [] + ); + const initialRecords = [1, 2]; + const { result } = renderHook(() => + usePaginatedCollection({ + initialRecords, + totalCount: 4, + fetchMore, + fetchSize: 2, + }) + ); + + await act(async () => result.current.onFetchMore()); + + expect(fetchMore).toHaveBeenCalledWith(2); + expect(result.current.results[0]).toEqual([1, 2, 3, 4]); + expect(result.current.canFetchMore).toBe(false); +}); + +test('coalesces concurrent requests for the same page', async () => { + const fetchMore = jest.fn(async () => [3, 4]); + const initialRecords = [1, 2]; + const { result } = renderHook(() => + usePaginatedCollection({ + initialRecords, + totalCount: 4, + fetchMore, + fetchSize: 2, + }) + ); + + await act(async () => + Promise.all([result.current.onFetchMore(2), result.current.onFetchMore(2)]) + ); + + expect(fetchMore).toHaveBeenCalledTimes(1); + expect(result.current.results[0]).toEqual([1, 2, 3, 4]); +}); diff --git a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 539931b58c1..a1080e131f7 100644 --- a/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -136,17 +136,19 @@ export function usePaginatedCollection({ 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 + ).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, + firstFetchIndex ); return internalFetchMore(fetchIndex); From b924fe9552ad6190f7b21b56a55cbddb05071a37 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 10:31:01 +0200 Subject: [PATCH 12/70] Support query results refresh and split panes --- .../lib/components/QueryBuilder/Results.tsx | 71 ++++++++++++++++++- .../components/QueryBuilder/ResultsTable.tsx | 29 +++++--- .../QueryBuilder/ResultsWrapper.tsx | 59 +++++++++++++-- 3 files changed, 144 insertions(+), 15 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index efcc0ca536d..1aec14be0f1 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -88,6 +88,10 @@ export type QueryResultsProps = { readonly extraButtons: JSX.Element | undefined; readonly tableClassName?: string; readonly selectedRows: GetSet>; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; + readonly refreshToken?: number; readonly resultsRef?: React.MutableRefObject< RA | undefined >; @@ -110,6 +114,10 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { extraButtons, tableClassName = '', selectedRows: [selectedRows, setSelectedRows], + onResults: handleResults, + scrollRef, + restoreScrollTopRef, + refreshToken, resultsRef, displayedFields, } = props; @@ -125,6 +133,37 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { fetchSize: props.fetchSize, totalCount: props.totalCount, }); + const currentResultsRef = React.useRef(results); + currentResultsRef.current = results; + const previousRefreshToken = React.useRef(refreshToken); + + React.useEffect(() => { + if ( + refreshToken === undefined || + refreshToken === previousRefreshToken.current + ) + return; + previousRefreshToken.current = refreshToken; + const currentResults = currentResultsRef.current; + if (!Array.isArray(currentResults) || fetchResults === undefined) return; + + const offsets = Array.from( + { length: Math.ceil(currentResults.length / props.fetchSize) }, + (_, index) => index * props.fetchSize + ); + Promise.all(offsets.map((offset) => fetchResults(offset))) + .then((pages) => { + const refreshedResults = currentResults.slice(); + pages.forEach((page, pageIndex) => { + const offset = offsets[pageIndex]; + refreshedResults.splice(offset, page.length, ...page); + if (page.length < props.fetchSize) + refreshedResults.length = offset + page.length; + }); + setResults(refreshedResults); + }) + .catch(() => undefined); + }, [fetchResults, props.fetchSize, refreshToken, setResults]); const canMergeTable = canMerge(table); @@ -139,6 +178,25 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ); if (resultsRef !== undefined) resultsRef.current = results; + React.useEffect(() => { + if (results !== undefined) handleResults?.(results); + }, [handleResults, results]); + + React.useEffect(() => { + const scrollTop = restoreScrollTopRef?.current; + if ( + scrollTop === undefined || + results === undefined || + restoreScrollTopRef === undefined + ) + return; + restoreScrollTopRef.current = undefined; + requestAnimationFrame(() => { + if (scrollRef?.current !== null && scrollRef?.current !== undefined) + scrollRef.current.scrollTop = scrollTop; + }); + }, [results, restoreScrollTopRef, scrollRef]); + const [pickListsLoaded = false] = useAsyncState( React.useCallback( async () => @@ -452,7 +510,10 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ${tableClassName} ${showResults ? 'border-b border-gray-500' : ''} `} - ref={scrollerRef} + ref={(element): void => { + scrollerRef.current = element; + if (scrollRef !== undefined) scrollRef.current = element; + }} role="table" style={{ gridTemplateColumns: [ @@ -551,6 +612,12 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { lastSelectedRow.current = rowIndex; }} + onRowSelected={(rowIndex): void => { + const id = loadedResults[rowIndex][queryIdField] as number; + setSelectedRows(new Set([id])); + handleSelected?.([id]); + lastSelectedRow.current = rowIndex; + }} /> ) : undefined} {isFetching || (!showResults && Array.isArray(results)) ? ( @@ -564,7 +631,7 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { ); } -export function TableHeaderCell({ +function TableHeaderCell({ columnIndex, fieldSpec, sortConfig, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx index 62445d877d1..0aa88372cd5 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsTable.tsx @@ -35,6 +35,7 @@ export function QueryResultsTable({ wrapQueryResults, selectedRows, onSelected: handleSelected, + onRowSelected: handleRowSelected, }: { readonly table: SpecifyTable; readonly fieldSpecs: RA; @@ -47,6 +48,7 @@ export function QueryResultsTable({ isSelected: boolean, isShiftClick: boolean ) => void; + readonly onRowSelected: (index: number) => void; }): JSX.Element { const recordFormatter = React.useMemo( () => getAuditRecordFormatter(fieldSpecs), @@ -74,6 +76,7 @@ export function QueryResultsTable({ onSelected={(isSelected, isShiftClick): void => handleSelected(index, isSelected, isShiftClick) } + onRowSelected={(): void => handleRowSelected(index)} /> ))} @@ -91,6 +94,7 @@ function Row({ showCellEllipsis, wrapQueryResults, onSelected: handleSelected, + onRowSelected: handleRowSelected, }: { readonly table: SpecifyTable; readonly fieldSpecs: RA; @@ -104,6 +108,7 @@ function Row({ readonly showCellEllipsis: boolean; readonly wrapQueryResults: boolean; readonly onSelected?: (isSelected: boolean, isShiftClick: boolean) => void; + readonly onRowSelected?: () => void; }): JSX.Element { // REFACTOR: replace this with getResourceViewUrl() const [resource] = useLiveState< @@ -151,14 +156,14 @@ function Row({ `} role="row" onClick={ - typeof handleSelected === 'function' - ? ({ target, shiftKey }): void => + typeof handleRowSelected === 'function' + ? ({ target }): void => /* * Ignore clicks on the "View" links and formatted audit log cell * links */ (target as Element).closest('a') === null - ? handleSelected?.(!isSelected, shiftKey) + ? handleRowSelected?.() : undefined : undefined } @@ -181,12 +186,18 @@ function Row({ className={`${getCellClassName(condenseQueryResults)} sticky`} role="cell" > - +
    { + event.stopPropagation(); + handleSelected?.(!isSelected, event.shiftKey); + }} + > + +
    ) => void; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; + readonly refreshToken?: number; + readonly splitPane?: JSX.Element; + readonly splitHorizontal?: boolean; + readonly splitterKey?: number; readonly onReRun: () => void; }): JSX.Element | null { const newProps = useQueryResultsWrapper(props); - return newProps === undefined ? ( - props.queryRunCount === 0 ? null : ( + if (newProps === undefined) + return props.queryRunCount === 0 ? null : (
    {loadingGif}
    - ) - ) : ( + ); + + const queryResults = (
    ); + + return splitPane === undefined ? ( + queryResults + ) : ( + +
    + {splitHorizontal ? queryResults : splitPane} +
    +
    + {splitHorizontal ? splitPane : queryResults} +
    +
    + ); } type ResultsProps = { @@ -77,6 +119,9 @@ type ResultsProps = { newFields: RA ) => void; readonly selectedRows: GetSet>; + readonly onResults?: (results: RA) => void; + readonly scrollRef?: React.MutableRefObject; + readonly restoreScrollTopRef?: React.MutableRefObject; readonly resultsRef?: React.MutableRefObject< RA | undefined >; @@ -121,7 +166,10 @@ export function useQueryResultsWrapper({ recordSetId, forceCollection, onSortChange: handleSortChange, + onResults: handleResults, selectedRows: [selectedRows, setSelectedRows], + scrollRef, + restoreScrollTopRef, resultsRef, }: ResultsProps): PartialProps | undefined { /* @@ -207,6 +255,9 @@ export function useQueryResultsWrapper({ displayedFields: queryFields, fieldSpecs, initialData, + onResults: handleResults, + scrollRef, + restoreScrollTopRef, sortConfig: queryFields .filter(({ isDisplay }) => isDisplay) .map((field) => field.sortType), From 0fe8a7404d9a54c823db94c3ef5738883be1140a Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 10:31:08 +0200 Subject: [PATCH 13/70] Split query builder results state --- .../lib/components/QueryBuilder/Header.tsx | 22 +++ .../QueryBuilder/QueryBuilderResults.tsx | 160 +++++++++++++++++ .../lib/components/QueryBuilder/Wrapped.tsx | 161 ++++++------------ .../__tests__/useQueryExecution.test.tsx | 76 +++++++++ .../QueryBuilder/useQueryExecution.ts | 53 ++++++ .../QueryBuilder/useQuerySplitView.ts | 58 +++++++ 6 files changed, 424 insertions(+), 106 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts create mode 100644 specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx index a4c09d0b0a5..bcf7e790d38 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx @@ -26,6 +26,7 @@ import { useQueryViewPref } from './Context'; import { QueryEditButton } from './Edit'; import { QueryLoanReturn } from './LoanReturn'; import type { MainState } from './reducer'; +import { treeText } from '../../localization/tree'; export type QueryView = { readonly basicView: RA; @@ -45,6 +46,10 @@ export function QueryHeader({ unsetUnloadProtect, onTriedToSave: handleTriedToSave, onSaved: handleSaved, + isSplit, + isHorizontal, + onToggleSplit, + onToggleOrientation, }: { readonly recordSet?: SpecifyResource; readonly query: SerializedResource; @@ -60,6 +65,10 @@ export function QueryHeader({ readonly unsetUnloadProtect: () => void; readonly onTriedToSave: () => void; readonly onSaved: () => void; + readonly isSplit: boolean; + readonly isHorizontal: boolean; + readonly onToggleSplit: () => void; + readonly onToggleOrientation: () => void; }): JSX.Element { // Detects any query being deleted and updates it every where and redirect const navigate = useNavigate(); @@ -129,6 +138,19 @@ export function QueryHeader({ : preferencesText.basicView()} + + {hasToolPermission( 'queryBuilder', queryResource.isNew() ? 'create' : 'update' diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx new file mode 100644 index 00000000000..ab7512afeee --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx @@ -0,0 +1,160 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { localized, type RA } from '../../utils/types'; +import { BatchEditFromQuery } from '../BatchEdit'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import type { RecordSet, SpQuery, SpQueryField } from '../DataModel/types'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { hasPermission } from '../Permissions/helpers'; +import { datasetVariants } from '../WbUtils/datasetVariants'; +import { MakeRecordSetButton } from './Components'; +import { QueryExportButtons } from './Export'; +import type { QueryField } from './helpers'; +import type { MainState } from './reducer'; +import type { QueryResultRow } from './Results'; +import { QueryResultsWrapper } from './ResultsWrapper'; + +export function QueryBuilderResults({ + table, + query, + queryResource, + recordSet, + forceCollection, + state, + isReadOnly, + saveRequired, + getQueryFieldRecords, + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + resultsRef, + isSplit, + isHorizontal, + splitterKey, + onReRun: handleReRun, + onRunQuery: handleRunQuery, + onSelected: handleSelected, + onSortChange: handleSortChange, +}: { + readonly table: SpecifyTable; + readonly query: SerializedResource; + readonly queryResource: SpecifyResource; + readonly recordSet: SpecifyResource | undefined; + readonly forceCollection: number | undefined; + readonly state: MainState; + readonly isReadOnly: boolean; + readonly saveRequired: boolean; + readonly getQueryFieldRecords: + | (() => RA>) + | undefined; + readonly selectedRows: ReadonlySet; + readonly setSelectedRows: React.Dispatch< + React.SetStateAction> + >; + readonly selectedIndex: number; + readonly setSelectedIndex: React.Dispatch>; + readonly resultsRef: React.MutableRefObject< + RA | undefined + >; + readonly isSplit: boolean; + readonly isHorizontal: boolean; + readonly splitterKey: number; + readonly onReRun: () => void; + readonly onRunQuery: (fields?: RA) => void; + readonly onSelected: (ids: RA) => void; + readonly onSortChange: (fields: RA) => void; +}): JSX.Element | null { + const selectedIds = React.useMemo( + () => Array.from(selectedRows), + [selectedRows] + ); + const recordPreview = ( +
    + {selectedIds.length === 0 ? ( +

    {commonText.select()}

    + ) : ( + { + setSelectedRows(new Set()); + setSelectedIndex(0); + }} + onDelete={undefined} + onSaved={(): void => handleRunQuery()} + onSlide={setSelectedIndex} + /> + )} +
    + ); + + return hasPermission('/querybuilder/query', 'execute') ? ( + + ) : undefined + } + extraButtons={ + <> + {datasetVariants.batchEdit.canCreate() && ( + + )} + {query.countOnly ? undefined : ( + + )} + + } + fields={state.fields} + forceCollection={forceCollection} + queryResource={queryResource} + queryRunCount={state.queryRunCount} + recordSetId={recordSet?.id} + resultsRef={resultsRef} + selectedRows={[selectedRows, setSelectedRows]} + splitterKey={splitterKey} + splitHorizontal={isHorizontal} + splitPane={isSplit ? recordPreview : undefined} + table={table} + onReRun={handleReRun} + onSelected={handleSelected} + onSortChange={handleSortChange} + /> + ) : null; +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 4feffd5c331..6249c8b0073 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -17,7 +17,6 @@ import { Container } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Form } from '../Atoms/Form'; import { icons } from '../Atoms/Icons'; -import { BatchEditFromQuery } from '../BatchEdit'; import { ReadOnlyContext } from '../Core/Contexts'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes'; @@ -41,19 +40,18 @@ import { } from '../WbPlanView/mappingHelpers'; import { getMappingLineData } from '../WbPlanView/navigator'; import { navigatorSpecs } from '../WbPlanView/navigatorSpecs'; -import { datasetVariants } from '../WbUtils/datasetVariants'; import { CheckReadAccess } from './CheckReadAccess'; -import { MakeRecordSetButton } from './Components'; import { IsQueryBasicContext, useQueryViewPref } from './Context'; -import { QueryExportButtons } from './Export'; import { QueryFields } from './Fields'; import { QueryFromMap } from './FromMap'; import { QueryHeader } from './Header'; import { unParseQueryFields } from './helpers'; import { getInitialState, reducer } from './reducer'; import type { QueryResultRow } from './Results'; -import { QueryResultsWrapper } from './ResultsWrapper'; +import { QueryBuilderResults } from './QueryBuilderResults'; import { QueryToolbar } from './Toolbar'; +import { useQueryExecution } from './useQueryExecution'; +import { useQuerySplitView } from './useQuerySplitView'; const fetchTreeRanks = async (): Promise => treeRanksPromise.then(f.true); @@ -76,14 +74,12 @@ export function QueryBuilder( return treeRanksLoaded ? : ; } -// REFACTOR: split this component function Wrapped({ query: queryResource, recordSet, forceCollection, isEmbedded = false, autoRun = false, - hideRunButton = false, // If present, this callback is called when query results are selected onSelected: handleSelected, onChange: handleChange, @@ -93,8 +89,6 @@ function Wrapped({ readonly forceCollection: number | undefined; readonly isEmbedded?: boolean; readonly autoRun?: boolean; - /** When true, hides the toolbar's "Query" run button (used by the Data Views query editor) */ - readonly hideRunButton?: boolean; readonly onSelected?: (selected: RA) => void; readonly onChange?: (props: { readonly fields: RA>; @@ -109,9 +103,6 @@ function Wrapped({ const [treeRanksLoaded = false] = useAsyncState(fetchTreeRanks, false); const table = getTableById(query.contextTableId); - const [selectedRows, setSelectedRows] = React.useState>( - new Set() - ); const buildInitialState = React.useCallback( () => @@ -224,26 +215,13 @@ function Wrapped({ unParseQueryFields(state.baseTableName, fields) : undefined; - /* - * REFACTOR: simplify this (move "executed query" state into this component - * and get rid of queryRunCount) - */ - function runQuery( - mode: 'count' | 'regular', - fields: typeof state.fields = state.fields - ): void { - if (!hasPermission('/querybuilder/query', 'execute')) return; - setQuery({ - ...query, - fields: getQueryFieldRecords?.(fields) ?? query.fields, - countOnly: mode === 'count', - }); - /* - * Wait for new query to propagate before re running it - * TEST: check if this still works after updating to React 18 - */ - globalThis.setTimeout(() => dispatch({ type: 'RunQueryAction' }), 0); - } + const { runQuery, scheduleQueryRun } = useQueryExecution({ + query, + fields: state.fields, + getQueryFieldRecords, + setQuery, + onRun: (): void => dispatch({ type: 'RunQueryAction' }), + }); /* * Require only one of these permissions as query builder could be useful with @@ -276,16 +254,6 @@ function Wrapped({ useTitle(localized(query.name)); - const [isQueryRunPending, handleQueryRunPending, handleNoQueryRunPending] = - useBooleanState(); - React.useEffect(() => { - if (!isQueryRunPending) return; - handleNoQueryRunPending(); - runQuery('regular'); - // Only reRun when isQueryRunPending is true, not when runQuery changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isQueryRunPending, handleNoQueryRunPending]); - const [isScrolledTop, handleScrollTop, handleScrolledDown] = useBooleanState(true); @@ -305,6 +273,17 @@ function Wrapped({ const resultsRef = React.useRef | undefined>( undefined ); + const { + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + isSplit, + isHorizontal, + splitterKey, + toggleSplit, + toggleOrientation, + } = useQuerySplitView(resultsRef); const showSeries = React.useMemo( () => @@ -377,6 +356,10 @@ function Wrapped({ dispatch({ type: 'SavedQueryAction' }); }} onTriedToSave={handleTriedToSave} + isSplit={isSplit} + isHorizontal={isHorizontal} + onToggleSplit={toggleSplit} + onToggleOrientation={toggleOrientation} />
    @@ -588,7 +571,6 @@ function Wrapped({ showHiddenFields={showHiddenFields} showSeries={showSeries} tableName={table.name} - hideRunButton={hideRunButton} onRunCountOnly={(): void => runQuery('count')} onSubmitClick={(): void => form?.checkValidity() === false @@ -618,68 +600,35 @@ function Wrapped({ }} />
    - {hasPermission('/querybuilder/query', 'execute') && ( - - ) : undefined - } - extraButtons={ - <> - {datasetVariants.batchEdit.canCreate() && ( - - )} - {query.countOnly ? undefined : ( - - )} - - } - fields={state.fields} - forceCollection={forceCollection} - queryResource={queryResource} - queryRunCount={state.queryRunCount} - recordSetId={recordSet?.id} - resultsRef={resultsRef} - selectedRows={[selectedRows, setSelectedRows]} - table={table} - onReRun={(): void => - dispatch({ - type: 'RunQueryAction', - }) - } - onSelected={handleSelected} - onSortChange={(fields): void => { - dispatch({ - type: 'ChangeFieldsAction', - fields, - }); - runQuery('regular', fields); - }} - /> - )} + dispatch({ type: 'RunQueryAction' })} + onRunQuery={(fields): void => runQuery('regular', fields)} + onSelected={(ids): void => { + setSelectedIndex(Math.max(0, ids.length - 1)); + handleSelected?.(ids); + }} + onSortChange={(fields): void => { + dispatch({ type: 'ChangeFieldsAction', fields }); + runQuery('regular', fields); + }} + /> diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx new file mode 100644 index 00000000000..cdc86505744 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/__tests__/useQueryExecution.test.tsx @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react'; + +import { hasPermission } from '../../Permissions/helpers'; +import type { SerializedResource } from '../../DataModel/helperTypes'; +import type { SpQuery, SpQueryField } from '../../DataModel/types'; +import type { QueryField } from '../helpers'; +import { useQueryExecution } from '../useQueryExecution'; + +jest.mock('../../Permissions/helpers', () => ({ + hasPermission: jest.fn(() => true), +})); + +const query = { fields: [] } as unknown as SerializedResource; +const fields = [] as const as readonly QueryField[]; +const serializedFields = [ + { fieldName: 'Name' }, +] as unknown as readonly SerializedResource[]; + +afterEach(() => jest.useRealTimers()); + +test('serializes the current fields and defers an authorized query run', () => { + jest.useFakeTimers(); + const setQuery = jest.fn(); + const onRun = jest.fn(); + const getQueryFieldRecords = jest.fn(() => serializedFields); + const { result } = renderHook(() => + useQueryExecution({ + query, + fields, + getQueryFieldRecords, + setQuery, + onRun, + }) + ); + + act(() => result.current.runQuery('count')); + + expect(hasPermission).toHaveBeenCalledWith('/querybuilder/query', 'execute'); + expect(getQueryFieldRecords).toHaveBeenCalledWith(fields); + expect(setQuery).toHaveBeenCalledWith({ + ...query, + fields: serializedFields, + countOnly: true, + }); + expect(onRun).not.toHaveBeenCalled(); + + act(() => jest.runOnlyPendingTimers()); + + expect(onRun).toHaveBeenCalledTimes(1); +}); + +test('schedules a regular query run after pending input changes', () => { + jest.useFakeTimers(); + const setQuery = jest.fn(); + const onRun = jest.fn(); + const { result } = renderHook(() => + useQueryExecution({ + query, + fields, + getQueryFieldRecords: undefined, + setQuery, + onRun, + }) + ); + + act(() => result.current.scheduleQueryRun()); + + expect(setQuery).toHaveBeenCalledWith({ + ...query, + countOnly: false, + }); + + act(() => jest.runOnlyPendingTimers()); + + expect(onRun).toHaveBeenCalledTimes(1); +}); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts new file mode 100644 index 00000000000..d36bc5047af --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQueryExecution.ts @@ -0,0 +1,53 @@ +import React from 'react'; + +import { useBooleanState } from '../../hooks/useBooleanState'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpQuery, SpQueryField } from '../DataModel/types'; +import { hasPermission } from '../Permissions/helpers'; +import type { RA } from '../../utils/types'; +import type { QueryField } from './helpers'; + +export function useQueryExecution({ + query, + fields, + getQueryFieldRecords, + setQuery, + onRun, +}: { + readonly query: SerializedResource; + readonly fields: RA; + readonly getQueryFieldRecords: + | ((fields: RA) => RA>) + | undefined; + readonly setQuery: (query: SerializedResource) => void; + readonly onRun: () => void; +}): { + readonly runQuery: ( + mode: 'count' | 'regular', + fields?: RA + ) => void; + readonly scheduleQueryRun: () => void; +} { + const [isQueryRunPending, scheduleQueryRun, clearQueryRunPending] = + useBooleanState(); + const runQuery = React.useCallback( + (mode: 'count' | 'regular', queryFields: RA = fields): void => { + if (!hasPermission('/querybuilder/query', 'execute')) return; + setQuery({ + ...query, + fields: getQueryFieldRecords?.(queryFields) ?? query.fields, + countOnly: mode === 'count', + }); + globalThis.setTimeout(onRun, 0); + }, + [fields, getQueryFieldRecords, onRun, query, setQuery] + ); + + React.useEffect(() => { + if (!isQueryRunPending) return; + clearQueryRunPending(); + runQuery('regular'); + }, [clearQueryRunPending, isQueryRunPending, runQuery]); + + return { runQuery, scheduleQueryRun }; +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts new file mode 100644 index 00000000000..124d5eaad0a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts @@ -0,0 +1,58 @@ +import React from 'react'; + +import type { RA } from '../../utils/types'; +import { queryIdField, type QueryResultRow } from './Results'; + +export function useQuerySplitView( + resultsRef: React.MutableRefObject | undefined> +): { + readonly selectedRows: ReadonlySet; + readonly setSelectedRows: React.Dispatch< + React.SetStateAction> + >; + readonly selectedIndex: number; + readonly setSelectedIndex: React.Dispatch>; + readonly isSplit: boolean; + readonly isHorizontal: boolean; + readonly splitterKey: number; + readonly toggleSplit: () => void; + readonly toggleOrientation: () => void; +} { + const [selectedRows, setSelectedRows] = React.useState>( + new Set() + ); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [isSplit, setIsSplit] = React.useState(false); + const [isHorizontal, setIsHorizontal] = React.useState(true); + const [splitterKey, setSplitterKey] = React.useState(0); + + const toggleSplit = (): void => { + const nextIsSplit = !isSplit; + setIsSplit(nextIsSplit); + if (nextIsSplit && selectedRows.size === 0) { + const firstId = resultsRef.current?.find( + (result) => result !== undefined + )?.[queryIdField]; + if (typeof firstId === 'number') { + setSelectedRows(new Set([firstId])); + setSelectedIndex(0); + } + } + }; + const toggleOrientation = (): void => { + setIsHorizontal(!isHorizontal); + setSplitterKey((key) => key + 1); + }; + + return { + selectedRows, + setSelectedRows, + selectedIndex, + setSelectedIndex, + isSplit, + isHorizontal, + splitterKey, + toggleSplit, + toggleOrientation, + }; +} From e7466ba16fef821134b603964335e9c3ca383fd1 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 10:31:13 +0200 Subject: [PATCH 14/70] Configure Data View queries --- config/backstop/app_resources.xml | 1 + config/backstop/data_view_queries.json | 4 + .../AppResources/TabDefinitions.tsx | 5 + .../lib/components/AppResources/types.tsx | 9 + .../components/DataViews/DataViewTables.tsx | 237 +++++++----------- .../lib/components/DataViews/QueryEditor.tsx | 180 +++++++++---- .../__tests__/DataViewTables.test.tsx | 42 ++++ .../DataViews/__tests__/queries.test.ts | 119 +++++++++ .../js_src/lib/components/DataViews/config.ts | 45 ++++ .../lib/components/DataViews/queries.ts | 202 +++++++++++++++ .../components/Toolbar/QueryTablesWrapper.tsx | 35 ++- .../js_src/lib/localization/dataViews.ts | 21 +- 12 files changed, 655 insertions(+), 245 deletions(-) create mode 100644 config/backstop/data_view_queries.json create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/config.ts create mode 100644 specifyweb/frontend/js_src/lib/components/DataViews/queries.ts diff --git a/config/backstop/app_resources.xml b/config/backstop/app_resources.xml index 97329256324..0e2585d0c42 100644 --- a/config/backstop/app_resources.xml +++ b/config/backstop/app_resources.xml @@ -3,4 +3,5 @@ + diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json new file mode 100644 index 00000000000..10fca4432c3 --- /dev/null +++ b/config/backstop/data_view_queries.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "queries": {} +} diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx index 32576883d96..afa89dd59d4 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/TabDefinitions.tsx @@ -39,6 +39,7 @@ import { WebLinkEditor } from '../WebLinks/Editor'; import { webLinksSpec } from '../WebLinks/spec'; import { useCodeMirrorExtensions } from './EditorComponents'; import type { appResourceSubTypes } from './types'; +import { DataViewQueryEditor } from '../DataViews/QueryEditor'; export type AppResourceEditorType = 'generic' | 'json' | 'visual' | 'xml'; @@ -159,6 +160,10 @@ export const visualAppResourceEditors = f.store< visual: UserPreferencesEditor, json: AppResourceTextEditor, }, + dataViewQueries: { + visual: DataViewQueryEditor, + json: AppResourceTextEditor, + }, collectionPreferences: { visual: CollectionPreferencesEditor, json: AppResourceTextEditor, diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 8a0b8f089da..7619ae8f9a1 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -9,6 +9,7 @@ import { icons } from '../Atoms/Icons'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpAppResourceDir, Tables } from '../DataModel/types'; import type { AppResourceMode } from './helpers'; +import { dataViewsText } from '../../localization/dataViews'; export type AppResourceScope = | 'collection' @@ -102,6 +103,14 @@ export const appResourceSubTypes = ensure>()({ icon: icons.cog, label: preferencesText.defaultUserPreferences(), }, + dataViewQueries: { + mimeType: 'application/json', + name: 'DataViewQueries', + documentationUrl: undefined, + icon: icons.eye, + label: dataViewsText.dataViewQueries(), + scope: ['discipline', 'user'], + }, // TODO: There should be useTemplate: false below? (like it is for userPreferences) collectionPreferences: { mimeType: 'application/json', diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 66cdb08aa50..3631a202033 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -4,110 +4,118 @@ 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 type { IR, RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import { icons } from '../Atoms/Icons'; -import { fetchCollection } from '../DataModel/collection'; import { serializeResource } from '../DataModel/serializers'; import { SpecifyTable } from '../DataModel/specifyTable'; -import { getTableById, strictGetTable } from '../DataModel/tables'; -import { Tables } from '../DataModel/types'; +import type { Tables } from '../DataModel/types'; import { raise } from '../Errors/Crash'; import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { hasToolPermission } from '../Permissions/helpers'; -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'; -import { createDefaultDataViewQuery } from './defaultQueries'; -import { DataViewQueryEditor } from './QueryEditor'; - -const defaultDataViewTablesConfig: RA = [ - 'Accession', - 'Agent', - 'CollectionObject', - 'CollectingEvent', - 'Gift', - 'Loan', - 'Locality', -]; +import { defaultDataViewTablesConfig, useDataViewTables } from './config'; +import { DataViewQueryEditorContent } from './QueryEditor'; +import { + saveUserDataViewQueries, + serializeDataViewQueries, + useDataViewQueries, +} from './queries'; export function DataViewTables(): JSX.Element { const handleClose = React.useContext(OverlayContext); const [tables, setTables] = useDataViewTables(); const [isEditing, handleEditing] = useBooleanState(); + const [queries, reloadQueries] = useDataViewQueries(); + const [queryData, setQueryData] = React.useState(); + const [queryTable, setQueryTable] = React.useState< + keyof Tables | undefined + >(); const counts = useTableRecordCounts(tables); - const [queryIds, refreshQueryId] = useDataViewQueryIds(tables); - const [editingQueryTable, setEditingQueryTable] = React.useState< - SpecifyTable | undefined - >(undefined); - return isEditing ? ( - - ) : ( - <> + const handleOpenQueryEditor = (tableName: keyof Tables): void => { + if (queries === undefined) return; + setQueryData(serializeDataViewQueries(queries)); + setQueryTable(tableName); + }; + const handleCloseQueryEditor = (): void => { + setQueryTable(undefined); + setQueryData(undefined); + }; + if (queryTable !== undefined && queryData !== undefined) + return ( - - - {commonText.close()} + + {commonText.cancel()} + { + saveUserDataViewQueries(queryData) + .then(reloadQueries) + .then(handleCloseQueryEditor) + .catch(raise); + }} + > + {commonText.save()} + } - className={{ - container: dialogClassNames.narrowContainer, - }} - headerButtons={} - icon={icons.eye} - onClose={handleClose} + header={dataViewsText.configureQuery()} + onClose={handleCloseQueryEditor} > - `/specify/dataviews/${name.toLowerCase()}`} - isDisabled={(table): boolean => - typeof queryIds[table.name] !== 'number' - } - tables={tables} - onClick={undefined} - renderAction={(table): JSX.Element => { - const hasQuery = typeof queryIds[table.name] === 'number'; - return ( - setEditingQueryTable(table)} - /> - ); - }} + - {editingQueryTable !== undefined && ( - setEditingQueryTable(undefined)} - onSaved={(): void => refreshQueryId(editingQueryTable)} - /> - )} - + ); + return isEditing ? ( + + ) : ( + + + + {commonText.close()} + + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + headerButtons={ +
    + +
    + } + icon={icons.eye} + onClose={handleClose} + > + `/specify/dataviews/${name.toLowerCase()}`} + tables={tables} + onClick={undefined} + onEdit={handleOpenQueryEditor} + /> +
    ); } @@ -115,7 +123,7 @@ 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( +export function useTableRecordCounts( tables: RA ): IR { const [counts, setCounts] = React.useState>({}); @@ -153,80 +161,3 @@ function useTableRecordCounts( return counts; } - -/** - * Looks up each table's saved "Data View" query (Spquery.isDataView=true), - * if one exists. Absent key = still loading; undefined value = none found. - */ -function useDataViewQueryIds( - tables: RA -): readonly [IR, (table: SpecifyTable) => void] { - const [queryIds, setQueryIds] = React.useState>({}); - const seededTables = React.useRef>(new Set()); - - const fetchQueryId = React.useCallback(async (table: SpecifyTable) => { - const { records } = await fetchCollection('SpQuery', { - contextTableId: table.tableId, - isDataView: true, - domainFilter: false, - limit: 1, - }); - let queryId: number | undefined = records[0]?.id; - if ( - queryId === undefined && - !seededTables.current.has(table.name) && - hasToolPermission('queryBuilder', 'create') - ) { - seededTables.current.add(table.name); - queryId = await createDefaultDataViewQuery(table.name).catch( - (): undefined => undefined - ); - } - setQueryIds((previousIds) => ({ - ...previousIds, - [table.name]: queryId, - })); - }, []); - - React.useEffect(() => { - let destructorCalled = false; - tables.forEach((table) => { - fetchQueryId(table) - .then(() => undefined) - .catch((error) => { - if (!destructorCalled) raise(error); - }); - }); - return (): void => { - destructorCalled = true; - }; - }, [tables, fetchQueryId]); - - return [queryIds, fetchQueryId] as const; -} - -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/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index eb4b089e3db..6c0651b2e08 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -1,69 +1,139 @@ import React from 'react'; -import { useAsyncState } from '../../hooks/useAsyncState'; import { dataViewsText } from '../../localization/dataViews'; -import { commonText } from '../../localization/common'; -import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { fetchResource, resourceOn } from '../DataModel/resource'; -import { deserializeResource } from '../DataModel/serializers'; -import type { SpecifyTable } from '../DataModel/specifyTable'; -import type { SpQuery } from '../DataModel/types'; -import { Dialog, dialogClassNames } from '../Molecules/Dialog'; -import { createQuery } from '../QueryBuilder'; +import type { RA } from '../../utils/types'; +import { Label, Select } from '../Atoms/Form'; +import type { AppResourceTabProps } from '../AppResources/TabDefinitions'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpQueryField, Tables } from '../DataModel/types'; import { QueryBuilder } from '../QueryBuilder/Wrapped'; +import { defaultDataViewTablesConfig, useDataViewTables } from './config'; +import { + getDataViewQueryDefinition, + makeDataViewQuery, + parseDataViewQueries, + serializeDataViewQueries, + serializeStableDataViewQueries, + type DataViewQueriesFile, +} from './queries'; +import { schemaText } from '../../localization/schema'; -/** - * Let a user create or edit the single "Data View" query for a table - */ export function DataViewQueryEditor({ - table, - queryId, - onClose: handleClose, - onSaved: handleSaved, + data, + onChange: handleChange, +}: AppResourceTabProps): JSX.Element { + return ( + handleChange(nextData)} + /> + ); +} + +export function DataViewQueryEditorContent({ + data, + onChange: handleChange, + tableName: lockedTableName, }: { - readonly table: SpecifyTable; - readonly queryId: number | undefined; - readonly onClose: () => void; - readonly onSaved: () => void; -}): JSX.Element | null { - const [query] = useAsyncState>( - React.useCallback(async () => { - if (typeof queryId === 'number') - return fetchResource('SpQuery', queryId).then(deserializeResource); - const newQuery = createQuery( - dataViewsText.dataViewQueryName({ tableLabel: table.label }), - table - ); - newQuery.set('isDataView', true); - return newQuery; - }, [queryId, table]), - true + readonly data: string | null | undefined; + readonly onChange: (data: string) => void; + readonly tableName?: keyof Tables; +}): JSX.Element { + const initialFile = React.useMemo(() => parseDataViewQueries(data), [data]); + const [file, setFile] = React.useState(initialFile); + const fileRef = React.useRef(file); + const [tables] = useDataViewTables(); + const tableNames = React.useMemo>( + () => tables.map(({ name }) => name), + [tables] ); + const [tableName, setTableName] = React.useState( + lockedTableName ?? tableNames[0] ?? defaultDataViewTablesConfig[0] + ); + + React.useEffect(() => { + if (lockedTableName !== undefined) setTableName(lockedTableName); + }, [lockedTableName]); - React.useEffect( + React.useEffect(() => { + if (lockedTableName === undefined && !tableNames.includes(tableName)) + setTableName(tableNames[0] ?? defaultDataViewTablesConfig[0]); + }, [lockedTableName, tableName, tableNames]); + + React.useEffect(() => { + if ( + serializeStableDataViewQueries(fileRef.current) === + serializeStableDataViewQueries(initialFile) + ) + return; + fileRef.current = initialFile; + setFile(initialFile); + }, [initialFile]); + + const query = React.useMemo( () => - query === undefined - ? undefined - : resourceOn(query, 'saved', handleSaved, false), - [query, handleSaved] + makeDataViewQuery(tableName, getDataViewQueryDefinition(file, tableName)), + [file, tableName] + ); + + const handleQueryChange = React.useCallback( + (changes: { + readonly fields: RA>; + readonly isDistinct: boolean | null; + readonly searchSynonymy: boolean | null; + readonly isSeries: boolean | null; + }): void => { + const nextFile: DataViewQueriesFile = { + ...file, + queries: { + ...file.queries, + [tableName]: { + fields: changes.fields, + selectDistinct: changes.isDistinct ?? false, + searchSynonymy: changes.searchSynonymy ?? false, + smushed: changes.isSeries ?? false, + }, + }, + }; + if ( + serializeStableDataViewQueries(nextFile) === + serializeStableDataViewQueries(fileRef.current) + ) + return; + fileRef.current = nextFile; + setFile(nextFile); + handleChange(serializeDataViewQueries(nextFile)); + }, + [file, handleChange, tableName] ); - return query === undefined ? null : ( - - - + return ( +
    + {lockedTableName === undefined ? ( + + {schemaText.table()} + + + ) : undefined} +
    + +
    +

    {dataViewsText.configureQuery()}

    +
    ); } diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx new file mode 100644 index 00000000000..38a1f18a9f3 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx @@ -0,0 +1,42 @@ +import { renderHook, waitFor } from '@testing-library/react'; + +import { Http } from '../../../utils/ajax/definitions'; +import { throttledPromise } from '../../../utils/ajax/throttledPromise'; +import { + queryCountPromiseGenerator, + querySpecToResource, +} from '../../Statistics/hooks'; +import { useTableRecordCounts } from '../DataViewTables'; + +jest.mock('../../../utils/ajax/throttledPromise', () => ({ + throttledPromise: jest.fn( + (_key: string, generator: () => Promise) => generator() + ), +})); + +jest.mock('../../DataModel/serializers', () => ({ + serializeResource: (resource: unknown) => resource, +})); + +jest.mock('../../Statistics/hooks', () => ({ + queryCountPromiseGenerator: jest.fn(), + querySpecToResource: jest.fn((tableName: string) => ({ tableName })), +})); + +test('fetches and returns a record count for each Data Views table', async () => { + (queryCountPromiseGenerator as jest.Mock).mockImplementation( + ({ tableName }: { readonly tableName: string }) => + async () => ({ + status: Http.OK, + data: { count: tableName === 'Agent' ? 3 : 7 }, + }) + ); + const tables = [{ name: 'Agent' }, { name: 'Loan' }] as never; + + const { result } = renderHook(() => useTableRecordCounts(tables)); + + await waitFor(() => expect(result.current).toEqual({ Agent: 3, Loan: 7 })); + + expect(querySpecToResource).toHaveBeenCalledTimes(2); + expect(throttledPromise).toHaveBeenCalledTimes(2); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts new file mode 100644 index 00000000000..8f8e6e6b90d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts @@ -0,0 +1,119 @@ +import { addMissingFields } from '../../DataModel/addMissingFields'; +import { serializeResource } from '../../DataModel/serializers'; +import { strictGetTable } from '../../DataModel/tables'; +import { requireContext } from '../../../tests/helpers'; +import { + defaultDataViewQuery, + getDataViewQueryDefinition, + makeDataViewQuery, + parseDataViewQueries, + serializeDataViewQueries, +} from '../queries'; +import { getNumericResultId } from '../index'; + +requireContext(); + +test('invalid Data View query resources fall back to an empty version 1 file', () => { + expect(parseDataViewQueries('{invalid')).toEqual({ version: 1, queries: {} }); + expect(parseDataViewQueries('{"version":2,"queries":{}}')).toEqual({ + version: 1, + queries: {}, + }); + expect(parseDataViewQueries('{"version":1,"queries":null}')).toEqual({ + version: 1, + queries: {}, + }); + expect(parseDataViewQueries('{"version":1,"queries":[]}')).toEqual({ + version: 1, + queries: {}, + }); +}); + +test('result IDs accept finite numbers and numeric strings only', () => { + expect(getNumericResultId(42)).toBe(42); + expect(getNumericResultId(' 42 ')).toBe(42); + expect(getNumericResultId('')).toBeUndefined(); + expect(getNumericResultId('invalid')).toBeUndefined(); + expect(getNumericResultId(Number.NaN)).toBeUndefined(); + expect(getNumericResultId(Number.POSITIVE_INFINITY)).toBeUndefined(); + expect(getNumericResultId(null)).toBeUndefined(); +}); + +test('Data View query definitions round trip as JSON', () => { + const data = { + version: 1 as const, + queries: { + Agent: { + fields: [addMissingFields('SpQueryField', { fieldName: 'Name' })], + selectDistinct: true, + }, + }, + }; + expect(parseDataViewQueries(serializeDataViewQueries(data))).toEqual(data); + expect(parseDataViewQueries(data)).toEqual(data); +}); + +test('missing table definitions use generated defaults', () => { + const definition = getDataViewQueryDefinition( + parseDataViewQueries(undefined), + 'Agent' + ); + expect(definition.fields.length).toBeGreaterThan(0); + expect(definition.selectDistinct).toBe(false); + expect(defaultDataViewQuery('Agent')).toEqual(definition); +}); + +test('stored table definitions override defaults in runtime queries', () => { + const definition = { + fields: [addMissingFields('SpQueryField', { fieldName: 'Name' })], + selectDistinct: true, + searchSynonymy: true, + smushed: true, + }; + const file = { + version: 1 as const, + queries: { Agent: definition }, + }; + const storedDefinition = getDataViewQueryDefinition(file, 'Agent'); + const query = makeDataViewQuery('Agent', storedDefinition); + + expect(storedDefinition).toBe(definition); + expect(serializeResource(query).fields).toHaveLength(1); + expect(query.get('selectDistinct')).toBe(true); + expect(query.get('searchSynonymy')).toBe(true); + expect(query.get('smushed')).toBe(true); +}); + +test('generated defaults include every unhidden literal field', () => { + const table = strictGetTable('Agent'); + const expected = new Set([ + ...table.literalFields + .filter( + ({ isHidden, isVirtual, isRelationship, name }) => + !isHidden && + !isVirtual && + !isRelationship && + !['id', 'timestampcreated', 'timestampmodified', 'version'].includes( + name.toLowerCase() + ) && + name !== table.idField.name + ) + .map(({ name }) => name), + ]); + expect( + new Set( + defaultDataViewQuery('Agent').fields.map(({ fieldName }) => fieldName) + ) + ).toEqual(expected); +}); + +test('runtime queries are ephemeral and use the configured fields', () => { + const definition = defaultDataViewQuery('Agent'); + const query = makeDataViewQuery('Agent', definition); + expect(query.isNew()).toBe(true); + expect(query.get('contextName')).toBe('Agent'); + expect(query.get('specifyUser')).toBeDefined(); + expect(serializeResource(query).fields).toHaveLength( + definition.fields.length + ); +}); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/config.ts b/specifyweb/frontend/js_src/lib/components/DataViews/config.ts new file mode 100644 index 00000000000..66e56d3e680 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/config.ts @@ -0,0 +1,45 @@ +import React from 'react'; + +import type { RA } from '../../utils/types'; +import type { GetSet } from '../../utils/types'; +import { getTableById, strictGetTable } from '../DataModel/tables'; +import type { SpecifyTable } from '../DataModel/specifyTable'; +import type { Tables } from '../DataModel/types'; +import { userPreferences } from '../Preferences/userPreferences'; +import { tablesFilter } from '../SchemaConfig/Tables'; + +export const defaultDataViewTablesConfig: RA = [ + 'Accession', + 'Agent', + 'CollectionObject', + 'CollectingEvent', + 'Gift', + 'Loan', + 'Locality', +]; + +export 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/queries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts new file mode 100644 index 00000000000..5be421b9259 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts @@ -0,0 +1,202 @@ +import React from 'react'; + +import { useAsyncState } from '../../hooks/useAsyncState'; +import { addMissingFields } from '../DataModel/addMissingFields'; +import type { SerializedResource } from '../DataModel/helperTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; +import { serializeResource } from '../DataModel/serializers'; +import { strictGetTable } from '../DataModel/tables'; +import type { SpQuery, SpQueryField, Tables } from '../DataModel/types'; +import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { createQuery } from '../QueryBuilder'; +import { getAppResourceUrl } from '../../utils/ajax/helpers'; +import { ajax } from '../../utils/ajax'; +import { Http } from '../../utils/ajax/definitions'; +import { ping } from '../../utils/ajax/ping'; +import { clearUrlCache } from '../RouterCommands/CacheBuster'; +import { keysToLowerCase } from '../../utils/utils'; +import type { RA, RR } from '../../utils/types'; + +export const dataViewQueriesResourceName = 'DataViewQueries'; + +export type DataViewQueryDefinition = { + readonly fields: RA>; + readonly selectDistinct?: boolean; + readonly searchSynonymy?: boolean; + readonly smushed?: boolean; +}; + +export type DataViewQueriesFile = { + readonly version: 1; + readonly queries: Partial>; +}; + +const emptyDataViewQueries: DataViewQueriesFile = { version: 1, queries: {} }; + +function isDataViewQueriesFile(value: unknown): value is DataViewQueriesFile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false; + const { version, queries } = value as { + readonly version?: unknown; + readonly queries?: unknown; + }; + return ( + version === 1 && + typeof queries === 'object' && + queries !== null && + !Array.isArray(queries) + ); +} + +export function parseDataViewQueries(data: unknown): DataViewQueriesFile { + if (data === null || data === undefined) return emptyDataViewQueries; + try { + const parsed: unknown = + typeof data === 'string' + ? data.trim() === '' + ? undefined + : JSON.parse(data) + : data; + return isDataViewQueriesFile(parsed) ? parsed : emptyDataViewQueries; + } catch { + return emptyDataViewQueries; + } +} + +export function serializeDataViewQueries(data: DataViewQueriesFile): string { + return JSON.stringify(data, undefined, 2); +} + +/** Ignore transient Backbone resource metadata when comparing editor state. */ +export function serializeStableDataViewQueries( + data: DataViewQueriesFile +): string { + return JSON.stringify(data, (key, value: unknown) => + new Set([ + 'id', + 'resource_uri', + 'timestampCreated', + 'timestampModified', + 'version', + ]).has(key) + ? undefined + : value + ); +} + +export function defaultDataViewQuery( + tableName: keyof Tables +): DataViewQueryDefinition { + const table = strictGetTable(tableName); + const fields = table.literalFields + .filter( + ({ isHidden, isVirtual, name, isRelationship }) => + !isHidden && + !isVirtual && + !isRelationship && + !dataViewDefaultFieldBlacklist.has(name.toLowerCase()) && + name !== table.idField.name + ) + .map(({ name }) => + serializeResource( + QueryFieldSpec.fromPath(table.name, [name]).toSpQueryField() + ) + ); + return { + fields, + selectDistinct: false, + searchSynonymy: false, + smushed: false, + }; +} + +export const dataViewDefaultFieldBlacklist = new Set([ + 'id', + 'timestampcreated', + 'timestampmodified', + 'version', +]); + +export function getDataViewQueryDefinition( + file: DataViewQueriesFile, + tableName: keyof Tables +): DataViewQueryDefinition { + return file.queries[tableName] ?? defaultDataViewQuery(tableName); +} + +export function makeDataViewQuery( + tableName: keyof Tables, + definition: DataViewQueryDefinition +): SpecifyResource { + const table = strictGetTable(tableName); + const query = createQuery(`Data View: ${table.label}`, table); + query.set( + 'fields', + definition.fields.map((field) => addMissingFields('SpQueryField', field)) + ); + query.set('selectDistinct', definition.selectDistinct ?? false); + query.set('searchSynonymy', definition.searchSynonymy ?? false); + query.set('smushed', definition.smushed ?? false); + query.set('countOnly', false); + return query; +} + +/** Fetches the context-resolved resource, including discipline and defaults. */ +export function useDataViewQueries(): [ + DataViewQueriesFile | undefined, + () => void, +] { + const [reload, setReload] = React.useState(0); + const [data] = useAsyncState( + React.useCallback( + async () => + ajax( + `${getAppResourceUrl(dataViewQueriesResourceName, 'quiet')}&dataViewReload=${reload}`, + { + cache: 'no-store', + headers: { Accept: 'application/json' }, + errorMode: 'silent', + expectedErrors: [Http.NO_CONTENT, Http.NOT_FOUND], + } + ).then(({ data }) => parseDataViewQueries(data)), + [reload] + ), + true + ); + return [data, (): void => setReload((value) => value + 1)]; +} + +export async function saveUserDataViewQueries(data: string): Promise { + const resources = await ajax< + RA<{ + readonly id: number; + readonly name: string; + readonly mimetype: string; + }> + >('/context/user_resource/', { headers: { Accept: 'application/json' } }); + const [resource, ...duplicates] = resources.data.filter( + ({ name, mimetype }) => + name === dataViewQueriesResourceName && mimetype === 'application/json' + ); + const payload = keysToLowerCase({ + name: dataViewQueriesResourceName, + mimetype: 'application/json', + metadata: '', + data, + }); + await ping( + resource === undefined + ? '/context/user_resource/' + : `/context/user_resource/${resource.id}/`, + { + method: resource === undefined ? 'POST' : 'PUT', + body: payload, + } + ); + await Promise.all( + duplicates.map(async ({ id }) => + ping(`/context/user_resource/${id}/`, { method: 'DELETE' }) + ) + ); + await clearUrlCache(getAppResourceUrl(dataViewQueriesResourceName)); +} diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx index a3c8c4c0d33..4bcb3cd3e73 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesWrapper.tsx @@ -116,21 +116,15 @@ export function QueryTables({ }: { readonly tables: RA; readonly onClick: ((tableName: keyof Tables) => void) | undefined; - readonly onEdit?: (table: SpecifyTable) => void; + readonly onEdit?: (tableName: keyof Tables) => void; readonly counts?: IR; readonly getHref?: (tableName: keyof Tables) => string; }): JSX.Element { return (
      - {tables.map((table, index) => { - const { name, label } = table; - return ( -
    • + {tables.map(({ name, label }, index) => ( +
    • +
      - - {handleEdit === undefined ? undefined : ( - handleEdit(table)} - /> - )} -
    • - ); - })} + + {handleEdit === undefined ? undefined : ( + handleEdit(name)} + /> + )} + + ))}
    ); } diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index d76496cdc2e..91453e1d19f 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -20,22 +20,11 @@ export const dataViewsText = createDictionary({ configureDataViews: { 'en-us': 'Configure Data Views tables', }, - selectRecordToView: { - 'en-us': 'Select a record to view it here', + dataViewQueries: { + comment: 'The name of the Data View query app resource type', + 'en-us': 'Data View queries', }, - dataViewQueryEditorTitle: { - 'en-us': '{tableLabel:string} Data View Query', - }, - dataViewQueryName: { - 'en-us': '{tableLabel:string} Data View', - }, - createDataViewQuery: { - 'en-us': 'Create Data View query', - }, - editDataViewQuery: { - 'en-us': 'Edit Data View query', - }, - noDataViewQuery: { - 'en-us': 'This table does not have a Data View query configured yet', + configureQuery: { + 'en-us': 'Configure query', }, } as const); From 70542db2c26a07207269b6e0d5d0afa85580dd92 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 10:31:19 +0200 Subject: [PATCH 15/70] Add Data Views record preview --- .../js_src/lib/components/DataViews/index.tsx | 572 +++++++----------- 1 file changed, 223 insertions(+), 349 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 69477e6b090..69371dd646d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,405 +1,279 @@ import React from 'react'; -import Splitter from 'm-react-splitters'; import { useParams } from 'react-router-dom'; -import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; -import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; -import type { SpecifyResource } from '../DataModel/legacyTypes'; -import { fetchResource } from '../DataModel/resource'; -import { SpecifyTable } from '../DataModel/specifyTable'; -import { getTable } from '../DataModel/tables'; -import { ProtectedTable } from '../Permissions/PermissionDenied'; -import { NotFoundView } from '../Router/NotFoundView'; -import { ResourceView } from '../Forms/ResourceView'; +import Splitter from 'm-react-splitters'; + import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; -import type { SpQuery } from '../DataModel/types'; -import { useAsyncState } from '../../hooks/useAsyncState'; -import { useInfiniteScroll } from '../../hooks/useInfiniteScroll'; -import { RA } from '../../utils/types'; -import { replaceItem } from '../../utils/utils'; -import { Container, H3 } from '../Atoms'; import { Button } from '../Atoms/Button'; -import { loadingGif } from '../Molecules'; -import { Http } from '../../utils/ajax/definitions'; -import { userPreferences } from '../Preferences/userPreferences'; -import { interactionsText } from '../../localization/interactions'; -import { QueryFieldSpec } from '../QueryBuilder/fieldSpec'; +import { DataEntry } from '../Atoms/DataEntry'; +import type { Tables } from '../DataModel/types'; +import { getTable } from '../DataModel/tables'; +import { ProtectedTable } from '../Permissions/PermissionDenied'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; +import { parseQueryFields, unParseQueryFields } from '../QueryBuilder/helpers'; +import { queryIdField } from '../QueryBuilder/Results'; +import { NotFoundView } from '../Router/NotFoundView'; import { - flippedSortTypes, - type SortTypes, - sortTypes, -} from '../QueryBuilder/helpers'; -import { QueryResultsTable } from '../QueryBuilder/ResultsTable'; -import { type QueryResultRow, TableHeaderCell } from '../QueryBuilder/Results'; -import { runQuery } from '../QueryBuilder/ResultsWrapper'; -import { QueryToForms } from '../QueryBuilder/ToForms'; -import { queryCountPromiseGenerator } from '../Statistics/hooks'; + getDataViewQueryDefinition, + makeDataViewQuery, + useDataViewQueries, +} from './queries'; +import type { DataViewQueriesFile } from './queries'; +import { treeText } from '../../localization/tree'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); - const table = getTable(tableName); return table === undefined ? ( ) : ( - + ); } -/** The table's saved Data View query (Spquery.isDataView=true), or false if none exists */ -function useDataViewQuery( - table: SpecifyTable -): SerializedResource | false | undefined { - const [query] = useAsyncState | false>( - React.useCallback(async () => { - const { records } = await fetchCollection('SpQuery', { - contextTableId: table.tableId, - isDataView: true, - domainFilter: false, - limit: 1, - }); - return records.length === 0 - ? false - : fetchResource('SpQuery', records[0].id); - }, [table]), - true - ); - return query; +export function getNumericResultId(value: unknown): number | undefined { + const numericId = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : undefined; + return typeof numericId === 'number' && Number.isFinite(numericId) + ? numericId + : undefined; } -function DataViewFromTableWrapped({ - table, +function DataViewFromTable({ + tableName, }: { - readonly table: SpecifyTable; + readonly tableName: keyof Tables; }): JSX.Element | null { - const dataViewQuery = useDataViewQuery(table); - - const displayFields = React.useMemo( - () => - dataViewQuery === undefined || dataViewQuery === false - ? undefined - : dataViewQuery.fields - .filter((field) => field.isDisplay) - .sort((left, right) => left.position - right.position), - [dataViewQuery] + const [queries] = useDataViewQueries(); + return queries === undefined ? null : ( + ); +} - const fieldSpecs = React.useMemo( - () => - displayFields?.map((field) => - QueryFieldSpec.fromStringId(field.stringId, field.isRelFld ?? false) - ), - [displayFields] +function LoadedDataViewFromTable({ + tableName, + queries, +}: { + readonly tableName: keyof Tables; + readonly queries: DataViewQueriesFile; +}): JSX.Element | null { + const table = getTable(tableName); + const [selectedIds, setSelectedIds] = React.useState>( + [] ); - - const [columnSort, setColumnSort] = React.useState>([]); - React.useEffect( - () => - setColumnSort( - displayFields?.map((field) => sortTypes[field.sortType]) ?? [] - ), - [displayFields] + const selectedIdsRef = React.useRef(selectedIds); + selectedIdsRef.current = selectedIds; + const resultOrderRef = React.useRef>([]); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [isHorizontal, setIsHorizontal] = React.useState(true); + const [splitterKey, setSplitterKey] = React.useState(0); + const [refreshToken, setRefreshToken] = React.useState(0); + const [queryRunCount, setQueryRunCount] = React.useState(1); + const [runtimeFields, setRuntimeFields] = React.useState< + ReturnType | undefined + >(undefined); + const resultsScrollRef = React.useRef(null); + const restoreScrollTopRef = React.useRef(undefined); + const selectedRows = React.useMemo( + () => [new Set(selectedIds), (): void => undefined] as const, + [selectedIds] ); + React.useEffect(() => { + setSelectedIds([]); + setSelectedIndex(0); + }, [tableName]); - // The executed query, with each display field's sortType overridden by columnSort - const sortedQuery = React.useMemo((): - | SerializedResource - | undefined => { - if ( - displayFields === undefined || - dataViewQuery === undefined || - dataViewQuery === false - ) - return undefined; - const sortByStringId = new Map( - displayFields.map((field, index) => [field.stringId, columnSort[index]]) - ); - return { - ...dataViewQuery, - fields: dataViewQuery.fields.map((field) => - sortByStringId.has(field.stringId) - ? { - ...field, - sortType: - flippedSortTypes[sortByStringId.get(field.stringId) ?? 'none'], - } - : field - ), - }; - }, [dataViewQuery, displayFields, columnSort]); - - const totalCountRef = React.useRef(undefined); + const handleResults = React.useCallback( + (rows: ReadonlyArray | undefined>): void => { + const orderedIds = rows.flatMap((row) => { + const id = getNumericResultId(row?.[queryIdField]); + return id === undefined ? [] : [id]; + }); + resultOrderRef.current = orderedIds; - const fetchRows = React.useCallback( - async ( - offset: number, - limit = DEFAULT_FETCH_LIMIT - ): Promise> => { - if ( - dataViewQuery === undefined || - dataViewQuery === false || - sortedQuery === undefined - ) - return []; - if (totalCountRef.current === undefined) { - const countResponse = await queryCountPromiseGenerator(dataViewQuery)(); - totalCountRef.current = - countResponse.status === Http.OK ? countResponse.data.count : 0; + if (selectedIdsRef.current.length === 0) { + const firstId = orderedIds[0]; + if (firstId !== undefined) { + setSelectedIds([firstId]); + setSelectedIndex(0); + } + return; } - return runQuery(sortedQuery, { limit, offset }); + + const positions = new Map( + orderedIds.map((id, index) => [id, index] as const) + ); + const reordered = [...selectedIdsRef.current].sort( + (left, right) => + (positions.get(left) ?? Number.MAX_SAFE_INTEGER) - + (positions.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + setSelectedIds(reordered); }, - [dataViewQuery, sortedQuery] + [] ); + const handleRefresh = React.useCallback((): void => { + if (resultsScrollRef.current !== null) + restoreScrollTopRef.current = resultsScrollRef.current.scrollTop; + setRefreshToken((token) => token + 1); + }, []); + if (table === undefined) return null; - const [initialRows] = useAsyncState( - React.useCallback( - async () => (dataViewQuery === undefined ? undefined : fetchRows(0)), - [dataViewQuery, fetchRows] - ), - true + const definition = React.useMemo( + () => getDataViewQueryDefinition(queries, tableName), + [queries, tableName] ); - - if (dataViewQuery === false) - return ( - -

    {dataViewsText.noDataViewQuery()}

    -
    - ); - - return fieldSpecs === undefined || initialRows === undefined ? null : ( - - setColumnSort((previous) => replaceItem(previous, index, sortType)) - } - /> + const query = React.useMemo( + () => + makeDataViewQuery(tableName, { + ...definition, + fields: runtimeFields ?? definition.fields, + }), + [definition, runtimeFields, tableName] ); -} - -function DataViewFromTable({ - table, - fieldSpecs, - totalCount, - initialRows, - columnSort, - onFetchRows: handleFetchRows, - onSortChange: handleSortChange, -}: { - readonly table: SpecifyTable; - readonly fieldSpecs: RA; - readonly totalCount: number; - readonly initialRows: RA; - readonly columnSort: RA; - readonly onFetchRows: ( - offset: number, - limit?: number - ) => Promise>; - readonly onSortChange: (index: number, sortType: SortTypes) => void; -}): JSX.Element | null { - const [rows, setRows] = React.useState>(initialRows); - const [selectedRows, setSelectedRows] = React.useState>( - new Set() + const fields = React.useMemo( + () => parseQueryFields(runtimeFields ?? definition.fields), + [definition.fields, runtimeFields] ); - const [activeId, setActiveId] = React.useState(undefined); - const canFetchMore = rows.length < totalCount; - const [showLineNumber] = userPreferences.use( - 'queryBuilder', - 'appearance', - 'showLineNumber' + const results = ( + { + setRuntimeFields(unParseQueryFields(table.name, newFields)); + setQueryRunCount((count) => count + 1); + }} + onSelected={(ids): void => { + const positions = new Map( + resultOrderRef.current.map((id, index) => [id, index] as const) + ); + const orderedIds = [...ids].sort( + (left, right) => + (positions.get(left) ?? Number.MAX_SAFE_INTEGER) - + (positions.get(right) ?? Number.MAX_SAFE_INTEGER) + ); + setSelectedIds(orderedIds); + const focusedId = ids.at(-1); + setSelectedIndex( + focusedId === undefined + ? 0 + : Math.max(0, orderedIds.indexOf(focusedId)) + ); + }} + queryRunCount={queryRunCount} + queryResource={query} + recordSetId={undefined} + forceCollection={undefined} + fields={fields} + selectedRows={selectedRows} + table={table} + onResults={handleResults} + refreshToken={refreshToken} + restoreScrollTopRef={restoreScrollTopRef} + scrollRef={resultsScrollRef} + /> ); - - const handleLoadMore = React.useCallback(async (): Promise => { - const newRows = await handleFetchRows(rows.length); - setRows((previousRows) => [...previousRows, ...newRows]); - }, [handleFetchRows, rows.length]); - - const scrollerRef = React.useRef(null); - const { isFetching, handleScroll } = useInfiniteScroll( - canFetchMore ? handleLoadMore : undefined, - scrollerRef + const form = ( +
    + {selectedIds.length === 0 ? ( +

    {commonText.select()}

    + ) : ( + { + setSelectedIds([]); + setSelectedIndex(0); + }} + onDelete={undefined} + onSaved={handleRefresh} + onSlide={(index): void => setSelectedIndex(index)} + /> + )} +
    ); - const handleDelete = (id: number): void => { - setRows((previousRows) => previousRows.filter((row) => row[0] !== id)); - setSelectedRows( - (previousSelected) => - new Set(Array.from(previousSelected).filter((rowId) => rowId !== id)) - ); - setActiveId((previousActiveId) => - previousActiveId === id ? undefined : previousActiveId - ); + const changeOrientation = (horizontal: boolean): void => { + if (horizontal === isHorizontal) return; + setIsHorizontal(horizontal); + setSplitterKey((key) => key + 1); }; - const handleSaved = async (): Promise => { - const refreshedRows = await handleFetchRows(0, rows.length); - setRows(refreshedRows); - }; + // In side-by-side mode the results are the primary (left) pane. In + // stacked mode the record is the primary (top) pane, so it remains the + // first thing users see after selecting a row. + const primaryPane = isHorizontal ? results : form; + const secondaryPane = isHorizontal ? form : results; return ( - -
    -

    - {commonText.colonLine({ - label: dataViewsText.tableRecords({ tableLabel: table.label }), - value: `(${ - selectedRows.size === 0 - ? totalCount - : `${selectedRows.size}/${totalCount}` - })`, - })} -

    - {selectedRows.size > 0 && ( - setSelectedRows(new Set())}> - {interactionsText.deselectAll()} - - )} -
    - + + + {dataViewsText.tableRecords({ tableLabel: table.label })} + + + changeOrientation(true)} /> -
    -
    + changeOrientation(false)} + /> + +
    +
    + {primaryPane} +
    'minmax(120px,1fr)'), - ].join(' '), - }} + className={`flex h-full min-h-0 min-w-0 overflow-auto ${ + isHorizontal ? 'border-l' : 'border-t' + } border-gray-400`} > -
    -
    - {showLineNumber && ( - - )} - - - {fieldSpecs.map((fieldSpec, index) => ( - - handleSortChange(index, sortType) - } - /> - ))} -
    -
    -
    - { - const id = rows[index][0] as number; - setSelectedRows((previousSelected) => { - const newSelected = new Set(previousSelected); - if (isSelected) newSelected.add(id); - else newSelected.delete(id); - return newSelected; - }); - if (isShiftClick) return; - setActiveId((previousActiveId) => - isSelected - ? id - : previousActiveId === id - ? undefined - : previousActiveId - ); - }} - /> - {isFetching && ( -
    - {loadingGif} -
    - )} -
    + {secondaryPane}
    - setActiveId(undefined)} - onDeleted={(id): void => handleDelete(id)} - onSaved={(): void => void handleSaved()} - />
    - - ); -} - -function DataViewRecordPane({ - table, - recordId, - onClose: handleClose, - onSaved: handleSaved, - onDeleted: handleDeleted, -}: { - readonly table: SpecifyTable; - readonly recordId: number | undefined; - readonly onClose: () => void; - readonly onSaved: (id: number) => void; - readonly onDeleted: (id: number) => void; -}): JSX.Element { - const resource = React.useMemo | undefined>( - () => - recordId === undefined ? undefined : new table.Resource({ id: recordId }), - [table, recordId] - ); - - return ( -
    - {resource === undefined ? ( -
    - {dataViewsText.selectRecordToView()} -
    - ) : ( - handleDeleted(recordId!)} - onSaved={(): void => handleSaved(recordId!)} - /> - )}
    ); } From 00a11426d3012e59a508d0592cae593a1b22145c Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:12:12 +0200 Subject: [PATCH 16/70] Fix: Serialize query-resource saves --- .../js_src/lib/components/DataViews/DataViewTables.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 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 3631a202033..9f2f1a77a6a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -34,6 +34,7 @@ export function DataViewTables(): JSX.Element { const [isEditing, handleEditing] = useBooleanState(); const [queries, reloadQueries] = useDataViewQueries(); const [queryData, setQueryData] = React.useState(); + const [isSavingQuery, setIsSavingQuery] = React.useState(false); const [queryTable, setQueryTable] = React.useState< keyof Tables | undefined >(); @@ -56,11 +57,15 @@ export function DataViewTables(): JSX.Element { {commonText.cancel()} { + if (isSavingQuery) return; + setIsSavingQuery(true); saveUserDataViewQueries(queryData) .then(reloadQueries) .then(handleCloseQueryEditor) - .catch(raise); + .catch(raise) + .finally(() => setIsSavingQuery(false)); }} > {commonText.save()} From 3d07e69b26cce9b6c2c7c2f0053df81478bf8325 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:13:55 +0200 Subject: [PATCH 17/70] Fix: Validate each stored query definition before returning it --- .../DataViews/__tests__/queries.test.ts | 10 ++++++++++ .../js_src/lib/components/DataViews/queries.ts | 16 +++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts index 8f8e6e6b90d..fdbcf45ce38 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts @@ -63,6 +63,16 @@ test('missing table definitions use generated defaults', () => { expect(defaultDataViewQuery('Agent')).toEqual(definition); }); +test('malformed table definitions use generated defaults', () => { + const file = parseDataViewQueries( + JSON.stringify({ version: 1, queries: { Agent: { selectDistinct: true } } }) + ); + const definition = getDataViewQueryDefinition(file, 'Agent'); + + expect(definition).toEqual(defaultDataViewQuery('Agent')); + expect(() => makeDataViewQuery('Agent', definition)).not.toThrow(); +}); + test('stored table definitions override defaults in runtime queries', () => { const definition = { fields: [addMissingFields('SpQueryField', { fieldName: 'Name' })], diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts index 5be421b9259..56bd12ddaa2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts +++ b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts @@ -48,6 +48,17 @@ function isDataViewQueriesFile(value: unknown): value is DataViewQueriesFile { ); } +function isDataViewQueryDefinition( + value: unknown +): value is DataViewQueryDefinition { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Array.isArray((value as { readonly fields?: unknown }).fields) + ); +} + export function parseDataViewQueries(data: unknown): DataViewQueriesFile { if (data === null || data === undefined) return emptyDataViewQueries; try { @@ -121,7 +132,10 @@ export function getDataViewQueryDefinition( file: DataViewQueriesFile, tableName: keyof Tables ): DataViewQueryDefinition { - return file.queries[tableName] ?? defaultDataViewQuery(tableName); + const definition: unknown = file.queries[tableName]; + return isDataViewQueryDefinition(definition) + ? definition + : defaultDataViewQuery(tableName); } export function makeDataViewQuery( From de29927ce87c93fe1c3d936bd6d6df65c5b60bda Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:17:19 +0200 Subject: [PATCH 18/70] Fix: Clear obsolete record counts when table selection changes --- .../components/DataViews/DataViewTables.tsx | 4 ++- .../__tests__/DataViewTables.test.tsx | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 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 9f2f1a77a6a..637909a8a70 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -135,6 +135,8 @@ export function useTableRecordCounts( React.useEffect(() => { let destructorCalled = false; + const tableNames = new Set(tables.map(({ name }) => name)); + setCounts({}); tables.forEach((table) => { const query = serializeResource( querySpecToResource(table.name, { @@ -151,7 +153,7 @@ export function useTableRecordCounts( JSON.stringify(query) ) .then((count) => { - if (destructorCalled) return; + if (destructorCalled || !tableNames.has(table.name)) return; setCounts((previousCounts) => ({ ...previousCounts, [table.name]: count, diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx index 38a1f18a9f3..c8c9bd556a4 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/DataViewTables.test.tsx @@ -40,3 +40,31 @@ test('fetches and returns a record count for each Data Views table', async () => expect(querySpecToResource).toHaveBeenCalledTimes(2); expect(throttledPromise).toHaveBeenCalledTimes(2); }); + +test('clears stale record counts when Data Views tables change', async () => { + (queryCountPromiseGenerator as jest.Mock).mockImplementation( + ({ tableName }: { readonly tableName: string }) => + async () => ({ + status: Http.OK, + data: { count: tableName === 'Agent' ? 3 : 7 }, + }) + ); + const agentTable = { name: 'Agent' } as never; + const loanTable = { name: 'Loan' } as never; + + const { result, rerender } = renderHook( + ({ tables }) => useTableRecordCounts(tables), + { initialProps: { tables: [agentTable] } } + ); + + await waitFor(() => expect(result.current).toEqual({ Agent: 3 })); + rerender({ tables: [loanTable] }); + + expect(result.current).toEqual({}); + + await waitFor(() => expect(result.current).toEqual({ Loan: 7 })); + rerender({ tables: [agentTable] }); + + expect(result.current).toEqual({}); + await waitFor(() => expect(result.current).toEqual({ Agent: 3 })); +}); From 7afe8e629bc763a337fe602a39d591929544daae Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:28:04 +0200 Subject: [PATCH 19/70] Tests --- .../__tests__/AppResourcesFilters.test.tsx | 1 + .../__tests__/allAppResources.test.ts | 43 ++++++++-------- .../defaultAppResourceFilters.test.ts | 49 ++++++++++--------- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx index 471c51d4d04..54626b82ab3 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/AppResourcesFilters.test.tsx @@ -60,6 +60,7 @@ describe('AppResourcesFilters', () => { 'collectionPreferences', 'dataEntryTables', 'dataObjectFormatters', + 'dataViewQueries', 'defaultUserPreferences', 'expressSearchConfig', 'interactionsTables', diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts index 293b78f890a..7f14c15133e 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/allAppResources.test.ts @@ -2,25 +2,26 @@ import { allAppResources } from '../filtersHelpers'; test('allAppResources', () => { expect(allAppResources).toMatchInlineSnapshot(` - [ - "collectionPreferences", - "dataEntryTables", - "dataObjectFormatters", - "defaultUserPreferences", - "expressSearchConfig", - "interactionsTables", - "label", - "leafletLayers", - "otherAppResources", - "otherJsonResource", - "otherPropertiesResource", - "otherXmlResource", - "report", - "rssExportFeed", - "typeSearches", - "uiFormatters", - "userPreferences", - "webLinks", - ] - `); +[ + "collectionPreferences", + "dataEntryTables", + "dataObjectFormatters", + "dataViewQueries", + "defaultUserPreferences", + "expressSearchConfig", + "interactionsTables", + "label", + "leafletLayers", + "otherAppResources", + "otherJsonResource", + "otherPropertiesResource", + "otherXmlResource", + "report", + "rssExportFeed", + "typeSearches", + "uiFormatters", + "userPreferences", + "webLinks", +] +`); }); diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts index 4be98addc17..e2c4c6f2913 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts +++ b/specifyweb/frontend/js_src/lib/components/AppResources/__tests__/defaultAppResourceFilters.test.ts @@ -2,28 +2,29 @@ import { defaultAppResourceFilters } from '../filtersHelpers'; test('defaultAppResourceFilters', () => { expect(defaultAppResourceFilters).toMatchInlineSnapshot(` - { - "appResources": [ - "collectionPreferences", - "dataEntryTables", - "dataObjectFormatters", - "defaultUserPreferences", - "expressSearchConfig", - "interactionsTables", - "label", - "leafletLayers", - "otherAppResources", - "otherJsonResource", - "otherPropertiesResource", - "otherXmlResource", - "report", - "rssExportFeed", - "typeSearches", - "uiFormatters", - "userPreferences", - "webLinks", - ], - "viewSets": true, - } - `); +{ + "appResources": [ + "collectionPreferences", + "dataEntryTables", + "dataObjectFormatters", + "dataViewQueries", + "defaultUserPreferences", + "expressSearchConfig", + "interactionsTables", + "label", + "leafletLayers", + "otherAppResources", + "otherJsonResource", + "otherPropertiesResource", + "otherXmlResource", + "report", + "rssExportFeed", + "typeSearches", + "uiFormatters", + "userPreferences", + "webLinks", + ], + "viewSets": true, +} +`); }); From 71fac98b0d9de2bcbe85b1f6e18ee1f212df20d7 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:42:34 +0200 Subject: [PATCH 20/70] Fix: Avoid infinite rendering when table has no query defined --- .../lib/components/DataViews/QueryEditor.tsx | 12 +++++++++--- .../DataViews/__tests__/queries.test.ts | 16 ++++++++++++---- .../js_src/lib/components/DataViews/queries.ts | 14 +++++++++++--- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index 6c0651b2e08..ddf81e7693b 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -9,7 +9,7 @@ import type { SpQueryField, Tables } from '../DataModel/types'; import { QueryBuilder } from '../QueryBuilder/Wrapped'; import { defaultDataViewTablesConfig, useDataViewTables } from './config'; import { - getDataViewQueryDefinition, + getStoredDataViewQueryDefinition, makeDataViewQuery, parseDataViewQueries, serializeDataViewQueries, @@ -72,8 +72,13 @@ export function DataViewQueryEditorContent({ const query = React.useMemo( () => - makeDataViewQuery(tableName, getDataViewQueryDefinition(file, tableName)), - [file, tableName] + makeDataViewQuery( + tableName, + getStoredDataViewQueryDefinition(fileRef.current, tableName) ?? { + fields: [], + } + ), + [tableName] ); const handleQueryChange = React.useCallback( @@ -129,6 +134,7 @@ export function DataViewQueryEditorContent({ autoRun={false} forceCollection={undefined} isEmbedded + key={tableName} query={query} onChange={handleQueryChange} /> diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts index fdbcf45ce38..d9230beeb0c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts +++ b/specifyweb/frontend/js_src/lib/components/DataViews/__tests__/queries.test.ts @@ -5,6 +5,7 @@ import { requireContext } from '../../../tests/helpers'; import { defaultDataViewQuery, getDataViewQueryDefinition, + getStoredDataViewQueryDefinition, makeDataViewQuery, parseDataViewQueries, serializeDataViewQueries, @@ -54,15 +55,21 @@ test('Data View query definitions round trip as JSON', () => { }); test('missing table definitions use generated defaults', () => { - const definition = getDataViewQueryDefinition( - parseDataViewQueries(undefined), - 'Agent' - ); + const file = parseDataViewQueries(undefined); + const definition = getDataViewQueryDefinition(file, 'Agent'); + + expect(getStoredDataViewQueryDefinition(file, 'Agent')).toBeUndefined(); expect(definition.fields.length).toBeGreaterThan(0); expect(definition.selectDistinct).toBe(false); expect(defaultDataViewQuery('Agent')).toEqual(definition); }); +test('empty Data View query definitions create an empty query', () => { + const query = makeDataViewQuery('Agent', { fields: [] }); + + expect(serializeResource(query).fields).toEqual([]); +}); + test('malformed table definitions use generated defaults', () => { const file = parseDataViewQueries( JSON.stringify({ version: 1, queries: { Agent: { selectDistinct: true } } }) @@ -85,6 +92,7 @@ test('stored table definitions override defaults in runtime queries', () => { queries: { Agent: definition }, }; const storedDefinition = getDataViewQueryDefinition(file, 'Agent'); + expect(getStoredDataViewQueryDefinition(file, 'Agent')).toBe(definition); const query = makeDataViewQuery('Agent', storedDefinition); expect(storedDefinition).toBe(definition); diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts index 56bd12ddaa2..bf760f8201f 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts +++ b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts @@ -132,10 +132,18 @@ export function getDataViewQueryDefinition( file: DataViewQueriesFile, tableName: keyof Tables ): DataViewQueryDefinition { + return ( + getStoredDataViewQueryDefinition(file, tableName) ?? + defaultDataViewQuery(tableName) + ); +} + +export function getStoredDataViewQueryDefinition( + file: DataViewQueriesFile, + tableName: keyof Tables +): DataViewQueryDefinition | undefined { const definition: unknown = file.queries[tableName]; - return isDataViewQueryDefinition(definition) - ? definition - : defaultDataViewQuery(tableName); + return isDataViewQueryDefinition(definition) ? definition : undefined; } export function makeDataViewQuery( From 23cab4dfb93fdf76f053a80de9524e008d37cf35 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Tue, 1 Sep 2026 13:57:49 +0200 Subject: [PATCH 21/70] Fix: Do not add tables to json files only on select change --- .../js_src/lib/components/DataViews/QueryEditor.tsx | 6 ++++++ .../frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx | 1 + 2 files changed, 7 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index ddf81e7693b..d35dc0dec54 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -88,6 +88,12 @@ export function DataViewQueryEditorContent({ readonly searchSynonymy: boolean | null; readonly isSeries: boolean | null; }): void => { + if ( + changes.fields.length === 0 && + getStoredDataViewQueryDefinition(fileRef.current, tableName) === + undefined + ) + return; const nextFile: DataViewQueriesFile = { ...file, queries: { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 6249c8b0073..5765c6aa899 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -153,6 +153,7 @@ function Wrapped({ React.useEffect(checkForChanges, [state.fields]); React.useEffect(() => { + if (state === pendingState) return; handleChange?.({ fields: unParseQueryFields(state.baseTableName, state.fields), isDistinct: query.selectDistinct, From fb73ad342f32bddef5143e96a8f6bb259f9758a3 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:15:15 -0500 Subject: [PATCH 22/70] fix: move split view over to the left side --- .../lib/components/QueryBuilder/Header.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx index bcf7e790d38..ecfb0b651bb 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx @@ -131,14 +131,7 @@ export function QueryHeader({ /> ) : undefined} -
    - setIsBasic(!isBasic)}> - {isBasic - ? preferencesText.detailedView() - : preferencesText.basicView()} - - - + +
    + setIsBasic(!isBasic)}> + {isBasic + ? preferencesText.detailedView() + : preferencesText.basicView()} + + {hasToolPermission( 'queryBuilder', queryResource.isNew() ? 'create' : 'update' From e53352df623d829959f2879f4bdc00e91a52cebb Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:36:48 -0500 Subject: [PATCH 23/70] refactor: extract split-view logic --- .../js_src/lib/components/DataViews/index.tsx | 70 +++++------------ .../lib/components/QueryBuilder/Header.tsx | 23 +++--- .../QueryBuilder/QueryBuilderResults.tsx | 3 - .../lib/components/QueryBuilder/Results.tsx | 11 ++- .../QueryBuilder/ResultsWrapper.tsx | 33 ++------ .../lib/components/QueryBuilder/SplitView.tsx | 78 +++++++++++++++++++ .../lib/components/QueryBuilder/Wrapped.tsx | 2 - .../QueryBuilder/useQuerySplitView.ts | 11 +-- 8 files changed, 126 insertions(+), 105 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 69371dd646d..0171ff49db2 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -1,10 +1,8 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import Splitter from 'm-react-splitters'; import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; -import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; import type { Tables } from '../DataModel/types'; import { getTable } from '../DataModel/tables'; @@ -13,6 +11,11 @@ import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; import { QueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; import { parseQueryFields, unParseQueryFields } from '../QueryBuilder/helpers'; import { queryIdField } from '../QueryBuilder/Results'; +import { + SplitView, + SplitViewOrientationButton, + useSplitViewOrientation, +} from '../QueryBuilder/SplitView'; import { NotFoundView } from '../Router/NotFoundView'; import { getDataViewQueryDefinition, @@ -20,7 +23,6 @@ import { useDataViewQueries, } from './queries'; import type { DataViewQueriesFile } from './queries'; -import { treeText } from '../../localization/tree'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -77,15 +79,14 @@ function LoadedDataViewFromTable({ selectedIdsRef.current = selectedIds; const resultOrderRef = React.useRef>([]); const [selectedIndex, setSelectedIndex] = React.useState(0); - const [isHorizontal, setIsHorizontal] = React.useState(true); - const [splitterKey, setSplitterKey] = React.useState(0); + const resultsScrollRef = React.useRef(null); + const restoreScrollTopRef = React.useRef(undefined); + const { isHorizontal, toggleOrientation } = useSplitViewOrientation(); const [refreshToken, setRefreshToken] = React.useState(0); const [queryRunCount, setQueryRunCount] = React.useState(1); const [runtimeFields, setRuntimeFields] = React.useState< ReturnType | undefined >(undefined); - const resultsScrollRef = React.useRef(null); - const restoreScrollTopRef = React.useRef(undefined); const selectedRows = React.useMemo( () => [new Set(selectedIds), (): void => undefined] as const, [selectedIds] @@ -218,18 +219,6 @@ function LoadedDataViewFromTable({
    ); - const changeOrientation = (horizontal: boolean): void => { - if (horizontal === isHorizontal) return; - setIsHorizontal(horizontal); - setSplitterKey((key) => key + 1); - }; - - // In side-by-side mode the results are the primary (left) pane. In - // stacked mode the record is the primary (top) pane, so it remains the - // first thing users see after selecting a row. - const primaryPane = isHorizontal ? results : form; - const secondaryPane = isHorizontal ? form : results; - return (
    @@ -237,42 +226,19 @@ function LoadedDataViewFromTable({ {dataViewsText.tableRecords({ tableLabel: table.label })} - changeOrientation(true)} - /> - changeOrientation(false)} +
    - -
    - {primaryPane} -
    -
    - {secondaryPane} -
    -
    +
    ); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx index ecfb0b651bb..f6bfa24a94f 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx @@ -27,6 +27,7 @@ import { QueryEditButton } from './Edit'; import { QueryLoanReturn } from './LoanReturn'; import type { MainState } from './reducer'; import { treeText } from '../../localization/tree'; +import { SplitViewOrientationButton } from './SplitView'; export type QueryView = { readonly basicView: RA; @@ -132,18 +133,16 @@ export function QueryHeader({ ) : undefined} - + aria-pressed={isSplit} + icon="template" + title={treeText.splitView()} + onClick={onToggleSplit} + /> +
    setIsBasic(!isBasic)}> diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx index ab7512afeee..413e06eb367 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/QueryBuilderResults.tsx @@ -34,7 +34,6 @@ export function QueryBuilderResults({ resultsRef, isSplit, isHorizontal, - splitterKey, onReRun: handleReRun, onRunQuery: handleRunQuery, onSelected: handleSelected, @@ -62,7 +61,6 @@ export function QueryBuilderResults({ >; readonly isSplit: boolean; readonly isHorizontal: boolean; - readonly splitterKey: number; readonly onReRun: () => void; readonly onRunQuery: (fields?: RA) => void; readonly onSelected: (ids: RA) => void; @@ -148,7 +146,6 @@ export function QueryBuilderResults({ recordSetId={recordSet?.id} resultsRef={resultsRef} selectedRows={[selectedRows, setSelectedRows]} - splitterKey={splitterKey} splitHorizontal={isHorizontal} splitPane={isSplit ? recordPreview : undefined} table={table} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index 1aec14be0f1..c2a65bf8381 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -228,8 +228,15 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { const [showCellEllipsis, setShowCellEllipsis] = React.useState(false); const lastSelectedRow = React.useRef(undefined); - // Unselect all rows when query is reRun - React.useEffect(() => setSelectedRows(new Set()), [fieldSpecs]); + // Unselect all rows when the query fields change, but do not clear the + // parent-owned selection when this component is remounted while changing + // split-view orientation. + const previousFieldSpecs = React.useRef(fieldSpecs); + React.useEffect(() => { + if (previousFieldSpecs.current === fieldSpecs) return; + previousFieldSpecs.current = fieldSpecs; + setSelectedRows(new Set()); + }, [fieldSpecs, setSelectedRows]); const showResults = Array.isArray(results) && diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsWrapper.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsWrapper.tsx index 18d2f3be237..12ee437ba75 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsWrapper.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/ResultsWrapper.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Splitter from 'm-react-splitters'; import { ajax } from '../../utils/ajax'; import type { GetSet, RA } from '../../utils/types'; @@ -25,6 +24,7 @@ import { } from './helpers'; import type { QueryResultRow } from './Results'; import { QueryResults } from './Results'; +import { SplitView } from './SplitView'; // TODO: [FEATURE] allow customizing this and other constants as make sense const fetchSize = 40; @@ -38,7 +38,6 @@ export function QueryResultsWrapper({ refreshToken, splitPane, splitHorizontal, - splitterKey, ...props }: ResultsProps & { readonly createRecordSet: JSX.Element | undefined; @@ -50,7 +49,6 @@ export function QueryResultsWrapper({ readonly refreshToken?: number; readonly splitPane?: JSX.Element; readonly splitHorizontal?: boolean; - readonly splitterKey?: number; readonly onReRun: () => void; }): JSX.Element | null { const newProps = useQueryResultsWrapper(props); @@ -78,28 +76,13 @@ export function QueryResultsWrapper({ return splitPane === undefined ? ( queryResults ) : ( - -
    - {splitHorizontal ? queryResults : splitPane} -
    -
    - {splitHorizontal ? splitPane : queryResults} -
    -
    + ); } diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx new file mode 100644 index 00000000000..7d837c8d0d9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/SplitView.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import Splitter from 'm-react-splitters'; + +import { Button } from '../Atoms/Button'; +import { treeText } from '../../localization/tree'; + +export function useSplitViewOrientation(): { + readonly isHorizontal: boolean; + readonly toggleOrientation: () => void; +} { + const [isHorizontal, setIsHorizontal] = React.useState(true); + return { + isHorizontal, + toggleOrientation: (): void => setIsHorizontal((horizontal) => !horizontal), + }; +} + +export function SplitViewOrientationButton({ + isHorizontal, + disabled = false, + onToggle: handleToggle, +}: { + readonly isHorizontal: boolean; + readonly disabled?: boolean; + readonly onToggle: () => void; +}): JSX.Element { + return ( + + ); +} + +export function SplitView({ + primaryPane, + secondaryPane, + primaryPaneKey, + secondaryPaneKey, + isHorizontal, +}: { + readonly primaryPane: JSX.Element; + readonly secondaryPane: JSX.Element; + readonly primaryPaneKey: string; + readonly secondaryPaneKey: string; + readonly isHorizontal: boolean; +}): JSX.Element { + return ( + +
    + {primaryPane} +
    +
    + {secondaryPane} +
    +
    + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index 5765c6aa899..e78a0c219a9 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -281,7 +281,6 @@ function Wrapped({ setSelectedIndex, isSplit, isHorizontal, - splitterKey, toggleSplit, toggleOrientation, } = useQuerySplitView(resultsRef); @@ -616,7 +615,6 @@ function Wrapped({ selectedRows={selectedRows} setSelectedIndex={setSelectedIndex} setSelectedRows={setSelectedRows} - splitterKey={splitterKey} state={state} table={table} onReRun={(): void => dispatch({ type: 'RunQueryAction' })} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts index 124d5eaad0a..730642cb7f1 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts @@ -2,6 +2,7 @@ import React from 'react'; import type { RA } from '../../utils/types'; import { queryIdField, type QueryResultRow } from './Results'; +import { useSplitViewOrientation } from './SplitView'; export function useQuerySplitView( resultsRef: React.MutableRefObject | undefined> @@ -14,7 +15,6 @@ export function useQuerySplitView( readonly setSelectedIndex: React.Dispatch>; readonly isSplit: boolean; readonly isHorizontal: boolean; - readonly splitterKey: number; readonly toggleSplit: () => void; readonly toggleOrientation: () => void; } { @@ -23,8 +23,7 @@ export function useQuerySplitView( ); const [selectedIndex, setSelectedIndex] = React.useState(0); const [isSplit, setIsSplit] = React.useState(false); - const [isHorizontal, setIsHorizontal] = React.useState(true); - const [splitterKey, setSplitterKey] = React.useState(0); + const { isHorizontal, toggleOrientation } = useSplitViewOrientation(); const toggleSplit = (): void => { const nextIsSplit = !isSplit; @@ -39,11 +38,6 @@ export function useQuerySplitView( } } }; - const toggleOrientation = (): void => { - setIsHorizontal(!isHorizontal); - setSplitterKey((key) => key + 1); - }; - return { selectedRows, setSelectedRows, @@ -51,7 +45,6 @@ export function useQuerySplitView( setSelectedIndex, isSplit, isHorizontal, - splitterKey, toggleSplit, toggleOrientation, }; From a65637391648d3506a3a1486bbcfce20ea60d405 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:10:56 -0500 Subject: [PATCH 24/70] feat(data-views): add query config option in views --- .../js_src/lib/components/DataViews/index.tsx | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 0171ff49db2..9f64f93f6f3 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -3,7 +3,9 @@ import { useParams } from 'react-router-dom'; import { commonText } from '../../localization/common'; import { dataViewsText } from '../../localization/dataViews'; +import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { H2 } from '../Atoms'; import type { Tables } from '../DataModel/types'; import { getTable } from '../DataModel/tables'; import { ProtectedTable } from '../Permissions/PermissionDenied'; @@ -17,12 +19,18 @@ import { useSplitViewOrientation, } from '../QueryBuilder/SplitView'; import { NotFoundView } from '../Router/NotFoundView'; +import { Dialog } from '../Molecules/Dialog'; +import { raise } from '../Errors/Crash'; import { getDataViewQueryDefinition, makeDataViewQuery, + saveUserDataViewQueries, + serializeDataViewQueries, useDataViewQueries, } from './queries'; import type { DataViewQueriesFile } from './queries'; +import { DataViewQueryEditorContent } from './QueryEditor'; +import { TableIcon } from '../Molecules/TableIcon'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -54,12 +62,13 @@ function DataViewFromTable({ }: { readonly tableName: keyof Tables; }): JSX.Element | null { - const [queries] = useDataViewQueries(); + const [queries, reloadQueries] = useDataViewQueries(); return queries === undefined ? null : ( ); } @@ -67,9 +76,11 @@ function DataViewFromTable({ function LoadedDataViewFromTable({ tableName, queries, + reloadQueries, }: { readonly tableName: keyof Tables; readonly queries: DataViewQueriesFile; + readonly reloadQueries: () => void; }): JSX.Element | null { const table = getTable(tableName); const [selectedIds, setSelectedIds] = React.useState>( @@ -84,6 +95,8 @@ function LoadedDataViewFromTable({ const { isHorizontal, toggleOrientation } = useSplitViewOrientation(); const [refreshToken, setRefreshToken] = React.useState(0); const [queryRunCount, setQueryRunCount] = React.useState(1); + const [queryData, setQueryData] = React.useState(); + const [isSavingQuery, setIsSavingQuery] = React.useState(false); const [runtimeFields, setRuntimeFields] = React.useState< ReturnType | undefined >(undefined); @@ -130,6 +143,9 @@ function LoadedDataViewFromTable({ restoreScrollTopRef.current = resultsScrollRef.current.scrollTop; setRefreshToken((token) => token + 1); }, []); + const handleCloseQueryEditor = (): void => setQueryData(undefined); + const handleOpenQueryEditor = (): void => + setQueryData(serializeDataViewQueries(queries)); if (table === undefined) return null; const definition = React.useMemo( @@ -149,6 +165,41 @@ function LoadedDataViewFromTable({ [definition.fields, runtimeFields] ); + if (queryData !== undefined) + return ( + + + {commonText.cancel()} + + { + if (isSavingQuery) return; + setIsSavingQuery(true); + saveUserDataViewQueries(queryData) + .then(reloadQueries) + .then(handleCloseQueryEditor) + .catch(raise) + .finally(() => setIsSavingQuery(false)); + }} + > + {commonText.save()} + + + } + header={dataViewsText.configureQuery()} + onClose={handleCloseQueryEditor} + > + + + ); + const results = ( - - - {dataViewsText.tableRecords({ tableLabel: table.label })} - - +
    +
    + +

    + {dataViewsText.tableRecords({ tableLabel: table.label })} +

    + +
    - + +
    Date: Tue, 1 Sep 2026 20:52:38 -0500 Subject: [PATCH 25/70] fix(data-views): make padding appropriate --- .../frontend/js_src/lib/components/DataViews/index.tsx | 6 +++--- 1 file 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 9f64f93f6f3..0fa0f5bb08c 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -266,13 +266,13 @@ function LoadedDataViewFromTable({ onSaved={handleRefresh} onSlide={(index): void => setSelectedIndex(index)} /> - )} + )}
    ); return ( -
    -
    +
    +

    From bcd1c809ecbe6c1dac50a9680d5bdb8c5e8f431a Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:15:04 -0500 Subject: [PATCH 26/70] feat(preferences): add split view prefs --- .../js_src/lib/components/DataViews/index.tsx | 42 +++++++++++++++---- .../Preferences/UserDefinitions.tsx | 37 ++++++++++++++++ .../lib/components/QueryBuilder/Header.tsx | 10 +---- .../lib/components/QueryBuilder/SplitView.tsx | 21 +++++++++- .../QueryBuilder/useQuerySplitView.ts | 17 +++++++- .../js_src/lib/localization/dataViews.ts | 10 +++++ 6 files changed, 116 insertions(+), 21 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 0fa0f5bb08c..00fd21f598d 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -16,6 +16,7 @@ import { queryIdField } from '../QueryBuilder/Results'; import { SplitView, SplitViewOrientationButton, + SplitViewToggleButton, useSplitViewOrientation, } from '../QueryBuilder/SplitView'; import { NotFoundView } from '../Router/NotFoundView'; @@ -31,6 +32,7 @@ import { import type { DataViewQueriesFile } from './queries'; import { DataViewQueryEditorContent } from './QueryEditor'; import { TableIcon } from '../Molecules/TableIcon'; +import { userPreferences } from '../Preferences/userPreferences'; export function TableDataView(): JSX.Element { const { tableName = '' } = useParams(); @@ -92,7 +94,20 @@ function LoadedDataViewFromTable({ const [selectedIndex, setSelectedIndex] = React.useState(0); const resultsScrollRef = React.useRef(null); const restoreScrollTopRef = React.useRef(undefined); - const { isHorizontal, toggleOrientation } = useSplitViewOrientation(); + const [splitViewByDefault] = userPreferences.use( + 'dataViews', + 'general', + 'splitViewByDefault' + ); + const [splitViewOrientation] = userPreferences.use( + 'dataViews', + 'general', + 'splitViewOrientation' + ); + const [isSplit, setIsSplit] = React.useState(splitViewByDefault); + const { isHorizontal, toggleOrientation } = useSplitViewOrientation( + splitViewOrientation === 'horizontal' + ); const [refreshToken, setRefreshToken] = React.useState(0); const [queryRunCount, setQueryRunCount] = React.useState(1); const [queryData, setQueryData] = React.useState(); @@ -266,7 +281,7 @@ function LoadedDataViewFromTable({ onSaved={handleRefresh} onSlide={(index): void => setSelectedIndex(index)} /> - )} + )}

    ); @@ -280,20 +295,29 @@ function LoadedDataViewFromTable({
    + setIsSplit((split) => !split)} + />
    - + {isSplit ? ( + + ) : ( + results + )}
    ); diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 375843c28e9..fd49058feeb 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -19,6 +19,7 @@ import { reportsText } from '../../localization/report'; import { resourcesText } from '../../localization/resources'; import { schemaText } from '../../localization/schema'; import { statsText } from '../../localization/stats'; +import { treeText } from '../../localization/tree'; import type { Language } from '../../localization/utils/config'; import { LANGUAGE } from '../../localization/utils/config'; import { wbPlanText } from '../../localization/wbPlan'; @@ -1795,6 +1796,24 @@ export const userPreferenceDefinitions = { general: { title: preferencesText.general(), items: { + splitViewByDefault: definePref({ + title: dataViewsText.splitViewByDefault(), + description: dataViewsText.splitViewDescription(), + requiresReload: false, + visible: true, + defaultValue: false, + type: 'java.lang.Boolean', + }), + splitViewOrientation: definePref<'horizontal' | 'vertical'>({ + title: dataViewsText.splitViewOrientation(), + requiresReload: false, + visible: true, + defaultValue: 'horizontal', + values: [ + { value: 'horizontal', title: treeText.horizontal() }, + { value: 'vertical', title: treeText.vertical() }, + ], + }), noRestrictionsMode: definePref({ title: preferencesText.noRestrictionsMode(), description: ( @@ -1928,6 +1947,24 @@ export const userPreferenceDefinitions = { general: { title: preferencesText.general(), items: { + splitViewByDefault: definePref({ + title: dataViewsText.splitViewByDefault(), + description: dataViewsText.splitViewDescription(), + requiresReload: false, + visible: true, + defaultValue: true, + type: 'java.lang.Boolean', + }), + splitViewOrientation: definePref<'horizontal' | 'vertical'>({ + title: dataViewsText.splitViewOrientation(), + requiresReload: false, + visible: true, + defaultValue: 'horizontal', + values: [ + { value: 'horizontal', title: treeText.horizontal() }, + { value: 'vertical', title: treeText.vertical() }, + ], + }), shownTables: definePref>({ title: localized('_shownTables'), requiresReload: false, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx index f6bfa24a94f..c9a87512fd4 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Header.tsx @@ -26,8 +26,7 @@ import { useQueryViewPref } from './Context'; import { QueryEditButton } from './Edit'; import { QueryLoanReturn } from './LoanReturn'; import type { MainState } from './reducer'; -import { treeText } from '../../localization/tree'; -import { SplitViewOrientationButton } from './SplitView'; +import { SplitViewOrientationButton, SplitViewToggleButton } from './SplitView'; export type QueryView = { readonly basicView: RA; @@ -132,12 +131,7 @@ export function QueryHeader({ /> ) : undefined} - + void; } { - const [isHorizontal, setIsHorizontal] = React.useState(true); + const [isHorizontal, setIsHorizontal] = React.useState(defaultHorizontal); return { isHorizontal, toggleOrientation: (): void => setIsHorizontal((horizontal) => !horizontal), @@ -35,6 +35,23 @@ export function SplitViewOrientationButton({ ); } +export function SplitViewToggleButton({ + isSplit, + onToggle: handleToggle, +}: { + readonly isSplit: boolean; + readonly onToggle: () => void; +}): JSX.Element { + return ( + + ); +} + export function SplitView({ primaryPane, secondaryPane, diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts index 730642cb7f1..43497b4f18c 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/useQuerySplitView.ts @@ -3,6 +3,7 @@ import React from 'react'; import type { RA } from '../../utils/types'; import { queryIdField, type QueryResultRow } from './Results'; import { useSplitViewOrientation } from './SplitView'; +import { userPreferences } from '../Preferences/userPreferences'; export function useQuerySplitView( resultsRef: React.MutableRefObject | undefined> @@ -22,8 +23,20 @@ export function useQuerySplitView( new Set() ); const [selectedIndex, setSelectedIndex] = React.useState(0); - const [isSplit, setIsSplit] = React.useState(false); - const { isHorizontal, toggleOrientation } = useSplitViewOrientation(); + const [splitViewByDefault] = userPreferences.use( + 'queryBuilder', + 'general', + 'splitViewByDefault' + ); + const [splitViewOrientation] = userPreferences.use( + 'queryBuilder', + 'general', + 'splitViewOrientation' + ); + const [isSplit, setIsSplit] = React.useState(splitViewByDefault); + const { isHorizontal, toggleOrientation } = useSplitViewOrientation( + splitViewOrientation === 'horizontal' + ); const toggleSplit = (): void => { const nextIsSplit = !isSplit; diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 91453e1d19f..356039d5698 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -27,4 +27,14 @@ export const dataViewsText = createDictionary({ configureQuery: { 'en-us': 'Configure query', }, + splitViewByDefault: { + 'en-us': 'Enable split view by default', + }, + splitViewDescription: { + 'en-us': + 'Split view displays query results alongside a record preview, allowing you to review records without leaving the results list.', + }, + splitViewOrientation: { + 'en-us': 'Default split view orientation', + }, } as const); From 7ba926e6215e245ba22e08d4c2daac1147291871 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:44:57 -0500 Subject: [PATCH 27/70] fix(data-views): merge DataViewQueries across app-resource hierarchy --- specifyweb/backend/context/app_resource.py | 39 +++++++++++++++++++ .../context/tests/test_app_resource.py | 24 ++++++++++++ 2 files changed, 63 insertions(+) diff --git a/specifyweb/backend/context/app_resource.py b/specifyweb/backend/context/app_resource.py index bd57471dfe3..b1b46bfb886 100644 --- a/specifyweb/backend/context/app_resource.py +++ b/specifyweb/backend/context/app_resource.py @@ -5,6 +5,7 @@ database or the filesystem with database resources taking precedence over the filesystem. """ import errno +import json import logging import os from xml.etree import ElementTree @@ -62,6 +63,11 @@ def get_app_resource(collection, user, resource_name, additional_default=False): logger.info('looking for app resource %r for user %s in %s', resource_name, user and user.name, collection and collection.collectionname) + # Data View queries are keyed by table, so a more-specific resource can + # override individual tables while inheriting the remaining definitions. + if resource_name == 'DataViewQueries': + return get_data_view_queries_resource(collection, user) + # Handling for DataObjFormatters to support fallback to defaults if resource_name == 'DataObjFormatters': custom_formatter = None @@ -119,6 +125,39 @@ def get_app_resource(collection, user, resource_name, additional_default=False): # resource was not found return None + +def get_data_view_queries_resource(collection, user): + """Return DataViewQueries merged across the app-resource hierarchy.""" + resources = [] + for level in DIR_LEVELS: + resource = get_app_resource_from_db(collection, user, level, 'DataViewQueries') + if resource is None: + resource = load_resource_at_level(collection, user, level, 'DataViewQueries') + if resource is not None: + resources.append(resource) + + if not resources: + return None + + queries = {} + for resource, _mimetype, _id in reversed(resources): + try: + data = json.loads(resource) + except (TypeError, json.JSONDecodeError): + continue + if ( + not isinstance(data, dict) + or data.get('version') != 1 + or not isinstance(data.get('queries'), dict) + ): + continue + queries.update(data['queries']) + + # Return the most-specific resource's metadata while exposing the merged + # JSON payload to the client. + _resource, mimetype, resource_id = resources[0] + return json.dumps({'version': 1, 'queries': queries}), mimetype, resource_id + def get_usertype(user): return user and user.usertype and user.usertype.replace(' ', '').lower() diff --git a/specifyweb/backend/context/tests/test_app_resource.py b/specifyweb/backend/context/tests/test_app_resource.py index 9cdc4a68776..5e7ebbb6196 100644 --- a/specifyweb/backend/context/tests/test_app_resource.py +++ b/specifyweb/backend/context/tests/test_app_resource.py @@ -3,12 +3,36 @@ from specifyweb.specify.tests.test_api import ApiTests from unittest.mock import Mock, patch +from specifyweb.backend.context.app_resource import get_data_view_queries_resource + FOUND_RESOURCE = ('"Value"', "text/xml", 4) class TestAppResource(ApiTests): + @patch("specifyweb.backend.context.app_resource.load_resource_at_level") + @patch("specifyweb.backend.context.app_resource.get_app_resource_from_db") + def test_data_view_queries_inherit_by_table( + self, get_from_db: Mock, load_from_filesystem: Mock + ): + discipline = ('{"version": 1, "queries": {"Agent": {"fields": [1]}, "Loan": {"fields": [2]}}}', 'application/json', 1) + personal = ('{"version": 1, "queries": {"Agent": {"fields": [3]}}}', 'application/json', 2) + get_from_db.side_effect = lambda _collection, _user, level, _name: { + 'Discipline': discipline, + 'Personal': personal, + }.get(level) + load_from_filesystem.return_value = None + + result = get_data_view_queries_resource(self.specify_collection, self.specifyuser) + + self.assertIsNotNone(result) + self.assertEqual( + result[0], + '{"version": 1, "queries": {"Agent": {"fields": [3]}, "Loan": {"fields": [2]}}}', + ) + self.assertEqual(result[1:], ('application/json', 2)) + def test_no_name(self): c = Client() c.force_login(self.specifyuser) From ed6d17cd9d01406f908bce2af5385afc50470a44 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:01:32 -0500 Subject: [PATCH 28/70] fix(data-views): make app resources preview function --- .../lib/components/DataViews/QueryEditor.tsx | 44 +++++++------------ .../lib/components/QueryBuilder/Toolbar.tsx | 11 ++++- .../lib/components/QueryBuilder/Wrapped.tsx | 6 +-- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index d35dc0dec54..44bdbccc430 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -2,12 +2,11 @@ import React from 'react'; import { dataViewsText } from '../../localization/dataViews'; import type { RA } from '../../utils/types'; -import { Label, Select } from '../Atoms/Form'; import type { AppResourceTabProps } from '../AppResources/TabDefinitions'; import type { SerializedResource } from '../DataModel/helperTypes'; import type { SpQueryField, Tables } from '../DataModel/types'; import { QueryBuilder } from '../QueryBuilder/Wrapped'; -import { defaultDataViewTablesConfig, useDataViewTables } from './config'; +import { defaultDataViewTablesConfig } from './config'; import { getStoredDataViewQueryDefinition, makeDataViewQuery, @@ -17,6 +16,7 @@ import { type DataViewQueriesFile, } from './queries'; import { schemaText } from '../../localization/schema'; +import { TableList } from '../SchemaConfig/Tables'; export function DataViewQueryEditor({ data, @@ -42,24 +42,14 @@ export function DataViewQueryEditorContent({ const initialFile = React.useMemo(() => parseDataViewQueries(data), [data]); const [file, setFile] = React.useState(initialFile); const fileRef = React.useRef(file); - const [tables] = useDataViewTables(); - const tableNames = React.useMemo>( - () => tables.map(({ name }) => name), - [tables] - ); const [tableName, setTableName] = React.useState( - lockedTableName ?? tableNames[0] ?? defaultDataViewTablesConfig[0] + lockedTableName ?? defaultDataViewTablesConfig[0] ); React.useEffect(() => { if (lockedTableName !== undefined) setTableName(lockedTableName); }, [lockedTableName]); - React.useEffect(() => { - if (lockedTableName === undefined && !tableNames.includes(tableName)) - setTableName(tableNames[0] ?? defaultDataViewTablesConfig[0]); - }, [lockedTableName, tableName, tableNames]); - React.useEffect(() => { if ( serializeStableDataViewQueries(fileRef.current) === @@ -119,23 +109,21 @@ export function DataViewQueryEditorContent({ ); return ( -
    +
    {lockedTableName === undefined ? ( - - {schemaText.table()} - - +
    +

    {schemaText.tables()}

    + void) => + (): void => + setTableName(table.name) + } + /> +
    ) : undefined} -
    +
    {queryText.countOnly()} - + { + // QueryBuilder can be embedded inside another form (for example + // the DataViewQueries app resource editor). Do not let this + // submit that parent form and reload the page. + event.preventDefault(); + event.stopPropagation(); + handleSubmitClick(); + }} + > {queryText.query()} diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx index e78a0c219a9..dfd2d2aebfe 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Wrapped.tsx @@ -572,11 +572,7 @@ function Wrapped({ showSeries={showSeries} tableName={table.name} onRunCountOnly={(): void => runQuery('count')} - onSubmitClick={(): void => - form?.checkValidity() === false - ? runQuery('regular') - : undefined - } + onSubmitClick={(): void => runQuery('regular')} onToggleDistinct={(): void => { setQuery({ ...query, From a1b50a13b9b7ca408a13250e2422f122f4dbc2be Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:13:38 -0500 Subject: [PATCH 29/70] fix(data-views): keep table selection in app resources after save --- .../lib/components/DataViews/QueryEditor.tsx | 15 ++++++++++----- .../js_src/lib/utils/cache/definitions.ts | 2 ++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index 44bdbccc430..b9bc8650882 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -17,6 +17,7 @@ import { } from './queries'; import { schemaText } from '../../localization/schema'; import { TableList } from '../SchemaConfig/Tables'; +import { useCachedState } from '../../hooks/useCachedState'; export function DataViewQueryEditor({ data, @@ -42,8 +43,12 @@ export function DataViewQueryEditorContent({ const initialFile = React.useMemo(() => parseDataViewQueries(data), [data]); const [file, setFile] = React.useState(initialFile); const fileRef = React.useRef(file); + const [rememberedTable, setRememberedTable] = useCachedState( + 'appResources', + 'dataViewQueriesTable' + ); const [tableName, setTableName] = React.useState( - lockedTableName ?? defaultDataViewTablesConfig[0] + lockedTableName ?? rememberedTable ?? defaultDataViewTablesConfig[0] ); React.useEffect(() => { @@ -116,10 +121,10 @@ export function DataViewQueryEditorContent({ void) => - (): void => - setTableName(table.name) - } + getAction={(table): (() => void) => (): void => { + setTableName(table.name); + setRememberedTable(table.name); + }} />
    ) : undefined} diff --git a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts index 4275a3a3099..6197dd5490c 100644 --- a/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts +++ b/specifyweb/frontend/js_src/lib/utils/cache/definitions.ts @@ -155,6 +155,8 @@ export type CacheDefinitions = { readonly conformation: RA; readonly filters: AppResourceFilters; readonly showHiddenTables: boolean; + /** Last table selected in the DataViewQueries visual editor */ + readonly dataViewQueriesTable: keyof Tables; }; readonly pageSizes: RR; readonly formEditor: { From e6cbccd239f92441d07404440923601bf5768975 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:19 -0500 Subject: [PATCH 30/70] feat: add new aggregators for system tables --- config/backstop/dataobj_formatters.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config/backstop/dataobj_formatters.xml b/config/backstop/dataobj_formatters.xml index c12c121e3dc..1597d8b977f 100644 --- a/config/backstop/dataobj_formatters.xml +++ b/config/backstop/dataobj_formatters.xml @@ -522,7 +522,7 @@ - + @@ -550,5 +550,8 @@ + + + \ No newline at end of file From a346934bbdd36769baf5f37f87fff2fcc115488b Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:36:30 -0500 Subject: [PATCH 31/70] feat(data-views): add scope to collection --- .../frontend/js_src/lib/components/AppResources/types.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index 7619ae8f9a1..e8d30b8c722 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -109,7 +109,8 @@ export const appResourceSubTypes = ensure>()({ documentationUrl: undefined, icon: icons.eye, label: dataViewsText.dataViewQueries(), - scope: ['discipline', 'user'], + useTemplate: true, + scope: ['collection', 'discipline', 'user'], }, // TODO: There should be useTemplate: false below? (like it is for userPreferences) collectionPreferences: { From 52e8985f4555c767da516c3c8cc8bc0a1e156255 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:39:31 -0500 Subject: [PATCH 32/70] feat(data-views): add default queries --- config/backstop/data_view_queries.json | 926 ++++++++++++++++++++++++- 1 file changed, 924 insertions(+), 2 deletions(-) diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json index 10fca4432c3..ec354c886b9 100644 --- a/config/backstop/data_view_queries.json +++ b/config/backstop/data_view_queries.json @@ -1,4 +1,926 @@ { "version": 1, - "queries": {} -} + "queries": { + "Accession": { + "fields": [ + { + "tableList": "7", + "stringId": "7.accession.accessionNumber", + "fieldName": "accessionNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.dateAccessioned", + "fieldName": "dateAccessioned", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.dateReceived", + "fieldName": "dateReceived", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.status", + "fieldName": "status", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7", + "stringId": "7.accession.type", + "fieldName": "type", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "7,1-collectionObjects", + "stringId": "7,1-collectionObjects.collectionobject.collectionObjects", + "fieldName": "collectionObjects", + "isRelFld": true, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Agent": { + "fields": [ + { + "tableList": "5", + "stringId": "5.agent.lastName", + "fieldName": "lastName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.firstName", + "fieldName": "firstName", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.middleInitial", + "fieldName": "middleInitial", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.email", + "fieldName": "email", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "5", + "stringId": "5.agent.title", + "fieldName": "title", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Borrow": { + "fields": [ + { + "tableList": "18", + "stringId": "18.borrow.invoiceNumber", + "fieldName": "invoiceNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.borrowDate", + "fieldName": "borrowDate", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.currentDueDate", + "fieldName": "currentDueDate", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.dateClosed", + "fieldName": "dateClosed", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.receivedDate", + "fieldName": "receivedDate", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "18", + "stringId": "18.borrow.isClosed", + "fieldName": "isClosed", + "isRelFld": false, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectingEvent": { + "fields": [ + { + "tableList": "10", + "stringId": "10.collectingevent.stationFieldNumber", + "fieldName": "stationFieldNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10", + "stringId": "10.collectingevent.method", + "fieldName": "method", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10", + "stringId": "10.collectingevent.startDate", + "fieldName": "startDate", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.localityName", + "fieldName": "localityName", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.latitude1", + "fieldName": "latitude1", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.longitude1", + "fieldName": "longitude1", + "isRelFld": false, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2", + "stringId": "10,2.locality.datum", + "fieldName": "datum", + "isRelFld": false, + "sortType": 0, + "position": 6, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "10,2,3", + "stringId": "10,2,3.geography.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 7, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectingTrip": { + "fields": [], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Collection": { + "fields": [ + { + "tableList": "23", + "stringId": "23.collection.collectionName", + "fieldName": "collectionName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23", + "stringId": "23.collection.code", + "fieldName": "code", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23", + "stringId": "23.collection.collectionType", + "fieldName": "collectionType", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23,26", + "stringId": "23,26.discipline.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "23,26,96", + "stringId": "23,26,96.division.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "CollectionObject": { + "fields": [ + { + "tableList": "1", + "stringId": "1.collectionobject.catalogNumber", + "fieldName": "catalogNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,5-cataloger", + "stringId": "1,5-cataloger.agent.cataloger", + "fieldName": "cataloger", + "isRelFld": true, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1", + "stringId": "1.collectionobject.catalogedDate", + "fieldName": "catalogedDate", + "isRelFld": false, + "sortType": 2, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations,4-preferredTaxon", + "stringId": "1,9-determinations,4-preferredTaxon.taxon.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations", + "stringId": "1,9-determinations.determination.isCurrent", + "fieldName": "isCurrent", + "isRelFld": false, + "sortType": 0, + "position": 4, + "isDisplay": false, + "operStart": 6, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,10", + "stringId": "1,10.collectingevent.collectingEvent", + "fieldName": "collectingEvent", + "isRelFld": true, + "sortType": 0, + "position": 5, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,63-preparations", + "stringId": "1,63-preparations.preparation.preparations", + "fieldName": "preparations", + "isRelFld": true, + "sortType": 0, + "position": 6, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,10,30-collectors", + "stringId": "1,10,30-collectors.collector.collectors", + "fieldName": "collectors", + "isRelFld": true, + "sortType": 0, + "position": 7, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "1,9-determinations", + "stringId": "1,9-determinations.determination.determinations", + "fieldName": "determinations", + "isRelFld": true, + "sortType": 0, + "position": 8, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Appraisal": { + "fields": [ + { + "tableList": "67", + "stringId": "67.appraisal.appraisalNumber", + "fieldName": "appraisalNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67", + "stringId": "67.appraisal.appraisalDate", + "fieldName": "appraisalDate", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67", + "stringId": "67.appraisal.appraisalValue", + "fieldName": "appraisalValue", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "67,1-collectionObjects", + "stringId": "67,1-collectionObjects.collectionobject.collectionObjects", + "fieldName": "collectionObjects", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Deaccession": { + "fields": [ + { + "tableList": "163", + "stringId": "163.deaccession.deaccessionNumber", + "fieldName": "deaccessionNumber", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163", + "stringId": "163.deaccession.status", + "fieldName": "status", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,34-disposals", + "stringId": "163,34-disposals.disposal.disposals", + "fieldName": "disposals", + "isRelFld": true, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,40-exchangeOuts", + "stringId": "163,40-exchangeOuts.exchangeout.exchangeOuts", + "fieldName": "exchangeOuts", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "163,131-gifts", + "stringId": "163,131-gifts.gift.gifts", + "fieldName": "gifts", + "isRelFld": true, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Discipline": { + "fields": [ + { + "tableList": "26", + "stringId": "26.discipline.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "26", + "stringId": "26.discipline.type", + "fieldName": "type", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "26,96", + "stringId": "26,96.division.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Gift": { + "fields": [], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Loan": { + "fields": [], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Locality": { + "fields": [ + { + "tableList": "2", + "stringId": "2.locality.localityName", + "fieldName": "localityName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.latitude1", + "fieldName": "latitude1", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.longitude1", + "fieldName": "longitude1", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2", + "stringId": "2.locality.datum", + "fieldName": "datum", + "isRelFld": false, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "2,3", + "stringId": "2,3.geography.geography", + "fieldName": "geography", + "isRelFld": true, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "PrepType": { + "fields": [ + { + "tableList": "65", + "stringId": "65.preptype.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "65", + "stringId": "65.preptype.isLoanable", + "fieldName": "isLoanable", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Taxon": { + "fields": [ + { + "tableList": "4", + "stringId": "4.taxon.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4", + "stringId": "4.taxon.author", + "fieldName": "author", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4", + "stringId": "4.taxon.isAccepted", + "fieldName": "isAccepted", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4,77-definitionItem", + "stringId": "4,77-definitionItem.taxontreedefitem.definitionItem", + "fieldName": "definitionItem", + "isRelFld": true, + "sortType": 0, + "position": 3, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "4,4-children", + "stringId": "4,4-children.taxon.children", + "fieldName": "children", + "isRelFld": true, + "sortType": 0, + "position": 4, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + } + } +} \ No newline at end of file From d2574f48bdd0368421bc0601e4c15679a4c820ee Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:09:57 -0500 Subject: [PATCH 33/70] fix(data-views): change per-table data view queries without overwriting --- .../lib/components/AppResources/types.tsx | 2 +- .../components/DataViews/DataViewTables.tsx | 10 ++++++-- .../js_src/lib/components/DataViews/index.tsx | 9 +++++-- .../lib/components/DataViews/queries.ts | 24 +++++++++++++++++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx index e8d30b8c722..b45cc958313 100644 --- a/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx +++ b/specifyweb/frontend/js_src/lib/components/AppResources/types.tsx @@ -109,7 +109,7 @@ export const appResourceSubTypes = ensure>()({ documentationUrl: undefined, icon: icons.eye, label: dataViewsText.dataViewQueries(), - useTemplate: true, + useTemplate: false, scope: ['collection', 'discipline', 'user'], }, // TODO: There should be useTemplate: false below? (like it is for userPreferences) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 637909a8a70..5ccdc00aa10 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -23,6 +23,7 @@ import { QueryTables } from '../Toolbar/QueryTablesWrapper'; import { defaultDataViewTablesConfig, useDataViewTables } from './config'; import { DataViewQueryEditorContent } from './QueryEditor'; import { + getDataViewQueryDefinition, saveUserDataViewQueries, serializeDataViewQueries, useDataViewQueries, @@ -41,7 +42,12 @@ export function DataViewTables(): JSX.Element { const counts = useTableRecordCounts(tables); const handleOpenQueryEditor = (tableName: keyof Tables): void => { if (queries === undefined) return; - setQueryData(serializeDataViewQueries(queries)); + setQueryData( + serializeDataViewQueries({ + version: 1, + queries: { [tableName]: getDataViewQueryDefinition(queries, tableName) }, + }) + ); setQueryTable(tableName); }; const handleCloseQueryEditor = (): void => { @@ -61,7 +67,7 @@ export function DataViewTables(): JSX.Element { onClick={(): void => { if (isSavingQuery) return; setIsSavingQuery(true); - saveUserDataViewQueries(queryData) + saveUserDataViewQueries(queryData, queryTable) .then(reloadQueries) .then(handleCloseQueryEditor) .catch(raise) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx index 00fd21f598d..c8ab74d69df 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -160,7 +160,12 @@ function LoadedDataViewFromTable({ }, []); const handleCloseQueryEditor = (): void => setQueryData(undefined); const handleOpenQueryEditor = (): void => - setQueryData(serializeDataViewQueries(queries)); + setQueryData( + serializeDataViewQueries({ + version: 1, + queries: { [tableName]: getDataViewQueryDefinition(queries, tableName) }, + }) + ); if (table === undefined) return null; const definition = React.useMemo( @@ -193,7 +198,7 @@ function LoadedDataViewFromTable({ onClick={(): void => { if (isSavingQuery) return; setIsSavingQuery(true); - saveUserDataViewQueries(queryData) + saveUserDataViewQueries(queryData, tableName) .then(reloadQueries) .then(handleCloseQueryEditor) .catch(raise) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts index bf760f8201f..d88a342651a 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts +++ b/specifyweb/frontend/js_src/lib/components/DataViews/queries.ts @@ -188,7 +188,10 @@ export function useDataViewQueries(): [ return [data, (): void => setReload((value) => value + 1)]; } -export async function saveUserDataViewQueries(data: string): Promise { +export async function saveUserDataViewQueries( + data: string, + tableName: keyof Tables +): Promise { const resources = await ajax< RA<{ readonly id: number; @@ -200,11 +203,28 @@ export async function saveUserDataViewQueries(data: string): Promise { ({ name, mimetype }) => name === dataViewQueriesResourceName && mimetype === 'application/json' ); + const existingData = + resource === undefined + ? undefined + : await ajax<{ readonly data?: string }>( + `/context/user_resource/${resource.id}/`, + { headers: { Accept: 'application/json' } } + ).then(({ data: resourceData }) => resourceData.data); + const incoming = parseDataViewQueries(data); + const current = parseDataViewQueries(existingData); + const editedQuery = incoming.queries[tableName]; + const mergedData = serializeDataViewQueries({ + version: 1, + queries: { + ...current.queries, + ...(editedQuery === undefined ? {} : { [tableName]: editedQuery }), + }, + }); const payload = keysToLowerCase({ name: dataViewQueriesResourceName, mimetype: 'application/json', metadata: '', - data, + data: mergedData, }); await ping( resource === undefined From 612be0568f58bdacf24f0386e789ef57e3f3b8a6 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:10:21 -0500 Subject: [PATCH 34/70] feat: add new tree table aggregators --- config/backstop/dataobj_formatters.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/backstop/dataobj_formatters.xml b/config/backstop/dataobj_formatters.xml index 1597d8b977f..9d30f45d9b9 100644 --- a/config/backstop/dataobj_formatters.xml +++ b/config/backstop/dataobj_formatters.xml @@ -553,5 +553,8 @@ + + + \ No newline at end of file From 269fd0611ec821f592dd2f86bd7589dbc75dfa94 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:11:55 -0500 Subject: [PATCH 35/70] fix(data-views): update taxon query --- config/backstop/data_view_queries.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json index ec354c886b9..3ed4d6155df 100644 --- a/config/backstop/data_view_queries.json +++ b/config/backstop/data_view_queries.json @@ -891,26 +891,13 @@ "isNot": false, "isStrict": false }, - { - "tableList": "4,77-definitionItem", - "stringId": "4,77-definitionItem.taxontreedefitem.definitionItem", - "fieldName": "definitionItem", - "isRelFld": true, - "sortType": 0, - "position": 3, - "isDisplay": true, - "operStart": 8, - "startValue": "", - "isNot": false, - "isStrict": false - }, { "tableList": "4,4-children", "stringId": "4,4-children.taxon.children", "fieldName": "children", "isRelFld": true, "sortType": 0, - "position": 4, + "position": 3, "isDisplay": true, "operStart": 8, "startValue": "", From a59a8fad16fe50b4eb3ce5d50400adddea0e5ad3 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:16:30 -0500 Subject: [PATCH 36/70] fix(data-views): add storage and geo queries --- config/backstop/data_view_queries.json | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json index 3ed4d6155df..8b6c2f00c12 100644 --- a/config/backstop/data_view_queries.json +++ b/config/backstop/data_view_queries.json @@ -908,6 +908,85 @@ "selectDistinct": false, "searchSynonymy": false, "smushed": false + }, + "Storage": { + "fields": [ + { + "tableList": "58", + "stringId": "58.storage.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "58,58-children", + "stringId": "58,58-children.storage.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false + }, + "Geography": { + "fields": [ + { + "tableList": "3", + "stringId": "3.geography.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 0, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "3,45-definitionItem", + "stringId": "3,45-definitionItem.geographytreedefitem.name", + "fieldName": "name", + "isRelFld": false, + "sortType": 0, + "position": 1, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + }, + { + "tableList": "3,3-children", + "stringId": "3,3-children.geography.fullName", + "fieldName": "fullName", + "isRelFld": false, + "sortType": 0, + "position": 2, + "isDisplay": true, + "operStart": 8, + "startValue": "", + "isNot": false, + "isStrict": false + } + ], + "selectDistinct": false, + "searchSynonymy": false, + "smushed": false } } } \ No newline at end of file From 9001fbc192be096002172aecb86ced833754a988 Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:21:12 -0500 Subject: [PATCH 37/70] fix(data-views): improve localization --- .../components/DataViews/DataViewTables.tsx | 6 ++++- .../Preferences/UserDefinitions.tsx | 24 ++++++++++++++----- .../js_src/lib/localization/dataViews.ts | 14 +++++------ 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx index 5ccdc00aa10..b2804cc01ad 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -92,7 +92,11 @@ export function DataViewTables(): JSX.Element { return isEditing ? ( ({ - title: dataViewsText.splitViewByDefault(), - description: dataViewsText.splitViewDescription(), + title: dataViewsText.splitViewByDefault({ + splitView: treeText.splitView(), + }), + description: dataViewsText.splitViewDescription({ + splitView: treeText.splitView(), + }), requiresReload: false, visible: true, defaultValue: false, type: 'java.lang.Boolean', }), splitViewOrientation: definePref<'horizontal' | 'vertical'>({ - title: dataViewsText.splitViewOrientation(), + title: dataViewsText.splitViewOrientation({ + splitView: treeText.splitView(), + }), requiresReload: false, visible: true, defaultValue: 'horizontal', @@ -1948,15 +1954,21 @@ export const userPreferenceDefinitions = { title: preferencesText.general(), items: { splitViewByDefault: definePref({ - title: dataViewsText.splitViewByDefault(), - description: dataViewsText.splitViewDescription(), + title: dataViewsText.splitViewByDefault({ + splitView: treeText.splitView(), + }), + description: dataViewsText.splitViewDescription({ + splitView: treeText.splitView(), + }), requiresReload: false, visible: true, defaultValue: true, type: 'java.lang.Boolean', }), splitViewOrientation: definePref<'horizontal' | 'vertical'>({ - title: dataViewsText.splitViewOrientation(), + title: dataViewsText.splitViewOrientation({ + splitView: treeText.splitView(), + }), requiresReload: false, visible: true, defaultValue: 'horizontal', diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts index 356039d5698..c91abef0628 100644 --- a/specifyweb/frontend/js_src/lib/localization/dataViews.ts +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -18,23 +18,23 @@ export const dataViewsText = createDictionary({ 'en-us': '{tableLabel:string} Records', }, configureDataViews: { - 'en-us': 'Configure Data Views tables', + 'en-us': 'Configure {dataViews:string} tables', }, dataViewQueries: { comment: 'The name of the Data View query app resource type', - 'en-us': 'Data View queries', + 'en-us': 'Data View Queries', + }, + splitViewByDefault: { + 'en-us': 'Enable {splitView:string} by default', }, configureQuery: { 'en-us': 'Configure query', }, - splitViewByDefault: { - 'en-us': 'Enable split view by default', - }, splitViewDescription: { 'en-us': - 'Split view displays query results alongside a record preview, allowing you to review records without leaving the results list.', + '{splitView:string} displays query results alongside a record preview, allowing you to review records without leaving the results list.', }, splitViewOrientation: { - 'en-us': 'Default split view orientation', + 'en-us': 'Default {splitView:string} orientation', }, } as const); From ce86aaef2b0b202e067379378bb9bb8c635e3c2e Mon Sep 17 00:00:00 2001 From: Grant Fitzsimmons <37256050+grantfitzsimmons@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:41:02 -0500 Subject: [PATCH 38/70] fix(data-views): aggregate geography --- config/backstop/data_view_queries.json | 27 +++++++------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/config/backstop/data_view_queries.json b/config/backstop/data_view_queries.json index 8b6c2f00c12..140a4c1adbb 100644 --- a/config/backstop/data_view_queries.json +++ b/config/backstop/data_view_queries.json @@ -946,8 +946,8 @@ "fields": [ { "tableList": "3", - "stringId": "3.geography.fullName", - "fieldName": "fullName", + "stringId": "3.geography.", + "fieldName": "", "isRelFld": false, "sortType": 0, "position": 0, @@ -957,26 +957,13 @@ "isNot": false, "isStrict": false }, - { - "tableList": "3,45-definitionItem", - "stringId": "3,45-definitionItem.geographytreedefitem.name", - "fieldName": "name", - "isRelFld": false, - "sortType": 0, - "position": 1, - "isDisplay": true, - "operStart": 8, - "startValue": "", - "isNot": false, - "isStrict": false - }, { "tableList": "3,3-children", - "stringId": "3,3-children.geography.fullName", - "fieldName": "fullName", - "isRelFld": false, + "stringId": "3,3-children.geography.children", + "fieldName": "children", + "isRelFld": true, "sortType": 0, - "position": 2, + "position": 1, "isDisplay": true, "operStart": 8, "startValue": "", @@ -989,4 +976,4 @@ "smushed": false } } -} \ No newline at end of file +} From 1997d46fd02dfc3815d458477fe5040e87f27cec Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 2 Sep 2026 09:14:02 +0200 Subject: [PATCH 39/70] Feat: Add a visual badge to tables with a defined query --- .../lib/components/DataViews/QueryEditor.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index b9bc8650882..7a610bafc83 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -119,12 +119,22 @@ export function DataViewQueryEditorContent({

    {schemaText.tables()}

    + getStoredDataViewQueryDefinition(file, table.name) === + undefined ? undefined : ( + + ) + } cacheKey="appResources" currentTableName={tableName} - getAction={(table): (() => void) => (): void => { - setTableName(table.name); - setRememberedTable(table.name); - }} + getAction={(table): (() => void) => + (): void => { + setTableName(table.name); + setRememberedTable(table.name); + }} />
    ) : undefined} From 41389f26145d1fc9d5f9320e0ecb3322acc4b5b6 Mon Sep 17 00:00:00 2001 From: Caroline Denis Date: Wed, 2 Sep 2026 09:30:51 +0200 Subject: [PATCH 40/70] Feat: Reuse the collapsible and search list fct from schema config --- .../lib/components/DataViews/QueryEditor.tsx | 42 ++++----- .../lib/components/SchemaConfig/Sidebar.tsx | 82 +++++----------- .../lib/components/SchemaConfig/Tables.tsx | 94 ++++++++++++++++++- 3 files changed, 132 insertions(+), 86 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx index 7a610bafc83..68fd0f04f92 100644 --- a/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataViews/QueryEditor.tsx @@ -15,8 +15,7 @@ import { serializeStableDataViewQueries, type DataViewQueriesFile, } from './queries'; -import { schemaText } from '../../localization/schema'; -import { TableList } from '../SchemaConfig/Tables'; +import { CollapsibleTableList } from '../SchemaConfig/Tables'; import { useCachedState } from '../../hooks/useCachedState'; export function DataViewQueryEditor({ @@ -116,27 +115,24 @@ export function DataViewQueryEditorContent({ return (
    {lockedTableName === undefined ? ( -
    -

    {schemaText.tables()}

    - - getStoredDataViewQueryDefinition(file, table.name) === - undefined ? undefined : ( - - ) - } - cacheKey="appResources" - currentTableName={tableName} - getAction={(table): (() => void) => - (): void => { - setTableName(table.name); - setRememberedTable(table.name); - }} - /> -
    + + getStoredDataViewQueryDefinition(file, table.name) === + undefined ? undefined : ( + + ) + } + cacheKey="appResources" + currentTableName={tableName} + getAction={(table): (() => void) => + (): void => { + setTableName(table.name); + setRememberedTable(table.name); + }} + /> ) : undefined}
    - setIsCollapsed(false)} - /> - - ) : ( - + return ( + + modifiedTables.includes(table.name) ? ( + + * + + ) : undefined + } + cacheKey="schemaConfig" + currentTableName={tableName} + getAction={(table): string => + `/specify/schema-config/${language}/${table.name}/` + } + localizeTableNames={false} + /> ); } diff --git a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx index 078fd597891..b6bd59ccf5d 100644 --- a/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx +++ b/specifyweb/frontend/js_src/lib/components/SchemaConfig/Tables.tsx @@ -1,12 +1,14 @@ import React from 'react'; import { useCachedState } from '../../hooks/useCachedState'; +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; import { wbPlanText } from '../../localization/wbPlan'; import type { CacheDefinitions } from '../../utils/cache/definitions'; import type { RA } from '../../utils/types'; import { localized } from '../../utils/types'; import { sortFunction } from '../../utils/utils'; -import { Ul } from '../Atoms'; +import { H3, Ul } from '../Atoms'; import { Button } from '../Atoms/Button'; import { Input, Label } from '../Atoms/Form'; import { Link } from '../Atoms/Link'; @@ -135,11 +137,16 @@ export function TableList({ return isVisible ? (
  • {typeof action === 'function' ? ( - {content} + + {content} + ) : ( @@ -160,3 +167,84 @@ export function TableList({
  • ); } + +/** + * A `TableList` wrapped in a collapsible, searchable panel. + * Used by both the schema config sidebar and the data view query editor. + */ +export function CollapsibleTableList({ + cacheKey, + getAction, + filter: extraFilter, + localizeTableNames = true, + currentTableName, + badge, + asAside = false, +}: { + readonly cacheKey: CacheKey; + readonly getAction: (table: SpecifyTable) => string | (() => void); + readonly filter?: (table: SpecifyTable) => boolean; + readonly localizeTableNames?: boolean; + readonly currentTableName?: string; + readonly badge?: (table: SpecifyTable) => React.ReactNode; + // Use