Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1720e88
feat: implement basic Data Views
melton-jason Apr 21, 2026
c1a9e96
Merge remote-tracking branch 'origin/main' into issue-6565
melton-jason Apr 21, 2026
4433f4c
Merge remote-tracking branch 'origin/main' into issue-6565
melton-jason May 6, 2026
d3d70bc
Merge branch 'main' into issue-6565
grantfitzsimmons Jul 2, 2026
84e073e
Merge branch 'main' into issue-6565
grantfitzsimmons Aug 12, 2026
ab32217
Merge remote-tracking branch 'origin/main' into issue-6565
CarolineDenis Aug 25, 2026
615557f
Merge branch 'main' into issue-6565
CarolineDenis Aug 25, 2026
f76ce7f
Refactor: Improve fetch index
CarolineDenis Aug 25, 2026
9a00309
Chore: cleanup
CarolineDenis Aug 25, 2026
1c07b95
Feat: Add a new sort picker in dataView
CarolineDenis Aug 25, 2026
79328b1
Fix: Typescript types
CarolineDenis Aug 25, 2026
43fe3a0
Fix: Typescript types
CarolineDenis Aug 25, 2026
9462044
Fix: Typescript types
CarolineDenis Aug 25, 2026
f12b86c
Fix: Typescript
CarolineDenis Aug 25, 2026
ac1031d
Fix: Use localization
CarolineDenis Aug 25, 2026
f169454
Update specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx
CarolineDenis Aug 25, 2026
d1603fb
Potential fix for pull request finding 'CodeQL / Unused variable, imp…
CarolineDenis Aug 25, 2026
f14ceff
Fix: Localization error
CarolineDenis Aug 25, 2026
6c33abb
Refactor: reuse QueryTables comp in DataViews
CarolineDenis Aug 25, 2026
b91e06b
Fix: Keep canFetchMore true for sparse entries
CarolineDenis Aug 25, 2026
99d8b42
Fix: Change data view dialog header
CarolineDenis Aug 25, 2026
f59bd59
Fix: Restrict default lis of tables
CarolineDenis Aug 25, 2026
e1d4313
Fix: Reuse existing localization
CarolineDenis Aug 25, 2026
e356403
Clean: Remove use current scope
CarolineDenis Aug 25, 2026
c758849
Fix: typo in timestamp field
CarolineDenis Aug 25, 2026
4a38754
Fix: Reset table state when table.name changes
CarolineDenis Aug 25, 2026
3c9d3b4
Feat: Add a total count amount next to each table
CarolineDenis Aug 26, 2026
cf38805
Merge branch 'main' into issue-6565
CarolineDenis Aug 26, 2026
4cdffc4
Lint code with ESLint and Prettier
CarolineDenis Aug 26, 2026
29d0b24
Merge branch 'main' into issue-6565
grantfitzsimmons Aug 28, 2026
0e54224
Lint code with ESLint and Prettier
grantfitzsimmons Aug 28, 2026
6084b52
Merge remote-tracking branch 'origin/main' into issue-6565
CarolineDenis Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function Wrapped({
<Button.BorderedGray
onClick={(): void =>
loading(
ping('/accounts/logout/', {method: 'POST'}).then(() =>
ping('/accounts/logout/', { method: 'POST' }).then(() =>
globalThis.location.assign(
formatUrl('/specify/command/logout/', { next: nextUrl })
)
Expand Down
4 changes: 3 additions & 1 deletion specifyweb/frontend/js_src/lib/components/Core/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ function MissingAgent(): JSX.Element {
}}
forceToTop
header={userText.noAgent()}
onClose={(): void => globalThis.location.assign('/specify/command/logout/')}
onClose={(): void =>
globalThis.location.assign('/specify/command/logout/')
}
>
{userText.noAgentDescription()}
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function CustomEditTables({
? formsText.configureDataEntryTables()
: formsText.configureInteractionTables()
}
isNoRestrictionMode={false}
showHiddenTables={false}
tables={tables}
onChange={handleChange}
onClose={handleClose}
Expand Down
145 changes: 145 additions & 0 deletions specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React from 'react';
import { useBooleanState } from '../../hooks/useBooleanState';
import { commonText } from '../../localization/common';
import { dataViewsText } from '../../localization/dataViews';
import { Http } from '../../utils/ajax/definitions';
import { throttledPromise } from '../../utils/ajax/throttledPromise';
import type { GetSet, IR, RA } from '../../utils/types';
import { Button } from '../Atoms/Button';
import { DataEntry } from '../Atoms/DataEntry';
import { icons } from '../Atoms/Icons';
import { serializeResource } from '../DataModel/serializers';
import { SpecifyTable } from '../DataModel/specifyTable';
import { getTableById, strictGetTable } from '../DataModel/tables';
import { Tables } from '../DataModel/types';
import { raise } from '../Errors/Crash';
import { Dialog, dialogClassNames } from '../Molecules/Dialog';
import { userPreferences } from '../Preferences/userPreferences';
import { OverlayContext } from '../Router/Router';
import { tablesFilter } from '../SchemaConfig/Tables';
import {
queryCountPromiseGenerator,
querySpecToResource,
} from '../Statistics/hooks';
import { TablesListEdit } from '../Toolbar/QueryTablesEdit';
import { QueryTables } from '../Toolbar/QueryTablesWrapper';

const defaultDataViewTablesConfig: RA<keyof Tables> = [
'Accession',
'Agent',
'CollectionObject',
'CollectingEvent',
'Gift',
'Loan',
'Locality',
];

export function DataViewTables(): JSX.Element {
const handleClose = React.useContext(OverlayContext);
const [tables, setTables] = useDataViewTables();
const [isEditing, handleEditing] = useBooleanState();
const counts = useTableRecordCounts(tables);
return isEditing ? (
<TablesListEdit
defaultTables={defaultDataViewTablesConfig}
header={dataViewsText.configureDataViews()}
tables={tables}
onChange={setTables}
onClose={handleClose}
/>
) : (
<Dialog
header={dataViewsText.dataViewsTitle()}
buttons={
<>
<span className="-ml-2 flex-1" />
<Button.Secondary onClick={handleClose}>
{commonText.close()}
</Button.Secondary>
</>
}
className={{
container: dialogClassNames.narrowContainer,
}}
headerButtons={<DataEntry.Edit onClick={handleEditing} />}
icon={icons.eye}
onClose={handleClose}
>
<QueryTables
counts={counts}
getHref={(name) => `/specify/dataviews/${name.toLowerCase()}`}
tables={tables}
onClick={undefined}
/>
</Dialog>
);
}

/**
* Fetches the number of records in each table in the background, using the
* same count-only query builder logic that is used in the Statistics page.
*/
function useTableRecordCounts(
tables: RA<SpecifyTable>
): IR<number | undefined> {
const [counts, setCounts] = React.useState<IR<number | undefined>>({});

React.useEffect(() => {
let destructorCalled = false;
tables.forEach((table) => {
const query = serializeResource(
querySpecToResource(table.name, {
tableName: table.name,
fields: [],
})
);
throttledPromise<number | undefined>(
'queryStats',
async () =>
queryCountPromiseGenerator(query)().then((response) =>
response.status === Http.OK ? response.data.count : undefined
),
JSON.stringify(query)
)
.then((count) => {
if (destructorCalled) return;
setCounts((previousCounts) => ({
...previousCounts,
[table.name]: count,
}));
})
.catch(raise);
});
return (): void => {
destructorCalled = true;
};
}, [tables]);

return counts;
}

function useDataViewTables(): GetSet<RA<SpecifyTable>> {
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<SpecifyTable>) =>
setTables(models.map((model) => model.tableId)),
[setTables]
);

return [allowedTables, handleChange];
}
151 changes: 151 additions & 0 deletions specifyweb/frontend/js_src/lib/components/DataViews/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import {
type CollectionFetchFilters,
DEFAULT_FETCH_LIMIT,
fetchCollection,
} from '../DataModel/collection';
import { AnySchema, SerializedResource } from '../DataModel/helperTypes';
import { SpecifyTable } from '../DataModel/specifyTable';
import { getTable } from '../DataModel/tables';
import { ProtectedTable } from '../Permissions/PermissionDenied';
import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds';
import { NotFoundView } from '../Router/NotFoundView';
import { f } from '../../utils/functools';
import { commonText } from '../../localization/common';
import { dataViewsText } from '../../localization/dataViews';
import { usePaginatedCollection } from '../../hooks/usePaginatedCollection';
import { Tables } from '../DataModel/types';
import { useAsyncState } from '../../hooks/useAsyncState';
import { RA } from '../../utils/types';
import { Label } from '../Atoms/Form';
import { OrderPicker, type OrderPickerOrder } from '../Preferences/Renderers';
import { attachmentsText } from '../../localization/attachments';

export function TableDataView(): JSX.Element {
const { tableName = '' } = useParams();

const table = getTable(tableName);

return table === undefined ? (
<NotFoundView />
) : (
<ProtectedTable tableName={table.name} action="read">
<DataViewFromTableWrapped table={table} />
</ProtectedTable>
);
}

function DataViewFromTableWrapped<SCHEMA extends AnySchema>({
table,
}: {
readonly table: SpecifyTable<SCHEMA>;
}): JSX.Element | null {
const defaultOrder =
table.getLiteralField('timestampCreated') === undefined
? '-id'
: '-timestampCreated';

const [order, setOrder] = React.useState<OrderPickerOrder<SCHEMA, 'id'>>(
defaultOrder as OrderPickerOrder<SCHEMA, 'id'>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

React.useEffect(() => {
setOrder(defaultOrder as OrderPickerOrder<SCHEMA, 'id'>);
}, [table.name, defaultOrder]);

const handleFetchingCollection = React.useCallback(
(offset: number = 0) =>
fetchCollection(table.name, {
offset,
limit: DEFAULT_FETCH_LIMIT,
domainFilter: true,
orderBy: order,
} as CollectionFetchFilters<Tables[SCHEMA['tableName']]>),
[order, table]
);

const [collection] = useAsyncState(handleFetchingCollection, true);

return collection === undefined ? null : (
<DataViewFromTable
key={table.name}
table={table}
initialRecords={collection.records}
totalCount={collection.totalCount}
onFetchRecords={(index) =>
handleFetchingCollection(index).then(({ records }) => records)
}
headerButtons={
<>
<Label.Inline>
{attachmentsText.orderBy()}
<div>
<OrderPicker<SCHEMA, 'id'>
additionalFields={[{ name: 'id', label: commonText.id() }]}
includeHiddenFields
includeVirtualFields={false}
order={order}
table={table}
onChange={setOrder}
/>
</div>
</Label.Inline>
</>
}
/>
);
}

function DataViewFromTable<SCHEMA extends AnySchema>({
table,
totalCount: initialTotalCount,
initialRecords,
onFetchRecords: handleFetchRecords,
headerButtons,
}: {
readonly table: SpecifyTable<SCHEMA>;
readonly totalCount: number;
readonly initialRecords: RA<SerializedResource<Tables[SCHEMA['tableName']]>>;
readonly onFetchRecords: (
index: number
) => Promise<RA<SerializedResource<Tables[SCHEMA['tableName']]>>>;
readonly headerButtons: JSX.Element;
}): JSX.Element | null {
// FEATURE: Use useNavigator and keep current record/index in query
// parameter of page

const {
results: [collection],
totalCount: [totalCount],
onFetchMore: handleFetchMore,
} = usePaginatedCollection({
fetchMore: handleFetchRecords,
initialRecords,
totalCount: initialTotalCount,
});

return collection === undefined ? null : (
<RecordSelectorFromIds
canRemove={false}
defaultIndex={0}
headerButtons={headerButtons}
dialog={false}
ids={collection.map((record) => 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);
}}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { attachmentsText } from '../../localization/attachments';
import { batchEditText } from '../../localization/batchEdit';
import { commonText } from '../../localization/common';
import { dataViewsText } from '../../localization/dataViews';
import { headerText } from '../../localization/header';
import { interactionsText } from '../../localization/interactions';
import { queryText } from '../../localization/query';
Expand All @@ -14,7 +15,7 @@ import { treeText } from '../../localization/tree';
import { wbText } from '../../localization/workbench';
import { getCache } from '../../utils/cache';
import { f } from '../../utils/functools';
import type { IR } from '../../utils/types';
import { IR } from '../../utils/types';
import { ensure } from '../../utils/types';
import { icons } from '../Atoms/Icons';
import type { MenuItem } from '../Core/Main';
Expand Down Expand Up @@ -72,6 +73,11 @@ const rawMenuItems = ensure<IR<Omit<MenuItem, 'name'>>>()({
hasToolPermission('queryBuilder', 'read') ||
hasPermission('/querybuilder/query', 'execute'),
},
dataViews: {
url: '/specify/overlay/dataviews/',
title: dataViewsText.dataViewsTitle(),
icon: icons.eye,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
recordSets: {
url: '/specify/overlay/record-sets/',
title: commonText.recordSets(),
Expand Down
Loading
Loading