diff --git a/frontend/packages/data-portal/app/components/TablePageLayout.tsx b/frontend/packages/data-portal/app/components/TablePageLayout.tsx index ce3c68c20..24ab8c9c4 100644 --- a/frontend/packages/data-portal/app/components/TablePageLayout.tsx +++ b/frontend/packages/data-portal/app/components/TablePageLayout.tsx @@ -4,54 +4,121 @@ import { ReactNode, useEffect, useMemo } from 'react' import { TABLE_PAGE_LAYOUT_LOG_ID } from 'app/constants/error' import { MAX_PER_PAGE } from 'app/constants/pagination' +import { QueryParams } from 'app/constants/query' import { TestIds } from 'app/constants/testIds' import { LayoutContext, LayoutContextValue } from 'app/context/Layout.context' import { cns } from 'app/utils/cns' import { ErrorBoundary } from './ErrorBoundary' import { TableCount } from './Table/TableCount' +import { Tabs } from './Tabs' +export interface TablePageLayoutProps { + header?: ReactNode + + tabs: TableLayoutTab[] // If there is only 1 tab, the tab selector will not show. + tabsTitle?: string + + downloadModal?: ReactNode + drawers?: ReactNode +} + +export interface TableLayoutTab { + title: string + + filterPanel?: ReactNode + + table: ReactNode + noResults?: ReactNode + pageQueryParamKey?: string + + filteredCount: number + totalCount: number + countLabel: string // e.g. "objects" in "1 of 3 objects". +} + +/** Standard page structure for browsing + filtering list(s) of objects. */ export function TablePageLayout({ + header, + tabs, + tabsTitle, downloadModal, drawers, +}: TablePageLayoutProps) { + const [searchParams, setSearchParams] = useSearchParams() + + const activeTabTitle = searchParams.get(QueryParams.TableTab) + const activeTab = tabs.find((tab) => tab.title === activeTabTitle) ?? tabs[0] + + return ( + <> + {downloadModal} + +
+ {header} + + {tabs.length > 1 && ( + <> + {tabsTitle &&
{tabsTitle}
} + { + setSearchParams((prev) => { + prev.set(QueryParams.TableTab, tabTitle) + return prev + }) + }} + tabs={tabs.map((tab) => ({ + label: ( + <> + {tab.title} + + {tab.filteredCount} + + + ), + value: tab.title, + }))} + /> + + )} + + + {drawers} +
+ + ) +} + +/** Table + filters for 1 tab. */ +function TablePageTabContent({ + title, + filterPanel, filteredCount, - filters: filterPanel, - header, - noResults, table, - title, + noResults, + pageQueryParamKey = QueryParams.Page, totalCount, - type, -}: { - downloadModal?: ReactNode - drawers?: ReactNode - filteredCount: number - filters?: ReactNode - header?: ReactNode - noResults?: ReactNode - table: ReactNode - title: string - totalCount: number - type: string -}) { + countLabel, +}: TableLayoutTab) { const [searchParams, setSearchParams] = useSearchParams() - const page = +(searchParams.get('page') ?? '1') + const pageQueryParamValue = +(searchParams.get(pageQueryParamKey) ?? '1') useEffect(() => { - if (Math.ceil(filteredCount / MAX_PER_PAGE) < page) { + if (Math.ceil(filteredCount / MAX_PER_PAGE) < pageQueryParamValue) { setSearchParams( (prev) => { - prev.delete('page') + prev.delete(pageQueryParamKey) return prev }, { replace: true }, ) } - }, [filteredCount, page, setSearchParams]) + }, [filteredCount, pageQueryParamKey, pageQueryParamValue, setSearchParams]) function setPage(nextPage: number) { setSearchParams((prev) => { - prev.set('page', `${nextPage}`) + prev.set(pageQueryParamKey, `${nextPage}`) return prev }) } @@ -65,83 +132,73 @@ export function TablePageLayout({ return ( - {downloadModal} - -
- {header} - -
- {filterPanel && ( -
- {filterPanel} -
+
+ {filterPanel && ( +
+ {filterPanel} +
+ )} + +
-
+

+ {title} +

+ + +
- // Translate to the left by half the filter panel width to align with the header - filterPanel && 'screen-2040:translate-x-[-100px] max-w-content', + +
{table}
+
+ +
+ {filteredCount === 0 && noResults} + + {filteredCount > MAX_PER_PAGE && ( +
+ setPage(pageQueryParamValue + 1)} + onPreviousPage={() => setPage(pageQueryParamValue - 1)} + onPageChange={(nextPage) => setPage(nextPage)} + /> +
)} - > -
-

- {title} -

- - -
- - -
{table}
-
- -
- {filteredCount === 0 && noResults} - - {filteredCount > MAX_PER_PAGE && ( -
- setPage(page + 1)} - onPreviousPage={() => setPage(page - 1)} - onPageChange={(nextPage) => setPage(nextPage)} - /> -
- )} -
- - {drawers}
) diff --git a/frontend/packages/data-portal/app/components/Tabs.tsx b/frontend/packages/data-portal/app/components/Tabs.tsx index 5e61442b4..852a4554b 100644 --- a/frontend/packages/data-portal/app/components/Tabs.tsx +++ b/frontend/packages/data-portal/app/components/Tabs.tsx @@ -1,14 +1,15 @@ import Tab from '@mui/material/Tab' import MUITabs, { TabsProps } from '@mui/material/Tabs' +import { ReactNode } from 'react' import { cns } from 'app/utils/cns' -export interface TabData { - label: string +export interface TabData { + label: ReactNode value: T } -export function Tabs({ +export function Tabs({ tabs, value, onChange, @@ -43,7 +44,7 @@ export function Tabs({ ), selected: '!text-black', }} - key={tab.value} + key={String(tab.value)} {...tab} /> ))} diff --git a/frontend/packages/data-portal/app/constants/query.ts b/frontend/packages/data-portal/app/constants/query.ts index a275eb746..8166816ca 100644 --- a/frontend/packages/data-portal/app/constants/query.ts +++ b/frontend/packages/data-portal/app/constants/query.ts @@ -1,5 +1,6 @@ export enum QueryParams { AnnotationId = 'annotation_id', + AnnotationsPage = 'annotations-page', AnnotationSoftware = 'annotation-software', AuthorName = 'author', AuthorOrcid = 'author_orcid', @@ -26,6 +27,7 @@ export enum QueryParams { ReconstructionMethod = 'reconstruction_method', ReconstructionSoftware = 'reconstruction_software', Tab = 'tab', + TableTab = 'table-tab', TiltRangeMax = 'tilt_max', TiltRangeMin = 'tilt_min', TomogramProcessing = 'tomogram-processing', diff --git a/frontend/packages/data-portal/app/graphql/getRunById.server.ts b/frontend/packages/data-portal/app/graphql/getRunById.server.ts index 1e1c6a742..0c19ae320 100644 --- a/frontend/packages/data-portal/app/graphql/getRunById.server.ts +++ b/frontend/packages/data-portal/app/graphql/getRunById.server.ts @@ -15,7 +15,7 @@ import { MAX_PER_PAGE } from 'app/constants/pagination' import { FilterState, getFilterState } from 'app/hooks/useFilter' const GET_RUN_BY_ID_QUERY = gql(` - query GetRunById($id: Int, $limit: Int, $offset: Int, $filter: annotations_bool_exp, $fileFilter: annotation_files_bool_exp) { + query GetRunById($id: Int, $limit: Int, $annotationsOffset: Int, $filter: annotations_bool_exp, $fileFilter: annotation_files_bool_exp) { runs(where: { id: { _eq: $id } }) { id name @@ -128,7 +128,7 @@ const GET_RUN_BY_ID_QUERY = gql(` annotation_table: tomogram_voxel_spacings { annotations( limit: $limit, - offset: $offset, + offset: $annotationsOffset, where: $filter, order_by: [ { ground_truth_status: desc } @@ -343,12 +343,12 @@ function getFileFilter(filterState: FilterState) { export async function getRunById({ client, id, - page = 1, + annotationsPage, params = new URLSearchParams(), }: { client: ApolloClient id: number - page?: number + annotationsPage: number params?: URLSearchParams }): Promise> { return client.query({ @@ -356,7 +356,7 @@ export async function getRunById({ variables: { id, limit: MAX_PER_PAGE, - offset: (page - 1) * MAX_PER_PAGE, + annotationsOffset: (annotationsPage - 1) * MAX_PER_PAGE, filter: getFilter(getFilterState(params)), fileFilter: getFileFilter(getFilterState(params)), }, diff --git a/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx b/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx index 3405da2fc..c52cd47f5 100644 --- a/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx +++ b/frontend/packages/data-portal/app/routes/browse-data.datasets.tsx @@ -11,7 +11,6 @@ import { getBrowseDatasets } from 'app/graphql/getBrowseDatasets.server' import { useDatasets } from 'app/hooks/useDatasets' import { useFilter } from 'app/hooks/useFilter' import { useI18n } from 'app/hooks/useI18n' -import { i18n } from 'app/i18n' export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url) @@ -45,19 +44,23 @@ export default function BrowseDatasetsPage() { return ( } - table={} - totalCount={datasetCount} - noResults={ - {i18n.clearFilters}} - /> - } + tabs={[ + { + title: t('datasets'), + filterPanel: , + table: , + noResults: ( + {t('clearFilters')}} + /> + ), + filteredCount: filteredDatasetCount, + totalCount: datasetCount, + countLabel: t('datasets'), + }, + ]} /> ) } diff --git a/frontend/packages/data-portal/app/routes/browse-data.depositions.tsx b/frontend/packages/data-portal/app/routes/browse-data.depositions.tsx index 53950351e..9f6ad10f6 100644 --- a/frontend/packages/data-portal/app/routes/browse-data.depositions.tsx +++ b/frontend/packages/data-portal/app/routes/browse-data.depositions.tsx @@ -56,18 +56,22 @@ export default function BrowseDepositionsPage() { return ( } - totalCount={depositionCount} - noResults={ - {t('clearFilters')}} - /> - } + tabs={[ + { + title: t('depositions'), + table: , + noResults: ( + {t('clearFilters')}} + /> + ), + filteredCount: filteredDepositionCount, + totalCount: depositionCount, + countLabel: t('depositions'), + }, + ]} /> ) } diff --git a/frontend/packages/data-portal/app/routes/datasets.$id.tsx b/frontend/packages/data-portal/app/routes/datasets.$id.tsx index 56eaf08d6..40fbe1974 100644 --- a/frontend/packages/data-portal/app/routes/datasets.$id.tsx +++ b/frontend/packages/data-portal/app/routes/datasets.$id.tsx @@ -3,12 +3,12 @@ import { json, LoaderFunctionArgs } from '@remix-run/server-runtime' import { apolloClient } from 'app/apollo.server' +import { TablePageLayout } from 'app/components//TablePageLayout' import { DatasetMetadataDrawer } from 'app/components/Dataset' import { DatasetHeader } from 'app/components/Dataset/DatasetHeader' import { RunsTable } from 'app/components/Dataset/RunsTable' import { DownloadModal } from 'app/components/Download' import { RunFilter } from 'app/components/RunFilter' -import { TablePageLayout } from 'app/components/TablePageLayout' import { QueryParams } from 'app/constants/query' import { getDatasetById } from 'app/graphql/getDatasetById.server' import { useDatasetById } from 'app/hooks/useDatasetById' @@ -51,8 +51,17 @@ export default function DatasetByIdPage() { return ( } + tabs={[ + { + title: t('runs'), + filterPanel: , + table: , + filteredCount: dataset.filtered_runs_count.aggregate?.count ?? 0, + totalCount: dataset.runs_aggregate.aggregate?.count ?? 0, + countLabel: i18n.runs, + }, + ]} downloadModal={ } drawers={} - filteredCount={dataset.filtered_runs_count.aggregate?.count ?? 0} - header={} - table={} - totalCount={dataset.runs_aggregate.aggregate?.count ?? 0} - filters={} /> ) } diff --git a/frontend/packages/data-portal/app/routes/runs.$id.tsx b/frontend/packages/data-portal/app/routes/runs.$id.tsx index c276b53f0..f63288b25 100644 --- a/frontend/packages/data-portal/app/routes/runs.$id.tsx +++ b/frontend/packages/data-portal/app/routes/runs.$id.tsx @@ -7,13 +7,13 @@ import { useMemo } from 'react' import { match } from 'ts-pattern' import { apolloClient } from 'app/apollo.server' +import { TablePageLayout } from 'app/components//TablePageLayout' import { AnnotationFilter } from 'app/components/AnnotationFilter/AnnotationFilter' import { DownloadModal } from 'app/components/Download' import { RunHeader } from 'app/components/Run' import { AnnotationDrawer } from 'app/components/Run/AnnotationDrawer' import { AnnotationTable } from 'app/components/Run/AnnotationTable' import { RunMetadataDrawer } from 'app/components/Run/RunMetadataDrawer' -import { TablePageLayout } from 'app/components/TablePageLayout' import { QueryParams } from 'app/constants/query' import { getRunById } from 'app/graphql/getRunById.server' import { useDownloadModalQueryParamState } from 'app/hooks/useDownloadModalQueryParamState' @@ -36,11 +36,13 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } const url = new URL(request.url) - const page = +(url.searchParams.get(QueryParams.Page) ?? '1') + const annotationsPage = +( + url.searchParams.get(QueryParams.AnnotationsPage) ?? '1' + ) const { data } = await getRunById({ id, - page, + annotationsPage, client: apolloClient, params: url.searchParams, }) @@ -66,6 +68,7 @@ export function shouldRevalidate(args: ShouldRevalidateFunctionArgs) { QueryParams.ObjectShapeType, QueryParams.MethodType, QueryParams.AnnotationSoftware, + QueryParams.AnnotationsPage, ], }) } @@ -175,8 +178,18 @@ export default function RunByIdPage() { return ( } + tabs={[ + { + title: t('annotations'), + filterPanel: , + filteredCount, + table: , + pageQueryParamKey: QueryParams.AnnotationsPage, + totalCount, + countLabel: i18n.annotations, + }, + ]} downloadModal={ } - filters={} - filteredCount={filteredCount} - header={} - table={} - totalCount={totalCount} /> ) } diff --git a/frontend/packages/data-portal/e2e/dialogs/singleRun.ts b/frontend/packages/data-portal/e2e/dialogs/singleRun.ts index 0ab7b466d..0caf7d814 100644 --- a/frontend/packages/data-portal/e2e/dialogs/singleRun.ts +++ b/frontend/packages/data-portal/e2e/dialogs/singleRun.ts @@ -30,7 +30,7 @@ const TOMOTABS = [ async function fetchData() { const client = getApolloClient() - return getRunById({ client, id: +E2E_CONFIG.runId }) + return getRunById({ client, id: +E2E_CONFIG.runId, annotationsPage: 1 }) } export function testSingleRunDownloadDialog() { diff --git a/frontend/packages/data-portal/e2e/filters/utils.ts b/frontend/packages/data-portal/e2e/filters/utils.ts index cf4e72dc7..7ba5c0985 100644 --- a/frontend/packages/data-portal/e2e/filters/utils.ts +++ b/frontend/packages/data-portal/e2e/filters/utils.ts @@ -217,14 +217,14 @@ export async function validateAnnotationsTable({ client, page, params, - pageNumber, + pageNumber = 1, id = +E2E_CONFIG.runId, }: TableValidatorOptions & { id?: number }) { const { data } = await getRunById({ client, params, id, - page: pageNumber, + annotationsPage: pageNumber, }) await validateTable({ diff --git a/frontend/packages/data-portal/e2e/pageObjects/filters/filtersActor.ts b/frontend/packages/data-portal/e2e/pageObjects/filters/filtersActor.ts index 9856793fd..a640c779f 100644 --- a/frontend/packages/data-portal/e2e/pageObjects/filters/filtersActor.ts +++ b/frontend/packages/data-portal/e2e/pageObjects/filters/filtersActor.ts @@ -46,7 +46,7 @@ export class FiltersActor { }: { client: ApolloClient id: number - pageNumber?: number + pageNumber: number url: string queryParamKey?: QueryParams queryParamValue: string @@ -63,7 +63,7 @@ export class FiltersActor { client, params, id, - page: pageNumber, + annotationsPage: pageNumber, }) return data @@ -166,7 +166,7 @@ export class FiltersActor { }: { client: ApolloClient id: number - pageNumber?: number + pageNumber: number url: string queryParamKey?: QueryParams queryParamValue: string diff --git a/frontend/packages/data-portal/e2e/pageObjects/filters/utils.ts b/frontend/packages/data-portal/e2e/pageObjects/filters/utils.ts index c90c067ce..171dbcf47 100644 --- a/frontend/packages/data-portal/e2e/pageObjects/filters/utils.ts +++ b/frontend/packages/data-portal/e2e/pageObjects/filters/utils.ts @@ -36,7 +36,7 @@ export function getExpectedUrlWithQueryParams({ export async function getAnnotationsTableTestData({ client, params, - pageNumber, + pageNumber = 1, id = +E2E_CONFIG.runId, }: TableValidatorOptions & { id?: number }): Promise< ApolloQueryResult['data'] @@ -45,7 +45,7 @@ export async function getAnnotationsTableTestData({ client, params, id, - page: pageNumber, + annotationsPage: pageNumber, }) return data diff --git a/frontend/packages/data-portal/e2e/pageObjects/metadataDrawer/utils.ts b/frontend/packages/data-portal/e2e/pageObjects/metadataDrawer/utils.ts index c81a0ea86..33777180d 100644 --- a/frontend/packages/data-portal/e2e/pageObjects/metadataDrawer/utils.ts +++ b/frontend/packages/data-portal/e2e/pageObjects/metadataDrawer/utils.ts @@ -177,6 +177,7 @@ export async function getSingleRunTestMetadata( const { data } = await getRunById({ client, id: +E2E_CONFIG.runId, + annotationsPage: 1, }) const [run] = data.runs @@ -207,6 +208,7 @@ export async function getAnnotationTestData( const { data } = await getRunById({ client, id: +E2E_CONFIG.runId, + annotationsPage: 1, }) const [run] = data.runs diff --git a/frontend/packages/eslint-config/typescript.cjs b/frontend/packages/eslint-config/typescript.cjs index e43a379ef..f52450bd7 100644 --- a/frontend/packages/eslint-config/typescript.cjs +++ b/frontend/packages/eslint-config/typescript.cjs @@ -62,6 +62,8 @@ module.exports = { // Sometimes it's safe to call async functions and not handle their errors. '@typescript-eslint/no-misused-promises': 'off', + // Allow us to use function/variable hoisting. + '@typescript-eslint/no-use-before-define': 'off', 'unused-imports/no-unused-imports': 'error', 'unused-imports/no-unused-vars': [