From 4c677178c39a907d49ce769f61f74dd0e1da3ca5 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 16:22:00 +0300 Subject: [PATCH 01/16] add basic crossmatch results page --- src/App.tsx | 9 ++ src/components/ui/button.tsx | 2 +- src/pages/CrossmatchResults.tsx | 257 ++++++++++++++++++++++++++++++++ src/pages/TableDetails.tsx | 28 +++- 4 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 src/pages/CrossmatchResults.tsx 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() { } /> + + + + } + /> diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx new file mode 100644 index 0000000..4745f3a --- /dev/null +++ b/src/pages/CrossmatchResults.tsx @@ -0,0 +1,257 @@ +import { ReactElement, useEffect, useState } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { + CommonTable, + Column, + CellPrimitive, +} from "../components/ui/common-table"; +import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; +import type { + GetRecordsCrossmatchResponse, + RecordCrossmatch, + RecordCrossmatchStatus, + HttpValidationError, + ValidationError, +} from "../clients/admin/types.gen"; + +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"); + + 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 handlePageSizeChange(newPageSize: number): void { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("page_size", newPageSize.toString()); + newSearchParams.set("page", "0"); + setSearchParams(newSearchParams); + } + + function handleStatusChange(newStatus: string): void { + const newSearchParams = new URLSearchParams(searchParams); + if (newStatus === "all") { + newSearchParams.delete("status"); + } else { + newSearchParams.set("status", newStatus); + } + newSearchParams.set("page", "0"); + setSearchParams(newSearchParams); + } + + function getRecordName(record: RecordCrossmatch): string { + return record.catalogs.designation?.name || record.record_id; + } + + function getCandidates(record: RecordCrossmatch): string { + if (record.status === "new") { + return "NULL"; + } + + if (record.status === "existing" && record.metadata.pgc) { + return `PGC ${record.metadata.pgc}`; + } + + if (record.status === "collided" && record.metadata.possible_matches) { + return record.metadata.possible_matches + .map((pgc: number) => `PGC ${pgc}`) + .join(", "); + } + + return "NULL"; + } + + function getStatusLabel(status: RecordCrossmatchStatus): string { + const statusLabels: Record = { + unprocessed: "Unprocessed", + new: "New", + collided: "Collided", + existing: "Existing", + }; + return statusLabels[status]; + } + + const columns: Column[] = [ + { name: "Record Name" }, + { name: "Status" }, + { name: "Candidates" }, + ]; + + const tableData: Record[] = + data?.records.map((record: RecordCrossmatch) => ({ + "Record Name": getRecordName(record), + Status: getStatusLabel(record.status), + Candidates: getCandidates(record), + })) || []; + + if (loading) { + return ( +
+
+

Loading crossmatch results...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error

+

+ {error.detail?.map((err: ValidationError) => err.msg).join(", ") || + "An error occurred"} +

