From 45ef70109536dc1aec1e598a44a57c8c571582b1 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:22:36 +0000 Subject: [PATCH 1/6] add tables list page --- configs/config.js | 4 +- src/App.tsx | 10 ++ src/config.ts | 12 +- src/pages/CrossmatchResults.tsx | 4 +- src/pages/ObjectDetails.tsx | 4 +- src/pages/RecordCrossmatchDetails.tsx | 4 +- src/pages/SearchResults.tsx | 4 +- src/pages/TableDetails.tsx | 4 +- src/pages/Tables.tsx | 200 ++++++++++++++++++++++++++ vite.config.ts | 12 ++ 10 files changed, 245 insertions(+), 13 deletions(-) create mode 100644 src/pages/Tables.tsx diff --git a/configs/config.js b/configs/config.js index baae7b8..4b55549 100644 --- a/configs/config.js +++ b/configs/config.js @@ -1,4 +1,4 @@ window.__APP_CONFIG__ = { - backendBaseUrl: "http://leda.kraysent.dev", - adminBaseUrl: "http://leda.kraysent.dev" + backendBaseUrl: "http://leda.sao.ru", + adminBaseUrl: "http://leda.sao.ru" }; diff --git a/src/App.tsx b/src/App.tsx index b7ec2d4..96072b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { NotFoundPage } from "./pages/NotFound"; import { TableDetailsPage } from "./pages/TableDetails"; import { CrossmatchResultsPage } from "./pages/CrossmatchResults"; import { RecordCrossmatchDetailsPage } from "./pages/RecordCrossmatchDetails"; +import { TablesPage } from "./pages/Tables"; import { Layout } from "./components/ui/layout"; import { SearchBar } from "./components/ui/searchbar"; @@ -48,6 +49,15 @@ function App() { } /> + + + + + } + /> void; +} + +function TablesFilters({ + query, + pageSize, + onApplyFilters, +}: TablesFiltersProps): ReactElement { + const [localQuery, setLocalQuery] = useState(query || ""); + const [localPageSize, setLocalPageSize] = useState(pageSize); + + useEffect(() => { + setLocalQuery(query || ""); + setLocalPageSize(pageSize); + }, [query, pageSize]); + + function applyFilters(): void { + onApplyFilters(localQuery, localPageSize); + } + + return ( +
+ + setLocalPageSize(parseInt(value))} + /> +
+ +
+
+ ); +} + +interface TablesResultsProps { + data: GetTableListResponse | null; +} + +function TablesResults({ data }: TablesResultsProps): ReactElement { + const columns: Column[] = [ + { + name: "Name", + renderCell: (value: CellPrimitive) => { + if (typeof value === "string") { + return {value}; + } + return ; + }, + }, + { name: "Description" }, + { name: "Entries" }, + { name: "Fields" }, + ]; + + const tableData: Record[] = + data?.tables.map((table: TableListItem) => ({ + Name: table.name, + Description: table.description, + Entries: table.num_entries, + Fields: table.num_fields, + })) ?? []; + + return ; +} + +async function fetcher( + query: string | null, + page: number, + pageSize: number, +): Promise { + const response = await getTableList({ + client: adminClient, + query: { + query: query?.trim() || undefined, + page, + page_size: pageSize, + }, + }); + + if (response.error) { + throw new Error( + (response.error as { detail?: ValidationError[] }).detail + ?.map((err: ValidationError) => err.msg) + .join(", ") || "Failed to fetch tables", + ); + } + + if (!response.data) { + throw new Error("No data received from server"); + } + + return response.data.data; +} + +export function TablesPage(): ReactElement { + const [searchParams, setSearchParams] = useSearchParams(); + + const query = searchParams.get("q"); + const page = parseInt(searchParams.get("page") || "0"); + const pageSize = parseInt(searchParams.get("page_size") || "25"); + + useEffect(() => { + document.title = "Tables | HyperLEDA"; + }, []); + + const { data, loading, error } = useDataFetching( + () => fetcher(query, page, pageSize), + [query, page, pageSize], + ); + + function handlePageChange(newPage: number): void { + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("page", newPage.toString()); + setSearchParams(newSearchParams); + } + + function handleApplyFilters(newQuery: string, newPageSize: number): void { + const newSearchParams = new URLSearchParams(searchParams); + + if (newQuery.trim()) { + newSearchParams.set("q", newQuery.trim()); + } else { + newSearchParams.delete("q"); + } + + newSearchParams.set("page_size", newPageSize.toString()); + newSearchParams.set("page", "0"); + + setSearchParams(newSearchParams); + } + + function Content(): ReactElement { + if (loading) return ; + if (error) return ; + if (!data?.tables) return ; + + return ( + <> + + + + ); + } + + return ( + <> +

Tables

+ + + + ); +} diff --git a/vite.config.ts b/vite.config.ts index f961a45..1ad7e80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,4 +5,16 @@ import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": { + target: "http://leda.sao.ru", + changeOrigin: true, + }, + "/admin": { + target: "http://leda.sao.ru", + changeOrigin: true, + }, + }, + }, }); From 104e809a3c90741ecf526b79cd5368f692568e88 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:28:23 +0000 Subject: [PATCH 2/6] change table name --- src/pages/Tables.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/Tables.tsx b/src/pages/Tables.tsx index c628f8b..53f2632 100644 --- a/src/pages/Tables.tsx +++ b/src/pages/Tables.tsx @@ -87,16 +87,16 @@ function TablesResults({ data }: TablesResultsProps): ReactElement { }, }, { name: "Description" }, - { name: "Entries" }, - { name: "Fields" }, + { name: "Number of records" }, + { name: "Number of columns" }, ]; const tableData: Record[] = data?.tables.map((table: TableListItem) => ({ Name: table.name, Description: table.description, - Entries: table.num_entries, - Fields: table.num_fields, + "Number of records": table.num_entries, + "Number of columns": table.num_fields, })) ?? []; return ; @@ -105,7 +105,7 @@ function TablesResults({ data }: TablesResultsProps): ReactElement { async function fetcher( query: string | null, page: number, - pageSize: number, + pageSize: number ): Promise { const response = await getTableList({ client: adminClient, @@ -120,7 +120,7 @@ async function fetcher( throw new Error( (response.error as { detail?: ValidationError[] }).detail ?.map((err: ValidationError) => err.msg) - .join(", ") || "Failed to fetch tables", + .join(", ") || "Failed to fetch tables" ); } @@ -144,7 +144,7 @@ export function TablesPage(): ReactElement { const { data, loading, error } = useDataFetching( () => fetcher(query, page, pageSize), - [query, page, pageSize], + [query, page, pageSize] ); function handlePageChange(newPage: number): void { From 564a5569f4c668322f3cf4d4971c8c4798ea23c6 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:31:36 +0000 Subject: [PATCH 3/6] realtime page refreshing --- src/pages/Tables.tsx | 69 +++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/src/pages/Tables.tsx b/src/pages/Tables.tsx index 53f2632..5b49ad9 100644 --- a/src/pages/Tables.tsx +++ b/src/pages/Tables.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement, useEffect, useState, useRef } from "react"; import { useSearchParams } from "react-router-dom"; import { CommonTable, @@ -13,7 +13,6 @@ import type { TableListItem, ValidationError, } from "../clients/admin/types.gen"; -import { Button } from "../components/ui/button"; import { Loading } from "../components/ui/loading"; import { ErrorPage } from "../components/ui/error-page"; import { Link } from "../components/ui/link"; @@ -21,27 +20,42 @@ import { useDataFetching } from "../hooks/useDataFetching"; import { Pagination } from "../components/ui/pagination"; import { adminClient } from "../clients/config"; +const SEARCH_DEBOUNCE_MS = 300; + interface TablesFiltersProps { query: string | null; pageSize: number; - onApplyFilters: (query: string, pageSize: number) => void; + onQueryChange: (query: string) => void; + onPageSizeChange: (pageSize: number) => void; } function TablesFilters({ query, pageSize, - onApplyFilters, + onQueryChange, + onPageSizeChange, }: TablesFiltersProps): ReactElement { const [localQuery, setLocalQuery] = useState(query || ""); - const [localPageSize, setLocalPageSize] = useState(pageSize); + const debounceRef = useRef | null>(null); useEffect(() => { - setLocalQuery(query || ""); - setLocalPageSize(pageSize); - }, [query, pageSize]); + setLocalQuery(query ?? ""); + }, [query]); + + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); - function applyFilters(): void { - onApplyFilters(localQuery, localPageSize); + function handleQueryChange(value: string): void { + setLocalQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + onQueryChange(value); + }, SEARCH_DEBOUNCE_MS); } return ( @@ -49,9 +63,8 @@ function TablesFilters({ setLocalPageSize(parseInt(value))} + value={pageSize.toString()} + onChange={(value) => onPageSizeChange(parseInt(value))} /> -
- -
); } @@ -153,18 +163,22 @@ export function TablesPage(): ReactElement { setSearchParams(newSearchParams); } - function handleApplyFilters(newQuery: string, newPageSize: number): void { + function updateParams(updates: { + q?: string; + page_size?: number; + }): void { const newSearchParams = new URLSearchParams(searchParams); - - if (newQuery.trim()) { - newSearchParams.set("q", newQuery.trim()); - } else { - newSearchParams.delete("q"); + if (updates.q !== undefined) { + if (updates.q.trim()) { + newSearchParams.set("q", updates.q.trim()); + } else { + newSearchParams.delete("q"); + } + } + if (updates.page_size !== undefined) { + newSearchParams.set("page_size", updates.page_size.toString()); } - - newSearchParams.set("page_size", newPageSize.toString()); newSearchParams.set("page", "0"); - setSearchParams(newSearchParams); } @@ -192,7 +206,8 @@ export function TablesPage(): ReactElement { updateParams({ q })} + onPageSizeChange={(size) => updateParams({ page_size: size })} /> From 6e8d0f4a278c154a7469aff4adf61bcf597f91da Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:41:30 +0000 Subject: [PATCH 4/6] add loading spinner for common table --- src/components/ui/common-table.tsx | 112 ++++++++++++++++------------- src/hooks/useDataFetching.ts | 2 + src/pages/CrossmatchResults.tsx | 11 +-- src/pages/Tables.tsx | 11 +-- 4 files changed, 78 insertions(+), 58 deletions(-) diff --git a/src/components/ui/common-table.tsx b/src/components/ui/common-table.tsx index 4ed1ba8..46a38ff 100644 --- a/src/components/ui/common-table.tsx +++ b/src/components/ui/common-table.tsx @@ -1,6 +1,7 @@ import React, { ReactElement, ReactNode } from "react"; import classNames from "classnames"; import { Hint } from "./hint"; +import { Loading } from "./loading"; export type CellPrimitive = ReactElement | string | number; @@ -13,6 +14,7 @@ export interface Column { interface CommonTableProps { columns: Column[]; data: Record[]; + loading?: boolean; className?: string; tableClassName?: string; headerClassName?: string; @@ -25,6 +27,7 @@ interface CommonTableProps { export function CommonTable({ columns, data, + loading = false, className = "", tableClassName = "", headerClassName = "bg-gray-700 border-gray-600", @@ -62,58 +65,71 @@ export function CommonTable({ )} -
- - - - {columns.map((column) => ( -
+
+ + + + {columns.map((column) => ( + + ))} + + + + + {data.map((row, rowIndex) => ( + onRowClick?.(row, rowIndex)} > - {column.hint ? ( - - {column.name} - - ) : ( - column.name - )} - + {columns.map((column) => { + const cellValue = row[column.name]; + return ( + + ); + })} + ))} - - - - - {data.map((row, rowIndex) => ( - onRowClick?.(row, rowIndex)} - > - {columns.map((column) => { - const cellValue = row[column.name]; - return ( - - ); - })} - - ))} - -
+ {column.hint ? ( + + {column.name} + + ) : ( + column.name + )} +
+ {renderCell(cellValue, column)} +
- {renderCell(cellValue, column)} -
+ +
+
+ {loading && ( +
+ +
+ )} ); diff --git a/src/hooks/useDataFetching.ts b/src/hooks/useDataFetching.ts index d4fe15f..a564571 100644 --- a/src/hooks/useDataFetching.ts +++ b/src/hooks/useDataFetching.ts @@ -15,6 +15,8 @@ export function useDataFetching( const [error, setError] = useState(null); useEffect(() => { + setLoading(true); + setError(null); async function fetchData(): Promise { try { const result = await fetcher(); diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index a45cecf..a6bec2a 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -93,9 +93,10 @@ function CrossmatchFilters({ interface CrossmatchResultsProps { data: GetRecordsCrossmatchResponse | null; + loading?: boolean; } -function CrossmatchResults({ data }: CrossmatchResultsProps): ReactElement { +function CrossmatchResults({ data, loading }: CrossmatchResultsProps): ReactElement { function getRecordName(record: RecordCrossmatch): ReactElement { const displayName = record.catalogs.designation?.name || record.record_id; return ( @@ -158,7 +159,7 @@ function CrossmatchResults({ data }: CrossmatchResultsProps): ReactElement { Candidates: index, })) || []; - return ; + return ; } async function fetcher( @@ -245,13 +246,13 @@ export function CrossmatchResultsPage(): ReactElement { } function Content(): ReactElement { - if (loading) return ; - if (error) return ; + if (error && !data) return ; + if (!data?.records && loading) return ; if (!data?.records) return ; return ( <> - + ; + return ; } async function fetcher( @@ -183,13 +184,13 @@ export function TablesPage(): ReactElement { } function Content(): ReactElement { - if (loading) return ; - if (error) return ; + if (error && !data) return ; + if (!data?.tables && loading) return ; if (!data?.tables) return ; return ( <> - + Date: Sat, 21 Feb 2026 23:42:03 +0000 Subject: [PATCH 5/6] formatters --- src/pages/CrossmatchResults.tsx | 5 ++++- src/pages/Tables.tsx | 11 ++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pages/CrossmatchResults.tsx b/src/pages/CrossmatchResults.tsx index a6bec2a..7d12412 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -96,7 +96,10 @@ interface CrossmatchResultsProps { loading?: boolean; } -function CrossmatchResults({ data, loading }: CrossmatchResultsProps): ReactElement { +function CrossmatchResults({ + data, + loading, +}: CrossmatchResultsProps): ReactElement { function getRecordName(record: RecordCrossmatch): ReactElement { const displayName = record.catalogs.designation?.name || record.record_id; return ( diff --git a/src/pages/Tables.tsx b/src/pages/Tables.tsx index bdd5509..b186be6 100644 --- a/src/pages/Tables.tsx +++ b/src/pages/Tables.tsx @@ -116,7 +116,7 @@ function TablesResults({ data, loading }: TablesResultsProps): ReactElement { async function fetcher( query: string | null, page: number, - pageSize: number + pageSize: number, ): Promise { const response = await getTableList({ client: adminClient, @@ -131,7 +131,7 @@ async function fetcher( throw new Error( (response.error as { detail?: ValidationError[] }).detail ?.map((err: ValidationError) => err.msg) - .join(", ") || "Failed to fetch tables" + .join(", ") || "Failed to fetch tables", ); } @@ -155,7 +155,7 @@ export function TablesPage(): ReactElement { const { data, loading, error } = useDataFetching( () => fetcher(query, page, pageSize), - [query, page, pageSize] + [query, page, pageSize], ); function handlePageChange(newPage: number): void { @@ -164,10 +164,7 @@ export function TablesPage(): ReactElement { setSearchParams(newSearchParams); } - function updateParams(updates: { - q?: string; - page_size?: number; - }): void { + function updateParams(updates: { q?: string; page_size?: number }): void { const newSearchParams = new URLSearchParams(searchParams); if (updates.q !== undefined) { if (updates.q.trim()) { From d69048a4c02fa9e312dfee3b32490f084c1b1032 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sat, 21 Feb 2026 23:43:01 +0000 Subject: [PATCH 6/6] add agents md file --- AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..388b141 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +## Development + +### Running checks + +To check the code against static checks, run: + +```shell +make check +``` + +Sometimes errors this command produces (such as import sorting) can be fixed automatically using: + +```shell +make fix +``` + +If the check command fails, make sure to always run the fix command first prior to trying to fix changes yourself.