Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Refactor <TablePageLayout> to support multiple tabs #911

Merged
merged 16 commits into from
Jul 25, 2024
235 changes: 146 additions & 89 deletions frontend/packages/data-portal/app/components/TablePageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}

<div className="flex flex-col flex-auto">
{header}

{tabs.length > 1 && (
<>
{tabsTitle && <div className="text-sds-header-l">{tabsTitle}</div>}
<Tabs
value={activeTab.title}
onChange={(tabTitle: string) => {
setSearchParams((prev) => {
prev.set(QueryParams.TableTab, tabTitle)
return prev
})
}}
tabs={tabs.map((tab) => ({
label: (
<>
<span>{tab.title}</span>
<span className="text-sds-gray-500 ml-[24px]">
{tab.filteredCount}
</span>
</>
),
value: tab.title,
}))}
/>
</>
)}
<TablePageTabContent {...activeTab} />

{drawers}
</div>
</>
)
}

/** 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
})
}
Expand All @@ -65,83 +132,73 @@ export function TablePageLayout({

return (
<LayoutContext.Provider value={contextValue}>
{downloadModal}

<div className="flex flex-col flex-auto">
{header}

<div className="flex flex-auto">
{filterPanel && (
<div
className={cns(
'flex flex-col flex-shrink-0 w-[235px]',
'border-t border-r border-sds-gray-300',
)}
>
{filterPanel}
</div>
<div className="flex flex-auto">
{filterPanel && (
<div
className={cns(
'flex flex-col flex-shrink-0 w-[235px]',
'border-t border-r border-sds-gray-300',
)}
>
{filterPanel}
</div>
)}

<div
className={cns(
'flex flex-col flex-auto screen-2040:items-center',
'pt-sds-xl pb-sds-xxl',
'border-t border-sds-gray-300',
'overflow-x-scroll max-w-full',
)}

>
<div
className={cns(
'flex flex-col flex-auto screen-2040:items-center',
'pt-sds-xl pb-sds-xxl',
'border-t border-sds-gray-300',
'overflow-x-scroll max-w-full',
'flex flex-col flex-auto w-full',

// Translate to the left by half the filter panel width to align with the header
filterPanel && 'screen-2040:translate-x-[-100px] max-w-content',
)}
>
<div
className={cns(
'flex flex-col flex-auto w-full',
<div className="px-sds-xl flex items-center gap-x-sds-xl">
<p className="text-sds-header-l leading-sds-header-l font-semibold">
{title}
</p>

<TableCount
filteredCount={filteredCount}
totalCount={totalCount}
type={countLabel}
/>
</div>

// Translate to the left by half the filter panel width to align with the header
filterPanel && 'screen-2040:translate-x-[-100px] max-w-content',
<ErrorBoundary logId={TABLE_PAGE_LAYOUT_LOG_ID}>
<div className="overflow-x-scroll">{table}</div>
</ErrorBoundary>

<div className="px-sds-xl">
{filteredCount === 0 && noResults}

{filteredCount > MAX_PER_PAGE && (
<div
className="w-full flex justify-center mt-sds-xxl"
data-testid={TestIds.Pagination}
>
<Pagination
currentPage={pageQueryParamValue}
pageSize={MAX_PER_PAGE}
totalCount={
totalCount === filteredCount ? totalCount : filteredCount
}
onNextPage={() => setPage(pageQueryParamValue + 1)}
onPreviousPage={() => setPage(pageQueryParamValue - 1)}
onPageChange={(nextPage) => setPage(nextPage)}
/>
</div>
)}
>
<div className="px-sds-xl flex items-center gap-x-sds-xl">
<p className="text-sds-header-l leading-sds-header-l font-semibold">
{title}
</p>

<TableCount
filteredCount={filteredCount}
totalCount={totalCount}
type={type}
/>
</div>

<ErrorBoundary logId={TABLE_PAGE_LAYOUT_LOG_ID}>
<div className="overflow-x-scroll">{table}</div>
</ErrorBoundary>

<div className="px-sds-xl">
{filteredCount === 0 && noResults}

{filteredCount > MAX_PER_PAGE && (
<div
className="w-full flex justify-center mt-sds-xxl"
data-testid={TestIds.Pagination}
>
<Pagination
currentPage={page}
pageSize={MAX_PER_PAGE}
totalCount={
totalCount === filteredCount
? totalCount
: filteredCount
}
onNextPage={() => setPage(page + 1)}
onPreviousPage={() => setPage(page - 1)}
onPageChange={(nextPage) => setPage(nextPage)}
/>
</div>
)}
</div>
</div>
</div>
</div>

{drawers}
</div>
</LayoutContext.Provider>
)
Expand Down
9 changes: 5 additions & 4 deletions frontend/packages/data-portal/app/components/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends string> {
label: string
export interface TabData<T> {
label: ReactNode
value: T
}

export function Tabs<T extends string>({
export function Tabs<T>({
tabs,
value,
onChange,
Expand Down Expand Up @@ -43,7 +44,7 @@ export function Tabs<T extends string>({
),
selected: '!text-black',
}}
key={tab.value}
key={String(tab.value)}
{...tab}
/>
))}
Expand Down
2 changes: 2 additions & 0 deletions frontend/packages/data-portal/app/constants/query.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export enum QueryParams {
AnnotationId = 'annotation_id',
AnnotationsPage = 'annotations-page',
AnnotationSoftware = 'annotation-software',
AuthorName = 'author',
AuthorOrcid = 'author_orcid',
Expand All @@ -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',
Expand Down
10 changes: 5 additions & 5 deletions frontend/packages/data-portal/app/graphql/getRunById.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -343,20 +343,20 @@ function getFileFilter(filterState: FilterState) {
export async function getRunById({
client,
id,
page = 1,
annotationsPage,
params = new URLSearchParams(),
}: {
client: ApolloClient<NormalizedCacheObject>
id: number
page?: number
annotationsPage: number
params?: URLSearchParams
}): Promise<ApolloQueryResult<GetRunByIdQuery>> {
return client.query({
query: GET_RUN_BY_ID_QUERY,
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)),
},
Expand Down
Loading
Loading