+
+
+ ); + } + + if (!tableName) { + return ( +
+
+

+ Missing Table Name +

+

+ Please provide a table_name parameter. +

+
+
+ ); + } + + return ( +
+
+

+ Crossmatch Results for {tableName} +

+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

Crossmatch Records

+
+ Showing {tableData.length} records +
+
+
+ +
+ + + Page {page + 1} + + +
+
+ ); +} diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 5703fee..95b433d 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,6 +12,7 @@ import { Column, CommonTable, } from "../components/ui/common-table"; +import { Button } from "../components/ui/button"; import { CopyButton } from "../components/ui/copy-button"; import { Link } from "../components/ui/link"; import { getResource } from "../resources/resources"; @@ -156,6 +157,8 @@ function MarkingRules(props: MarkingRulesProps): ReactElement { interface CrossmatchStatsProps { table: GetTableResponse; + tableName: string; + navigate: (path: string) => void; } function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { @@ -164,7 +167,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 +176,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 +186,20 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement { return
; } + function handleViewCrossmatchResults(): void { + props.navigate( + `/crossmatch?table_name=${encodeURIComponent(props.tableName)}`, + ); + } + return ( -

Crossmatch Statistics

+
+

Crossmatch Statistics

+ +
); } @@ -304,7 +318,11 @@ export function TableDetailsPage(): ReactElement {
- +
) : error ? ( From 0d8f3b49fcc1ce8a166a1d1b1c18bcb56be55c62 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 16:28:19 +0300 Subject: [PATCH 02/16] add badges --- src/pages/CrossmatchResults.tsx | 45 +++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 4745f3a..05056f1 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -5,6 +5,7 @@ import { Column, CellPrimitive, } from "../components/ui/common-table"; +import { CopyButton } from "../components/ui/copy-button"; import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; import type { GetRecordsCrossmatchResponse, @@ -101,22 +102,38 @@ export function CrossmatchResultsPage(): ReactElement { return record.catalogs.designation?.name || record.record_id; } - function getCandidates(record: RecordCrossmatch): string { + function renderCandidates(record: RecordCrossmatch): ReactElement { if (record.status === "new") { - return "NULL"; + return NULL; } if (record.status === "existing" && record.metadata.pgc) { - return `PGC ${record.metadata.pgc}`; + const pgcText = `PGC ${record.metadata.pgc}`; + return ( +
+ {pgcText} +
+ ); } if (record.status === "collided" && record.metadata.possible_matches) { - return record.metadata.possible_matches - .map((pgc: number) => `PGC ${pgc}`) - .join(", "); + const pgcNumbers = record.metadata.possible_matches; + + return ( +
+ {pgcNumbers.map((pgc: number, index: number) => ( +
+ PGC {pgc} +
+ ))} +
+ ); } - return "NULL"; + return NULL; } function getStatusLabel(status: RecordCrossmatchStatus): string { @@ -132,14 +149,22 @@ export function CrossmatchResultsPage(): ReactElement { const columns: Column[] = [ { name: "Record Name" }, { name: "Status" }, - { name: "Candidates" }, + { + 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) => ({ + data?.records.map((record: RecordCrossmatch, index: number) => ({ "Record Name": getRecordName(record), Status: getStatusLabel(record.status), - Candidates: getCandidates(record), + Candidates: index, })) || []; if (loading) { From 25d666ad1870b25dbfdfdda5301cd241dc29656a Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 16:31:21 +0300 Subject: [PATCH 03/16] move badge component --- src/components/ui/badge.tsx | 16 ++++++++++++++++ src/pages/CrossmatchResults.tsx | 15 ++++----------- src/pages/TableDetails.tsx | 8 +++----- 3 files changed, 23 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/badge.tsx diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..4af19d8 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; + +interface BadgeProps { + children: React.ReactNode; + className?: string; +} + +export function Badge({ children, className = "" }: BadgeProps): ReactElement { + return ( +
+ {children} +
+ ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 05056f1..6352e75 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -5,7 +5,7 @@ import { Column, CellPrimitive, } from "../components/ui/common-table"; -import { CopyButton } from "../components/ui/copy-button"; +import { Badge } from "../components/ui/badge"; import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; import type { GetRecordsCrossmatchResponse, @@ -109,11 +109,7 @@ export function CrossmatchResultsPage(): ReactElement { if (record.status === "existing" && record.metadata.pgc) { const pgcText = `PGC ${record.metadata.pgc}`; - return ( -
- {pgcText} -
- ); + return {pgcText}; } if (record.status === "collided" && record.metadata.possible_matches) { @@ -122,12 +118,9 @@ export function CrossmatchResultsPage(): ReactElement { return (
{pgcNumbers.map((pgc: number, index: number) => ( -
+ PGC {pgc} -
+ ))}
); diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 95b433d..f22bc7c 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -14,6 +14,7 @@ import { } 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 { getResource } from "../resources/resources"; @@ -56,12 +57,9 @@ function renderUCD(ucd: CellPrimitive): ReactElement { ucd.split(";").forEach((word, index) => { words.push( -
+ {word} -
, + , ); }); From 2931bcd616a94ef1dd1ae1167c75dd5bcfab33ff Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 16:40:08 +0300 Subject: [PATCH 04/16] add link to PGC object in the table --- src/components/ui/badge.tsx | 26 ++++++++++++++++++-------- src/pages/CrossmatchResults.tsx | 8 ++++---- src/pages/TableDetails.tsx | 2 +- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 4af19d8..22612d6 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,16 +1,26 @@ import { ReactElement } from "react"; +import { Link } from "./link"; interface BadgeProps { children: React.ReactNode; className?: string; + href?: string; } -export function Badge({ children, className = "" }: BadgeProps): ReactElement { - return ( -
- {children} -
- ); +export function Badge({ + children, + className = "", + href, +}: BadgeProps): ReactElement { + const badgeClasses = `inline-block bg-gray-600 rounded px-1.5 py-0.5 text-sm mr-0.5 mb-0.5 ${className}`; + + if (href) { + return ( + + {children} + + ); + } + + return
{children}
; } diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 6352e75..10dcf39 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -108,8 +108,8 @@ export function CrossmatchResultsPage(): ReactElement { } if (record.status === "existing" && record.metadata.pgc) { - const pgcText = `PGC ${record.metadata.pgc}`; - return {pgcText}; + const pgcText = `${record.metadata.pgc}`; + return {pgcText}; } if (record.status === "collided" && record.metadata.possible_matches) { @@ -118,8 +118,8 @@ export function CrossmatchResultsPage(): ReactElement { return (
{pgcNumbers.map((pgc: number, index: number) => ( - - PGC {pgc} + + {pgc} ))}
diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index f22bc7c..089814f 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -57,7 +57,7 @@ function renderUCD(ucd: CellPrimitive): ReactElement { ucd.split(";").forEach((word, index) => { words.push( - + {word} , ); From bde22f46ea65a9b3a41d895ad035b8b955b0a980 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 16:56:18 +0300 Subject: [PATCH 05/16] move dropdown to a separate component & fix button styling --- src/assets/texts.json | 8 +++- src/components/ui/button.tsx | 4 ++ src/components/ui/dropdown.tsx | 40 +++++++++++++++++ src/pages/CrossmatchResults.tsx | 79 ++++++++++++++------------------- 4 files changed, 83 insertions(+), 48 deletions(-) create mode 100644 src/components/ui/dropdown.tsx diff --git a/src/assets/texts.json b/src/assets/texts.json index e843a99..24bb09d 100644 --- a/src/assets/texts.json +++ b/src/assets/texts.json @@ -2,6 +2,10 @@ "title": { "catalog.designation": "Designation", "catalog.icrs": "Celestial coordinates", - "catalog.redshift": "Redshift" + "catalog.redshift": "Redshift", + "crossmatch.status.unprocessed": "Unprocessed", + "crossmatch.status.new": "New", + "crossmatch.status.collided": "Collided", + "crossmatch.status.existing": "Existing" } -} +} \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0b5fc85..83dded8 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -17,6 +17,10 @@ export function Button(props: ButtonProps): ReactElement { disabled={props.disabled} className={classNames( "px-2 py-2 box-border flex items-center font-semibold border-1 border-[#1a1a1a] rounded-lg bg-[#1a1a1a] hover:border-[#646cff] transition-colors duration-300 active:border-white cursor-pointer", + { + "opacity-50 cursor-not-allowed hover:border-[#1a1a1a] active:border-[#1a1a1a]": + props.disabled, + }, props.className, )} > diff --git a/src/components/ui/dropdown.tsx b/src/components/ui/dropdown.tsx new file mode 100644 index 0000000..e456f4e --- /dev/null +++ b/src/components/ui/dropdown.tsx @@ -0,0 +1,40 @@ +import { ReactElement } from "react"; + +interface DropdownOption { + value: string; + label?: string; +} + +interface DropdownProps { + title: string; + options: DropdownOption[]; + defaultValue: string; + value: string; + onChange: (value: string) => void; +} + +export function Dropdown({ + title, + options, + value, + onChange, +}: DropdownProps): ReactElement { + return ( +
+ + +
+ ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 10dcf39..2063062 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -6,6 +6,7 @@ import { CellPrimitive, } from "../components/ui/common-table"; import { Badge } from "../components/ui/badge"; +import { Dropdown } from "../components/ui/dropdown"; import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; import type { GetRecordsCrossmatchResponse, @@ -14,6 +15,8 @@ import type { HttpValidationError, ValidationError, } from "../clients/admin/types.gen"; +import { getResource } from "../resources/resources"; +import { Button } from "../components/ui/button"; export function CrossmatchResultsPage(): ReactElement { const [searchParams, setSearchParams] = useSearchParams(); @@ -130,13 +133,7 @@ export function CrossmatchResultsPage(): ReactElement { } function getStatusLabel(status: RecordCrossmatchStatus): string { - const statusLabels: Record = { - unprocessed: "Unprocessed", - new: "New", - collided: "Collided", - existing: "Existing", - }; - return statusLabels[status]; + return getResource(`crossmatch.status.${status}`).Title; } const columns: Column[] = [ @@ -207,38 +204,32 @@ export function CrossmatchResultsPage(): ReactElement {
-
- - -
+ -
- - -
+ handlePageSizeChange(parseInt(value))} + />
@@ -252,23 +243,19 @@ export function CrossmatchResultsPage(): ReactElement {
- - + Page {page + 1} - - +
); From 0575c3f62463c66cdd4011e5ea57e602c7c67e4a Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 17:00:46 +0300 Subject: [PATCH 06/16] move loading to a separate component --- src/components/ui/loading.tsx | 19 +++++++++++++++++++ src/pages/CrossmatchResults.tsx | 9 ++------- src/pages/ObjectDetails.tsx | 5 ++--- src/pages/SearchResults.tsx | 3 ++- src/pages/TableDetails.tsx | 11 +++-------- 5 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 src/components/ui/loading.tsx diff --git a/src/components/ui/loading.tsx b/src/components/ui/loading.tsx new file mode 100644 index 0000000..071abd1 --- /dev/null +++ b/src/components/ui/loading.tsx @@ -0,0 +1,19 @@ +import { ReactElement } from "react"; + +interface LoadingProps { + message?: string; + className?: string; +} + +export function Loading({ + message = "Loading...", + className = "", +}: LoadingProps): ReactElement { + return ( +
+
+

{message}

+
+
+ ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 2063062..2a4bc79 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -17,6 +17,7 @@ import type { } from "../clients/admin/types.gen"; import { getResource } from "../resources/resources"; import { Button } from "../components/ui/button"; +import { Loading } from "../components/ui/loading"; export function CrossmatchResultsPage(): ReactElement { const [searchParams, setSearchParams] = useSearchParams(); @@ -158,13 +159,7 @@ export function CrossmatchResultsPage(): ReactElement { })) || []; if (loading) { - return ( -
-
-

Loading crossmatch results...

-
-
- ); + return ; } if (error) { diff --git a/src/pages/ObjectDetails.tsx b/src/pages/ObjectDetails.tsx index a58657a..51c5ad7 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -4,6 +4,7 @@ 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 { querySimpleApiV1QuerySimpleGet } from "../clients/backend/sdk.gen"; import { PgcObject, Schema } from "../clients/backend/types.gen"; @@ -230,9 +231,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..4b88bb9 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -8,6 +8,7 @@ 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"; function objectClickHandler( navigate: NavigateFunction, @@ -72,7 +73,7 @@ export function SearchResultsPage(): ReactElement { /> {loading ? ( -

Loading...

+ ) : (
{results.length > 0 ? ( diff --git a/src/pages/TableDetails.tsx b/src/pages/TableDetails.tsx index 089814f..f4c6c6b 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -16,6 +16,7 @@ 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 { getResource } from "../resources/resources"; function renderBibliography(bib: Bibliography): ReactElement { @@ -56,11 +57,7 @@ function renderUCD(ucd: CellPrimitive): ReactElement { const words: ReactElement[] = []; ucd.split(";").forEach((word, index) => { - words.push( - - {word} - , - ); + words.push({word}); }); return ( @@ -309,9 +306,7 @@ export function TableDetailsPage(): ReactElement { return (
{loading ? ( -
-

Loading...

-
+ ) : table ? (
From a51d95451c71234da78a401236459b7e3aed21c2 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 17:02:45 +0300 Subject: [PATCH 07/16] animated loading --- src/components/ui/loading.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/ui/loading.tsx b/src/components/ui/loading.tsx index 071abd1..ce802e7 100644 --- a/src/components/ui/loading.tsx +++ b/src/components/ui/loading.tsx @@ -11,8 +11,17 @@ export function Loading({ }: LoadingProps): ReactElement { return (
-
-

{message}

+
+
+
+
+
+

+ {message} +

); From 3c863596433509979d9836cdfb8fdef648a73d00 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 17:14:26 +0300 Subject: [PATCH 08/16] add common error page component --- src/components/ui/error-page.tsx | 60 ++++++++++++++++++++++++++++++++ src/pages/CrossmatchResults.tsx | 37 ++++++++++---------- src/pages/NotFound.tsx | 25 +++++-------- src/pages/ObjectDetails.tsx | 19 ++++++---- src/pages/SearchResults.tsx | 9 ++++- src/pages/TableDetails.tsx | 30 ++++++++++------ 6 files changed, 126 insertions(+), 54 deletions(-) create mode 100644 src/components/ui/error-page.tsx diff --git a/src/components/ui/error-page.tsx b/src/components/ui/error-page.tsx new file mode 100644 index 0000000..52a53df --- /dev/null +++ b/src/components/ui/error-page.tsx @@ -0,0 +1,60 @@ +import { ReactElement, ReactNode } from "react"; +import { Button } from "./button"; + +interface ErrorPageProps { + title: string; + message: string; + children?: ReactNode; + className?: string; + showLargeText?: boolean; +} + +export function ErrorPage({ + title, + message, + children, + className = "", + showLargeText = false, +}: ErrorPageProps): ReactElement { + return ( +
+
+
+ {showLargeText &&

404

} +

{title}

+

{message}

+
+ + {children && ( +
{children}
+ )} +
+
+ ); +} + +export function ErrorPageBackButton({ + onClick, +}: { + onClick: () => void; +}): ReactElement { + return ( + + ); +} + +export function ErrorPageHomeButton({ + onClick, +}: { + onClick: () => void; +}): ReactElement { + return ( + + ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index 2a4bc79..b258987 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -18,6 +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"; export function CrossmatchResultsPage(): ReactElement { const [searchParams, setSearchParams] = useSearchParams(); @@ -164,30 +165,28 @@ export function CrossmatchResultsPage(): ReactElement { if (error) { return ( -
-
-

Error

-

- {error.detail?.map((err: ValidationError) => err.msg).join(", ") || - "An error occurred"} -

-
-
+ err.msg).join(", ") || + "An error occurred" + } + className="p-8" + > + navigate("/")} /> + ); } if (!tableName) { return ( -
-
-

- Missing Table Name -

-

- Please provide a table_name parameter. -

-
-
+ + navigate("/")} /> + ); } 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 51c5ad7..3b89a22 100644 --- a/src/pages/ObjectDetails.tsx +++ b/src/pages/ObjectDetails.tsx @@ -1,10 +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"; @@ -22,12 +26,13 @@ function searchHandler(navigate: NavigateFunction) { function renderNotFound(navigate: NavigateFunction) { return ( -
- -

Object not found.

-
+ + + navigate("/")} /> + ); } diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 4b88bb9..599b59a 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -9,6 +9,7 @@ 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, @@ -132,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 f4c6c6b..9e327a6 100644 --- a/src/pages/TableDetails.tsx +++ b/src/pages/TableDetails.tsx @@ -17,6 +17,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 { getResource } from "../resources/resources"; function renderBibliography(bib: Bibliography): ReactElement { @@ -251,19 +252,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("/")} /> + ); } @@ -319,9 +329,9 @@ export function TableDetailsPage(): ReactElement {
) : error ? ( - renderError(error) + renderError(error, navigate) ) : ( - renderNotFound() + renderNotFound(navigate) )}
); From b5da9e1dc4e1fc4e31f4b8df1bf8093554291dd8 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 17:22:40 +0300 Subject: [PATCH 09/16] apply filters button --- src/pages/CrossmatchResults.tsx | 35 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index b258987..e4bfad6 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -33,6 +33,14 @@ export function CrossmatchResultsPage(): ReactElement { 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); + + useEffect(() => { + setLocalStatus(status || "all"); + setLocalPageSize(pageSize); + }, [status, pageSize]); + useEffect(() => { async function fetchData() { if (!tableName) { @@ -85,21 +93,18 @@ export function CrossmatchResultsPage(): ReactElement { setSearchParams(newSearchParams); } - function handlePageSizeChange(newPageSize: number): void { + function applyFilters(): void { const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.set("page_size", newPageSize.toString()); - newSearchParams.set("page", "0"); - setSearchParams(newSearchParams); - } - function handleStatusChange(newStatus: string): void { - const newSearchParams = new URLSearchParams(searchParams); - if (newStatus === "all") { + if (localStatus === "all") { newSearchParams.delete("status"); } else { - newSearchParams.set("status", newStatus); + newSearchParams.set("status", localStatus); } + + newSearchParams.set("page_size", localPageSize.toString()); newSearchParams.set("page", "0"); + setSearchParams(newSearchParams); } @@ -208,8 +213,8 @@ export function CrossmatchResultsPage(): ReactElement { { value: "existing", label: "Existing" }, ]} defaultValue="all" - value={status || "all"} - onChange={handleStatusChange} + value={localStatus} + onChange={setLocalStatus} /> handlePageSizeChange(parseInt(value))} + value={localPageSize.toString()} + onChange={(value) => setLocalPageSize(parseInt(value))} /> + +
+ +
From e70a4200dcdfbc6f0456e10bb617eb608dc2c305 Mon Sep 17 00:00:00 2001 From: Artyom Zaporozhets Date: Sun, 14 Sep 2025 17:29:22 +0300 Subject: [PATCH 10/16] add text filter for table_name --- src/components/ui/text-filter.tsx | 32 +++++++++++++++++++++++++++++++ src/pages/CrossmatchResults.tsx | 28 +++++++++++++++++++++------ 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 src/components/ui/text-filter.tsx diff --git a/src/components/ui/text-filter.tsx b/src/components/ui/text-filter.tsx new file mode 100644 index 0000000..543f104 --- /dev/null +++ b/src/components/ui/text-filter.tsx @@ -0,0 +1,32 @@ +import { ReactElement } from "react"; + +interface TextFieldProps { + title: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: "text" | "email" | "password" | "number"; +} + +export function TextFilter({ + title, + value, + onChange, + placeholder, + type = "text", +}: TextFieldProps): ReactElement { + return ( +
+ + onChange(e.target.value)} + 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" + /> +
+ ); +} diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index e4bfad6..bba1201 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -7,6 +7,7 @@ import { } from "../components/ui/common-table"; import { Badge } from "../components/ui/badge"; import { Dropdown } from "../components/ui/dropdown"; +import { TextFilter } from "../components/ui/text-filter"; import { getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet } from "../clients/admin/sdk.gen"; import type { GetRecordsCrossmatchResponse, @@ -35,11 +36,13 @@ export function CrossmatchResultsPage(): ReactElement { const [localStatus, setLocalStatus] = useState(status || "all"); const [localPageSize, setLocalPageSize] = useState(pageSize); + const [localTableName, setLocalTableName] = useState(tableName || ""); useEffect(() => { setLocalStatus(status || "all"); setLocalPageSize(pageSize); - }, [status, pageSize]); + setLocalTableName(tableName || ""); + }, [status, pageSize, tableName]); useEffect(() => { async function fetchData() { @@ -96,6 +99,12 @@ export function CrossmatchResultsPage(): ReactElement { 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 { @@ -198,13 +207,20 @@ export function CrossmatchResultsPage(): ReactElement { return (
-

- Crossmatch Results for {tableName} -

+

+ Crossmatch results +

+ + Date: Sun, 14 Sep 2025 18:06:27 +0300 Subject: [PATCH 11/16] rename dropdown --- .../ui/{dropdown.tsx => dropdown-filter.tsx} | 10 +++++----- src/pages/CrossmatchResults.tsx | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) rename src/components/ui/{dropdown.tsx => dropdown-filter.tsx} (80%) diff --git a/src/components/ui/dropdown.tsx b/src/components/ui/dropdown-filter.tsx similarity index 80% rename from src/components/ui/dropdown.tsx rename to src/components/ui/dropdown-filter.tsx index e456f4e..8235d6f 100644 --- a/src/components/ui/dropdown.tsx +++ b/src/components/ui/dropdown-filter.tsx @@ -1,24 +1,24 @@ import { ReactElement } from "react"; -interface DropdownOption { +interface DropdownFilterOption { value: string; label?: string; } -interface DropdownProps { +interface DropdownFilterProps { title: string; - options: DropdownOption[]; + options: DropdownFilterOption[]; defaultValue: string; value: string; onChange: (value: string) => void; } -export function Dropdown({ +export function DropdownFilter({ title, options, value, onChange, -}: DropdownProps): ReactElement { +}: DropdownFilterProps): ReactElement { return (