diff --git a/src/App.tsx b/src/App.tsx index 3a84019..dbc6901 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { SearchResultsPage } from "./pages/SearchResults"; import { ObjectDetailsPage } from "./pages/ObjectDetails"; import { NotFoundPage } from "./pages/NotFound"; import { TableDetailsPage } from "./pages/TableDetails"; +import { CrossmatchResultsPage } from "./pages/CrossmatchResults"; function Layout({ children }: { children: React.ReactNode }) { return ( @@ -51,6 +52,14 @@ function App() { } /> + + + + } + /> + {children} + + ); + } + + return
{children}
; +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 317445d..9151382 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import classNames from "classnames"; interface ButtonProps { children: React.ReactNode; - onClick?: () => void; + onClick?: (event: React.MouseEvent) => void; className?: string; type?: "button" | "submit" | "reset"; disabled?: boolean; @@ -13,10 +13,14 @@ export function Button(props: ButtonProps): ReactElement { return ( + ); +} + +export function ErrorPageHomeButton({ + onClick, +}: { + onClick: () => void; +}): ReactElement { + return ( + + ); +} diff --git a/src/components/ui/link-button.tsx b/src/components/ui/link-button.tsx new file mode 100644 index 0000000..e09d040 --- /dev/null +++ b/src/components/ui/link-button.tsx @@ -0,0 +1,19 @@ +import { ReactElement, ReactNode } from "react"; +import { MdOpenInNew } from "react-icons/md"; +import { Link } from "./link"; + +interface LinkButtonProps { + children?: ReactNode; + to: string; +} + +export function LinkButton(props: LinkButtonProps): ReactElement { + return ( +
+
{props.children}
+ + + +
+ ); +} diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx index 7983a71..7999d88 100644 --- a/src/components/ui/link.tsx +++ b/src/components/ui/link.tsx @@ -3,16 +3,21 @@ import { ReactElement } from "react"; interface LinkProps { children?: ReactElement | string; href: string; + className?: string; } export function Link(props: LinkProps): ReactElement { const content = props.children ?? props.href; + const baseClass = "text-green-500 hover:text-green-600 transition-colors"; + const className = props.className + ? `${baseClass} ${props.className}` + : baseClass; return ( {content} diff --git a/src/components/ui/loading.tsx b/src/components/ui/loading.tsx new file mode 100644 index 0000000..ce802e7 --- /dev/null +++ b/src/components/ui/loading.tsx @@ -0,0 +1,28 @@ +import { ReactElement } from "react"; + +interface LoadingProps { + message?: string; + className?: string; +} + +export function Loading({ + message = "Loading...", + className = "", +}: LoadingProps): ReactElement { + return ( +
+
+
+
+
+
+

+ {message} +

+
+
+ ); +} diff --git a/src/components/ui/text-filter.tsx b/src/components/ui/text-filter.tsx new file mode 100644 index 0000000..c5e7adb --- /dev/null +++ b/src/components/ui/text-filter.tsx @@ -0,0 +1,39 @@ +import { ReactElement } from "react"; + +interface TextFieldProps { + title: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: "text" | "email" | "password" | "number"; + onEnter?: () => void; +} + +export function TextFilter({ + title, + value, + onChange, + placeholder, + type = "text", + onEnter, +}: TextFieldProps): ReactElement { + return ( +
+ + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && onEnter) { + onEnter(); + } + }} + placeholder={placeholder} + className="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent h-10 w-full" + /> +
+ ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx new file mode 100644 index 0000000..88a5a0b --- /dev/null +++ b/src/pages/CrossmatchResults.tsx @@ -0,0 +1,280 @@ +import { ReactElement, useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { + CommonTable, + Column, + CellPrimitive, +} from "../components/ui/common-table"; +import { Badge } from "../components/ui/badge"; +import { DropdownFilter } from "../components/ui/dropdown-filter"; +import { TextFilter } from "../components/ui/text-filter"; +import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; +import type { + GetRecordsCrossmatchResponse, + RecordCrossmatch, + RecordCrossmatchStatus, + HttpValidationError, + ValidationError, +} from "../clients/admin/types.gen"; +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 { LinkButton } from "../components/ui/link-button"; + +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"); + + const [localStatus, setLocalStatus] = useState(status || "all"); + const [localPageSize, setLocalPageSize] = useState(pageSize); + const [localTableName, setLocalTableName] = useState(tableName || ""); + + useEffect(() => { + setLocalStatus(status || "all"); + setLocalPageSize(pageSize); + 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); + } + + newSearchParams.set("page_size", localPageSize.toString()); + newSearchParams.set("page", "0"); + + setSearchParams(newSearchParams); + } + + function getRecordName(record: RecordCrossmatch): string { + return record.catalogs.designation?.name || record.record_id; + } + + function renderCandidates(record: RecordCrossmatch): ReactElement { + if (record.status === "new") { + return NULL; + } + + 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} + + ))} +
+ ); + } + + return NULL; + } + + function getStatusLabel(status: RecordCrossmatchStatus): string { + return getResource(`crossmatch.status.${status}`).Title; + } + + const columns: Column[] = [ + { name: "Record Name" }, + { name: "Status" }, + { + name: "Candidates", + renderCell: (recordIndex: CellPrimitive) => { + if (typeof recordIndex === "number" && data?.records[recordIndex]) { + return renderCandidates(data.records[recordIndex]); + } + return NULL; + }, + }, + ]; + + const tableData: Record[] = + data?.records.map((record: RecordCrossmatch, index: number) => ({ + "Record Name": getRecordName(record), + Status: getStatusLabel(record.status), + Candidates: index, + })) || []; + + if (loading) { + return ; + } + + if (error) { + return ( + err.msg).join(", ") || + "An error occurred" + } + className="p-8" + > + navigate("/")} /> + + ); + } + + if (!tableName) { + return ( + + navigate("/")} /> + + ); + } + + return ( +
+
+

Crossmatch results

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

Crossmatch records

+
+ Showing {tableData.length} records +
+
+
+ +
+ + Page {page + 1} + +
+
+ ); +} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 1fd06d5..742fb95 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -1,25 +1,16 @@ import { useNavigate } from "react-router-dom"; -import { Button } from "../components/ui/button"; +import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; export function NotFoundPage() { const navigate = useNavigate(); return ( -
-
-
-

404

-

Page Not Found

-

- The page you're looking for doesn't exist or has been moved. -

-
-
- -
-
-
+ + navigate("/")} /> + ); } diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index a58657a..e2ca63d 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -1,9 +1,14 @@ import { ReactElement, useEffect, useState } from "react"; import { NavigateFunction, useNavigate, useParams } from "react-router-dom"; import { SearchBar } from "../components/ui/searchbar"; -import { Button } from "../components/ui/button"; import { AladinViewer } from "../components/ui/aladin"; import { CommonTable } from "../components/ui/common-table"; +import { Loading } from "../components/ui/loading"; +import { + ErrorPage, + ErrorPageBackButton, + ErrorPageHomeButton, +} from "../components/ui/error-page"; import { querySimpleApiV1QuerySimpleGet } from "../clients/backend/sdk.gen"; import { PgcObject, Schema } from "../clients/backend/types.gen"; @@ -21,12 +26,13 @@ function searchHandler(navigate: NavigateFunction) { function renderNotFound(navigate: NavigateFunction) { return ( -
- -

Object not found.

-
+ + + navigate("/")} /> + ); } @@ -146,7 +152,7 @@ function renderObjectDetails( /> )}
-

