From 3970009dcd927056639a12045aefb25db57816b9 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 20:32:29 +0300 Subject: [PATCH 01/13] use common table for search reults --- src/components/ui/common-table.tsx | 4 ++ src/pages/SearchResults.tsx | 88 ++++++++++++++++++------------ 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/components/ui/common-table.tsx b/src/components/ui/common-table.tsx index 0061498..b9a942e 100644 --- a/src/components/ui/common-table.tsx +++ b/src/components/ui/common-table.tsx @@ -19,6 +19,7 @@ interface CommonTableProps { columnHeaderClassName?: string; cellClassName?: string; children?: React.ReactNode; + onRowClick?: (row: Record, rowIndex: number) => void; } export function CommonTable({ @@ -30,6 +31,7 @@ export function CommonTable({ columnHeaderClassName = "bg-gray-600 text-white", cellClassName = "text-gray-200", children, + onRowClick, }: CommonTableProps): ReactElement { function renderCell(value: CellPrimitive, column: Column): ReactElement { if (column.renderCell) { @@ -90,7 +92,9 @@ export function CommonTable({ key={rowIndex} className={classNames( "bg-gray-700 hover:bg-gray-800 transition-colors duration-150", + onRowClick && "cursor-pointer", )} + onClick={() => onRowClick?.(row, rowIndex)} > {columns.map((column) => { const cellValue = row[column.name]; diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 599b59a..314735d 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useState } from "react"; +import React, { ReactElement, useEffect, useState } from "react"; import { NavigateFunction, useNavigate, @@ -6,8 +6,7 @@ import { } from "react-router-dom"; import { SearchPGCObject, backendClient } from "../clients/backend"; import { SearchBar } from "../components/ui/searchbar"; -import { AladinViewer } from "../components/ui/aladin"; -import { Card, CardContent } from "../components/ui/card"; +import { CommonTable, Column } from "../components/ui/common-table"; import { Loading } from "../components/ui/loading"; import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; @@ -44,6 +43,39 @@ export function SearchResultsPage(): ReactElement { const page = parseInt(searchParams.get("page") || "1"); const pageSize = parseInt(searchParams.get("pagesize") || "10"); + const columns: Column[] = [ + { + name: "PGC", + renderCell: (value: React.ReactElement | string | number) => ( + + {value} + + ), + }, + { + name: "Name", + renderCell: (value: React.ReactElement | string | number) => ( + {value || "N/A"} + ), + }, + { + name: "RA (deg)", + renderCell: (value: React.ReactElement | string | number) => ( + + {typeof value === "number" ? value.toFixed(6) : value} + + ), + }, + { + name: "Dec (deg)", + renderCell: (value: React.ReactElement | string | number) => ( + + {typeof value === "number" ? value.toFixed(6) : value} + + ), + }, + ]; + useEffect(() => { async function fetchResults() { if (!query.trim()) { @@ -76,40 +108,26 @@ export function SearchResultsPage(): ReactElement { {loading ? ( ) : ( -
+
{results.length > 0 ? ( <> - {results.map((object) => ( -
- - objectClickHandler(navigate, object)} - > - -

PGC {object.pgc}

-
- -

- Name: {object.catalogs.designation.design} -

-
- -

- J2000: {object.catalogs.icrs.ra} deg,{" "} - {object.catalogs.icrs.dec} deg -

-
-
-
- ))} + ({ + PGC: object.pgc, + Name: object.catalogs.designation.design, + "RA (deg)": object.catalogs.icrs.ra, + "Dec (deg)": object.catalogs.icrs.dec, + }))} + className="w-full" + onRowClick={(row) => { + const pgc = row.PGC as number; + const object = results.find((obj) => obj.pgc === pgc); + if (object) { + objectClickHandler(navigate, object); + } + }} + />
-
+ ); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ea8a759..77073fd 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -45,11 +45,11 @@ export function HomePage(): ReactElement { const navigate = useNavigate(); return ( -
+ <>
{homePageHint}
-
+ ); } diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index e18cb12..d880835 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -37,10 +37,12 @@ function renderNotFound(navigate: NavigateFunction) { ); } -function renderObjectDetails( - object: PgcObject, - schema: Schema | null, -): ReactElement { +interface ObjectDetailsProps { + object: PgcObject; + schema: Schema | null; +} + +function ObjectDetails({ object, schema }: ObjectDetailsProps): ReactElement { if (!object || !schema) return
; return ( @@ -116,7 +118,7 @@ export function ObjectDetailsPage(): ReactElement { }, [pgcId, navigate]); return ( -
+ <> ) : object ? ( - renderObjectDetails(object, schema) + ) : ( renderNotFound(navigate) )} -
+ ); } diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index a25d59a..7d3dab8 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -225,7 +225,7 @@ export function RecordCrossmatchDetailsPage(): ReactElement { }, [recordId, navigate]); return ( -
+ <> {loading ? ( ) : data ? ( @@ -233,6 +233,6 @@ export function RecordCrossmatchDetailsPage(): ReactElement { ) : ( renderNotFound(navigate) )} -
+ ); } diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 314735d..204eb8f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -98,7 +98,7 @@ export function SearchResultsPage(): ReactElement { }, [query, navigate, pageSize, page]); return ( -
+ <> )} -
+ ); } diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 4343f2f..c0b1792 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -321,7 +321,7 @@ export function TableDetailsPage(): ReactElement { }, [tableName, navigate]); return ( -
+ <> {loading ? ( ) : table ? ( @@ -340,6 +340,6 @@ export function TableDetailsPage(): ReactElement { ) : ( renderNotFound(navigate) )} -
+ ); } From 2c2d0fe94d4b2ae662182477a7c55edb5985e978 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 21:05:31 +0300 Subject: [PATCH 03/13] simplify search bar & object details loading & remove back buttons --- src/components/ui/error-page.tsx | 4 +-- src/components/ui/searchbar.tsx | 51 +++++++++++++++-------------- src/pages/ObjectDetails.tsx | 55 ++++++++------------------------ 3 files changed, 42 insertions(+), 68 deletions(-) diff --git a/src/components/ui/error-page.tsx b/src/components/ui/error-page.tsx index bf57a8a..21c1aae 100644 --- a/src/components/ui/error-page.tsx +++ b/src/components/ui/error-page.tsx @@ -3,7 +3,7 @@ import { Button } from "./button"; interface ErrorPageProps { title: string; - message: string; + message?: string; children?: ReactNode; className?: string; showLargeText?: boolean; @@ -24,7 +24,7 @@ export function ErrorPage({
{showLargeText &&

404

}

{title}

-

{message}

+ {message &&

{message}

}
{children && ( diff --git a/src/components/ui/searchbar.tsx b/src/components/ui/searchbar.tsx index 8e15eb4..12876f5 100644 --- a/src/components/ui/searchbar.tsx +++ b/src/components/ui/searchbar.tsx @@ -1,29 +1,34 @@ import { ReactElement, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, NavigateFunction, useNavigate } from "react-router-dom"; import classNames from "classnames"; import { Button } from "./button"; interface SearchBarProps { initialValue?: string; - onSearch: (query: string) => void; + onSearch?: (query: string) => void; className?: string; logoSize?: "small" | "large"; - showLogo?: boolean; } -export function SearchBar(props: SearchBarProps): ReactElement { - const { - initialValue = "", - logoSize = "large", - showLogo = true, - onSearch, - className, - } = props; +function searchHandler(navigate: NavigateFunction) { + return function f(query: string) { + navigate(`/query?q=${encodeURIComponent(query)}`); + }; +} + +export function SearchBar({ + initialValue = "", + logoSize = "small", + onSearch, + className, +}: SearchBarProps): ReactElement { const [searchQuery, setSearchQuery] = useState(initialValue); + const navigate = useNavigate(); + const onSearchHandler = onSearch ?? searchHandler(navigate); function handleSubmit() { if (searchQuery.trim()) { - onSearch(searchQuery); + onSearchHandler(searchQuery); } } @@ -35,18 +40,16 @@ export function SearchBar(props: SearchBarProps): ReactElement { className, )} > - {showLogo && ( - - HyperLeda Logo - - )} + + HyperLeda Logo +
- - navigate("/")} /> - - ); -} - interface ObjectDetailsProps { object: PgcObject; schema: Schema | null; @@ -80,12 +52,12 @@ export function ObjectDetailsPage(): ReactElement { const [object, setObject] = useState(null); const [schema, setSchema] = useState(null); const [loading, setLoading] = useState(true); - const navigate = useNavigate(); + const [error, setError] = useState(null); useEffect(() => { async function fetchObjectDetails() { if (!pgcId || isNaN(Number(pgcId))) { - navigate("/"); + setError("Invalid PGC number"); return; } @@ -104,33 +76,32 @@ export function ObjectDetailsPage(): ReactElement { const objectData = objects[0]; setObject(objectData); setSchema(schema || null); - } else { - console.error("Object not found"); } } catch (error) { - console.error("Error fetching object:", error); + setError(`Error fetching object: ${error}`); } finally { setLoading(false); } } fetchObjectDetails(); - }, [pgcId, navigate]); + }, [pgcId]); return ( <> - + {loading ? ( + ) : error ? ( + ) : object ? ( ) : ( - renderNotFound(navigate) + )} ); From fd32cd1e359c2cc9603ef4d85f82cfb9817dea80 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 21:07:24 +0300 Subject: [PATCH 04/13] early returns --- src/pages/ObjectDetails.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 1d150cc..67f5100 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -87,22 +87,23 @@ export function ObjectDetailsPage(): ReactElement { fetchObjectDetails(); }, [pgcId]); + function renderContent(): ReactElement { + if (loading) return ; + if (error) return ; + if (object) return ; + + return ( + + ); + } + return ( <> - - {loading ? ( - - ) : error ? ( - - ) : object ? ( - - ) : ( - - )} + {renderContent()} ); } From bb233d55526a53d26d95f5714661c7e297630cb7 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 21:20:43 +0300 Subject: [PATCH 05/13] remove card & early returns --- src/components/ui/card.tsx | 38 ------ src/pages/CrossmatchResults.tsx | 188 +++++++++++++------------- src/pages/ObjectDetails.tsx | 2 +- src/pages/RecordCrossmatchDetails.tsx | 20 +-- src/pages/SearchResults.tsx | 117 ++++++++-------- src/pages/TableDetails.tsx | 27 ++-- 6 files changed, 176 insertions(+), 216 deletions(-) delete mode 100644 src/components/ui/card.tsx diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx deleted file mode 100644 index dbd4068..0000000 --- a/src/components/ui/card.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { ReactElement } from "react"; -import classNames from "classnames"; - -interface CardProps { - children: React.ReactNode; - className?: string; - onClick?: () => void; - title?: string; -} - -export function Card(props: CardProps): ReactElement { - return ( -
- {props.title && ( -

{props.title}

- )} - {props.children} -
- ); -} - -interface CardContentProps { - children: React.ReactNode; - className?: string; -} - -export function CardContent(props: CardContentProps): ReactElement { - return ( -
{props.children}
- ); -} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index e2c1a2f..e008f34 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -187,106 +187,108 @@ export function CrossmatchResultsPage(): ReactElement { Candidates: index, })) || []; - if (loading) { - return ; - } + function renderContent(): ReactElement { + if (loading) return ; - if (error) { - return ( - err.msg).join(", ") || - "An error occurred" - } - className="p-8" - > - navigate("/")} /> - - ); - } + if (error) { + return ( + err.msg).join(", ") || + "An error occurred" + } + className="p-8" + > + navigate("/")} /> + + ); + } - if (!tableName) { - return ( - - navigate("/")} /> - - ); - } + if (!tableName) { + return ( + + navigate("/")} /> + + ); + } - return ( - <> -
-

Crossmatch results

- -
- - - - setLocalPageSize(parseInt(value))} - /> -
- + return ( + <> +
+

Crossmatch results

+ +
+ + + + setLocalPageSize(parseInt(value))} + /> +
+ +
-
- -
-

