diff --git a/apps/hpc-ftsadmin/src/app/components/filters/filter-flows-table.tsx b/apps/hpc-ftsadmin/src/app/components/filters/filter-flows-table.tsx index 4ae5acc9f..6a7fc5f00 100644 --- a/apps/hpc-ftsadmin/src/app/components/filters/filter-flows-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/filters/filter-flows-table.tsx @@ -9,7 +9,7 @@ import { t } from '../../../i18n'; import { LocalStorageSchema } from '../../utils/local-storage-type'; import { util } from '@unocha/hpc-core'; import { Alert } from '@mui/material'; -import { Query } from '../tables/table-utils'; +import type { FlowQuery, SetQuery } from '../tables/table-utils'; import { AppContext } from '../../context'; import { util as codecs, FormObjectValue } from '@unocha/hpc-data'; import validateForm from '../../utils/form-validation'; @@ -25,8 +25,8 @@ import { } from '../../utils/fn-promises'; interface Props { - query: Query; - setQuery: (newQuery: Query) => void; + query: FlowQuery; + setQuery: SetQuery; handleAbortController: () => void; } export interface FlowsFilterValues { @@ -136,10 +136,15 @@ export const FilterFlowsTable = (props: Props) => { if (query.filters !== encodedFilters) { handleAbortController(); } - setQuery({ - ...query, - page: 0, - filters: encodedFilters, + // We need to delay this action in a synchronous way to avoid + // calling 2 setState() actions in an uncontrolled way that could + // mess with internal React's component update cycle + setTimeout(() => { + setQuery({ + ...query, + page: 0, + filters: encodedFilters, + }); }); }; return ( diff --git a/apps/hpc-ftsadmin/src/app/components/filters/filter-organization-table.tsx b/apps/hpc-ftsadmin/src/app/components/filters/filter-organization-table.tsx index 276d2c62d..f9a1a1a2d 100644 --- a/apps/hpc-ftsadmin/src/app/components/filters/filter-organization-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/filters/filter-organization-table.tsx @@ -7,7 +7,7 @@ import { Environment } from '../../../environments/interface'; import { decodeFilters, encodeFilters } from '../../utils/parse-filters'; import { LanguageKey, t } from '../../../i18n'; import { Dayjs } from 'dayjs'; -import { Query } from '../tables/table-utils'; +import type { OrganizationQuery, SetQuery } from '../tables/table-utils'; import * as io from 'io-ts'; import validateForm from '../../utils/form-validation'; import { @@ -17,8 +17,8 @@ import { } from '../../utils/fn-promises'; interface Props { environment: Environment; - query: Query; - setQuery: (newQuery: Query) => void; + query: OrganizationQuery; + setQuery: SetQuery; lang: LanguageKey; } export interface OrganizationFilterValues { @@ -69,10 +69,15 @@ export const FilterOrganizationsTable = (props: Props) => { ) => void ) => { formikResetForm(); - setQuery({ - ...query, - page: 0, - filters: encodeFilters({}, ORGANIZATIONS_FILTER_INITIAL_VALUES), + // We need to delay this action in a synchronous way to avoid + // calling 2 setState() actions in an uncontrolled way that could + // mess with internal React's component update cycle + setTimeout(() => { + setQuery({ + ...query, + page: 0, + filters: encodeFilters({}, ORGANIZATIONS_FILTER_INITIAL_VALUES), + }); }); }; return ( diff --git a/apps/hpc-ftsadmin/src/app/components/filters/filter-pending-flows-table.tsx b/apps/hpc-ftsadmin/src/app/components/filters/filter-pending-flows-table.tsx index d0d2fe14d..0240352f3 100644 --- a/apps/hpc-ftsadmin/src/app/components/filters/filter-pending-flows-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/filters/filter-pending-flows-table.tsx @@ -5,7 +5,7 @@ import { C } from '@unocha/hpc-ui'; import { FormObjectValue } from '@unocha/hpc-data'; import { decodeFilters, encodeFilters } from '../../utils/parse-filters'; import { t } from '../../../i18n'; -import { Query } from '../tables/table-utils'; +import type { FlowQuery, SetQuery } from '../tables/table-utils'; import { useContext } from 'react'; import { AppContext } from '../../context'; import { @@ -14,8 +14,8 @@ import { fnUsageYears, } from '../../utils/fn-promises'; interface Props { - query: Query; - setQuery: (newQuery: Query) => void; + query: FlowQuery; + setQuery: SetQuery; handleAbortController: () => void; } export interface PendingFlowsFilterValues { @@ -80,10 +80,15 @@ export const FilterPendingFlowsTable = (props: Props) => { if (query.filters !== encodedFilters) { handleAbortController(); } - setQuery({ - ...query, - page: 0, - filters: encodedFilters, + // We need to delay this action in a synchronous way to avoid + // calling 2 setState() actions in an uncontrolled way that could + // mess with internal React's component update cycle + setTimeout(() => { + setQuery({ + ...query, + page: 0, + filters: encodedFilters, + }); }); }; return ( diff --git a/apps/hpc-ftsadmin/src/app/components/tables/flows-table.tsx b/apps/hpc-ftsadmin/src/app/components/tables/flows-table.tsx index 932e9b06f..18c638806 100644 --- a/apps/hpc-ftsadmin/src/app/components/tables/flows-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/tables/flows-table.tsx @@ -45,7 +45,8 @@ import { downloadExcel } from '../../utils/download-excel'; import DownloadIcon from '@mui/icons-material/Download'; import { ChipDiv, - Query, + type FlowQuery, + type SetQuery, RejectPendingFlowsButton, RenderChipsRow, StyledLoader, @@ -64,8 +65,8 @@ export interface FlowsTableProps { headers: TableHeadersProps[]; initialValues: FlowsFilterValues | PendingFlowsFilterValues; rowsPerPageOption: number[]; - query: Query; - setQuery: (newQuery: Query) => void; + query: FlowQuery; + setQuery: SetQuery; abortSignal?: AbortSignal; pending?: boolean; } @@ -81,17 +82,17 @@ export default function FlowsTable(props: FlowsTableProps) { const [tableInfoDisplay, setTableInfoDisplay] = useState( util.getLocalStorageItem('tableSettings', true) ); + const parsedFilters = parseFlowFilters(tableFilters, props.pending); const navigate = useNavigate(); const [state, load] = useDataLoader([query], () => env.model.flows.searchFlows({ limit: query.rowsPerPage, + page: query.page, sortField: query.orderBy, sortOrder: query.orderDir, ...parsedFilters, signal: props.abortSignal, - prevPageCursor: query.prevPageCursor, - nextPageCursor: query.nextPageCursor, }) ); const handleChipDelete = (fieldName: T) => { @@ -105,26 +106,11 @@ export default function FlowsTable(props: FlowsTableProps) { } }; - const handleChangePage = ( - newPage: number, - prevPageCursor: number, - nextPageCursor: number - ) => { - if (newPage > props.query.page) { - setQuery({ - ...query, - prevPageCursor: undefined, - nextPageCursor: nextPageCursor, - page: newPage, - }); - } else { - setQuery({ - ...query, - prevPageCursor: prevPageCursor, - nextPageCursor: undefined, - page: newPage, - }); - } + const handleChangePage = (newPage: number) => { + setQuery({ + ...query, + page: newPage, + }); }; const handleChangeRowsPerPage = ( @@ -858,13 +844,7 @@ export default function FlowsTable(props: FlowsTableProps) { count={data.searchFlows.total} rowsPerPage={query.rowsPerPage} page={query.page} - onPageChange={(_, newPage) => - handleChangePage( - newPage, - data.searchFlows.prevPageCursor, - data.searchFlows.nextPageCursor - ) - } + onPageChange={(_, newPage) => handleChangePage(newPage)} onRowsPerPageChange={handleChangeRowsPerPage} /> @@ -895,13 +875,7 @@ export default function FlowsTable(props: FlowsTableProps) { count={data.searchFlows.total} rowsPerPage={query.rowsPerPage} page={query.page} - onPageChange={(_, newPage) => - handleChangePage( - newPage, - data.searchFlows.prevPageCursor, - data.searchFlows.nextPageCursor - ) - } + onPageChange={(_, newPage) => handleChangePage(newPage)} onRowsPerPageChange={handleChangeRowsPerPage} /> diff --git a/apps/hpc-ftsadmin/src/app/components/tables/keywords-table.tsx b/apps/hpc-ftsadmin/src/app/components/tables/keywords-table.tsx index c6ea549d5..0dfc67909 100644 --- a/apps/hpc-ftsadmin/src/app/components/tables/keywords-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/tables/keywords-table.tsx @@ -37,7 +37,8 @@ import { import { ChipDiv, - Query, + type KeywordQuery, + type SetQuery, StyledLoader, TableHeaderButton, TopRowContainer, @@ -51,15 +52,10 @@ import { LocalStorageSchema } from '../../utils/local-storage-type'; import { Strings } from '../../../i18n/iface'; import { parseError } from '../../utils/map-functions'; -export type KeywordQuery = { - orderBy: string; - orderDir: string; - tableHeaders: string; -}; export interface KeywordTableProps { headers: TableHeadersProps[]; query: KeywordQuery; - setQuery: (newQuery: KeywordQuery) => void; + setQuery: SetQuery; } /** @@ -507,7 +503,7 @@ export default function KeywordTable(props: KeywordTableProps) { query.tableHeaders, lang, 'keywords', - query as Query, + query, setQuery )} onClick={(element) => { @@ -517,7 +513,7 @@ export default function KeywordTable(props: KeywordTableProps) { tableHeaders: encodeTableHeaders( element, 'keywords', - query as Query, + query, setQuery ), }); diff --git a/apps/hpc-ftsadmin/src/app/components/tables/organizations-table.tsx b/apps/hpc-ftsadmin/src/app/components/tables/organizations-table.tsx index b45105148..992abecf6 100644 --- a/apps/hpc-ftsadmin/src/app/components/tables/organizations-table.tsx +++ b/apps/hpc-ftsadmin/src/app/components/tables/organizations-table.tsx @@ -41,7 +41,8 @@ import { parseUpdatedCreatedBy } from '../../utils/map-functions'; import { OrganizationFilterValues } from '../filters/filter-organization-table'; import { ChipDiv, - Query, + type OrganizationQuery, + type SetQuery, RenderChipsRow, StyledLoader, TableHeaderButton, @@ -59,8 +60,8 @@ export interface OrganizationTableProps { headers: TableHeadersProps[]; initialValues: OrganizationFilterValues; rowsPerPageOption: number[]; - query: Query; - setQuery: (newQuery: Query) => void; + query: OrganizationQuery; + setQuery: SetQuery; } export default function OrganizationTable(props: OrganizationTableProps) { diff --git a/apps/hpc-ftsadmin/src/app/components/tables/table-utils.tsx b/apps/hpc-ftsadmin/src/app/components/tables/table-utils.tsx index b40204e1f..52c6b83ef 100644 --- a/apps/hpc-ftsadmin/src/app/components/tables/table-utils.tsx +++ b/apps/hpc-ftsadmin/src/app/components/tables/table-utils.tsx @@ -7,18 +7,37 @@ import CancelRoundedIcon from '@mui/icons-material/CancelRounded'; import { C } from '@unocha/hpc-ui'; import { util } from '@unocha/hpc-core'; import { LocalStorageSchema } from '../../utils/local-storage-type'; +import type { + FlowHeaderID, + KeywordHeaderID, + OrganizationHeaderID, +} from '../../utils/table-headers'; export type Query = { + orderDir: 'ASC' | 'DESC'; + tableHeaders: string; +}; + +export type FlowQuery = Query & { page: number; rowsPerPage: number; - orderBy: string; - orderDir: string; + orderBy: FlowHeaderID; + filters: string; +}; + +export type OrganizationQuery = Query & { + page: number; + rowsPerPage: number; + orderBy: OrganizationHeaderID; filters: string; - tableHeaders: string; - prevPageCursor?: number; - nextPageCursor?: number; }; +export type KeywordQuery = Query & { + orderBy: KeywordHeaderID; +}; + +export type SetQuery = (newQuery: T) => void; + export const StyledLoader = tw(C.Loader)` mx-auto `; diff --git a/apps/hpc-ftsadmin/src/app/pages/flows/flows-list.tsx b/apps/hpc-ftsadmin/src/app/pages/flows/flows-list.tsx index 10133ae6e..fb5a0d1b5 100644 --- a/apps/hpc-ftsadmin/src/app/pages/flows/flows-list.tsx +++ b/apps/hpc-ftsadmin/src/app/pages/flows/flows-list.tsx @@ -3,16 +3,6 @@ import { t } from '../../../i18n'; import PageMeta from '../../components/page-meta'; import { AppContext } from '../../context'; import tw from 'twin.macro'; - -import { - JsonParam, - NumberParam, - StringParam, - createEnumParam, - decodeNumber, - useQueryParams, - withDefault, -} from 'use-query-params'; import { DEFAULT_FLOW_TABLE_HEADERS, encodeTableHeaders, @@ -24,6 +14,8 @@ import FilterFlowsTable, { FLOWS_FILTER_INITIAL_VALUES, } from '../../components/filters/filter-flows-table'; import { useCallback, useEffect, useRef } from 'react'; +import useQueryParams from '../../utils/useQueryParams'; +import { FLOW_PARAMS_CODEC } from '../../utils/codecs'; interface Props { className?: string; @@ -51,44 +43,23 @@ export default (props: Props) => { }, []); const [query, setQuery] = useQueryParams({ - page: withDefault(NumberParam, 0), - rowsPerPage: withDefault( - { - ...NumberParam, - decode: (string) => { - // Prevent user requesting more than max number of rows - const number = decodeNumber(string); - return number && Math.min(number, Math.max(...rowsPerPageOptions)); - }, - }, - 50 - ), - orderBy: withDefault( - createEnumParam( - // Same as filter then map but this is acceptable to typescript - DEFAULT_FLOW_TABLE_HEADERS.reduce((acc, curr) => { - if (curr.sortable) { - return [...acc, curr.identifierID]; - } - - return acc; - }, [] as string[]) - ), - 'flow.updatedAt' - ), - orderDir: withDefault(createEnumParam(['ASC', 'DESC']), 'DESC'), - filters: withDefault(JsonParam, JSON.stringify({})), - tableHeaders: withDefault(StringParam, encodeTableHeaders([])), // Default value of table headers - prevPageCursor: withDefault(NumberParam, undefined), - nextPageCursor: withDefault(NumberParam, undefined), + codec: FLOW_PARAMS_CODEC, + initialValues: { + page: 0, + rowsPerPage: 50, + orderBy: 'flow.updatedAt', + orderDir: 'DESC', + filters: JSON.stringify({}), + tableHeaders: encodeTableHeaders([]), // Default value of table headers + }, }); const flowsTableProps: FlowsTableProps = { headers: DEFAULT_FLOW_TABLE_HEADERS, rowsPerPageOption: rowsPerPageOptions, initialValues: FLOWS_FILTER_INITIAL_VALUES, - query: query, - setQuery: setQuery, + query, + setQuery, abortSignal: abortControllerRef.current.signal, }; diff --git a/apps/hpc-ftsadmin/src/app/pages/flows/pending-flows-list.tsx b/apps/hpc-ftsadmin/src/app/pages/flows/pending-flows-list.tsx index 7442ddcc7..7bb8138cd 100644 --- a/apps/hpc-ftsadmin/src/app/pages/flows/pending-flows-list.tsx +++ b/apps/hpc-ftsadmin/src/app/pages/flows/pending-flows-list.tsx @@ -2,15 +2,6 @@ import { C, CLASSES, combineClasses } from '@unocha/hpc-ui'; import { t } from '../../../i18n'; import PageMeta from '../../components/page-meta'; import { AppContext } from '../../context'; -import { - JsonParam, - NumberParam, - StringParam, - createEnumParam, - decodeNumber, - useQueryParams, - withDefault, -} from 'use-query-params'; import FilterPendingFlowsTable, { PENDING_FLOWS_FILTER_INITIAL_VALUES, } from '../../components/filters/filter-pending-flows-table'; @@ -23,6 +14,8 @@ import FlowsTable, { FlowsTableProps, } from '../../components/tables/flows-table'; import { useCallback, useEffect, useRef } from 'react'; +import useQueryParams from '../../utils/useQueryParams'; +import { FLOW_PARAMS_CODEC } from '../../utils/codecs'; interface Props { className?: string; @@ -38,36 +31,15 @@ export default (props: Props) => { const abortControllerRef = useRef(new AbortController()); const [query, setQuery] = useQueryParams({ - page: withDefault(NumberParam, 0), - rowsPerPage: withDefault( - { - ...NumberParam, - decode: (string) => { - // Prevent user requesting more than max number of rows - const number = decodeNumber(string); - return number && Math.min(number, Math.max(...[10, 25, 50, 100])); - }, - }, - 50 - ), - orderBy: withDefault( - createEnumParam( - // Same as filter then map but this is acceptable to typescript - DEFAULT_FLOW_TABLE_HEADERS.reduce((acc, curr) => { - if (curr.sortable) { - return [...acc, curr.identifierID]; - } - - return acc; - }, [] as string[]) - ), - 'flow.updatedAt' - ), - orderDir: withDefault(createEnumParam(['ASC', 'DESC']), 'DESC'), - filters: withDefault(JsonParam, JSON.stringify({})), - tableHeaders: withDefault(StringParam, encodeTableHeaders([])), // Default value of table headers - prevPageCursor: withDefault(NumberParam, 0), - nextPageCursor: withDefault(NumberParam, 0), + codec: FLOW_PARAMS_CODEC, + initialValues: { + page: 0, + rowsPerPage: 50, + orderBy: 'flow.updatedAt', + orderDir: 'DESC', + filters: JSON.stringify({}), + tableHeaders: encodeTableHeaders([]), // Default value of table headers + }, }); const handleAbortController = useCallback(() => { @@ -85,8 +57,8 @@ export default (props: Props) => { headers: DEFAULT_FLOW_TABLE_HEADERS, initialValues: PENDING_FLOWS_FILTER_INITIAL_VALUES, rowsPerPageOption: [10, 25, 50, 100], - query: query, - setQuery: setQuery, + query, + setQuery, pending: true, abortSignal: abortControllerRef.current.signal, }; diff --git a/apps/hpc-ftsadmin/src/app/pages/keywords/keyword-list.tsx b/apps/hpc-ftsadmin/src/app/pages/keywords/keyword-list.tsx index 8d1aec373..12cff7b86 100644 --- a/apps/hpc-ftsadmin/src/app/pages/keywords/keyword-list.tsx +++ b/apps/hpc-ftsadmin/src/app/pages/keywords/keyword-list.tsx @@ -3,12 +3,6 @@ import { t } from '../../../i18n'; import PageMeta from '../../components/page-meta'; import { AppContext } from '../../context'; import tw from 'twin.macro'; -import { - StringParam, - createEnumParam, - useQueryParams, - withDefault, -} from 'use-query-params'; import { DEFAULT_KEYWORD_TABLE_HEADERS, encodeTableHeaders, @@ -16,6 +10,8 @@ import { import KeywordTable, { KeywordTableProps, } from '../../components/tables/keywords-table'; +import useQueryParams from '../../utils/useQueryParams'; +import { KEYWORD_PARAMS_CODEC } from '../../utils/codecs'; interface Props { className?: string; @@ -29,21 +25,12 @@ const LandingContainer = tw.div` `; export default (props: Props) => { const [query, setQuery] = useQueryParams({ - orderBy: withDefault( - createEnumParam( - // Same as filter then map but this is acceptable to typescript - DEFAULT_KEYWORD_TABLE_HEADERS.reduce((acc, curr) => { - if (curr.sortable) { - return [...acc, curr.identifierID]; - } - - return acc; - }, [] as string[]) - ), - 'keyword.name' - ), - orderDir: withDefault(createEnumParam(['ASC', 'DESC']), 'ASC'), - tableHeaders: withDefault(StringParam, encodeTableHeaders([], 'keywords')), + codec: KEYWORD_PARAMS_CODEC, + initialValues: { + orderBy: 'keyword.name', + orderDir: 'ASC', + tableHeaders: encodeTableHeaders([], 'keywords'), + }, }); const keywordTableProps: KeywordTableProps = { diff --git a/apps/hpc-ftsadmin/src/app/pages/organizations/organization-list.tsx b/apps/hpc-ftsadmin/src/app/pages/organizations/organization-list.tsx index 42f9e1a95..dc2ec9d70 100644 --- a/apps/hpc-ftsadmin/src/app/pages/organizations/organization-list.tsx +++ b/apps/hpc-ftsadmin/src/app/pages/organizations/organization-list.tsx @@ -3,15 +3,6 @@ import { t } from '../../../i18n'; import PageMeta from '../../components/page-meta'; import { AppContext, getEnv } from '../../context'; import tw from 'twin.macro'; -import { - JsonParam, - NumberParam, - StringParam, - createEnumParam, - decodeNumber, - useQueryParams, - withDefault, -} from 'use-query-params'; import { DEFAULT_ORGANIZATION_TABLE_HEADERS, encodeTableHeaders, @@ -22,6 +13,8 @@ import OrganizationTable, { import FilterOrganizationsTable, { ORGANIZATIONS_FILTER_INITIAL_VALUES, } from '../../components/filters/filter-organization-table'; +import useQueryParams from '../../utils/useQueryParams'; +import { ORGANIZATION_PARAMS_CODEC } from '../../utils/codecs'; interface Props { className?: string; @@ -37,39 +30,15 @@ export default (props: Props) => { const rowsPerPageOptions = [10, 25, 50, 100]; const [query, setQuery] = useQueryParams({ - page: withDefault(NumberParam, 0), - rowsPerPage: withDefault( - { - ...NumberParam, - decode: (string) => { - // Prevent user requesting more than max number of rows - const number = decodeNumber(string); - return number && Math.min(number, Math.max(...rowsPerPageOptions)); - }, - }, - 50 - ), - orderBy: withDefault( - createEnumParam( - // Same as filter then map but this is acceptable to typescript - DEFAULT_ORGANIZATION_TABLE_HEADERS.reduce((acc, curr) => { - if (curr.sortable) { - return [...acc, curr.identifierID]; - } - - return acc; - }, [] as string[]) - ), - 'organization.name' - ), - orderDir: withDefault(createEnumParam(['ASC', 'DESC']), 'ASC'), - filters: withDefault(JsonParam, JSON.stringify({})), - tableHeaders: withDefault( - StringParam, - encodeTableHeaders([], 'organizations') - ), - prevPageCursor: withDefault(NumberParam, 0), - nextPageCursor: withDefault(NumberParam, 0), + codec: ORGANIZATION_PARAMS_CODEC, + initialValues: { + page: 0, + rowsPerPage: 50, + orderBy: 'organization.name', + orderDir: 'ASC', + filters: JSON.stringify({}), + tableHeaders: encodeTableHeaders([], 'organizations'), + }, }); const organizationTableProps: OrganizationTableProps = { diff --git a/apps/hpc-ftsadmin/src/app/utils/codecs.ts b/apps/hpc-ftsadmin/src/app/utils/codecs.ts new file mode 100644 index 000000000..4195e116b --- /dev/null +++ b/apps/hpc-ftsadmin/src/app/utils/codecs.ts @@ -0,0 +1,64 @@ +import * as t from 'io-ts'; +import { util } from '@unocha/hpc-data'; +import { + DEFAULT_FLOW_TABLE_HEADERS, + DEFAULT_KEYWORD_TABLE_HEADERS, + DEFAULT_ORGANIZATION_TABLE_HEADERS, + type FlowHeaderID, + type KeywordHeaderID, + type OrganizationHeaderID, + type TableHeadersProps, +} from './table-headers'; + +const ROWS_PER_PAGE_OPTIONS = [10, 25, 50, 100] as const; +const ROWS_PER_PAGE = util.validInteger(ROWS_PER_PAGE_OPTIONS); + +const PARAMS_CODEC = t.type({ + page: util.INTEGER_FROM_STRING, + rowsPerPage: ROWS_PER_PAGE, + orderDir: t.keyof({ + ASC: 'ASC', + DESC: 'DESC', + }), + filters: t.string, + tableHeaders: t.string, +}); + +const extractIdentifierIds = < + T extends OrganizationHeaderID | FlowHeaderID | KeywordHeaderID, +>( + val: TableHeadersProps[] +) => { + return val.reduce( + (acc, { identifierID: id, sortable }) => { + if (sortable) { + acc[id] = id; + } + return acc; + }, + {} as Record + ); +}; + +export const FLOW_PARAMS_CODEC = t.intersection([ + PARAMS_CODEC, + t.type({ + orderBy: t.keyof(extractIdentifierIds(DEFAULT_FLOW_TABLE_HEADERS)), + }), +]); + +export const ORGANIZATION_PARAMS_CODEC = t.intersection([ + PARAMS_CODEC, + t.type({ + orderBy: t.keyof(extractIdentifierIds(DEFAULT_ORGANIZATION_TABLE_HEADERS)), + }), +]); + +export const KEYWORD_PARAMS_CODEC = t.type({ + orderBy: t.keyof(extractIdentifierIds(DEFAULT_KEYWORD_TABLE_HEADERS)), + orderDir: t.keyof({ + ASC: 'ASC', + DESC: 'DESC', + }), + tableHeaders: t.string, +}); diff --git a/apps/hpc-ftsadmin/src/app/utils/table-headers.ts b/apps/hpc-ftsadmin/src/app/utils/table-headers.ts index bd092e988..90773fd03 100644 --- a/apps/hpc-ftsadmin/src/app/utils/table-headers.ts +++ b/apps/hpc-ftsadmin/src/app/utils/table-headers.ts @@ -1,6 +1,6 @@ import { LanguageKey, t } from '../../i18n'; import { Strings } from '../../i18n/iface'; -import { Query } from '../components/tables/table-utils'; +import type { Query, SetQuery } from '../components/tables/table-utils'; import { FilterKeys } from './parse-filters'; /** Declare which tables there can be */ @@ -310,11 +310,11 @@ const defaultEncodeTableHeaders = (table: TableType) => { * Encodes the query param to obtain a string suitable for the URL, * use it alongside `decodeTableHeaders()` */ -export const encodeTableHeaders = ( +export const encodeTableHeaders = ( headers: Array, table: TableType = 'flows', - query?: Query, - setQuery?: (newQuery: Query) => void + query?: T, + setQuery?: SetQuery ): string => { if (headers.length === 0) { return defaultEncodeTableHeaders(table); @@ -399,12 +399,12 @@ const defaultDecodeTableHeaders = ( /** * Decodes the query param to obtain an ordered list of table headers */ -export const decodeTableHeaders = ( +export const decodeTableHeaders = ( queryParam: string, lang: LanguageKey, table: TableType = 'flows', - query?: Query, - setQuery?: (newQuery: Query) => void + query?: T, + setQuery?: SetQuery ): Array< TableHeadersProps > => { diff --git a/apps/hpc-ftsadmin/src/app/utils/useQueryParams.tsx b/apps/hpc-ftsadmin/src/app/utils/useQueryParams.tsx new file mode 100644 index 000000000..3300f5b35 --- /dev/null +++ b/apps/hpc-ftsadmin/src/app/utils/useQueryParams.tsx @@ -0,0 +1,66 @@ +import t from 'io-ts'; +import { useSearchParams } from 'react-router'; +import { isRight } from 'fp-ts/lib/Either'; + +type Props = { + codec: t.Type; + initialValues: T; +}; + +function toURLSearchParams(params: T): URLSearchParams { + const searchParams = new URLSearchParams(); + for (const key in params) { + if (params[key] !== undefined) { + searchParams.set(key, String(params[key])); + } + } + return searchParams; +} + +function useQueryParams>({ + codec, + initialValues, +}: Props) { + const stringifiedInitialValues: Record = {}; + for (const key in initialValues) { + stringifiedInitialValues[key] = String(initialValues[key]); + } + + const [searchParams, setSearchParams] = useSearchParams( + stringifiedInitialValues + ); + + const decodeParams = (params: URLSearchParams): T => { + const obj: Record = {}; + for (const [key, value] of params) { + if (value !== undefined) { + obj[key] = value; + } + } + + const result = codec.decode(obj); + + if (!isRight(result)) { + console.error('Invalid query params:', result); + console.warn('Reverting back to initial values...'); + return initialValues; + } + + return result.right; + }; + + const setValidatedSearchParams = (newParams: T) => { + const result = codec.decode(newParams); + + if (!isRight(result)) { + console.error('Invalid parameters to set:', result.left); + return; + } + const validatedParams = toURLSearchParams(newParams); + setSearchParams(validatedParams); + }; + + return [decodeParams(searchParams), setValidatedSearchParams] as const; +} + +export default useQueryParams; diff --git a/apps/hpc-ftsadmin/src/libs/useQueryParams.ts b/apps/hpc-ftsadmin/src/libs/useQueryParams.ts deleted file mode 100644 index adb640c50..000000000 --- a/apps/hpc-ftsadmin/src/libs/useQueryParams.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useContext } from 'react'; -import { - useNavigate, - useLocation, - UNSAFE_DataRouterContext, - UNSAFE_LocationContext, -} from 'react-router'; -import { type QueryParamAdapter, type PartialLocation } from 'use-query-params'; - -export const ReactRouter7Adapter = ({ - children, -}: { - children: (adapter: QueryParamAdapter) => React.ReactElement | null; -}) => { - // we need the navigator directly so we can access the current version - // of location in case of multiple updates within a render (e.g. #233) - // but we will limit our usage of it and have a backup to just use - // useLocation() output in case of some kind of breaking change we miss. - // see: https://github.com/remix-run/react-router/blob/f3d87dcc91fbd6fd646064b88b4be52c15114603/packages/react-router-dom/index.tsx#L113-L131 - const { location: unsafeLocation } = useContext(UNSAFE_LocationContext); - const navigate = useNavigate(); - const router = useContext(UNSAFE_DataRouterContext)?.router; - const location = useLocation(); - - const adapter: QueryParamAdapter = { - replace(location2: PartialLocation) { - navigate(location2.search || '?', { - replace: true, - state: location2.state, - }); - }, - push(location2: PartialLocation) { - navigate(location2.search || '?', { - replace: false, - state: location2.state, - }); - }, - get location() { - return router?.state?.location ?? unsafeLocation ?? location; - }, - }; - - return children(adapter); -}; diff --git a/apps/hpc-ftsadmin/src/main.tsx b/apps/hpc-ftsadmin/src/main.tsx index 14d0617e9..b30cbbfa1 100644 --- a/apps/hpc-ftsadmin/src/main.tsx +++ b/apps/hpc-ftsadmin/src/main.tsx @@ -13,8 +13,6 @@ import PageOrganizationsList from './app/pages/organizations/organization-list'; import PageOrganization from './app/pages/organizations/organization'; import { RouteParamsValidator } from './app/components/route-params-validator'; -import { QueryParamProvider } from 'use-query-params'; -import { ReactRouter7Adapter } from './libs/useQueryParams'; const rootElement = document.getElementById('root'); if (!rootElement) { @@ -25,11 +23,7 @@ const root = ReactDOM.createRoot(rootElement); const router = createBrowserRouter([ { path: paths.home(), - element: ( - - - - ), + element: , children: [ { path: paths.home(), element: }, { path: paths.flows(), element: }, diff --git a/libs/hpc-data/src/lib/flows.ts b/libs/hpc-data/src/lib/flows.ts index da9096b23..af6ad801a 100644 --- a/libs/hpc-data/src/lib/flows.ts +++ b/libs/hpc-data/src/lib/flows.ts @@ -223,8 +223,6 @@ export const SEARCH_FLOWS_RESULT = t.type({ searchFlows: t.type({ total: t.number, flows: FLOW_RESULT, - prevPageCursor: t.number, - nextPageCursor: t.number, hasNextPage: t.boolean, hasPreviousPage: t.boolean, pageSize: t.number, @@ -282,8 +280,7 @@ export type NestedFlowFilters = t.TypeOf; export const SEARCH_FLOWS_PARAMS = t.partial({ limit: t.number, - prevPageCursor: t.number, - nextPageCursor: t.number, + page: t.number, sortOrder: t.string, sortField: t.string, ...FLOW_FILTERS.props, diff --git a/libs/hpc-data/src/lib/util.ts b/libs/hpc-data/src/lib/util.ts index dfdf44f48..d2bfea88a 100644 --- a/libs/hpc-data/src/lib/util.ts +++ b/libs/hpc-data/src/lib/util.ts @@ -47,6 +47,31 @@ export const POSITIVE_INTEGER_FROM_STRING = new t.Type( t.identity ); +/** + * Accepts either an integer, or a string of an integer, that is contained in + * `integerOptions`. serializes to a number. + */ +export const validInteger = (integerOptions: readonly number[]) => + new t.Type( + 'VALID_INTEGER', + t.number.is, + (v, c) => { + if (typeof v === 'number') { + return Number.isInteger(v) && integerOptions.some((row) => row === v) + ? t.success(v) + : t.failure(v, c); + } else if (typeof v === 'string') { + return /^\d+$/.test(v) && + integerOptions.some((row) => row === parseInt(v)) + ? t.success(parseInt(v)) + : t.failure(v, c); + } else { + return t.failure(v, c); + } + }, + t.identity + ); + /** * Accepts either a number, or a string of a number, serializes to a number type. */ diff --git a/libs/hpc-live/src/lib/model.ts b/libs/hpc-live/src/lib/model.ts index 2a310c0cd..0d9fcab92 100644 --- a/libs/hpc-live/src/lib/model.ts +++ b/libs/hpc-live/src/lib/model.ts @@ -627,17 +627,9 @@ export class LiveModel implements Model { const query = `query { searchFlows${searchFlowsParams(params)} { total - - prevPageCursor - hasNextPage - - nextPageCursor - hasPreviousPage - pageSize - ${this.searchFlowFields} } }`; diff --git a/package-lock.json b/package-lock.json index 3b466d2eb..04cfe5733 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,8 +86,7 @@ "ts-jest": "29.1.0", "ts-node": "10.9.1", "twin.macro": "3.4.1", - "typescript": "5.2.2", - "use-query-params": "2.2.1" + "typescript": "5.2.2" }, "engines": { "node": ">=18.18.2 || >=20.9.0", @@ -5424,18 +5423,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@remix-run/router": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", - "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -17657,44 +17644,6 @@ } } }, - "node_modules/react-router-dom": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", - "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@remix-run/router": "1.21.0", - "react-router": "6.28.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/react-router-dom/node_modules/react-router": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", - "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@remix-run/router": "1.21.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/react-router/node_modules/cookie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", @@ -18348,13 +18297,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/serialize-query-params": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-2.0.2.tgz", - "integrity": "sha512-1chMo1dST4pFA9RDXAtF0Rbjaut4is7bzFbI1Z26IuMub68pNCILku85aYmeFhvnY//BXUPUhoRMjYcsT93J/Q==", - "dev": true, - "license": "ISC" - }, "node_modules/serve-index": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", @@ -20338,30 +20280,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, - "node_modules/use-query-params": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-2.2.1.tgz", - "integrity": "sha512-i6alcyLB8w9i3ZK3caNftdb+UnbfBRNPDnc89CNQWkGRmDrm/gfydHvMBfVsQJRq3NoHOM2dt/ceBWG2397v1Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "serialize-query-params": "^2.0.2" - }, - "peerDependencies": { - "@reach/router": "^1.2.1", - "react": ">=16.8.0", - "react-dom": ">=16.8.0", - "react-router-dom": ">=5" - }, - "peerDependenciesMeta": { - "@reach/router": { - "optional": true - }, - "react-router-dom": { - "optional": true - } - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index c01387e7c..69d6ea8ad 100644 --- a/package.json +++ b/package.json @@ -117,8 +117,7 @@ "ts-jest": "29.1.0", "ts-node": "10.9.1", "twin.macro": "3.4.1", - "typescript": "5.2.2", - "use-query-params": "2.2.1" + "typescript": "5.2.2" }, "babelMacros": { "twin": {