Skip to content

Commit c68f125

Browse files
committed
add records page
1 parent ba8f426 commit c68f125

4 files changed

Lines changed: 342 additions & 4 deletions

File tree

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ObjectDetailsPage } from "./pages/ObjectDetails";
55
import { NotFoundPage } from "./pages/NotFound";
66
import { TableDetailsPage } from "./pages/TableDetails";
77
import { CrossmatchResultsPage } from "./pages/CrossmatchResults";
8+
import { RecordsPage } from "./pages/Records";
89
import { RecordCrossmatchDetailsPage } from "./pages/RecordCrossmatchDetails";
910
import { TablesPage } from "./pages/Tables";
1011
import { Layout } from "./components/ui/Layout";
@@ -37,6 +38,7 @@ function App() {
3738
<Route path="/table/:tableName" element={<TableDetailsPage />} />
3839
<Route path="/tables" element={<TablesPage />} />
3940
<Route path="/crossmatch" element={<CrossmatchResultsPage />} />
41+
<Route path="/records" element={<RecordsPage />} />
4042
<Route
4143
path="/records/:recordId/crossmatch"
4244
element={

src/assets/texts.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"crossmatch.status.new": "New",
88
"crossmatch.status.collided": "Collided",
99
"crossmatch.status.existing": "Existing",
10+
"crossmatch.triage.unprocessed": "Unprocessed",
1011
"crossmatch.triage.pending": "Pending",
1112
"crossmatch.triage.resolved": "Resolved"
1213
}

src/pages/Records.tsx

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import { ReactElement, useEffect, useState } from "react";
2+
import { useSearchParams } from "react-router-dom";
3+
import {
4+
CommonTable,
5+
Column,
6+
CellPrimitive,
7+
} from "../components/ui/CommonTable";
8+
import { DropdownFilter } from "../components/core/DropdownFilter";
9+
import { TextFilter } from "../components/core/TextFilter";
10+
import { getRecords } from "../clients/admin/sdk.gen";
11+
import type {
12+
GetRecordsResponse,
13+
Record as RecordType,
14+
CrossmatchTriageStatus,
15+
} from "../clients/admin/types.gen";
16+
import { getResource } from "../resources/resources";
17+
import { Button } from "../components/core/Button";
18+
import { Loading } from "../components/core/Loading";
19+
import { ErrorPage } from "../components/ui/ErrorPage";
20+
import { Badge } from "../components/ui/Badge";
21+
import { Link } from "../components/core/Link";
22+
import { useDataFetching } from "../hooks/useDataFetching";
23+
import { Pagination } from "../components/ui/Pagination";
24+
import { adminClient } from "../clients/config";
25+
import type { ValidationError } from "../clients/admin/types.gen";
26+
27+
interface RecordsFiltersProps {
28+
tableName: string | null;
29+
triageStatus: string | null;
30+
pageSize: number;
31+
onApplyFilters: (
32+
tableName: string,
33+
triageStatus: string,
34+
pageSize: number,
35+
) => void;
36+
}
37+
38+
function RecordsFilters({
39+
tableName,
40+
triageStatus,
41+
pageSize,
42+
onApplyFilters,
43+
}: RecordsFiltersProps): ReactElement {
44+
const [localTriageStatus, setLocalTriageStatus] = useState<string>(
45+
triageStatus ?? "all",
46+
);
47+
const [localPageSize, setLocalPageSize] = useState<number>(pageSize);
48+
const [localTableName, setLocalTableName] = useState<string>(tableName || "");
49+
50+
useEffect(() => {
51+
setLocalTriageStatus(triageStatus ?? "all");
52+
setLocalPageSize(pageSize);
53+
setLocalTableName(tableName || "");
54+
}, [triageStatus, pageSize, tableName]);
55+
56+
function applyFilters(): void {
57+
onApplyFilters(localTableName, localTriageStatus, localPageSize);
58+
}
59+
60+
return (
61+
<div className="flex gap-4 mb-4">
62+
<TextFilter
63+
title="Table name"
64+
value={localTableName}
65+
onChange={setLocalTableName}
66+
placeholder="Enter table name"
67+
onEnter={applyFilters}
68+
/>
69+
<Link href={`/table/${localTableName.trim()}`} external />
70+
<DropdownFilter
71+
title="Manual check status"
72+
options={[
73+
{ value: "all", label: "All" },
74+
{ value: "unprocessed", label: "Unprocessed" },
75+
{ value: "pending", label: "Pending" },
76+
{ value: "resolved", label: "Resolved" },
77+
]}
78+
value={localTriageStatus}
79+
onChange={setLocalTriageStatus}
80+
/>
81+
<DropdownFilter
82+
title="Page size"
83+
options={[
84+
{ value: "10" },
85+
{ value: "25" },
86+
{ value: "50" },
87+
{ value: "100" },
88+
]}
89+
value={localPageSize.toString()}
90+
onChange={(value) => setLocalPageSize(parseInt(value))}
91+
/>
92+
<div className="flex items-end">
93+
<Button onClick={applyFilters}>Apply</Button>
94+
</div>
95+
</div>
96+
);
97+
}
98+
99+
interface RecordsTableProps {
100+
data: GetRecordsResponse | null;
101+
loading?: boolean;
102+
showCandidates?: boolean;
103+
}
104+
105+
function RecordsTable({
106+
data,
107+
loading,
108+
showCandidates = false,
109+
}: RecordsTableProps): ReactElement {
110+
function getRecordName(record: RecordType): ReactElement {
111+
const displayName = record.catalogs?.designation?.name || record.id;
112+
return <Link href={`/records/${record.id}/crossmatch`}>{displayName}</Link>;
113+
}
114+
115+
function getTriageStatusLabel(status: CrossmatchTriageStatus): string {
116+
return getResource(`crossmatch.triage.${status}`).Title;
117+
}
118+
119+
function renderCandidates(record: RecordType): ReactElement {
120+
const pgcNumbers = record.crossmatch.candidates.map((c) => c.pgc);
121+
return (
122+
<>
123+
{pgcNumbers.map((pgc, index) => (
124+
<Badge key={`${pgc}-${index}`} href={`/object/${pgc}`}>
125+
{pgc}
126+
</Badge>
127+
))}
128+
</>
129+
);
130+
}
131+
132+
const columns: Column[] = [
133+
{
134+
name: "Name",
135+
renderCell: (recordIndex: CellPrimitive) => {
136+
if (typeof recordIndex === "number" && data?.records[recordIndex]) {
137+
return getRecordName(data.records[recordIndex]);
138+
}
139+
return <span></span>;
140+
},
141+
},
142+
{
143+
name: "Manual check status",
144+
renderCell: (recordIndex: CellPrimitive) => {
145+
if (typeof recordIndex === "number" && data?.records[recordIndex]) {
146+
return getTriageStatusLabel(
147+
data.records[recordIndex].crossmatch.triage_status,
148+
);
149+
}
150+
return <span></span>;
151+
},
152+
},
153+
{
154+
name: "Nature",
155+
renderCell: (recordIndex: CellPrimitive) => {
156+
if (typeof recordIndex === "number" && data?.records[recordIndex]) {
157+
const typeName =
158+
data.records[recordIndex].catalogs?.nature?.type_name;
159+
return <span>{typeName ?? "—"}</span>;
160+
}
161+
return <span></span>;
162+
},
163+
},
164+
...(showCandidates
165+
? [
166+
{
167+
name: "Candidates",
168+
renderCell: (recordIndex: CellPrimitive) => {
169+
if (
170+
typeof recordIndex === "number" &&
171+
data?.records[recordIndex]
172+
) {
173+
return renderCandidates(data.records[recordIndex]);
174+
}
175+
return <span></span>;
176+
},
177+
},
178+
]
179+
: []),
180+
];
181+
182+
const tableData: Record<string, CellPrimitive>[] =
183+
data?.records.map((_record: RecordType, index: number) => {
184+
const row: Record<string, CellPrimitive> = {
185+
"Name": index,
186+
"Manual check status": index,
187+
"Nature": index,
188+
};
189+
if (showCandidates) {
190+
row["Candidates"] = index;
191+
}
192+
return row;
193+
}) || [];
194+
195+
return <CommonTable columns={columns} data={tableData} loading={loading} />;
196+
}
197+
198+
async function fetcher(
199+
tableName: string | null,
200+
triageStatus: CrossmatchTriageStatus | null,
201+
page: number,
202+
pageSize: number,
203+
): Promise<GetRecordsResponse> {
204+
if (!tableName) {
205+
throw new Error("Table name is required");
206+
}
207+
208+
const response = await getRecords({
209+
client: adminClient,
210+
query: {
211+
table_name: tableName,
212+
triage_status: triageStatus,
213+
page,
214+
page_size: pageSize,
215+
},
216+
});
217+
218+
if (response.error) {
219+
throw new Error(
220+
response.error.detail
221+
?.map((err: ValidationError) => err.msg)
222+
.join(", ") || "Failed to fetch records",
223+
);
224+
}
225+
226+
if (!response.data) {
227+
throw new Error("No data received from server");
228+
}
229+
230+
return response.data.data;
231+
}
232+
233+
export function RecordsPage(): ReactElement {
234+
const [searchParams, setSearchParams] = useSearchParams();
235+
236+
const tableName = searchParams.get("table_name");
237+
const triageStatusParam = searchParams.get("triage_status");
238+
const apiTriageStatus: CrossmatchTriageStatus | null =
239+
triageStatusParam === null || triageStatusParam === ""
240+
? null
241+
: triageStatusParam === "all"
242+
? null
243+
: (triageStatusParam as CrossmatchTriageStatus);
244+
const page = parseInt(searchParams.get("page") || "0");
245+
const pageSize = parseInt(searchParams.get("page_size") || "25");
246+
247+
useEffect(() => {
248+
document.title = `Records${tableName ? ` - ${tableName}` : ""} | HyperLEDA`;
249+
}, [tableName]);
250+
251+
const { data, loading, error } = useDataFetching(
252+
() => fetcher(tableName, apiTriageStatus, page, pageSize),
253+
[tableName, apiTriageStatus, page, pageSize],
254+
);
255+
256+
function handlePageChange(newPage: number): void {
257+
const newSearchParams = new URLSearchParams(searchParams);
258+
newSearchParams.set("page", newPage.toString());
259+
setSearchParams(newSearchParams);
260+
}
261+
262+
function handleApplyFilters(
263+
newTableName: string,
264+
newTriageStatus: string,
265+
newPageSize: number,
266+
): void {
267+
const newSearchParams = new URLSearchParams(searchParams);
268+
269+
if (newTableName.trim()) {
270+
newSearchParams.set("table_name", newTableName.trim());
271+
} else {
272+
newSearchParams.delete("table_name");
273+
}
274+
275+
if (newTriageStatus === "all") {
276+
newSearchParams.delete("triage_status");
277+
} else {
278+
newSearchParams.set("triage_status", newTriageStatus);
279+
}
280+
281+
newSearchParams.set("page_size", newPageSize.toString());
282+
newSearchParams.set("page", "0");
283+
284+
setSearchParams(newSearchParams);
285+
}
286+
287+
function Content(): ReactElement {
288+
if (error && !data) return <ErrorPage title="Error" message={error} />;
289+
if (!data?.records && loading) return <Loading />;
290+
if (!data?.records) return <ErrorPage title="Error" message="No records" />;
291+
292+
return (
293+
<>
294+
<RecordsTable
295+
data={data}
296+
loading={loading}
297+
showCandidates={triageStatusParam === "pending"}
298+
/>
299+
<Pagination
300+
page={page}
301+
pageSize={pageSize}
302+
records={data?.records}
303+
handlePageChange={handlePageChange}
304+
/>
305+
</>
306+
);
307+
}
308+
309+
return (
310+
<>
311+
<h2 className="text-3xl font-bold mb-4">Records</h2>
312+
<RecordsFilters
313+
tableName={tableName}
314+
triageStatus={triageStatusParam}
315+
pageSize={pageSize}
316+
onApplyFilters={handleApplyFilters}
317+
/>
318+
<Content />
319+
</>
320+
);
321+
}

src/pages/TableDetails.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,25 @@ function CrossmatchStats(props: CrossmatchStatsProps): ReactElement {
198198

199199
return (
200200
<CommonTable columns={columns} data={values} className="pb-5">
201-
<div className="flex justify-between items-center">
201+
<div className="flex justify-between items-center gap-4 flex-wrap">
202202
<h2 className="text-2xl font-bold">Crossmatch Statistics</h2>
203-
<Button onClick={handleViewCrossmatchResults}>
204-
View crossmatch results
205-
</Button>
203+
<div className="flex gap-2">
204+
<Button
205+
onClick={(e: React.MouseEvent) => {
206+
const url = `/records?table_name=${encodeURIComponent(props.tableName)}`;
207+
if (e.ctrlKey || e.metaKey) {
208+
window.open(url, "_blank");
209+
} else {
210+
props.navigate(url);
211+
}
212+
}}
213+
>
214+
See data
215+
</Button>
216+
<Button onClick={handleViewCrossmatchResults}>
217+
View crossmatch results
218+
</Button>
219+
</div>
206220
</div>
207221
</CommonTable>
208222
);

0 commit comments

Comments
 (0)