Crossmatch records

-
- Showing {tableData.length} records + +
+

Crossmatch records

+
+ Showing {tableData.length} records +
+
+ +
+ + Page {page + 1} +
- + + ); + } -
- - Page {page + 1} - -
- - ); + return <>{renderContent()}; } diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 67f5100..177deef 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -57,7 +57,7 @@ export function ObjectDetailsPage(): ReactElement { useEffect(() => { async function fetchObjectDetails() { if (!pgcId || isNaN(Number(pgcId))) { - setError("Invalid PGC number"); + setError(`Invalid PGC number ${pgcId}`); return; } diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index 7d3dab8..0408fb4 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -211,8 +211,6 @@ export function RecordCrossmatchDetailsPage(): ReactElement { if (response.data?.data) { setData(response.data.data); - } else { - console.error("Crossmatch record not found"); } } catch (error) { console.error("Error fetching crossmatch details:", error); @@ -224,15 +222,11 @@ export function RecordCrossmatchDetailsPage(): ReactElement { fetchCrossmatchDetails(); }, [recordId, navigate]); - return ( - <> - {loading ? ( - - ) : data ? ( - renderCrossmatchDetails(data) - ) : ( - renderNotFound(navigate) - )} - - ); + function renderContent(): ReactElement { + if (loading) return ; + if (data) return renderCrossmatchDetails(data); + return renderNotFound(navigate); + } + + return <>{renderContent()}; } diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 204eb8f..a8bd415 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -97,6 +97,65 @@ export function SearchResultsPage(): ReactElement { fetchResults(); }, [query, navigate, pageSize, page]); + function renderContent(): ReactElement { + if (loading) return ; + + if (results.length > 0) { + return ( +
+ ({ + PGC: object.pgc, + Name: object.catalogs.designation.design, + "RA (deg)": object.catalogs.icrs.ra, + "Dec (deg)": object.catalogs.icrs.dec, + }))} + className="w-full" + onRowClick={(row) => { + const pgc = row.PGC as number; + const object = results.find((obj) => obj.pgc === pgc); + if (object) { + objectClickHandler(navigate, object); + } + }} + /> +
+ + Page {page} + +
+
+ ); + } + + return ( + + navigate("/")} /> + + ); + } + return ( <> - - {loading ? ( - - ) : ( -
- {results.length > 0 ? ( - <> - ({ - PGC: object.pgc, - Name: object.catalogs.designation.design, - "RA (deg)": object.catalogs.icrs.ra, - "Dec (deg)": object.catalogs.icrs.dec, - }))} - className="w-full" - onRowClick={(row) => { - const pgc = row.PGC as number; - const object = results.find((obj) => obj.pgc === pgc); - if (object) { - objectClickHandler(navigate, object); - } - }} - /> -
- - Page {page} - -
- - ) : ( - - navigate("/")} /> - - )} -
- )} + {renderContent()} ); } diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index c0b1792..bbb0397 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -320,12 +320,11 @@ export function TableDetailsPage(): ReactElement { fetchData(); }, [tableName, navigate]); - return ( - <> - {loading ? ( - - ) : table ? ( -
+ function renderContent(): ReactElement { + if (loading) return ; + if (table) { + return ( + <> -
- ) : error ? ( - renderError(error, navigate) - ) : ( - renderNotFound(navigate) - )} - - ); + + ); + } + if (error) return renderError(error, navigate); + return renderNotFound(navigate); + } + + return <>{renderContent()}; } From e90b300f18bc7a51f7965cec10318daca0762fdc Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 21:53:45 +0300 Subject: [PATCH 06/13] unify error handling --- src/App.tsx | 3 ++ src/components/ui/error-page.tsx | 8 +-- src/pages/ObjectDetails.tsx | 83 ++++++++++++++++---------------- src/pages/TableDetails.tsx | 83 ++++++++++++-------------------- 4 files changed, 78 insertions(+), 99 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 33b689b..ddef0ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { TableDetailsPage } from "./pages/TableDetails"; import { CrossmatchResultsPage } from "./pages/CrossmatchResults"; import { RecordCrossmatchDetailsPage } from "./pages/RecordCrossmatchDetails"; import { Layout } from "./components/ui/layout"; +import { SearchBar } from "./components/ui/searchbar"; function App() { return ( @@ -32,6 +33,7 @@ function App() { path="/object/:pgcId" element={ + } @@ -40,6 +42,7 @@ function App() { path="/table/:tableName" element={ + } diff --git a/src/components/ui/error-page.tsx b/src/components/ui/error-page.tsx index 21c1aae..25dd36a 100644 --- a/src/components/ui/error-page.tsx +++ b/src/components/ui/error-page.tsx @@ -2,15 +2,15 @@ import { ReactElement, ReactNode } from "react"; import { Button } from "./button"; interface ErrorPageProps { - title: string; - message?: string; + title?: string; + message: string; children?: ReactNode; className?: string; showLargeText?: boolean; } export function ErrorPage({ - title, + title = "Encountered error", message, children, className = "", @@ -24,7 +24,7 @@ export function ErrorPage({
{showLargeText &&

404

}

{title}

- {message &&

{message}

} +

{message}

{children && ( diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 177deef..3cf8c68 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -1,6 +1,5 @@ import { ReactElement, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import { SearchBar } from "../components/ui/searchbar"; import { AladinViewer } from "../components/ui/aladin"; import { Loading } from "../components/ui/loading"; import { ErrorPage } from "../components/ui/error-page"; @@ -47,63 +46,63 @@ function ObjectDetails({ object, schema }: ObjectDetailsProps): ReactElement { ); } +async function fetcher( + pgcId: string | undefined, +): Promise<[PgcObject, Schema]> { + if (!pgcId || isNaN(Number(pgcId))) { + throw new Error(`Invalid PGC number: ${pgcId}`); + } + + const response = await querySimpleApiV1QuerySimpleGet({ + query: { + pgcs: [Number(pgcId)], + }, + }); + + if (response.error || !response.data) { + throw new Error(`Error during query: ${response.error}`); + } + + const objects = response.data.data.objects; + const schema = response.data.data.schema; + + if (!objects || objects.length === 0) { + throw new Error(`Object ${pgcId} not found`); + } + + return [objects[0], schema]; +} + export function ObjectDetailsPage(): ReactElement { const { pgcId } = useParams<{ pgcId: string }>(); - const [object, setObject] = useState(null); - const [schema, setSchema] = useState(null); + const [payload, setPayload] = useState<[PgcObject, Schema] | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - async function fetchObjectDetails() { - if (!pgcId || isNaN(Number(pgcId))) { - setError(`Invalid PGC number ${pgcId}`); - return; - } - - setLoading(true); + async function fetch() { try { - const response = await querySimpleApiV1QuerySimpleGet({ - query: { - pgcs: [Number(pgcId)], - }, - }); - - const objects = response.data?.data.objects; - const schema = response.data?.data.schema; - - if (objects && objects.length > 0) { - const objectData = objects[0]; - setObject(objectData); - setSchema(schema || null); - } + setPayload(await fetcher(pgcId)); } catch (error) { - setError(`Error fetching object: ${error}`); + setError(`${error}`); } finally { setLoading(false); } } - fetchObjectDetails(); + fetch(); }, [pgcId]); - function renderContent(): ReactElement { + const [object, schema] = payload || [null, null]; + + function RenderContent(): ReactElement { if (loading) return ; - if (error) return ; - if (object) return ; - - return ( - - ); + if (error) return ; + if (object && schema) + return ; + + return ; } - return ( - <> - - {renderContent()} - - ); + return ; } diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index bbb0397..b1dc39a 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -2,7 +2,6 @@ import { ReactElement, useEffect, useState } from "react"; import { Bibliography, GetTableResponse, - HttpValidationError, RecordCrossmatchStatus, } from "../clients/admin/types.gen"; import { getTableAdminApiV1TableGet } from "../clients/admin/sdk.gen"; @@ -17,7 +16,7 @@ import { CopyButton } from "../components/ui/copy-button"; import { Badge } from "../components/ui/badge"; import { Link } from "../components/ui/link"; import { Loading } from "../components/ui/loading"; -import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; +import { ErrorPage } from "../components/ui/error-page"; import { getResource } from "../resources/resources"; function renderBibliography(bib: Bibliography): ReactElement { @@ -259,59 +258,36 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { ); } -function renderNotFound(navigate: (path: string) => void): ReactElement { - return ( - - navigate("/")} /> - - ); -} +async function fetcher( + tableName: string | undefined, +): Promise { + if (!tableName) { + throw new Error("No table name provided"); + } -function renderError( - error: HttpValidationError, - navigate: (path: string) => void, -): ReactElement { - return ( - - navigate("/")} /> - - ); + const response = await getTableAdminApiV1TableGet({ + query: { table_name: tableName }, + }); + if (response.error) { + throw new Error(JSON.stringify(response.error)); + } + + return response.data.data; } export function TableDetailsPage(): ReactElement { const { tableName } = useParams<{ tableName: string }>(); - const [table, setTable] = useState(null); - const [error, setError] = useState(null); + const [payload, setPayload] = useState(null); + const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const navigate = useNavigate(); useEffect(() => { async function fetchData() { - if (!tableName) { - navigate("/"); - return; - } - try { - const response = await getTableAdminApiV1TableGet({ - query: { table_name: tableName }, - }); - if (response.error) { - setError(response.error); - return; - } - - if (response.data) { - setTable(response.data.data); - } - } catch (err) { - console.log("Error fetching table", err); + setPayload(await fetcher(tableName)); + } catch (error) { + setError(`${error}`); } finally { setLoading(false); } @@ -320,25 +296,26 @@ export function TableDetailsPage(): ReactElement { fetchData(); }, [tableName, navigate]); - function renderContent(): ReactElement { + function RenderContent(): ReactElement { if (loading) return ; - if (table) { + if (error) return ; + if (payload) { return ( <> - - + + - + ); } - if (error) return renderError(error, navigate); - return renderNotFound(navigate); + + return ; } - return <>{renderContent()}; + return ; } From 83908674b75e4405d2b7b2b2f1e5f0004ac01ad6 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 22:01:35 +0300 Subject: [PATCH 07/13] common hook for loading and error messages --- src/hooks/useDataFetching.ts | 33 +++++++++++++++++++++++++++++++++ src/pages/ObjectDetails.tsx | 24 +++++++----------------- src/pages/TableDetails.tsx | 24 +++++++----------------- 3 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 src/hooks/useDataFetching.ts diff --git a/src/hooks/useDataFetching.ts b/src/hooks/useDataFetching.ts new file mode 100644 index 0000000..d4fe15f --- /dev/null +++ b/src/hooks/useDataFetching.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; + +interface UseDataFetchingResult { + data: T | null; + loading: boolean; + error: string | null; +} + +export function useDataFetching( + fetcher: () => Promise, + dependencies: React.DependencyList = [], +): UseDataFetchingResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData(): Promise { + try { + const result = await fetcher(); + setData(result); + } catch (err) { + setError(`${err}`); + } finally { + setLoading(false); + } + } + + fetchData(); + }, dependencies); + + return { data, loading, error }; +} diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 3cf8c68..5e520b0 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement } from "react"; import { useParams } from "react-router-dom"; import { AladinViewer } from "../components/ui/aladin"; import { Loading } from "../components/ui/loading"; @@ -7,6 +7,7 @@ import { CatalogData } from "../components/ui/catalog-data"; import { Link } from "../components/ui/link"; import { querySimpleApiV1QuerySimpleGet } from "../clients/backend/sdk.gen"; import { PgcObject, Schema } from "../clients/backend/types.gen"; +import { useDataFetching } from "../hooks/useDataFetching"; interface ObjectDetailsProps { object: PgcObject; @@ -75,23 +76,12 @@ async function fetcher( export function ObjectDetailsPage(): ReactElement { const { pgcId } = useParams<{ pgcId: string }>(); - const [payload, setPayload] = useState<[PgcObject, Schema] | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - async function fetch() { - try { - setPayload(await fetcher(pgcId)); - } catch (error) { - setError(`${error}`); - } finally { - setLoading(false); - } - } - - fetch(); - }, [pgcId]); + const { + data: payload, + loading, + error, + } = useDataFetching(() => fetcher(pgcId), [pgcId]); const [object, schema] = payload || [null, null]; diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index b1dc39a..96391cc 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement } from "react"; import { Bibliography, GetTableResponse, @@ -18,6 +18,7 @@ import { Link } from "../components/ui/link"; import { Loading } from "../components/ui/loading"; import { ErrorPage } from "../components/ui/error-page"; import { getResource } from "../resources/resources"; +import { useDataFetching } from "../hooks/useDataFetching"; function renderBibliography(bib: Bibliography): ReactElement { let authors = ""; @@ -277,24 +278,13 @@ async function fetcher( export function TableDetailsPage(): ReactElement { const { tableName } = useParams<{ tableName: string }>(); - const [payload, setPayload] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); const navigate = useNavigate(); - useEffect(() => { - async function fetchData() { - try { - setPayload(await fetcher(tableName)); - } catch (error) { - setError(`${error}`); - } finally { - setLoading(false); - } - } - - fetchData(); - }, [tableName, navigate]); + const { + data: payload, + loading, + error, + } = useDataFetching(() => fetcher(tableName), [tableName]); function RenderContent(): ReactElement { if (loading) return ; From ae2792b46b343027d1e53131ec6b862dfb2347cf Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 22:28:36 +0300 Subject: [PATCH 08/13] refactor crossmatch results page --- src/components/ui/dropdown-filter.tsx | 1 - src/components/ui/text-filter.tsx | 3 - src/pages/CrossmatchResults.tsx | 405 ++++++++++++++------------ src/pages/ObjectDetails.tsx | 4 +- src/pages/TableDetails.tsx | 4 +- 5 files changed, 226 insertions(+), 191 deletions(-) diff --git a/src/components/ui/dropdown-filter.tsx b/src/components/ui/dropdown-filter.tsx index 52e48ee..f651c82 100644 --- a/src/components/ui/dropdown-filter.tsx +++ b/src/components/ui/dropdown-filter.tsx @@ -8,7 +8,6 @@ interface DropdownFilterOption { interface DropdownFilterProps { title: string; options: DropdownFilterOption[]; - defaultValue: string; value: string; onChange: (value: string) => void; } diff --git a/src/components/ui/text-filter.tsx b/src/components/ui/text-filter.tsx index c5e7adb..fa124a4 100644 --- a/src/components/ui/text-filter.tsx +++ b/src/components/ui/text-filter.tsx @@ -5,7 +5,6 @@ interface TextFieldProps { value: string; onChange: (value: string) => void; placeholder?: string; - type?: "text" | "email" | "password" | "number"; onEnter?: () => void; } @@ -14,7 +13,6 @@ export function TextFilter({ value, onChange, placeholder, - type = "text", onEnter, }: TextFieldProps): ReactElement { return ( @@ -23,7 +21,6 @@ export function TextFilter({ {title} onChange(e.target.value)} onKeyDown={(e) => { diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index e008f34..76b9c31 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -13,7 +13,6 @@ import type { GetRecordsCrossmatchResponse, RecordCrossmatch, RecordCrossmatchStatus, - HttpValidationError, ValidationError, } from "../clients/admin/types.gen"; import { getResource } from "../resources/resources"; @@ -21,20 +20,21 @@ import { Button } from "../components/ui/button"; import { Loading } from "../components/ui/loading"; import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; import { Link } from "../components/ui/link"; +import { useDataFetching } from "../hooks/useDataFetching"; -export function CrossmatchResultsPage(): ReactElement { - const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); - - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - - const tableName = searchParams.get("table_name"); - const status = searchParams.get("status") as RecordCrossmatchStatus | null; - const page = parseInt(searchParams.get("page") || "0"); - const pageSize = parseInt(searchParams.get("page_size") || "25"); +interface CrossmatchFiltersProps { + tableName: string | null; + status: RecordCrossmatchStatus | null; + pageSize: number; + onApplyFilters: (tableName: string, status: string, pageSize: number) => void; +} +function CrossmatchFilters({ + tableName, + status, + pageSize, + onApplyFilters, +}: CrossmatchFiltersProps): ReactElement { const [localStatus, setLocalStatus] = useState(status || "all"); const [localPageSize, setLocalPageSize] = useState(pageSize); const [localTableName, setLocalTableName] = useState(tableName || ""); @@ -45,79 +45,66 @@ export function CrossmatchResultsPage(): ReactElement { setLocalTableName(tableName || ""); }, [status, pageSize, tableName]); - useEffect(() => { - async function fetchData() { - if (!tableName) { - navigate("/"); - return; - } - - try { - setLoading(true); - const response = - await getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet({ - query: { - table_name: tableName, - status: status || undefined, - page: page, - page_size: pageSize, - }, - }); - - if (response.error) { - setError(response.error); - return; - } - - if (response.data) { - setData(response.data.data); - } - } catch (err) { - console.error("Error fetching crossmatch records", err); - setError({ - detail: [ - { - loc: [], - msg: "Failed to fetch crossmatch records", - type: "value_error", - }, - ], - }); - } finally { - setLoading(false); - } - } - - fetchData(); - }, [tableName, status, page, pageSize, navigate]); - - function handlePageChange(newPage: number): void { - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.set("page", newPage.toString()); - setSearchParams(newSearchParams); - } - function applyFilters(): void { - const newSearchParams = new URLSearchParams(searchParams); - - if (localTableName.trim()) { - newSearchParams.set("table_name", localTableName.trim()); - } else { - newSearchParams.delete("table_name"); - } - - if (localStatus === "all") { - newSearchParams.delete("status"); - } else { - newSearchParams.set("status", localStatus); - } + onApplyFilters(localTableName, localStatus, localPageSize); + } - newSearchParams.set("page_size", localPageSize.toString()); - newSearchParams.set("page", "0"); + return ( +
+ + + + setLocalPageSize(parseInt(value))} + /> +
+ +
+
+ ); +} - setSearchParams(newSearchParams); - } +interface CrossmatchResultsProps { + data: GetRecordsCrossmatchResponse; + tableName: string; + status: RecordCrossmatchStatus | null; + page: number; + pageSize: number; + onPageChange: (newPage: number) => void; + onApplyFilters: (tableName: string, status: string, pageSize: number) => void; +} +function CrossmatchResults({ + data, + page, + pageSize, + onPageChange, +}: CrossmatchResultsProps): ReactElement { function getRecordName(record: RecordCrossmatch): ReactElement { const displayName = record.catalogs.designation?.name || record.record_id; return ( @@ -128,30 +115,23 @@ export function CrossmatchResultsPage(): ReactElement { } function renderCandidates(record: RecordCrossmatch): ReactElement { - if (record.status === "new") { - return NULL; - } + let pgcNumbers: number[] = []; if (record.status === "existing" && record.metadata.pgc) { - const pgcText = `${record.metadata.pgc}`; - return {pgcText}; - } - - if (record.status === "collided" && record.metadata.possible_matches) { - const pgcNumbers = record.metadata.possible_matches; - - return ( -
- {pgcNumbers.map((pgc: number, index: number) => ( - - {pgc} - - ))} -
- ); + pgcNumbers = [record.metadata.pgc]; + } else if (record.status === "collided") { + pgcNumbers = record.metadata.possible_matches ?? []; } - return NULL; + return ( + <> + {pgcNumbers.map((pgc, index) => ( + + {pgc} + + ))} + + ); } function getStatusLabel(status: RecordCrossmatchStatus): string { @@ -160,7 +140,7 @@ export function CrossmatchResultsPage(): ReactElement { const columns: Column[] = [ { - name: "Record Name", + name: "Record name", renderCell: (recordIndex: CellPrimitive) => { if (typeof recordIndex === "number" && data?.records[recordIndex]) { return getRecordName(data.records[recordIndex]); @@ -182,24 +162,123 @@ export function CrossmatchResultsPage(): ReactElement { const tableData: Record[] = data?.records.map((record: RecordCrossmatch, index: number) => ({ - "Record Name": index, + "Record name": index, Status: getStatusLabel(record.status), Candidates: index, })) || []; - function renderContent(): ReactElement { + return ( + <> + +
+

Crossmatch records

+
+ Showing {tableData.length} records +
+
+
+ +
+ + Page {page + 1} + +
+ + ); +} + +async function fetcher( + tableName: string | null, + status: RecordCrossmatchStatus | null, + page: number, + pageSize: number, +): Promise { + if (!tableName) { + throw new Error("Table name is required"); + } + + const response = await getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet({ + query: { + table_name: tableName, + status: status, + page: page, + page_size: pageSize, + }, + }); + + if (response.error) { + throw new Error( + response.error.detail + ?.map((err: ValidationError) => err.msg) + .join(", ") || "Failed to fetch crossmatch records", + ); + } + + if (!response.data) { + throw new Error("No data received from server"); + } + + return response.data.data; +} + +export function CrossmatchResultsPage(): ReactElement { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const tableName = searchParams.get("table_name"); + const status = searchParams.get("status") as RecordCrossmatchStatus | null; + const page = parseInt(searchParams.get("page") || "0"); + const pageSize = parseInt(searchParams.get("page_size") || "25"); + + const { data, loading, error } = useDataFetching( + () => fetcher(tableName, status, page, pageSize), + [tableName, status, page, pageSize], + ); + + function handlePageChange(newPage: number): void { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("page", newPage.toString()); + setSearchParams(newSearchParams); + } + + function handleApplyFilters( + newTableName: string, + newStatus: string, + newPageSize: number, + ): void { + const newSearchParams = new URLSearchParams(searchParams); + + if (newTableName.trim()) { + newSearchParams.set("table_name", newTableName.trim()); + } else { + newSearchParams.delete("table_name"); + } + + if (newStatus === "all") { + newSearchParams.delete("status"); + } else { + newSearchParams.set("status", newStatus); + } + + newSearchParams.set("page_size", newPageSize.toString()); + newSearchParams.set("page", "0"); + + setSearchParams(newSearchParams); + } + + function Content(): ReactElement { if (loading) return ; if (error) { return ( - err.msg).join(", ") || - "An error occurred" - } - className="p-8" - > + navigate("/")} /> ); @@ -210,6 +289,15 @@ export function CrossmatchResultsPage(): ReactElement { + ); + } + + if (!data) { + return ( + navigate("/")} /> @@ -218,77 +306,28 @@ export function CrossmatchResultsPage(): ReactElement { } return ( - <> -
-

Crossmatch results

- -
- - - - setLocalPageSize(parseInt(value))} - /> -
- -
-
-
- - -
-

Crossmatch records

-
- Showing {tableData.length} records -
-
-
- -
- - Page {page + 1} - -
- + ); } - return <>{renderContent()}; + return ( + <> +

Crossmatch results

+ + + + ); } diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index 5e520b0..1d767f8 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -85,7 +85,7 @@ export function ObjectDetailsPage(): ReactElement { const [object, schema] = payload || [null, null]; - function RenderContent(): ReactElement { + function Content(): ReactElement { if (loading) return ; if (error) return ; if (object && schema) @@ -94,5 +94,5 @@ export function ObjectDetailsPage(): ReactElement { return ; } - return ; + return ; } diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 96391cc..4133fed 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -286,7 +286,7 @@ export function TableDetailsPage(): ReactElement { error, } = useDataFetching(() => fetcher(tableName), [tableName]); - function RenderContent(): ReactElement { + function Content(): ReactElement { if (loading) return ; if (error) return ; if (payload) { @@ -307,5 +307,5 @@ export function TableDetailsPage(): ReactElement { return ; } - return ; + return ; } From ec6af10dc6eba61fdef7e0f3c3970b44d56af6b6 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 22:44:03 +0300 Subject: [PATCH 09/13] some more crossmatch results page refactoring --- src/pages/CrossmatchResults.tsx | 105 ++++++++------------------------ 1 file changed, 26 insertions(+), 79 deletions(-) diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 76b9c31..6e3a31a 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -1,5 +1,5 @@ import { ReactElement, useEffect, useState } from "react"; -import { useSearchParams, useNavigate } from "react-router-dom"; +import { useSearchParams } from "react-router-dom"; import { CommonTable, Column, @@ -18,7 +18,7 @@ import type { import { getResource } from "../resources/resources"; import { Button } from "../components/ui/button"; import { Loading } from "../components/ui/loading"; -import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; +import { ErrorPage } from "../components/ui/error-page"; import { Link } from "../components/ui/link"; import { useDataFetching } from "../hooks/useDataFetching"; @@ -90,21 +90,10 @@ function CrossmatchFilters({ } interface CrossmatchResultsProps { - data: GetRecordsCrossmatchResponse; - tableName: string; - status: RecordCrossmatchStatus | null; - page: number; - pageSize: number; - onPageChange: (newPage: number) => void; - onApplyFilters: (tableName: string, status: string, pageSize: number) => void; + data: GetRecordsCrossmatchResponse | null; } -function CrossmatchResults({ - data, - page, - pageSize, - onPageChange, -}: CrossmatchResultsProps): ReactElement { +function CrossmatchResults({ data }: CrossmatchResultsProps): ReactElement { function getRecordName(record: RecordCrossmatch): ReactElement { const displayName = record.catalogs.designation?.name || record.record_id; return ( @@ -167,31 +156,7 @@ function CrossmatchResults({ Candidates: index, })) || []; - return ( - <> - -
-

Crossmatch records

-
- Showing {tableData.length} records -
-
-
- -
- - Page {page + 1} - -
- - ); + return ; } async function fetcher( @@ -230,7 +195,6 @@ async function fetcher( export function CrossmatchResultsPage(): ReactElement { const [searchParams, setSearchParams] = useSearchParams(); - const navigate = useNavigate(); const tableName = searchParams.get("table_name"); const status = searchParams.get("status") as RecordCrossmatchStatus | null; @@ -275,46 +239,29 @@ export function CrossmatchResultsPage(): ReactElement { function Content(): ReactElement { if (loading) return ; - - if (error) { - return ( - - navigate("/")} /> - - ); - } - - if (!tableName) { - return ( - - ); - } - - if (!data) { - return ( - - navigate("/")} /> - - ); - } + if (error) return ; return ( - + <> + +
+ + + Page {page + 1} (showing {data?.records.length} records) + + +
+ ); } From 968bbb025c5f9656f31a21aa61f7dcca0ad6a5d8 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 22:48:53 +0300 Subject: [PATCH 10/13] home page refactor --- src/App.tsx | 1 + src/pages/Home.tsx | 11 ----------- src/pages/NotFound.tsx | 7 +++++-- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ddef0ab..67680db 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ function App() { path="/" element={ + } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 77073fd..d251bd7 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,5 +1,3 @@ -import { NavigateFunction, useNavigate } from "react-router-dom"; -import { SearchBar } from "../components/ui/searchbar"; import { ReactElement } from "react"; import { Link } from "../components/ui/link"; @@ -35,18 +33,9 @@ const homePageHint: ReactElement = (
); -function searchHandler(navigate: NavigateFunction) { - return function f(query: string) { - navigate(`/query?q=${encodeURIComponent(query)}`); - }; -} - export function HomePage(): ReactElement { - const navigate = useNavigate(); - return ( <> -
{homePageHint}
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 742fb95..aeedae1 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,5 +1,6 @@ import { useNavigate } from "react-router-dom"; -import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; +import { ErrorPage } from "../components/ui/error-page"; +import { Button } from "../components/ui/button"; export function NotFoundPage() { const navigate = useNavigate(); @@ -10,7 +11,9 @@ export function NotFoundPage() { message="The page you're looking for doesn't exist or has been moved." showLargeText={true} > - navigate("/")} /> + ); } From 58fccc10aa934775de4e744ee849975b8496f8e4 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 22:55:48 +0300 Subject: [PATCH 11/13] refactor record crossmatch details page --- src/App.tsx | 2 + src/pages/RecordCrossmatchDetails.tsx | 96 ++++++++++++--------------- 2 files changed, 45 insertions(+), 53 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 67680db..b7ec2d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -60,6 +60,7 @@ function App() { path="/records/:recordId/crossmatch" element={ + } @@ -68,6 +69,7 @@ function App() { path="*" element={ + } diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index 0408fb4..cb5ae6d 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -1,8 +1,8 @@ -import { ReactElement, useEffect, useState } from "react"; -import { NavigateFunction, useNavigate, useParams } from "react-router-dom"; +import { ReactElement } from "react"; +import { useParams } from "react-router-dom"; import { AladinViewer } from "../components/ui/aladin"; import { Loading } from "../components/ui/loading"; -import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; +import { ErrorPage } from "../components/ui/error-page"; import { CatalogData } from "../components/ui/catalog-data"; import { getRecordCrossmatchAdminApiV1RecordCrossmatchGet } from "../clients/admin/sdk.gen"; import { @@ -14,17 +14,7 @@ import { import { Schema as BackendSchema } from "../clients/backend/types.gen"; import { getResource } from "../resources/resources"; import { Link } from "../components/ui/link"; - -function renderNotFound(navigate: NavigateFunction) { - return ( - - navigate("/")} /> - - ); -} +import { useDataFetching } from "../hooks/useDataFetching"; // TODO: remove when admin api uses the same structures as data api function convertAdminSchemaToBackendSchema( @@ -124,9 +114,13 @@ function convertCandidatesToAdditionalSources( : candidateSources; } -function renderCrossmatchDetails( - data: GetRecordCrossmatchResponse, -): ReactElement { +interface RecordCrossmatchDetailsProps { + data: GetRecordCrossmatchResponse; +} + +function RecordCrossmatchDetails({ + data, +}: RecordCrossmatchDetailsProps): ReactElement { const { crossmatch, candidates, schema } = data; const recordCatalogs = crossmatch.catalogs; const backendSchema = convertAdminSchemaToBackendSchema(schema); @@ -186,47 +180,43 @@ function renderCrossmatchDetails( ); } +async function fetcher( + recordId: string | undefined, +): Promise { + if (!recordId) { + throw new Error("Record ID is required"); + } + + const response = await getRecordCrossmatchAdminApiV1RecordCrossmatchGet({ + query: { + record_id: recordId, + }, + }); + + if (response.error || !response.data?.data) { + throw new Error( + `Error fetching crossmatch details: ${response.error || "Unknown error"}`, + ); + } + + return response.data.data; +} + export function RecordCrossmatchDetailsPage(): ReactElement { const { recordId } = useParams<{ recordId: string }>(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const navigate = useNavigate(); - - useEffect(() => { - async function fetchCrossmatchDetails() { - if (!recordId) { - navigate("/"); - return; - } - setLoading(true); - try { - const response = await getRecordCrossmatchAdminApiV1RecordCrossmatchGet( - { - query: { - record_id: recordId, - }, - }, - ); - - if (response.data?.data) { - setData(response.data.data); - } - } catch (error) { - console.error("Error fetching crossmatch details:", error); - } finally { - setLoading(false); - } - } - - fetchCrossmatchDetails(); - }, [recordId, navigate]); + const { data, loading, error } = useDataFetching( + () => fetcher(recordId), + [recordId], + ); - function renderContent(): ReactElement { + function Content(): ReactElement { if (loading) return ; - if (data) return renderCrossmatchDetails(data); - return renderNotFound(navigate); + if (error) return ; + if (data) return ; + + return ; } - return <>{renderContent()}; + return ; } From d164ba7fc7d792274777c5ecf426870e2c148e81 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 23:08:12 +0300 Subject: [PATCH 12/13] refactor SearchResults page & remove old backend client --- src/clients/backend.tsx | 212 ----------------------------------- src/pages/SearchResults.tsx | 214 +++++++++++++++++++++--------------- 2 files changed, 125 insertions(+), 301 deletions(-) delete mode 100644 src/clients/backend.tsx diff --git a/src/clients/backend.tsx b/src/clients/backend.tsx deleted file mode 100644 index f0b1569..0000000 --- a/src/clients/backend.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import axios from "axios"; - -export const API_BASE_URL = "http://dm2.sao.ru:81"; - -export interface SearchPGCObject { - pgc: number; - catalogs: SearchCatalogs; -} - -export interface SearchCatalogs { - designation: SearchDesignation; - icrs: EquatorialCoordinates; - redshift: Redshift; -} - -export interface SearchDesignation { - design: string; -} - -export interface PGCObject { - pgc: number; - catalogs: Catalogs; -} - -export interface Catalogs { - designation: Designation; - coordinates: Coordinates; - velocity: Velocity; -} - -export interface Designation { - name: string; -} - -export interface Coordinates { - equatorial: EquatorialCoordinates; - galactic: GalacticCoordinates; -} - -export interface EquatorialCoordinates { - ra: number; - dec: number; - e_ra: number; - e_dec: number; -} - -export interface GalacticCoordinates { - lon: number; - lat: number; - e_lon: number; - e_lat: number; -} - -export interface Velocity { - heliocentric: HeliocentricVelocity; - redshift: Redshift; -} - -export interface HeliocentricVelocity { - v: number; - e_v: number; -} - -export interface Redshift { - z: number; - e_z: number; -} - -export interface QueryParams { - page?: number; - pageSize?: number; -} - -export interface QueryResponse { - objects: SearchPGCObject[]; -} - -export interface QuerySimpleResponse { - objects: PGCObject[]; - schema: Schema; -} - -export interface Schema { - units: Units; -} - -export interface Units { - coordinates: CoordinateUnits; - velocity: VelocityUnits; -} - -export interface CoordinateUnits { - equatorial: EquatorialUnits; - galactic: GalacticUnits; -} - -export interface EquatorialUnits { - ra: string; - dec: string; - e_ra: string; - e_dec: string; -} - -export interface GalacticUnits { - lon: string; - lat: string; - e_lon: string; - e_lat: string; -} - -export interface VelocityUnits { - heliocentric: HeliocentricVelocityUnits; -} - -export interface HeliocentricVelocityUnits { - v: string; - e_v: string; -} - -export interface APIResponse { - data: T; -} - -export class HyperLEDAClient { - private static instance: HyperLEDAClient; - private axiosInstance = axios.create({ - baseURL: API_BASE_URL, - paramsSerializer: { - indexes: null, - }, - }); - - private constructor() {} - - public static getInstance(): HyperLEDAClient { - if (!HyperLEDAClient.instance) { - HyperLEDAClient.instance = new HyperLEDAClient(); - } - return HyperLEDAClient.instance; - } - - public async query( - queryString: string, - page: number = 0, - pageSize: number = 25, - ): Promise { - try { - const response = await this.axiosInstance.get>( - "/api/v1/query", - { - params: { - q: queryString, - page: page, - page_size: pageSize, - }, - }, - ); - return { - objects: response.data.data.objects || [], - }; - } catch (error) { - console.error("Error in query:", error); - throw error; - } - } - - public async querySimple(params: { - pgcs?: number[]; - ra?: number; - dec?: number; - radius?: number; - name?: string; - cz?: number; - cz_err_percent?: number; - page?: number; - page_size?: number; - }): Promise { - try { - const response = await this.axiosInstance.get< - APIResponse - >("/api/v1/query/simple", { params }); - return response.data.data; - } catch (error) { - console.error("Error in querySimple:", error); - throw error; - } - } - - public async queryByPGC( - pgcNumbers: number[], - page: number = 0, - pageSize: number = 25, - ): Promise { - try { - const response = await this.axiosInstance.get< - APIResponse - >("/api/v1/query/simple", { - params: { - pgcs: pgcNumbers, - page: page, - page_size: pageSize, - }, - }); - return response.data.data; - } catch (error) { - console.error("Error in queryByPGC:", error); - throw error; - } - } -} - -export const backendClient = HyperLEDAClient.getInstance(); diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index a8bd415..e157b2d 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,21 +1,16 @@ -import React, { ReactElement, useEffect, useState } from "react"; +import { ReactElement } from "react"; import { NavigateFunction, useNavigate, useSearchParams, } from "react-router-dom"; -import { SearchPGCObject, backendClient } from "../clients/backend"; import { SearchBar } from "../components/ui/searchbar"; import { CommonTable, Column } from "../components/ui/common-table"; import { Loading } from "../components/ui/loading"; import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; - -function objectClickHandler( - navigate: NavigateFunction, - object: SearchPGCObject, -) { - navigate(`/object/${object.pgc}`); -} +import { useDataFetching } from "../hooks/useDataFetching"; +import { querySimpleApiV1QuerySimpleGet } from "../clients/backend/sdk.gen"; +import { QuerySimpleResponse } from "../clients/backend/types.gen"; function searchHandler(navigate: NavigateFunction) { return function f(query: string) { @@ -34,15 +29,21 @@ function pageChangeHandler( ); } -export function SearchResultsPage(): ReactElement { - const [searchParams] = useSearchParams(); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(true); - const navigate = useNavigate(); - const query = searchParams.get("q") || ""; - const page = parseInt(searchParams.get("page") || "1"); - const pageSize = parseInt(searchParams.get("pagesize") || "10"); +interface SearchResultsProps { + results: QuerySimpleResponse; + query: string; + page: number; + pageSize: number; + navigate: NavigateFunction; +} +function SearchResults({ + results, + query, + page, + pageSize, + navigate, +}: SearchResultsProps): ReactElement { const columns: Column[] = [ { name: "PGC", @@ -76,84 +77,119 @@ export function SearchResultsPage(): ReactElement { }, ]; - useEffect(() => { - async function fetchResults() { - if (!query.trim()) { - navigate("/"); - return; - } - - setLoading(true); - try { - const response = await backendClient.query(query, page - 1, pageSize); - setResults(response.objects); - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - } + if (results.objects.length > 0) { + return ( +
+ ({ + PGC: object.pgc, + Name: object.catalogs.designation?.name || "N/A", + "RA (deg)": object.catalogs.coordinates?.equatorial.ra || 0, + "Dec (deg)": object.catalogs.coordinates?.equatorial.dec || 0, + }))} + className="w-full" + onRowClick={(row) => { + const pgc = row.PGC as number; + navigate(`/object/${pgc}`); + }} + /> +
+ + Page {page} + +
+
+ ); + } - fetchResults(); - }, [query, navigate, pageSize, page]); + return ( + + navigate("/")} /> + + ); +} - function renderContent(): ReactElement { - if (loading) return ; +async function fetcher( + query: string, + page: number, + pageSize: number, +): Promise { + if (!query.trim()) { + throw new Error("Empty query"); + } + + const response = await querySimpleApiV1QuerySimpleGet({ + query: { + name: query, + page: page, + page_size: pageSize, + }, + }); + + if (response.data?.data.objects.length === 0) { + throw new Error(`No objects found for query ${query}`); + } + + if (response.error || !response.data) { + throw new Error(`Error during query: ${response.error}`); + } + + return response.data.data; +} + +export function SearchResultsPage(): ReactElement { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const query = searchParams.get("q") || ""; + const page = parseInt(searchParams.get("page") || "1"); + const pageSize = parseInt(searchParams.get("pagesize") || "10"); - if (results.length > 0) { + const { + data: results, + loading, + error, + } = useDataFetching( + () => fetcher(query, page, pageSize), + [query, page, pageSize], + ); + + function Content(): ReactElement { + if (loading) return ; + if (error) return ; + if (results) { return ( -
- ({ - PGC: object.pgc, - Name: object.catalogs.designation.design, - "RA (deg)": object.catalogs.icrs.ra, - "Dec (deg)": object.catalogs.icrs.dec, - }))} - className="w-full" - onRowClick={(row) => { - const pgc = row.PGC as number; - const object = results.find((obj) => obj.pgc === pgc); - if (object) { - objectClickHandler(navigate, object); - } - }} - /> -
- - Page {page} - -
-
+ ); } - return ( - - navigate("/")} /> - - ); + return ; } return ( @@ -163,7 +199,7 @@ export function SearchResultsPage(): ReactElement { onSearch={searchHandler(navigate)} logoSize="small" /> - {renderContent()} + ); } From 95b7c29cd4dc9a23eda7c008cb48c26ef5a30db0 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Wed, 17 Sep 2025 23:19:00 +0300 Subject: [PATCH 13/13] add page names --- src/pages/CrossmatchResults.tsx | 4 ++++ src/pages/NotFound.tsx | 5 +++++ src/pages/ObjectDetails.tsx | 6 +++++- src/pages/RecordCrossmatchDetails.tsx | 6 +++++- src/pages/SearchResults.tsx | 6 +++++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 6e3a31a..97e2c11 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -201,6 +201,10 @@ export function CrossmatchResultsPage(): ReactElement { const page = parseInt(searchParams.get("page") || "0"); const pageSize = parseInt(searchParams.get("page_size") || "25"); + useEffect(() => { + document.title = `Crossmatch - ${tableName} | HyperLEDA`; + }, [tableName]); + const { data, loading, error } = useDataFetching( () => fetcher(tableName, status, page, pageSize), [tableName, status, page, pageSize], diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index aeedae1..2ce8712 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,10 +1,15 @@ import { useNavigate } from "react-router-dom"; import { ErrorPage } from "../components/ui/error-page"; import { Button } from "../components/ui/button"; +import { useEffect } from "react"; export function NotFoundPage() { const navigate = useNavigate(); + useEffect(() => { + document.title = `404 | HyperLEDA`; + }, []); + return ( (); + useEffect(() => { + document.title = `PGC ${pgcId} | HyperLEDA`; + }, [pgcId]); + const { data: payload, loading, diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index cb5ae6d..cbcbcf5 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from "react"; +import { ReactElement, useEffect } from "react"; import { useParams } from "react-router-dom"; import { AladinViewer } from "../components/ui/aladin"; import { Loading } from "../components/ui/loading"; @@ -210,6 +210,10 @@ export function RecordCrossmatchDetailsPage(): ReactElement { [recordId], ); + useEffect(() => { + document.title = `Crossmatch - ${data?.crossmatch.catalogs.designation?.name ?? recordId} | HyperLEDA`; + }, [data, recordId]); + function Content(): ReactElement { if (loading) return ; if (error) return ; diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index e157b2d..f3a8b98 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -1,4 +1,4 @@ -import { ReactElement } from "react"; +import { ReactElement, useEffect } from "react"; import { NavigateFunction, useNavigate, @@ -165,6 +165,10 @@ export function SearchResultsPage(): ReactElement { const page = parseInt(searchParams.get("page") || "1"); const pageSize = parseInt(searchParams.get("pagesize") || "10"); + useEffect(() => { + document.title = `${query} | HyperLEDA`; + }, [query]); + const { data: results, loading,