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. 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() { } /> + + + + + } + /> []; + 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/config.ts b/src/config.ts index 786a18d..36e5e7a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,7 +10,17 @@ declare global { } function getConfig(): AppConfig { - if (typeof window === "undefined" || !window.__APP_CONFIG__) { + if (typeof window === "undefined") { + throw new Error( + "App configuration is required. Please set window.__APP_CONFIG__", + ); + } + + if (import.meta.env.DEV) { + return { backendBaseUrl: "", adminBaseUrl: "" }; + } + + if (!window.__APP_CONFIG__) { throw new Error( "App configuration is required. Please set window.__APP_CONFIG__", ); 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 ba9a6b7..7d12412 100644 --- a/src/pages/CrossmatchResults.tsx +++ b/src/pages/CrossmatchResults.tsx @@ -8,7 +8,7 @@ import { 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 { getCrossmatchRecords } from "../clients/admin/sdk.gen"; import type { GetRecordsCrossmatchResponse, RecordCrossmatch, @@ -93,9 +93,13 @@ 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 +162,7 @@ function CrossmatchResults({ data }: CrossmatchResultsProps): ReactElement { Candidates: index, })) || []; - return ; + return ; } async function fetcher( @@ -171,7 +175,7 @@ async function fetcher( throw new Error("Table name is required"); } - const response = await getCrossmatchRecordsAdminApiV1RecordsCrossmatchGet({ + const response = await getCrossmatchRecords({ client: adminClient, query: { table_name: tableName, @@ -245,13 +249,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 ( <> - + void; + onPageSizeChange: (pageSize: number) => void; +} + +function TablesFilters({ + query, + pageSize, + onQueryChange, + onPageSizeChange, +}: TablesFiltersProps): ReactElement { + const [localQuery, setLocalQuery] = useState(query || ""); + const debounceRef = useRef | null>(null); + + useEffect(() => { + setLocalQuery(query ?? ""); + }, [query]); + + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, + [], + ); + + 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 ( +
+ + onPageSizeChange(parseInt(value))} + /> +
+ ); +} + +interface TablesResultsProps { + data: GetTableListResponse | null; + loading?: boolean; +} + +function TablesResults({ data, loading }: TablesResultsProps): ReactElement { + const columns: Column[] = [ + { + name: "Name", + renderCell: (value: CellPrimitive) => { + if (typeof value === "string") { + return {value}; + } + return ; + }, + }, + { name: "Description" }, + { name: "Number of records" }, + { name: "Number of columns" }, + ]; + + const tableData: Record[] = + data?.tables.map((table: TableListItem) => ({ + Name: table.name, + Description: table.description, + "Number of records": table.num_entries, + "Number of columns": 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 updateParams(updates: { q?: string; page_size?: number }): void { + const newSearchParams = new URLSearchParams(searchParams); + 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", "0"); + setSearchParams(newSearchParams); + } + + function Content(): ReactElement { + if (error && !data) return ; + if (!data?.tables && loading) return ; + if (!data?.tables) return ; + + return ( + <> + + + + ); + } + + return ( + <> +

Tables

+ updateParams({ q })} + onPageSizeChange={(size) => updateParams({ page_size: size })} + /> + + + ); +} 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, + }, + }, + }, });