+

{object.catalogs?.designation?.name || `PGC ${object.pgc}`}

PGC: {object.pgc}

@@ -155,21 +161,21 @@ function renderObjectDetails( {object.catalogs?.coordinates && ( -

Coordinates

+

Coordinates

Celestial coordinates of the object

)} {object.catalogs?.redshift && ( -

Redshift

+

Redshift

Redshift measurements

)} {object.catalogs?.velocity && ( -

Velocity

+

Velocity

Velocity measurements with respect to different apexes

@@ -230,9 +236,7 @@ export function ObjectDetailsPage(): ReactElement { /> {loading ? ( -
-

Loading...

-
+ ) : object ? ( renderObjectDetails(object, schema) ) : ( diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 1d3af8a..599b59a 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -8,6 +8,8 @@ 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 { Loading } from "../components/ui/loading"; +import { ErrorPage, ErrorPageHomeButton } from "../components/ui/error-page"; function objectClickHandler( navigate: NavigateFunction, @@ -72,7 +74,7 @@ export function SearchResultsPage(): ReactElement { /> {loading ? ( -

Loading...

+ ) : (
{results.length > 0 ? ( @@ -131,7 +133,13 @@ export function SearchResultsPage(): ReactElement {
) : ( -

No results found for "{query}"

+ + navigate("/")} /> + )}
)} diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 5703fee..75c1b53 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -3,7 +3,7 @@ import { Bibliography, GetTableResponse, HttpValidationError, - ObjectCrossmatchStatus, + RecordCrossmatchStatus, } from "../clients/admin/types.gen"; import { getTableAdminApiV1TableGet } from "../clients/admin/sdk.gen"; import { useNavigate, useParams } from "react-router-dom"; @@ -12,8 +12,12 @@ import { Column, CommonTable, } from "../components/ui/common-table"; +import { Button } from "../components/ui/button"; 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 { getResource } from "../resources/resources"; function renderBibliography(bib: Bibliography): ReactElement { @@ -54,14 +58,7 @@ function renderUCD(ucd: CellPrimitive): ReactElement { const words: ReactElement[] = []; ucd.split(";").forEach((word, index) => { - words.push( -
- {word} -
, - ); + words.push({word}); }); return ( @@ -148,7 +145,7 @@ function MarkingRules(props: MarkingRulesProps): ReactElement { return (

- Mapping of columns to catalog values for marking of records. + Mapping of columns to catalog values for marking of records

); @@ -156,6 +153,8 @@ function MarkingRules(props: MarkingRulesProps): ReactElement { interface CrossmatchStatsProps { table: GetTableResponse; + tableName: string; + navigate: (path: string) => void; } function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { @@ -164,7 +163,7 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { const values: Record[] = []; if (props.table.statistics) { - const statusLabels: Record = { + const statusLabels: Record = { unprocessed: "Unprocessed", new: "New", collided: "Collided", @@ -173,7 +172,7 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { Object.entries(props.table.statistics).forEach(([status, count]) => { values.push({ - Status: statusLabels[status as ObjectCrossmatchStatus] || status, + Status: statusLabels[status as RecordCrossmatchStatus] || status, Count: count || 0, }); }); @@ -183,9 +182,24 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { return
; } + function handleViewCrossmatchResults(event: React.MouseEvent): void { + const url = `/crossmatch?table_name=${encodeURIComponent(props.tableName)}&status=collided`; + + if (event.ctrlKey || event.metaKey) { + window.open(url, "_blank"); + } else { + props.navigate(url); + } + } + return ( -

Crossmatch Statistics

+
+

Crossmatch Statistics

+ +
); } @@ -242,19 +256,28 @@ function ColumnInfo(props: ColumnInfoProps): ReactElement { ); } -function renderNotFound(): ReactElement { +function renderNotFound(navigate: (path: string) => void): ReactElement { return ( -
-

Table not found.

-
+ + navigate("/")} /> + ); } -function renderError(error: HttpValidationError): ReactElement { +function renderError( + error: HttpValidationError, + navigate: (path: string) => void, +): ReactElement { return ( -
-

{error.detail?.toString()}

-
+ + navigate("/")} /> + ); } @@ -297,20 +320,22 @@ export function TableDetailsPage(): ReactElement { return (
{loading ? ( -
-

Loading...

-
+ ) : table ? (
- +
) : error ? ( - renderError(error) + renderError(error, navigate) ) : ( - renderNotFound() + renderNotFound(navigate) )}
);