diff --git a/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx index 649641d86ee..915edcdbbf8 100644 --- a/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx +++ b/specifyweb/frontend/js_src/lib/components/DataEntryTables/Edit.tsx @@ -75,7 +75,7 @@ function CustomEditTables({ ? formsText.configureDataEntryTables() : formsText.configureInteractionTables() } - isNoRestrictionMode={false} + showHiddenTables={false} tables={tables} onChange={handleChange} onClose={handleClose} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx new file mode 100644 index 00000000000..72c230909b9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/DataViewTables.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { useBooleanState } from '../../hooks/useBooleanState'; +import { commonText } from '../../localization/common'; +import { schemaText } from '../../localization/schema'; +import { GetSet, RA } from '../../utils/types'; +import { Ul } from '../Atoms'; +import { Button } from '../Atoms/Button'; +import { DataEntry } from '../Atoms/DataEntry'; +import { icons } from '../Atoms/Icons'; +import { Link } from '../Atoms/Link'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { getTableById, strictGetTable } from '../DataModel/tables'; +import { Tables } from '../DataModel/types'; +import { Dialog, dialogClassNames } from '../Molecules/Dialog'; +import { TableIcon } from '../Molecules/TableIcon'; +import { userPreferences } from '../Preferences/userPreferences'; +import { OverlayContext } from '../Router/Router'; +import { tablesFilter } from '../SchemaConfig/Tables'; +import { TablesListEdit } from '../Toolbar/QueryTablesEdit'; +import { dataViewsText } from '../../localization/dataViews'; + +const defaultDataViewTablesConfig: RA = [ + 'Accession', + 'AddressOfRecord', + 'Agent', + 'Appraisal', + 'Author', + 'Borrow', + 'CollectingEvent', + 'CollectingTrip', + 'Collection', + 'CollectionObject', + 'CollectionObjectGroup', + 'ConservDescription', + 'Container', + 'DNASequence', + 'Deaccession', + 'Determination', + 'Discipline', + 'Disposal', + 'Division', + 'ExchangeIn', + 'ExchangeOut', + 'Exsiccata', + 'FieldNotebook', + 'Geography', + 'GeologicTimePeriod', + 'Gift', + 'InfoRequest', + 'Institution', + 'Journal', + 'LithoStrat', + 'Loan', + 'Locality', + 'MaterialSample', + 'PaleoContext', + 'Permit', + 'Preparation', + 'PrepType', + 'ReferenceWork', + 'RepositoryAgreement', + 'Shipment', + 'Storage', + 'Taxon', + 'TectonicUnit', + 'TreatmentEvent', +]; + +export function DataViewTables(): JSX.Element { + const handleClose = React.useContext(OverlayContext); + const [tables, setTables] = useDataViewTables(); + const [isEditing, handleEditing] = useBooleanState(); + return isEditing ? ( + + ) : ( + + + + {commonText.close()} + + + } + className={{ + container: dialogClassNames.narrowContainer, + }} + headerButtons={} + icon={icons.eye} + onClose={handleClose} + > + {/* REFACTOR: Generalize QueryTables component */} +
    + {tables.map(({ name, label }, index) => ( +
  • + + + {label} + +
  • + ))} +
+
+ ); +} + +function useDataViewTables(): GetSet> { + const [tables, setTables] = userPreferences.use( + 'dataViews', + 'general', + 'shownTables' + ); + const visibleTables = + tables.length === 0 + ? defaultDataViewTablesConfig.map(strictGetTable) + : tables.map(getTableById); + + const allowedTables = visibleTables.filter((table) => + tablesFilter(true, false, true, table) + ); + + const handleChange = React.useCallback( + (models: RA) => + setTables(models.map((model) => model.tableId)), + [setTables] + ); + + return [allowedTables, handleChange]; +} diff --git a/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx new file mode 100644 index 00000000000..4b1b7d67391 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/DataViews/index.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { DEFAULT_FETCH_LIMIT, fetchCollection } from '../DataModel/collection'; +import { AnySchema, SerializedResource } from '../DataModel/helperTypes'; +import { SpecifyTable } from '../DataModel/specifyTable'; +import { getTable } from '../DataModel/tables'; +import { ProtectedTable } from '../Permissions/PermissionDenied'; +import { RecordSelectorFromIds } from '../FormSliders/RecordSelectorFromIds'; +import { NotFoundView } from '../Router/NotFoundView'; +import { f } from '../../utils/functools'; +import { dataViewsText } from '../../localization/dataViews'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; +import { Tables } from '../DataModel/types'; +import { useAsyncState } from '../../hooks/useAsyncState'; +import { RA } from '../../utils/types'; + +export function TableDataView(): JSX.Element { + const { tableName = '' } = useParams(); + + const table = getTable(tableName); + + return table === undefined ? ( + + ) : ( + + + + ); +} + +function DataViewFromTableWrapped({ + table, +}: { + readonly table: SpecifyTable; +}): JSX.Element | null { + const handleFetchingCollection = React.useCallback( + (offset: number = 0) => + fetchCollection(table.name, { + offset, + limit: DEFAULT_FETCH_LIMIT, + domainFilter: true, + orderBy: + table.getLiteralField('timestampCreated') === undefined + ? '-id' + : '-timestampcreated', + }), + [table] + ); + const [collection] = useAsyncState(handleFetchingCollection, true); + + return collection === undefined ? null : ( + + handleFetchingCollection(index).then(({ records }) => records) + } + /> + ); +} + +function DataViewFromTable({ + table, + totalCount: initialTotalCount, + initialRecords, + onFetchRecords: handleFetchRecords, +}: { + readonly table: SpecifyTable; + readonly totalCount: number; + readonly initialRecords: RA>; + readonly onFetchRecords: ( + index: number + ) => Promise>>; +}): JSX.Element | null { + // FEATURE: Use useNavigator and keep current record/index in query + // parameter of page + + const { + results: [collection], + totalCount: [totalCount], + onFetchMore: handleFetchMore, + } = usePaginatedCollection({ + fetchMore: handleFetchRecords, + initialRecords, + totalCount: initialTotalCount, + }); + + return collection === undefined ? null : ( + + // + // order} + // /> + // + // } + dialog={false} + ids={collection.map(({ id }) => id)} + isDependent={false} + isInRecordSet={false} + newResource={undefined} + table={table} + title={dataViewsText.tableRecords({ tableLabel: table.label })} + totalCount={totalCount} + onAdd={undefined} + onClone={undefined} + onClose={() => undefined} + onDelete={undefined} + onSaved={f.void} + onSlide={(new_index) => { + handleFetchMore(new_index); + }} + /> + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts index d9efbe6b6df..c8c6675dced 100644 --- a/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts +++ b/specifyweb/frontend/js_src/lib/components/Header/menuItemDefinitions.ts @@ -14,7 +14,7 @@ import { treeText } from '../../localization/tree'; import { wbText } from '../../localization/workbench'; import { getCache } from '../../utils/cache'; import { f } from '../../utils/functools'; -import type { IR } from '../../utils/types'; +import { IR, localized } from '../../utils/types'; import { ensure } from '../../utils/types'; import { icons } from '../Atoms/Icons'; import { @@ -76,6 +76,11 @@ const rawMenuItems = ensure>>()({ hasToolPermission('queryBuilder', 'read') || hasPermission('/querybuilder/query', 'execute'), }, + dataViews: { + url: '/specify/overlay/dataviews/', + title: localized('Data Views'), + icon: icons.eye, + }, recordSets: { url: '/specify/overlay/record-sets/', title: commonText.recordSets(), diff --git a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx index 469c072f1d6..385d12ea842 100644 --- a/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx +++ b/specifyweb/frontend/js_src/lib/components/Preferences/UserDefinitions.tsx @@ -60,6 +60,7 @@ import type { PreferencesVisibilityContext, } from './types'; import { definePref } from './types'; +import { dataViewsText } from '../../localization/dataViews'; const isLightMode = ({ isDarkMode, @@ -1889,6 +1890,24 @@ export const userPreferenceDefinitions = { }, }, }, + dataViews: { + title: dataViewsText.dataViewsTitle(), + subCategories: { + general: { + title: preferencesText.general(), + items: { + shownTables: definePref>({ + title: localized('_shownTables'), + requiresReload: false, + visible: false, + defaultValue: [], + renderer: f.never, + container: 'div', + }), + }, + }, + } + }, recordMerging: { title: mergingText.recordMerging(), subCategories: { diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx index cca9d18d34a..69bc4819504 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx +++ b/specifyweb/frontend/js_src/lib/components/QueryBuilder/Results.tsx @@ -3,6 +3,7 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useAsyncState } from '../../hooks/useAsyncState'; import { useInfiniteScroll } from '../../hooks/useInfiniteScroll'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import { f } from '../../utils/functools'; @@ -31,7 +32,6 @@ import { CreateRecordSet } from './CreateRecordSet'; import type { QueryFieldSpec } from './fieldSpec'; import type { QueryField } from './helpers'; import { sortTypes } from './helpers'; -import { useFetchQueryResults } from './hooks'; import { QueryResultsTable } from './ResultsTable'; import { QueryToForms } from './ToForms'; import { QueryToMap } from './ToMap'; @@ -119,7 +119,19 @@ export function QueryResults(props: QueryResultsProps): JSX.Element { onFetchMore: handleFetchMore, totalCount: [totalCount, setTotalCount], canFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedCollection({ + initialRecords: initialData, + fetchMore: fetchResults, + fetchSize: props.fetchSize, + totalCount: props.totalCount, + }); + + // const { + // results: [results, setResults], + // onFetchMore: handleFetchMore, + // totalCount: [totalCount, setTotalCount], + // canFetchMore, + // } = useFetchQueryResults(props); const canMergeTable = canMerge(table); diff --git a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx index 61dd841d016..576610f511d 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/OverlayRoutes.tsx @@ -14,9 +14,10 @@ import { treeText } from '../../localization/tree'; import { userText } from '../../localization/user'; import { welcomeText } from '../../localization/welcome'; import { wbText } from '../../localization/workbench'; -import type { RA } from '../../utils/types'; +import { RA } from '../../utils/types'; import { Redirect } from './Redirect'; import type { EnhancedRoute } from './RouterUtils'; +import { dataViewsText } from '../../localization/dataViews'; /* eslint-disable @typescript-eslint/promise-function-async */ /** @@ -141,6 +142,14 @@ export const overlayRoutes: RA = [ }, ], }, + { + path: 'dataviews', + title: dataViewsText.dataViewsTitle(), + element: () => + import('../DataViews/DataViewTables').then( + ({ DataViewTables }) => DataViewTables + ), + }, { path: 'record-sets', title: commonText.recordSets(), diff --git a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx index 4b8ca180f5a..f0885835d17 100644 --- a/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx +++ b/specifyweb/frontend/js_src/lib/components/Router/Routes.tsx @@ -376,6 +376,18 @@ export const routes: RA = [ }, ], }, + { + path: 'dataviews', + children: [ + { + path: ':tableName', + element: () => + import('../DataViews/index').then( + ({ TableDataView }) => TableDataView + ), + }, + ], + }, { path: 'user-preferences', title: preferencesText.preferences(), diff --git a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx index 5dc4454f599..b1131825c98 100644 --- a/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx +++ b/specifyweb/frontend/js_src/lib/components/SpecifyNetwork/Map.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useResource } from '../../hooks/resource'; import { useAsyncState } from '../../hooks/useAsyncState'; +import { usePaginatedCollection } from '../../hooks/usePaginatedCollection'; import { developmentText } from '../../localization/development'; import { specifyNetworkText } from '../../localization/specifyNetwork'; import { f } from '../../utils/functools'; @@ -18,7 +19,6 @@ import { LoadingScreen } from '../Molecules/Dialog'; import { queryFromTree } from '../QueryBuilder/fromTree'; import type { QueryField } from '../QueryBuilder/helpers'; import { parseQueryFields } from '../QueryBuilder/helpers'; -import { useFetchQueryResults } from '../QueryBuilder/hooks'; import type { QueryResultRow } from '../QueryBuilder/Results'; import { useQueryResultsWrapper } from '../QueryBuilder/ResultsWrapper'; import { @@ -144,7 +144,12 @@ function Map({ results: [results], canFetchMore, onFetchMore: handleFetchMore, - } = useFetchQueryResults(props); + } = usePaginatedCollection({ + fetchMore: props.fetchResults, + fetchSize: props.fetchSize, + totalCount: props.totalCount, + initialRecords: props.initialData, + }); const undefinedResult = results?.indexOf(undefined); const loadedResults = ( diff --git a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx index 5e07f49b1cc..ac2df9562e9 100644 --- a/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx +++ b/specifyweb/frontend/js_src/lib/components/Toolbar/QueryTablesEdit.tsx @@ -30,7 +30,7 @@ export function QueryTablesEdit({ ; readonly header: LocalizedString; readonly tables: RA; @@ -56,7 +56,7 @@ export function TablesListEdit({ const selectedValues = selectedTables.map(({ name }) => name); const allTables = Object.values(genericTables) .filter((table) => - tablesFilter(isNoRestrictionMode, false, true, table, selectedValues) + tablesFilter(showHiddenTables, false, true, table, selectedValues) ) .map(({ name, label }) => ({ name, label })); diff --git a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx similarity index 58% rename from specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx rename to specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx index 5877a45cc6b..3ae9fd74e4b 100644 --- a/specifyweb/frontend/js_src/lib/components/QueryBuilder/hooks.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/usePaginatedCollection.tsx @@ -1,42 +1,30 @@ import React from 'react'; - -import { useTriggerState } from '../../hooks/useTriggerState'; -import type { GetOrSet, IR, R, RA } from '../../utils/types'; -import { removeKey } from '../../utils/utils'; -import { raise, softFail } from '../Errors/Crash'; -import type { QueryResultRow, QueryResultsProps } from './Results'; - -export function useFetchQueryResults({ - initialData, - fetchResults, +import { GetOrSet, R, RA } from '../utils/types'; +import { useTriggerState } from './useTriggerState'; +import { removeKey, SET } from '../utils/utils'; +import { DEFAULT_FETCH_LIMIT } from '../components/DataModel/collection'; +import { raise, softFail } from '../components/Errors/Crash'; + +export function usePaginatedCollection({ + initialRecords, totalCount: initialTotalCount, - fetchSize, -}: Pick< - QueryResultsProps, - 'fetchResults' | 'fetchSize' | 'initialData' | 'totalCount' ->): { - readonly results: GetOrSet | undefined>; - readonly fetchersRef: { - readonly current: IR | void>>; - }; - readonly onFetchMore: (index?: number) => Promise | void>; - readonly totalCount: GetOrSet; - readonly canFetchMore: boolean; -} { - /* - * Warning: - * "results" can be a sparse array. Using sparse array to allow - * efficiently retrieving the last query result in a query that returns - * hundreds of thousands of results. - */ - const getSetResults = useTriggerState< - RA | undefined - >(initialData); - const [results, setResults] = getSetResults; + fetchSize = DEFAULT_FETCH_LIMIT, + fetchMore: rawHandleFetchMore, +}: { + readonly initialRecords?: RA; + readonly totalCount?: number; + readonly fetchSize?: number; + readonly fetchMore: + | ((offset: number) => Promise>) + | undefined; +}) { + const [results, setResults] = useTriggerState< + RA | undefined + >(initialRecords); const resultsRef = React.useRef(results); const handleSetResults: GetOrSet< - RA | undefined - >[1] = React.useCallback( + RA | undefined + >[typeof SET] = React.useCallback( (results) => { const resolved = typeof results === 'function' ? results(resultsRef.current) : results; @@ -47,47 +35,25 @@ export function useFetchQueryResults({ ); // Queue for fetching - const fetchersRef = React.useRef | void>>>({}); + const fetchersRef = React.useRef | undefined>>>( + {} + ); - const getSetTotalCount = useTriggerState(initialTotalCount); + const getSetTotalCount = useTriggerState( + initialTotalCount + ); const [totalCount] = getSetTotalCount; const canFetchMore = !Array.isArray(results) || totalCount === undefined || results.length < totalCount; - const handleFetchMore = React.useCallback( - async (index?: number): Promise | void> => { - const currentResults = resultsRef.current; - const canFetch = Array.isArray(currentResults); - - if (!canFetch || fetchResults === undefined) return undefined; - - const alreadyFetched = - currentResults.length === totalCount && - !currentResults.includes(undefined); - if (alreadyFetched) return undefined; - - /* - * REFACTOR: make this smarter - * when going to the last record, fetch 40 before the last - * when somewhere in the middle, adjust the fetch region to get the - * most unhatched records fetched - */ - const naiveFetchIndex = index ?? currentResults.length; - if (currentResults[naiveFetchIndex] !== undefined) return undefined; - - const fetchIndex = - /* If navigating backwards, fetch the previous 40 records */ - typeof index === 'number' && - typeof currentResults[index + 1] === 'object' && - currentResults[index - 1] === undefined && - index > fetchSize - ? naiveFetchIndex - fetchSize + 1 - : naiveFetchIndex; - + const internalFetchMore = React.useCallback( + async (index: number = 0): Promise | undefined> => { + const currentResults = resultsRef.current ?? []; + if (rawHandleFetchMore == undefined) return undefined; // Prevent concurrent fetching in different places - fetchersRef.current[fetchIndex] ??= fetchResults(fetchIndex) + fetchersRef.current[index] ??= rawHandleFetchMore(index) .then(async (newResults) => { if ( process.env.NODE_ENV === 'development' && @@ -108,30 +74,66 @@ export function useFetchQueryResults({ * This extends the sparse array to fit new results. Without this, * splice won't place the results in the correct place. */ - combinedResults[fetchIndex] ??= undefined; - combinedResults.splice(fetchIndex, newResults.length, ...newResults); + combinedResults[index] ??= undefined; + combinedResults.splice(index, newResults.length, ...newResults); handleSetResults(combinedResults); fetchersRef.current = removeKey( fetchersRef.current, - fetchIndex.toString() + index.toString() ); if (typeof index === 'number' && index >= combinedResults.length) return handleFetchMore(index); return newResults; }) - .catch(raise); + .catch((error) => { + raise(error); + return undefined; + }); + return fetchersRef.current[index]; + }, + [totalCount, setResults, rawHandleFetchMore] + ); + + const handleFetchMore = React.useCallback( + async (index?: number): Promise | undefined> => { + const currentResults = resultsRef.current; + const canFetch = Array.isArray(currentResults); + + if (!canFetch || rawHandleFetchMore === undefined) return undefined; + + const alreadyFetched = + currentResults.length === totalCount && + !currentResults.includes(undefined); + if (alreadyFetched) return undefined; + + /* + * REFACTOR: make this smarter + * when going to the last record, fetch 40 before the last + * when somewhere in the middle, adjust the fetch region to get the + * most unhatched records fetched + */ + const naiveFetchIndex = index ?? currentResults.length; + if (currentResults[naiveFetchIndex] !== undefined) return undefined; + + const fetchIndex = + /* If navigating backwards, fetch the previous 40 records */ + typeof index === 'number' && + typeof currentResults[index + 1] === 'object' && + currentResults[index - 1] === undefined && + index > fetchSize + ? naiveFetchIndex - fetchSize + 1 + : naiveFetchIndex; - return fetchersRef.current[fetchIndex]; + return internalFetchMore(fetchIndex); }, - [fetchResults, fetchSize, setResults, totalCount] + [rawHandleFetchMore, fetchSize, setResults, totalCount] ); return { - fetchersRef, - results: [results, handleSetResults], + results: [results, handleSetResults] as const, onFetchMore: handleFetchMore, totalCount: getSetTotalCount, canFetchMore, diff --git a/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx index 594f5b698f6..e4e623889c5 100644 --- a/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx +++ b/specifyweb/frontend/js_src/lib/hooks/useSerializedCollection.tsx @@ -11,7 +11,8 @@ import { useAsyncState } from './useAsyncState'; * A hook for fetching a collection of resources in a paginated way */ export function useSerializedCollection( - fetch: (offset: number) => Promise> + fetch: (offset: number) => Promise>, + showLoadingScreen: boolean = false ): readonly [ SerializedCollection | undefined, GetOrSet | undefined>[1], @@ -48,7 +49,7 @@ export function useSerializedCollection( collectionRef.current = undefined; return callback(); }, [callback]), - false + showLoadingScreen ); const collectionRef = React.useRef< SerializedCollection | undefined diff --git a/specifyweb/frontend/js_src/lib/localization/dataViews.ts b/specifyweb/frontend/js_src/lib/localization/dataViews.ts new file mode 100644 index 00000000000..e314b08086e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/localization/dataViews.ts @@ -0,0 +1,23 @@ +/** + * Localization strings for the Data Views component + * + * @module + */ + +import { createDictionary } from './utils'; + +// Refer to "Guidelines for Programmers" in ./README.md before editing this file + +export const dataViewsText = createDictionary({ + dataViewsTitle: { + 'comment': "The name of the component", + "en-us": "Data Views" + }, + tableRecords: { + 'comment': "Used as a dialog header within the Data Views component", + "en-us": "{tableLabel:string} Records" + }, + configureDataViews: { + "en-us": "Configure Data Views tables" + } +});